wraith/src-tauri/src/connections/mod.rs
Vantz Stockwell 6e33bbdcf1 fix: serialize tags as array (not JSON string), DevTools debug-only
- ConnectionRecord.tags changed from String to Vec<String> so the
  frontend receives a proper array instead of a raw JSON string.
  The old behavior caused v-for to iterate characters, corrupting
  the connection display in the sidebar.
- DevTools now only auto-opens in debug builds (cfg(debug_assertions)),
  not in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:58:58 -04:00

721 lines
25 KiB
Rust

//! Connection and connection-group CRUD service backed by SQLite.
//!
//! # Data model
//!
//! ```text
//! groups — hierarchical folders for organising connections
//! connections — individual SSH / RDP targets
//! ```
//!
//! Tags and options are stored as JSON strings in TEXT columns (matching the
//! schema in `001_initial.sql`). The service serialises/deserialises them
//! transparently so callers always deal with typed Rust values.
use rusqlite::params;
use serde::{Deserialize, Serialize};
use crate::db::Database;
// ── domain types ──────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConnectionGroup {
pub id: i64,
pub name: String,
pub parent_id: Option<i64>,
pub sort_order: i64,
pub icon: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionRecord {
pub id: i64,
pub name: String,
pub hostname: String,
pub port: i64,
pub protocol: String,
pub group_id: Option<i64>,
pub credential_id: Option<i64>,
pub color: Option<String>,
pub tags: Vec<String>,
pub notes: Option<String>,
pub options: String,
pub sort_order: i64,
pub last_connected: Option<String>,
}
// ── input types ───────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateConnectionInput {
pub name: String,
pub hostname: String,
pub port: i64,
pub protocol: String,
pub group_id: Option<i64>,
pub credential_id: Option<i64>,
pub color: Option<String>,
pub tags: Vec<String>,
pub notes: Option<String>,
/// Optional JSON object; defaults to `{}` when absent.
pub options: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateConnectionInput {
pub name: Option<String>,
pub hostname: Option<String>,
pub port: Option<i64>,
pub protocol: Option<String>,
pub group_id: Option<i64>,
pub credential_id: Option<i64>,
pub color: Option<String>,
pub tags: Option<Vec<String>>,
pub notes: Option<String>,
pub options: Option<String>,
}
// ── service ───────────────────────────────────────────────────────────────────
/// Provides CRUD operations for connections and connection groups.
#[derive(Clone)]
pub struct ConnectionService {
db: Database,
}
impl ConnectionService {
pub fn new(db: Database) -> Self {
Self { db }
}
// ── groups ────────────────────────────────────────────────────────────────
/// Create a new connection group.
///
/// `sort_order` is automatically set to `(max_existing + 1)` so new groups
/// appear at the bottom of the list.
pub fn create_group(
&self,
name: &str,
parent_id: Option<i64>,
) -> Result<ConnectionGroup, String> {
let conn = self.db.conn();
// Determine next sort_order within the same parent level.
let sort_order: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort_order), 0) + 1 FROM groups WHERE parent_id IS ?1",
params![parent_id],
|row| row.get(0),
)
.map_err(|e| format!("Failed to compute sort_order for group: {e}"))?;
conn.execute(
"INSERT INTO groups (name, parent_id, sort_order) VALUES (?1, ?2, ?3)",
params![name, parent_id, sort_order],
)
.map_err(|e| format!("Failed to create group '{name}': {e}"))?;
let id = conn.last_insert_rowid();
Ok(ConnectionGroup {
id,
name: name.to_string(),
parent_id,
sort_order,
icon: None,
})
}
/// Return all groups ordered by `(parent_id NULLS FIRST, sort_order)`.
pub fn list_groups(&self) -> Result<Vec<ConnectionGroup>, String> {
let conn = self.db.conn();
let mut stmt = conn
.prepare(
"SELECT id, name, parent_id, sort_order, icon
FROM groups
ORDER BY parent_id NULLS FIRST, sort_order ASC",
)
.map_err(|e| format!("Failed to prepare group list query: {e}"))?;
let groups = stmt
.query_map([], |row| {
Ok(ConnectionGroup {
id: row.get(0)?,
name: row.get(1)?,
parent_id: row.get::<_, Option<i64>>(2)?,
sort_order: row.get(3)?,
icon: row.get::<_, Option<String>>(4)?,
})
})
.map_err(|e| format!("Failed to list groups: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to read group row: {e}"))?;
Ok(groups)
}
/// Delete a group by ID.
///
/// Any connections in the group have their `group_id` set to NULL (via the
/// `ON DELETE SET NULL` FK constraint in the schema). Child groups
/// similarly have their `parent_id` set to NULL.
pub fn delete_group(&self, id: i64) -> Result<(), String> {
let conn = self.db.conn();
let affected = conn
.execute("DELETE FROM groups WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to delete group {id}: {e}"))?;
if affected == 0 {
return Err(format!("Group {id} not found"));
}
Ok(())
}
/// Rename a group.
pub fn rename_group(&self, id: i64, name: &str) -> Result<(), String> {
let conn = self.db.conn();
let affected = conn
.execute(
"UPDATE groups SET name = ?1 WHERE id = ?2",
params![name, id],
)
.map_err(|e| format!("Failed to rename group {id}: {e}"))?;
if affected == 0 {
return Err(format!("Group {id} not found"));
}
Ok(())
}
// ── connections ───────────────────────────────────────────────────────────
/// Create a new connection record.
pub fn create_connection(
&self,
input: CreateConnectionInput,
) -> Result<ConnectionRecord, String> {
let conn = self.db.conn();
// Serialise tags Vec → JSON array string.
let tags_json = serde_json::to_string(&input.tags)
.map_err(|e| format!("Failed to serialise tags: {e}"))?;
// Default options to `{}` when not supplied.
let options_json = input.options.unwrap_or_else(|| "{}".to_string());
// Validate options is at least parseable JSON (fail fast before insert).
serde_json::from_str::<serde_json::Value>(&options_json)
.map_err(|e| format!("options is not valid JSON: {e}"))?;
// sort_order = max + 1 within the same group (or global if no group).
let sort_order: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sort_order), 0) + 1 FROM connections WHERE group_id IS ?1",
params![input.group_id],
|row| row.get(0),
)
.map_err(|e| format!("Failed to compute sort_order for connection: {e}"))?;
conn.execute(
"INSERT INTO connections
(name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
input.name,
input.hostname,
input.port,
input.protocol,
input.group_id,
input.credential_id,
input.color,
tags_json,
input.notes,
options_json,
sort_order,
],
)
.map_err(|e| format!("Failed to insert connection '{}': {e}", input.name))?;
let id = conn.last_insert_rowid();
Ok(ConnectionRecord {
id,
name: input.name,
hostname: input.hostname,
port: input.port,
protocol: input.protocol,
group_id: input.group_id,
credential_id: input.credential_id,
color: input.color,
tags: input.tags,
notes: input.notes,
options: options_json,
sort_order,
last_connected: None,
})
}
/// Fetch a single connection by ID.
pub fn get_connection(&self, id: i64) -> Result<ConnectionRecord, String> {
let conn = self.db.conn();
conn.query_row(
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected
FROM connections
WHERE id = ?1",
params![id],
map_connection_row,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => format!("Connection {id} not found"),
other => format!("Failed to fetch connection {id}: {other}"),
})
}
/// Return all connections ordered by `(group_id NULLS FIRST, sort_order)`.
pub fn list_connections(&self) -> Result<Vec<ConnectionRecord>, String> {
let conn = self.db.conn();
let mut stmt = conn
.prepare(
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected
FROM connections
ORDER BY group_id NULLS FIRST, sort_order ASC",
)
.map_err(|e| format!("Failed to prepare connection list query: {e}"))?;
let records = stmt
.query_map([], map_connection_row)
.map_err(|e| format!("Failed to list connections: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to read connection row: {e}"))?;
Ok(records)
}
/// Update a connection. Only fields that are `Some` in `input` are written.
///
/// Builds a dynamic `SET` clause so a round-trip for unchanged fields is
/// never needed from the frontend.
pub fn update_connection(
&self,
id: i64,
input: UpdateConnectionInput,
) -> Result<(), String> {
// Build the SET clause dynamically.
let mut set_clauses: Vec<String> = Vec::new();
let mut positional: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
macro_rules! push_field {
($field:expr, $col:literal) => {
if let Some(val) = $field {
positional.push(Box::new(val));
set_clauses.push(format!("{} = ?{}", $col, positional.len()));
}
};
}
push_field!(input.name, "name");
push_field!(input.hostname, "hostname");
push_field!(input.port, "port");
push_field!(input.protocol, "protocol");
// group_id: `Some(id)` sets to that id; `None` means "field not in
// payload, leave unchanged". Callers that need to clear group_id
// should send a separate dedicated endpoint rather than relying on
// null JSON serialisation, because `Option<i64>` cannot distinguish
// "absent" from "null" at this layer.
push_field!(input.group_id, "group_id");
push_field!(input.credential_id, "credential_id");
push_field!(input.color, "color");
if let Some(tags) = input.tags {
let tags_json = serde_json::to_string(&tags)
.map_err(|e| format!("Failed to serialise tags: {e}"))?;
positional.push(Box::new(tags_json));
set_clauses.push(format!("tags = ?{}", positional.len()));
}
push_field!(input.notes, "notes");
if let Some(opts) = input.options {
serde_json::from_str::<serde_json::Value>(&opts)
.map_err(|e| format!("options is not valid JSON: {e}"))?;
positional.push(Box::new(opts));
set_clauses.push(format!("options = ?{}", positional.len()));
}
if set_clauses.is_empty() {
// Nothing to update — treat as a no-op success.
return Ok(());
}
// Append updated_at and the WHERE id binding.
set_clauses.push("updated_at = CURRENT_TIMESTAMP".to_string());
positional.push(Box::new(id));
let id_pos = positional.len();
let sql = format!(
"UPDATE connections SET {} WHERE id = ?{}",
set_clauses.join(", "),
id_pos
);
let conn = self.db.conn();
let params_refs: Vec<&dyn rusqlite::ToSql> =
positional.iter().map(|b| b.as_ref()).collect();
let affected = conn
.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("Failed to update connection {id}: {e}"))?;
if affected == 0 {
return Err(format!("Connection {id} not found"));
}
Ok(())
}
/// Delete a connection by ID.
pub fn delete_connection(&self, id: i64) -> Result<(), String> {
let conn = self.db.conn();
let affected = conn
.execute("DELETE FROM connections WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to delete connection {id}: {e}"))?;
if affected == 0 {
return Err(format!("Connection {id} not found"));
}
Ok(())
}
/// Search connections by `query` across `name`, `hostname`, `tags`, and
/// `notes` using case-insensitive LIKE matching.
///
/// Returns results ordered by name.
pub fn search(&self, query: &str) -> Result<Vec<ConnectionRecord>, String> {
let conn = self.db.conn();
// Wrap query in `%…%` for LIKE. Escape any literal `%` or `_` in the
// query so they're treated as ordinary characters.
let like_pattern = format!(
"%{}%",
query.replace('%', "\\%").replace('_', "\\_")
);
let mut stmt = conn
.prepare(
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected
FROM connections
WHERE name LIKE ?1 ESCAPE '\\'
OR hostname LIKE ?1 ESCAPE '\\'
OR tags LIKE ?1 ESCAPE '\\'
OR notes LIKE ?1 ESCAPE '\\'
ORDER BY name COLLATE NOCASE ASC",
)
.map_err(|e| format!("Failed to prepare search query: {e}"))?;
let records = stmt
.query_map(params![like_pattern], map_connection_row)
.map_err(|e| format!("Search query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to read search result row: {e}"))?;
Ok(records)
}
}
// ── private helpers ───────────────────────────────────────────────────────────
/// Map a rusqlite row from the `connections` SELECT into a [`ConnectionRecord`].
///
/// Column order must match the SELECT lists used throughout this module.
fn map_connection_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConnectionRecord> {
let tags_json: String = row.get(8)?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
Ok(ConnectionRecord {
id: row.get(0)?,
name: row.get(1)?,
hostname: row.get(2)?,
port: row.get(3)?,
protocol: row.get(4)?,
group_id: row.get::<_, Option<i64>>(5)?,
credential_id: row.get::<_, Option<i64>>(6)?,
color: row.get::<_, Option<String>>(7)?,
tags,
notes: row.get::<_, Option<String>>(9)?,
options: row.get(10)?,
sort_order: row.get(11)?,
last_connected: row.get::<_, Option<String>>(12)?,
})
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn make_service() -> ConnectionService {
let db = Database::open(std::path::Path::new(":memory:")).unwrap();
db.migrate().unwrap();
ConnectionService::new(db)
}
fn default_input(name: &str) -> CreateConnectionInput {
CreateConnectionInput {
name: name.to_string(),
hostname: "192.168.1.1".to_string(),
port: 22,
protocol: "ssh".to_string(),
group_id: None,
credential_id: None,
color: None,
tags: vec!["linux".to_string(), "prod".to_string()],
notes: None,
options: None,
}
}
// ── group tests ───────────────────────────────────────────────────────────
#[test]
fn create_and_list_group() {
let svc = make_service();
let g = svc.create_group("Production", None).unwrap();
assert_eq!(g.name, "Production");
assert_eq!(g.parent_id, None);
let groups = svc.list_groups().unwrap();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].id, g.id);
}
#[test]
fn create_nested_group() {
let svc = make_service();
let parent = svc.create_group("Root", None).unwrap();
let child = svc.create_group("Child", Some(parent.id)).unwrap();
assert_eq!(child.parent_id, Some(parent.id));
let groups = svc.list_groups().unwrap();
assert_eq!(groups.len(), 2);
}
#[test]
fn rename_group() {
let svc = make_service();
let g = svc.create_group("OldName", None).unwrap();
svc.rename_group(g.id, "NewName").unwrap();
let groups = svc.list_groups().unwrap();
assert_eq!(groups[0].name, "NewName");
}
#[test]
fn rename_nonexistent_group_errors() {
let svc = make_service();
assert!(svc.rename_group(999, "X").is_err());
}
#[test]
fn delete_group() {
let svc = make_service();
let g = svc.create_group("Temp", None).unwrap();
svc.delete_group(g.id).unwrap();
assert!(svc.list_groups().unwrap().is_empty());
}
#[test]
fn delete_nonexistent_group_errors() {
let svc = make_service();
assert!(svc.delete_group(999).is_err());
}
// ── connection tests ──────────────────────────────────────────────────────
#[test]
fn create_and_get_connection() {
let svc = make_service();
let rec = svc.create_connection(default_input("web-01")).unwrap();
assert!(rec.id > 0);
assert_eq!(rec.name, "web-01");
assert_eq!(rec.port, 22);
assert_eq!(rec.options, "{}");
let fetched = svc.get_connection(rec.id).unwrap();
assert_eq!(fetched.id, rec.id);
assert_eq!(fetched.hostname, "192.168.1.1");
}
#[test]
fn tags_serialised_as_vec() {
let svc = make_service();
let rec = svc.create_connection(default_input("tagged")).unwrap();
assert_eq!(rec.tags, vec!["linux", "prod"]);
}
#[test]
fn list_connections_empty() {
let svc = make_service();
assert!(svc.list_connections().unwrap().is_empty());
}
#[test]
fn list_connections_multiple() {
let svc = make_service();
svc.create_connection(default_input("alpha")).unwrap();
svc.create_connection(default_input("beta")).unwrap();
assert_eq!(svc.list_connections().unwrap().len(), 2);
}
#[test]
fn update_connection_name() {
let svc = make_service();
let rec = svc.create_connection(default_input("original")).unwrap();
svc.update_connection(
rec.id,
UpdateConnectionInput {
name: Some("renamed".to_string()),
hostname: None,
port: None,
protocol: None,
group_id: None,
credential_id: None,
color: None,
tags: None,
notes: None,
options: None,
},
)
.unwrap();
let updated = svc.get_connection(rec.id).unwrap();
assert_eq!(updated.name, "renamed");
// Untouched fields stay the same.
assert_eq!(updated.hostname, "192.168.1.1");
}
#[test]
fn update_connection_tags() {
let svc = make_service();
let rec = svc.create_connection(default_input("server")).unwrap();
svc.update_connection(
rec.id,
UpdateConnectionInput {
name: None,
hostname: None,
port: None,
protocol: None,
group_id: None,
credential_id: None,
color: None,
tags: Some(vec!["windows".to_string()]),
notes: None,
options: None,
},
)
.unwrap();
let updated = svc.get_connection(rec.id).unwrap();
assert_eq!(updated.tags, vec!["windows"]);
}
#[test]
fn update_nonexistent_connection_errors() {
let svc = make_service();
let result = svc.update_connection(
9999,
UpdateConnectionInput {
name: Some("x".to_string()),
hostname: None,
port: None,
protocol: None,
group_id: None,
credential_id: None,
color: None,
tags: None,
notes: None,
options: None,
},
);
assert!(result.is_err());
}
#[test]
fn delete_connection() {
let svc = make_service();
let rec = svc.create_connection(default_input("to-delete")).unwrap();
svc.delete_connection(rec.id).unwrap();
assert!(svc.get_connection(rec.id).is_err());
}
#[test]
fn delete_nonexistent_connection_errors() {
let svc = make_service();
assert!(svc.delete_connection(999).is_err());
}
// ── search tests ──────────────────────────────────────────────────────────
#[test]
fn search_by_name() {
let svc = make_service();
svc.create_connection(default_input("web-server-01")).unwrap();
svc.create_connection(default_input("db-server-01")).unwrap();
let results = svc.search("web").unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "web-server-01");
}
#[test]
fn search_by_hostname() {
let svc = make_service();
let mut input = default_input("db");
input.hostname = "db.internal.example.com".to_string();
svc.create_connection(input).unwrap();
let results = svc.search("internal").unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn search_case_insensitive() {
let svc = make_service();
svc.create_connection(default_input("MyServer")).unwrap();
assert_eq!(svc.search("myserver").unwrap().len(), 1);
assert_eq!(svc.search("MYSERVER").unwrap().len(), 1);
}
#[test]
fn search_no_results() {
let svc = make_service();
svc.create_connection(default_input("alpha")).unwrap();
assert!(svc.search("zzz").unwrap().is_empty());
}
#[test]
fn search_special_like_chars_not_treated_as_wildcards() {
let svc = make_service();
let mut input = default_input("literal_underscore");
input.hostname = "host_name".to_string();
svc.create_connection(input).unwrap();
// "_" as a LIKE wildcard would match everything single-char wide.
// After escaping it should only match "host_name".
let results = svc.search("host_name").unwrap();
assert_eq!(results.len(), 1);
}
}