- 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
225 lines
7.2 KiB
Rust
225 lines
7.2 KiB
Rust
use crate::models::ConnectionInput;
|
|
use crate::store::Store;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Mutex;
|
|
|
|
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct ImportRecord {
|
|
name: Option<String>,
|
|
db_type: String,
|
|
host: String,
|
|
port: Option<i64>,
|
|
username: Option<String>,
|
|
folder_id: Option<String>,
|
|
tag_ids: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkippedRecord {
|
|
pub index: usize,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ImportResult {
|
|
pub imported: usize,
|
|
pub skipped: usize,
|
|
pub skipped_records: Vec<SkippedRecord>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
|
|
let records: Vec<ImportRecord> =
|
|
serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
|
|
for (i, rec) in records.iter().enumerate() {
|
|
if rec.name.as_deref().unwrap_or("").is_empty() {
|
|
return Err(format!("record {}: name is required", i));
|
|
}
|
|
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
|
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
|
|
}
|
|
}
|
|
Ok(records)
|
|
}
|
|
|
|
pub fn import_connections_inner(
|
|
state: &Mutex<Store>,
|
|
json: String,
|
|
) -> Result<ImportResult, String> {
|
|
let records: Vec<ImportRecord> =
|
|
serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
let mut imported = 0usize;
|
|
let mut skipped_records = Vec::new();
|
|
for (i, rec) in records.iter().enumerate() {
|
|
let name = match &rec.name {
|
|
Some(n) if !n.is_empty() => n.clone(),
|
|
_ => {
|
|
skipped_records.push(SkippedRecord {
|
|
index: i,
|
|
reason: "missing or empty name".into(),
|
|
});
|
|
continue;
|
|
}
|
|
};
|
|
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
|
skipped_records.push(SkippedRecord {
|
|
index: i,
|
|
reason: format!("invalid db_type: {}", rec.db_type),
|
|
});
|
|
continue;
|
|
}
|
|
if rec.host.is_empty() {
|
|
skipped_records.push(SkippedRecord {
|
|
index: i,
|
|
reason: "missing or empty host".into(),
|
|
});
|
|
continue;
|
|
}
|
|
let input = ConnectionInput {
|
|
name,
|
|
db_type: rec.db_type.clone(),
|
|
host: rec.host.clone(),
|
|
port: rec.port,
|
|
username: rec.username.clone(),
|
|
folder_id: rec.folder_id.clone(),
|
|
password: None,
|
|
database: 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,
|
|
environment: None,
|
|
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
|
use_keychain: true,
|
|
};
|
|
match store.create_connection(input) {
|
|
Ok(_) => imported += 1,
|
|
Err(e) => skipped_records.push(SkippedRecord {
|
|
index: i,
|
|
reason: e,
|
|
}),
|
|
}
|
|
}
|
|
Ok(ImportResult {
|
|
imported,
|
|
skipped: skipped_records.len(),
|
|
skipped_records,
|
|
})
|
|
}
|
|
|
|
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
|
|
let store = state.lock().map_err(|e| e.to_string())?;
|
|
let conns = store.get_connections()?;
|
|
let export = serde_json::json!({ "version": 1, "connections": conns });
|
|
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_connections(
|
|
state: tauri::State<crate::AppState>,
|
|
json: String,
|
|
) -> Result<ImportResult, String> {
|
|
import_connections_inner(&state.db_store, json)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
|
export_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 parse_import_validates_required_fields() {
|
|
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
|
let parsed = parse_import(json).unwrap();
|
|
assert_eq!(parsed.len(), 1);
|
|
assert_eq!(parsed[0].name.as_deref(), Some("X"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_import_rejects_missing_name() {
|
|
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
|
assert!(parse_import(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_import_rejects_invalid_db_type() {
|
|
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
|
|
assert!(parse_import(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn import_connections_inserts_all() {
|
|
let st = state();
|
|
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
|
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
|
assert_eq!(result.imported, 2);
|
|
assert_eq!(result.skipped, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn import_connections_skips_invalid_keeps_valid() {
|
|
let st = state();
|
|
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
|
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
|
assert_eq!(result.imported, 2);
|
|
assert_eq!(result.skipped, 1);
|
|
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
|
|
}
|
|
|
|
#[test]
|
|
fn export_connections_returns_json() {
|
|
let st = state();
|
|
let _ = st.lock().unwrap().create_connection(ConnectionInput {
|
|
name: "A".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: 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,
|
|
environment: None,
|
|
tag_ids: vec![],
|
|
use_keychain: true,
|
|
});
|
|
let json = export_connections_inner(&st).unwrap();
|
|
assert!(json.contains("\"name\""));
|
|
assert!(json.contains("\"version\""));
|
|
}
|
|
}
|