diff --git a/scripts/import-quick-commands.ps1 b/scripts/import-quick-commands.ps1 new file mode 100644 index 00000000..748f8e5c --- /dev/null +++ b/scripts/import-quick-commands.ps1 @@ -0,0 +1,40 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$InputJson, + + [string]$DatabasePath, + [switch]$Replace, + [switch]$DryRun, + [switch]$NoBackup +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$manifestPath = Join-Path $repoRoot "src-tauri\Cargo.toml" + +$cargoArgs = @( + "run", + "--manifest-path", + $manifestPath, + "--example", + "import_quick_commands", + "--", + $InputJson +) + +if ($DatabasePath) { + $cargoArgs += @("--db", $DatabasePath) +} +if ($Replace) { + $cargoArgs += "--replace" +} +if ($DryRun) { + $cargoArgs += "--dry-run" +} +if ($NoBackup) { + $cargoArgs += "--no-backup" +} + +& cargo @cargoArgs +exit $LASTEXITCODE diff --git a/scripts/import-quick-commands.sh b/scripts/import-quick-commands.sh new file mode 100644 index 00000000..bcbed518 --- /dev/null +++ b/scripts/import-quick-commands.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + ./scripts/import-quick-commands.sh [--db ] [--replace] [--dry-run] [--no-backup] + +Options: + --db, --database-path, -d Path to nyaterm.redb. Defaults to ~/.nyaterm/nyaterm.redb. + --replace Replace existing quick commands instead of merging by id. + --dry-run Validate and print the planned result without writing. + --no-backup Do not create a .bak-* copy before writing. + -h, --help Show this help. + +Close NyaTerm before writing the database. +USAGE +} + +if [[ $# -eq 0 ]]; then + usage >&2 + exit 2 +fi + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac + +input_json="$1" +shift + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" +manifest_path="$repo_root/src-tauri/Cargo.toml" + +cargo_args=( + run + --manifest-path "$manifest_path" + --example import_quick_commands + -- + "$input_json" +) + +while [[ $# -gt 0 ]]; do + case "$1" in + --db|--database-path|-d) + shift + if [[ $# -eq 0 ]]; then + echo "error: --db requires a path" >&2 + exit 2 + fi + cargo_args+=(--db "$1") + shift + ;; + --replace) + cargo_args+=(--replace) + shift + ;; + --dry-run) + cargo_args+=(--dry-run) + shift + ;; + --no-backup) + cargo_args+=(--no-backup) + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cargo_bin="${CARGO:-}" +if [[ -z "$cargo_bin" ]]; then + if command -v cargo >/dev/null 2>&1; then + cargo_bin="cargo" + fi +fi + +if [[ -z "$cargo_bin" ]] || ! command -v "$cargo_bin" >/dev/null 2>&1; then + echo "error: cargo not found. Install Rust/Cargo or set CARGO=/path/to/cargo." >&2 + exit 127 +fi + +exec "$cargo_bin" "${cargo_args[@]}" diff --git a/scripts/quick-commands.import.example.json b/scripts/quick-commands.import.example.json new file mode 100644 index 00000000..205d1256 --- /dev/null +++ b/scripts/quick-commands.import.example.json @@ -0,0 +1,34 @@ +{ + "categories": [ + { + "id": "general", + "name": "General" + }, + { + "id": "k8s", + "name": "Kubernetes" + } + ], + "commands": [ + { + "id": "cmd-list-files", + "label": "List files", + "command": "ls -la", + "category_id": "general", + "description": "List files with details", + "color_tag": "blue", + "icon_tag": "terminal", + "pinned": true, + "execution_mode": "execute", + "source": "manual", + "risk_level": "low" + }, + { + "label": "Kubernetes pods", + "command": "kubectl get pods -A", + "category": "Kubernetes", + "execution_mode": "append", + "risk_level": "low" + } + ] +} diff --git a/src-tauri/examples/import_quick_commands.rs b/src-tauri/examples/import_quick_commands.rs new file mode 100644 index 00000000..fb12af3e --- /dev/null +++ b/src-tauri/examples/import_quick_commands.rs @@ -0,0 +1,492 @@ +use redb::{Database, ReadableDatabase, TableDefinition}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const DATABASE_FILE: &str = "nyaterm.redb"; +const JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs"); +const QUICK_COMMAND_KEY: &str = "quick-command"; + +type AnyResult = Result>; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct QuickCommandsConfig { + #[serde(default)] + commands: Vec, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct QuickCommandCategory { + id: String, + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct QuickCommand { + id: String, + label: String, + command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + category_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + color_tag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + icon_tag: Option, + #[serde(default)] + pinned: bool, + #[serde(default = "default_execution_mode")] + execution_mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + risk_level: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ImportFile { + Config(ImportConfig), + Commands(Vec), +} + +#[derive(Debug, Default, Deserialize)] +struct ImportConfig { + #[serde(default)] + commands: Vec, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Deserialize)] +struct ImportCategory { + #[serde(default)] + id: Option, + name: String, +} + +#[derive(Debug, Deserialize)] +struct ImportCommand { + #[serde(default)] + id: Option, + label: String, + command: String, + #[serde(default)] + category_id: Option, + #[serde(default)] + category: Option, + #[serde(default)] + description: Option, + #[serde(default)] + color_tag: Option, + #[serde(default)] + icon_tag: Option, + #[serde(default)] + pinned: bool, + #[serde(default = "default_execution_mode")] + execution_mode: String, + #[serde(default)] + source: Option, + #[serde(default)] + risk_level: Option, +} + +struct Args { + input_path: PathBuf, + db_path: PathBuf, + replace: bool, + dry_run: bool, + backup: bool, +} + +fn default_execution_mode() -> String { + "execute".to_string() +} + +fn main() -> AnyResult<()> { + let args = parse_args()?; + let import_file = read_import_file(&args.input_path)?; + + let mut config = if args.replace { + QuickCommandsConfig::default() + } else if args.db_path.exists() { + let db = Database::open(&args.db_path)?; + load_current_config(&db)? + } else { + QuickCommandsConfig::default() + }; + + let previous_commands = config.commands.len(); + let previous_categories = config.categories.len(); + let (imported_commands, imported_categories) = merge_import(&mut config, import_file)?; + + println!( + "Prepared quick-command import: {imported_commands} command(s), {imported_categories} category/categories" + ); + println!( + "Resulting config: {} command(s), {} category/categories", + config.commands.len(), + config.categories.len() + ); + + if args.dry_run { + println!("Dry run enabled. Database was not modified."); + return Ok(()); + } + + if let Some(parent) = args.db_path.parent() { + fs::create_dir_all(parent)?; + } + + if args.backup && args.db_path.exists() { + let backup_path = backup_database(&args.db_path)?; + println!("Backup created: {}", backup_path.display()); + } + + let db = if args.db_path.exists() { + Database::open(&args.db_path)? + } else { + Database::create(&args.db_path)? + }; + save_config(&db, &config)?; + + println!( + "Imported quick commands into {} (was {} command(s), {} category/categories).", + args.db_path.display(), + previous_commands, + previous_categories + ); + Ok(()) +} + +fn parse_args() -> AnyResult { + let mut input_path = None; + let mut db_path = None; + let mut replace = false; + let mut dry_run = false; + let mut backup = true; + let mut args = env::args().skip(1); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--db" => { + let value = args + .next() + .ok_or_else(|| input_error("--db requires a path"))?; + db_path = Some(PathBuf::from(value)); + } + "--replace" => replace = true, + "--dry-run" => dry_run = true, + "--no-backup" => backup = false, + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + value if value.starts_with('-') => { + return Err(input_error(format!("unknown option: {value}")).into()); + } + value => { + if input_path.is_some() { + return Err(input_error("only one input JSON file is supported").into()); + } + input_path = Some(PathBuf::from(value)); + } + } + } + + let input_path = input_path.ok_or_else(|| input_error("missing input JSON file"))?; + let db_path = db_path.unwrap_or(default_db_path()?); + + Ok(Args { + input_path, + db_path, + replace, + dry_run, + backup, + }) +} + +fn print_usage() { + println!( + "Usage:\n cargo run --manifest-path src-tauri/Cargo.toml --example import_quick_commands -- [--db ] [--replace] [--dry-run] [--no-backup]\n\nClose NyaTerm before writing the database. Without --db, the script writes to ~/.nyaterm/nyaterm.redb." + ); +} + +fn default_db_path() -> AnyResult { + let home = env::var_os("USERPROFILE") + .or_else(|| env::var_os("HOME")) + .map(PathBuf::from) + .ok_or_else(|| input_error("cannot determine home directory; pass --db explicitly"))?; + Ok(home.join(".nyaterm").join(DATABASE_FILE)) +} + +fn read_import_file(path: &Path) -> AnyResult { + let raw = fs::read_to_string(path)?; + Ok(serde_json::from_str(&raw)?) +} + +fn load_current_config(db: &Database) -> AnyResult { + let txn = db.begin_read()?; + let table = match txn.open_table(JSON_DOCS_TABLE) { + Ok(table) => table, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(QuickCommandsConfig::default()), + Err(error) => return Err(error.into()), + }; + + let Some(raw) = table.get(QUICK_COMMAND_KEY)? else { + return Ok(QuickCommandsConfig::default()); + }; + + Ok(serde_json::from_str(raw.value())?) +} + +fn save_config(db: &Database, config: &QuickCommandsConfig) -> AnyResult<()> { + let content = serde_json::to_string_pretty(config)?; + let txn = db.begin_write()?; + { + let mut table = txn.open_table(JSON_DOCS_TABLE)?; + table.insert(QUICK_COMMAND_KEY, content.as_str())?; + } + txn.commit()?; + Ok(()) +} + +fn merge_import( + config: &mut QuickCommandsConfig, + import_file: ImportFile, +) -> AnyResult<(usize, usize)> { + let import_config = match import_file { + ImportFile::Config(config) => config, + ImportFile::Commands(commands) => ImportConfig { + commands, + categories: Vec::new(), + }, + }; + + let mut category_names = BTreeMap::new(); + for category in &config.categories { + category_names.insert(category.name.clone(), category.id.clone()); + } + + let mut imported_categories = 0usize; + for category in import_config.categories { + let name = require_text(&category.name, "category.name")?; + let id_input = category.id.unwrap_or_else(|| slugify(&name)); + let id = normalize_id(&id_input, "category.id")?; + upsert_category( + config, + QuickCommandCategory { + id: id.clone(), + name: name.clone(), + }, + ); + category_names.insert(name, id); + imported_categories += 1; + } + + let existing_category_ids = config + .categories + .iter() + .map(|category| category.id.clone()) + .collect::>(); + + let mut seen_ids = BTreeSet::new(); + let mut imported_commands = 0usize; + for command in import_config.commands { + let label = require_text(&command.label, "command.label")?; + let command_text = require_text(&command.command, "command.command")?; + let id_input = command + .id + .unwrap_or_else(|| format!("cmd-{}", Uuid::new_v4())); + let id = normalize_id(&id_input, "command.id")?; + + if !seen_ids.insert(id.clone()) { + return Err(input_error(format!("duplicate command id in import file: {id}")).into()); + } + + let category_id = match (command.category_id, command.category) { + (Some(category_id), _) => Some(normalize_id(&category_id, "command.category_id")?), + (None, Some(category_name)) => { + let category_name = require_text(&category_name, "command.category")?; + let category_id = category_names + .get(&category_name) + .cloned() + .unwrap_or_else(|| slugify(&category_name)); + if !config + .categories + .iter() + .any(|category| category.id == category_id) + { + upsert_category( + config, + QuickCommandCategory { + id: category_id.clone(), + name: category_name.clone(), + }, + ); + category_names.insert(category_name, category_id.clone()); + imported_categories += 1; + } + Some(category_id) + } + (None, None) => None, + }; + + if let Some(category_id) = &category_id { + if !existing_category_ids.contains(category_id) + && !config + .categories + .iter() + .any(|category| category.id == *category_id) + { + upsert_category( + config, + QuickCommandCategory { + id: category_id.clone(), + name: category_id.clone(), + }, + ); + imported_categories += 1; + } + } + + let description = trim_optional(command.description); + let color_tag = trim_optional(command.color_tag); + let icon_tag = trim_optional(command.icon_tag); + let execution_mode = command.execution_mode.trim().to_string(); + let source = trim_optional(command.source); + let risk_level = trim_optional(command.risk_level); + + validate_execution_mode(&execution_mode)?; + if let Some(source) = source.as_deref() { + validate_one_of(source, &["manual", "ai"], "command.source")?; + } + if let Some(risk_level) = risk_level.as_deref() { + validate_one_of( + risk_level, + &["low", "medium", "high", "critical"], + "command.risk_level", + )?; + } + + upsert_command( + config, + QuickCommand { + id, + label, + command: command_text, + category_id, + description, + color_tag, + icon_tag, + pinned: command.pinned, + execution_mode, + source, + risk_level, + }, + ); + imported_commands += 1; + } + + Ok((imported_commands, imported_categories)) +} + +fn upsert_category(config: &mut QuickCommandsConfig, category: QuickCommandCategory) { + if let Some(existing) = config + .categories + .iter_mut() + .find(|item| item.id == category.id) + { + *existing = category; + } else { + config.categories.push(category); + } +} + +fn upsert_command(config: &mut QuickCommandsConfig, command: QuickCommand) { + if let Some(existing) = config + .commands + .iter_mut() + .find(|item| item.id == command.id) + { + *existing = command; + } else { + config.commands.push(command); + } +} + +fn backup_database(path: &Path) -> AnyResult { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(DATABASE_FILE); + let backup_path = path.with_file_name(format!("{file_name}.bak-{timestamp}")); + fs::copy(path, &backup_path)?; + Ok(backup_path) +} + +fn require_text(value: &str, field: &str) -> AnyResult { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(input_error(format!("{field} cannot be empty")).into()); + } + Ok(trimmed.to_string()) +} + +fn normalize_id(value: &str, field: &str) -> AnyResult { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(input_error(format!("{field} cannot be empty")).into()); + } + Ok(trimmed.to_string()) +} + +fn trim_optional(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn validate_execution_mode(value: &str) -> AnyResult<()> { + validate_one_of(value, &["execute", "append"], "command.execution_mode") +} + +fn validate_one_of(value: &str, allowed: &[&str], field: &str) -> AnyResult<()> { + if allowed.contains(&value) { + Ok(()) + } else { + Err(input_error(format!("{field} must be one of: {}", allowed.join(", "))).into()) + } +} + +fn slugify(value: &str) -> String { + let mut output = String::new(); + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + } else if ch == '-' || ch == '_' { + output.push(ch); + } else if ch.is_whitespace() && !output.ends_with('-') { + output.push('-'); + } + } + + let output = output.trim_matches('-').to_string(); + if output.is_empty() { + format!("category-{}", Uuid::new_v4()) + } else { + output + } +} + +fn input_error(message: impl Into) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidInput, message.into()) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 3ce6ccf9..42ab841c 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -1,7 +1,7 @@ -//! redb-backed persistence for NyaTerm's user data. +//! redb-backed persistence for `NyaTerm`'s user data. //! -//! The public API stores the same JSON/text payloads that used to live as -//! files under `~/.nyaterm`, so higher layers can keep their serde models. +//! The public API stores JSON/text payloads so higher layers can keep their +//! serde models. use crate::error::{AppError, AppResult}; use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; @@ -11,13 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; const DATABASE_FILE: &str = "nyaterm.redb"; -const LEGACY_CONFIG_DIR: &str = ".dragonfly"; -const LEGACY_DATABASE_FILE: &str = "dragonfly.redb"; -const SCHEMA_VERSION: &str = "1"; -const META_SCHEMA_VERSION: &str = "schema_version"; -const META_LEGACY_MIGRATED: &str = "legacy_migrated"; -const META_TABLE: TableDefinition<&str, &str> = TableDefinition::new("meta"); const JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs"); const TEXT_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("text_docs"); @@ -41,34 +35,10 @@ pub const TEXT_MASTER_KEY: &str = "master.key"; static DATABASE: OnceLock> = OnceLock::new(); -const LEGACY_JSON_FILES: &[(&str, &str)] = &[ - ("settings.json", JSON_SETTINGS), - ("sessions.json", JSON_SESSIONS), - ("keys.json", JSON_KEYS), - ("passwords.json", JSON_PASSWORDS), - ("credentials.json", JSON_CREDENTIALS), - ("otp.json", JSON_OTP), - ("proxies.json", JSON_PROXIES), - ("tunnels.json", JSON_TUNNELS), - ("quick-command.json", JSON_QUICK_COMMAND), - ("cloud_sync.json", JSON_CLOUD_SYNC), - ("cloud_sync_state.json", JSON_CLOUD_SYNC_STATE), - ("history.json", JSON_HISTORY), - ("ai-history.json", JSON_AI_HISTORY), - ("ai-audit.json", JSON_AI_AUDIT), -]; - -const LEGACY_TEXT_FILES: &[(&str, &str)] = &[ - ("known_hosts", TEXT_KNOWN_HOSTS), - ("master.key", TEXT_MASTER_KEY), -]; - pub fn init(config_dir: &Path) -> AppResult<()> { fs::create_dir_all(config_dir)?; - bootstrap_from_legacy_dragonfly_config(config_dir)?; let db_path = config_dir.join(DATABASE_FILE); let db = Arc::new(open_database(&db_path)?); - migrate_legacy_files(&db, config_dir)?; if DATABASE.set(db).is_err() { tracing::debug!("redb storage was already initialized"); @@ -81,20 +51,6 @@ fn database_path(config_dir: &Path) -> PathBuf { config_dir.join(DATABASE_FILE) } -#[allow(dead_code)] -pub fn json_key_for_legacy_file(file_name: &str) -> Option<&'static str> { - LEGACY_JSON_FILES - .iter() - .find_map(|(name, key)| (*name == file_name).then_some(*key)) -} - -#[allow(dead_code)] -pub fn text_key_for_legacy_file(file_name: &str) -> Option<&'static str> { - LEGACY_TEXT_FILES - .iter() - .find_map(|(name, key)| (*name == file_name).then_some(*key)) -} - pub fn load_json_doc(key: &str) -> AppResult { let Some(raw) = load_json_doc_raw(key)? else { return Ok(T::default()); @@ -165,55 +121,6 @@ fn default_config_dir() -> AppResult { Ok(home.join(".nyaterm")) } -fn bootstrap_from_legacy_dragonfly_config(config_dir: &Path) -> AppResult<()> { - let Some(home_dir) = config_dir.parent() else { - return Ok(()); - }; - - let legacy_dir = home_dir.join(LEGACY_CONFIG_DIR); - bootstrap_from_legacy_config_dir(config_dir, &legacy_dir) -} - -fn bootstrap_from_legacy_config_dir(config_dir: &Path, legacy_dir: &Path) -> AppResult<()> { - let db_path = config_dir.join(DATABASE_FILE); - if db_path.exists() || !legacy_dir.is_dir() { - return Ok(()); - } - - let legacy_db_path = legacy_dir.join(LEGACY_DATABASE_FILE); - if legacy_db_path.is_file() { - fs::copy(&legacy_db_path, &db_path)?; - tracing::info!( - "Migrated NyaTerm storage database from legacy Dragonfly path '{}'", - legacy_db_path.display() - ); - return Ok(()); - } - - let mut copied_legacy_files = 0usize; - for &(file_name, _) in LEGACY_JSON_FILES.iter().chain(LEGACY_TEXT_FILES.iter()) { - let source = legacy_dir.join(file_name); - if !source.is_file() { - continue; - } - let destination = config_dir.join(file_name); - if destination.exists() { - continue; - } - fs::copy(&source, &destination)?; - copied_legacy_files += 1; - } - - if copied_legacy_files > 0 { - tracing::info!( - "Copied {} legacy Dragonfly storage file(s) into NyaTerm storage for redb migration", - copied_legacy_files - ); - } - - Ok(()) -} - fn open_database(path: &Path) -> AppResult { if path.exists() { Database::open(path).map_err(storage_error) @@ -222,79 +129,6 @@ fn open_database(path: &Path) -> AppResult { } } -fn migrate_legacy_files(db: &Database, config_dir: &Path) -> AppResult<()> { - let mut migrated_files = Vec::new(); - let txn = db.begin_write().map_err(storage_error)?; - { - let mut meta = txn.open_table(META_TABLE).map_err(storage_error)?; - meta.insert(META_SCHEMA_VERSION, SCHEMA_VERSION) - .map_err(storage_error)?; - } - - { - let mut json_docs = txn.open_table(JSON_DOCS_TABLE).map_err(storage_error)?; - for (file_name, key) in LEGACY_JSON_FILES { - let path = config_dir.join(file_name); - if json_docs.get(*key).map_err(storage_error)?.is_some() { - migrated_files.push(path); - continue; - } - if !path.is_file() { - continue; - } - let content = fs::read_to_string(&path)?; - json_docs - .insert(*key, content.as_str()) - .map_err(storage_error)?; - migrated_files.push(path); - } - } - - { - let mut text_docs = txn.open_table(TEXT_DOCS_TABLE).map_err(storage_error)?; - for (file_name, key) in LEGACY_TEXT_FILES { - let path = config_dir.join(file_name); - if text_docs.get(*key).map_err(storage_error)?.is_some() { - migrated_files.push(path); - continue; - } - if !path.is_file() { - continue; - } - let content = fs::read_to_string(&path)?; - text_docs - .insert(*key, content.as_str()) - .map_err(storage_error)?; - migrated_files.push(path); - } - } - - { - let mut meta = txn.open_table(META_TABLE).map_err(storage_error)?; - meta.insert(META_LEGACY_MIGRATED, "true") - .map_err(storage_error)?; - } - - txn.commit().map_err(storage_error)?; - cleanup_legacy_files(migrated_files); - Ok(()) -} - -fn cleanup_legacy_files(paths: Vec) { - for path in paths { - if !path.is_file() { - continue; - } - if let Err(error) = fs::remove_file(&path) { - tracing::warn!( - "Failed to remove migrated legacy storage file '{}': {}", - path.display(), - error - ); - } - } -} - fn update_json_doc_in_db(db: &Database, key: &str, updater: F) -> AppResult where T: DeserializeOwned + Default + Serialize, @@ -405,148 +239,11 @@ mod tests { } #[test] - fn migration_keeps_existing_redb_values_and_removes_legacy_files() { - let dir = unique_config_dir("migration"); - fs::create_dir_all(&dir).expect("create temp dir"); - fs::write(dir.join("settings.json"), "{\"legacy\":true}").expect("write settings"); - fs::write(dir.join("known_hosts"), "legacy-host key value\n").expect("write known_hosts"); - - let db = open_database(&database_path(&dir)).expect("open db"); - write_json_doc(&db, JSON_SETTINGS, "{\"existing\":true}").expect("preseed"); - migrate_legacy_files(&db, &dir).expect("migrate"); - - assert_eq!( - read_json_doc(&db, JSON_SETTINGS) - .expect("read settings") - .as_deref(), - Some("{\"existing\":true}") - ); - assert_eq!( - read_text_doc(&db, TEXT_KNOWN_HOSTS) - .expect("read known_hosts") - .as_deref(), - Some("legacy-host key value\n") - ); - assert!(!dir.join("settings.json").exists()); - assert!(!dir.join("known_hosts").exists()); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn bootstrap_copies_legacy_dragonfly_redb_when_new_database_is_missing() { - let root = unique_config_dir("legacy-redb-root"); - let current_dir = root.join(".nyaterm"); - let legacy_dir = root.join(LEGACY_CONFIG_DIR); - fs::create_dir_all(¤t_dir).expect("create current dir"); - fs::create_dir_all(&legacy_dir).expect("create legacy dir"); - - let legacy_db = open_database(&legacy_dir.join(LEGACY_DATABASE_FILE)).expect("open legacy"); - write_json_doc(&legacy_db, JSON_SETTINGS, "{\"legacy\":true}").expect("write legacy"); - - bootstrap_from_legacy_config_dir(¤t_dir, &legacy_dir).expect("bootstrap"); - let current_db = open_database(&database_path(¤t_dir)).expect("open current"); - - assert_eq!( - read_json_doc(¤t_db, JSON_SETTINGS) - .expect("read settings") - .as_deref(), - Some("{\"legacy\":true}") - ); - assert!(legacy_dir.join(LEGACY_DATABASE_FILE).exists()); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn bootstrap_copies_legacy_dragonfly_files_for_redb_migration() { - let root = unique_config_dir("legacy-files-root"); - let current_dir = root.join(".nyaterm"); - let legacy_dir = root.join(LEGACY_CONFIG_DIR); - fs::create_dir_all(¤t_dir).expect("create current dir"); - fs::create_dir_all(&legacy_dir).expect("create legacy dir"); - fs::write(legacy_dir.join("settings.json"), "{\"legacy\":true}").expect("write settings"); - fs::write(legacy_dir.join("known_hosts"), "legacy-host key value\n") - .expect("write known_hosts"); - - bootstrap_from_legacy_config_dir(¤t_dir, &legacy_dir).expect("bootstrap"); - let db = open_database(&database_path(¤t_dir)).expect("open db"); - migrate_legacy_files(&db, ¤t_dir).expect("migrate copied legacy files"); - - assert_eq!( - read_json_doc(&db, JSON_SETTINGS) - .expect("read settings") - .as_deref(), - Some("{\"legacy\":true}") - ); - assert_eq!( - read_text_doc(&db, TEXT_KNOWN_HOSTS) - .expect("read known hosts") - .as_deref(), - Some("legacy-host key value\n") - ); - assert!(legacy_dir.join("settings.json").exists()); - assert!(legacy_dir.join("known_hosts").exists()); - assert!(!current_dir.join("settings.json").exists()); - assert!(!current_dir.join("known_hosts").exists()); - - let _ = fs::remove_dir_all(root); - } - - #[test] - fn migration_backfills_missing_docs_after_legacy_marker() { - let dir = unique_config_dir("migration-backfill"); - fs::create_dir_all(&dir).expect("create temp dir"); - fs::write( - dir.join("ai-history.json"), - "{\"sessions\":[],\"messages\":[]}", - ) - .expect("write ai history"); - - let db = open_database(&database_path(&dir)).expect("open db"); - let txn = db.begin_write().expect("begin write"); - { - let mut meta = txn.open_table(META_TABLE).expect("open meta"); - meta.insert(META_SCHEMA_VERSION, SCHEMA_VERSION) - .expect("schema version"); - meta.insert(META_LEGACY_MIGRATED, "true") - .expect("legacy marker"); - } - txn.commit().expect("commit marker"); - - migrate_legacy_files(&db, &dir).expect("migrate"); - - assert_eq!( - read_json_doc(&db, JSON_AI_HISTORY) - .expect("read ai history") - .as_deref(), - Some("{\"sessions\":[],\"messages\":[]}") - ); - assert!(!dir.join("ai-history.json").exists()); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn failed_migration_keeps_legacy_file() { - let dir = unique_config_dir("migration-failure"); - fs::create_dir_all(&dir).expect("create temp dir"); - fs::write(dir.join("settings.json"), [0xff, 0xfe]).expect("write invalid utf8"); - - let db = open_database(&database_path(&dir)).expect("open db"); - assert!(migrate_legacy_files(&db, &dir).is_err()); - assert!(dir.join("settings.json").exists()); - - let _ = fs::remove_dir_all(dir); - } - - #[test] - fn new_storage_initializes_redb_without_legacy_files() { + fn new_storage_opens_empty_redb() { let dir = unique_config_dir("new-storage"); fs::create_dir_all(&dir).expect("create temp dir"); let db = open_database(&database_path(&dir)).expect("open db"); - migrate_legacy_files(&db, &dir).expect("initialize"); assert!(database_path(&dir).exists()); assert!(!dir.join("settings.json").exists());