- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
363 lines
11 KiB
Rust
363 lines
11 KiB
Rust
use crate::models::{Connection, ConnectionInput};
|
|
use crate::store::Store;
|
|
use std::sync::Mutex;
|
|
|
|
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
|
|
|
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
|
if input.name.is_empty() || input.name.chars().count() > 100 {
|
|
return Err("name is required and must be 100 chars or fewer".into());
|
|
}
|
|
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
|
|
return Err(format!(
|
|
"db_type must be one of: {}",
|
|
VALID_DB_TYPES.join(", ")
|
|
));
|
|
}
|
|
if input.host.is_empty() || input.host.chars().count() > 255 {
|
|
return Err("host is required and must be 255 chars or fewer".into());
|
|
}
|
|
if input.db_type != "sqlite" {
|
|
match input.port {
|
|
Some(p) if (1..=65535).contains(&p) => {}
|
|
_ => return Err("port must be an integer between 1 and 65535 for this db_type".into()),
|
|
}
|
|
}
|
|
if let Some(u) = &input.username {
|
|
if u.chars().count() > 100 {
|
|
return Err("username must be 100 chars or fewer".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.get_connections()
|
|
}
|
|
|
|
pub fn create_connection_inner(
|
|
state: &Mutex<Store>,
|
|
input: ConnectionInput,
|
|
) -> Result<Connection, String> {
|
|
validate(&input)?;
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.create_connection(input)
|
|
}
|
|
|
|
pub fn update_connection_inner(
|
|
state: &Mutex<Store>,
|
|
id: String,
|
|
input: ConnectionInput,
|
|
) -> Result<Connection, String> {
|
|
validate(&input)?;
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.update_connection(&id, input)
|
|
}
|
|
|
|
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.delete_connection(id)
|
|
}
|
|
|
|
pub fn add_connection_tags_inner(
|
|
state: &Mutex<Store>,
|
|
connection_id: String,
|
|
tag_ids: Vec<String>,
|
|
) -> Result<(), String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.add_connection_tags(&connection_id, &tag_ids)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
|
|
get_connections_inner(&state.db_store)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_connection(
|
|
state: tauri::State<crate::AppState>,
|
|
input: ConnectionInput,
|
|
) -> Result<Connection, String> {
|
|
create_connection_inner(&state.db_store, input)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_connection(
|
|
state: tauri::State<crate::AppState>,
|
|
id: String,
|
|
input: ConnectionInput,
|
|
) -> Result<Connection, String> {
|
|
update_connection_inner(&state.db_store, id, input)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_connection(
|
|
state: tauri::State<crate::AppState>,
|
|
id: String,
|
|
app: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
delete_connection_inner(&state.db_store, &id)?;
|
|
// Purge keychain secrets (missing entries are no-ops) and close any
|
|
// SSH tunnel associated with the deleted connection.
|
|
let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id);
|
|
let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id);
|
|
let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id);
|
|
if let Ok(mut mgr) = state.ssh_manager.lock() {
|
|
mgr.close_tunnel(&id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_connection_tags(
|
|
state: tauri::State<crate::AppState>,
|
|
connection_id: String,
|
|
tag_ids: Vec<String>,
|
|
) -> Result<(), String> {
|
|
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
|
}
|
|
|
|
pub fn set_connection_favorite_inner(
|
|
state: &Mutex<Store>,
|
|
connection_id: String,
|
|
favorite: bool,
|
|
) -> Result<(), String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.set_connection_favorite(&connection_id, favorite)
|
|
}
|
|
|
|
pub fn record_recent_connection_inner(
|
|
state: &Mutex<Store>,
|
|
connection_id: String,
|
|
) -> Result<(), String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.record_recent_connection(&connection_id)
|
|
}
|
|
|
|
pub fn get_recent_connections_inner(
|
|
state: &Mutex<Store>,
|
|
limit: i64,
|
|
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.get_recent_connections(limit)
|
|
}
|
|
|
|
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
store.clear_recent_connections()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_connection_favorite(
|
|
state: tauri::State<crate::AppState>,
|
|
connection_id: String,
|
|
favorite: bool,
|
|
) -> Result<(), String> {
|
|
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn record_recent_connection(
|
|
state: tauri::State<crate::AppState>,
|
|
connection_id: String,
|
|
) -> Result<(), String> {
|
|
record_recent_connection_inner(&state.db_store, connection_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_recent_connections(
|
|
state: tauri::State<crate::AppState>,
|
|
limit: i64,
|
|
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
|
get_recent_connections_inner(&state.db_store, limit)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
|
|
clear_recent_connections_inner(&state.db_store)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::models::ConnectionInput;
|
|
use crate::store::Store;
|
|
|
|
fn state() -> std::sync::Mutex<Store> {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
|
std::sync::Mutex::new(Store::from_connection(conn))
|
|
}
|
|
|
|
#[test]
|
|
fn get_connections_returns_list() {
|
|
let st = state();
|
|
let result = get_connections_inner(&st);
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn create_connection_command_returns_connection() {
|
|
let st = state();
|
|
let input = ConnectionInput {
|
|
name: "Prod".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
environment: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_password: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
};
|
|
let result = create_connection_inner(&st, input.clone()).unwrap();
|
|
assert_eq!(result.name, "Prod");
|
|
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn create_connection_rejects_invalid_db_type() {
|
|
let st = state();
|
|
let input = ConnectionInput {
|
|
name: "X".into(),
|
|
db_type: "mongodb".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
environment: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_password: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
};
|
|
assert!(create_connection_inner(&st, input).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_connection_command_removes_it() {
|
|
let st = state();
|
|
let input = ConnectionInput {
|
|
name: "X".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
environment: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_password: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
};
|
|
let conn = create_connection_inner(&st, input).unwrap();
|
|
delete_connection_inner(&st, &conn.id).unwrap();
|
|
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn set_connection_favorite_command_persists() {
|
|
let st = state();
|
|
let input = ConnectionInput {
|
|
name: "P".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
password: None,
|
|
database: None,
|
|
environment: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_password: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
};
|
|
let conn = create_connection_inner(&st, input).unwrap();
|
|
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
|
|
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
|
|
}
|
|
|
|
#[test]
|
|
fn record_recent_command_upserts() {
|
|
let st = state();
|
|
let input = ConnectionInput {
|
|
name: "P".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
password: None,
|
|
database: None,
|
|
environment: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_password: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
};
|
|
let conn = create_connection_inner(&st, input).unwrap();
|
|
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
|
|
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
|
|
let recent = get_recent_connections_inner(&st, 10).unwrap();
|
|
assert_eq!(recent.len(), 1);
|
|
clear_recent_connections_inner(&st).unwrap();
|
|
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
|
}
|
|
}
|