diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33810abd..99585195 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,10 +13,10 @@ pub mod ai_notebook_export_tests; pub mod cli; pub mod clipboard_import; pub mod commands; +pub mod config; pub mod connection_appearance; #[cfg(test)] pub mod connection_appearance_tests; -pub mod config; pub mod connection_cache; #[cfg(test)] pub mod connection_cache_tests; @@ -45,6 +45,8 @@ pub mod models; pub mod models_tests; pub mod notebooks; pub mod paths; // Added +#[cfg(test)] +pub mod paths_tests; pub mod persistence; pub mod plugins; pub mod pool_manager; @@ -179,6 +181,10 @@ pub fn run() { let active_ext_drivers = crate::config::load_config_internal(&app.handle()).active_external_drivers; + // One-time migration of plugins stored under the legacy + // `com.debba.tabularis` directory into the unified `tabularis` dir. + crate::plugins::installer::migrate_legacy_plugins_dir(); + // Register built-in drivers tauri::async_runtime::block_on(async { drivers::registry::register_driver(drivers::mysql::MysqlDriver::new()).await; @@ -223,9 +229,7 @@ pub fn run() { // meant to be a dedicated plan viewer, not a full app launch. if let Some(path) = args.explain.clone() { log::info!("CLI --explain received: {path}"); - if let Err(e) = - explain_import::spawn_visual_explain_window(app, Some(path)) - { + if let Err(e) = explain_import::spawn_visual_explain_window(app, Some(path)) { log::error!("Failed to open Visual Explain window: {e}"); } // Close the default main window only AFTER visual-explain is diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 588eaca6..886d01ad 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -1,19 +1,40 @@ use directories::ProjectDirs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -pub fn get_app_config_dir() -> PathBuf { - if let Some(proj_dirs) = ProjectDirs::from("", "", "tabularis") { +fn project_dirs() -> Option { + ProjectDirs::from("", "", "tabularis") +} - #[cfg(target_os = "windows")] - { - proj_dirs.config_dir().parent().unwrap().to_path_buf() - } - #[cfg(not(target_os = "windows"))] - { - proj_dirs.config_dir().to_path_buf() - } +/// On Windows the `directories` crate nests a `config`/`data` leaf under +/// `%APPDATA%\tabularis`; strip it so every kind of app data shares a single +/// `tabularis` folder. On other platforms the path is returned unchanged. +/// Pure on its inputs so it stays unit-testable on any host. +pub(crate) fn unnested_app_dir(dir: &Path, strip_leaf: bool) -> PathBuf { + if strip_leaf { + dir.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| dir.to_path_buf()) } else { + dir.to_path_buf() + } +} + +/// Directory for app configuration (settings, themes, AI activity, ...). +pub fn get_app_config_dir() -> PathBuf { + match project_dirs() { + Some(proj_dirs) => unnested_app_dir(proj_dirs.config_dir(), cfg!(target_os = "windows")), + // Fallback for weird environments + None => PathBuf::from(".config/tabularis"), + } +} + +/// Directory for app data (installed plugins, ...). On Linux this resolves to +/// `~/.local/share/tabularis`; on macOS/Windows it shares the same `tabularis` +/// folder used by [`get_app_config_dir`]. +pub fn get_app_data_dir() -> PathBuf { + match project_dirs() { + Some(proj_dirs) => unnested_app_dir(proj_dirs.data_dir(), cfg!(target_os = "windows")), // Fallback for weird environments - PathBuf::from(".config/tabularis") + None => PathBuf::from(".local/share/tabularis"), } } diff --git a/src-tauri/src/paths_tests.rs b/src-tauri/src/paths_tests.rs new file mode 100644 index 00000000..35a1d12e --- /dev/null +++ b/src-tauri/src/paths_tests.rs @@ -0,0 +1,35 @@ +use crate::paths::{get_app_config_dir, get_app_data_dir, unnested_app_dir}; +use std::path::Path; + +#[test] +fn unnested_app_dir_strips_trailing_leaf_when_requested() { + let dir = Path::new("/home/u/.local/share/tabularis/data"); + assert_eq!( + unnested_app_dir(dir, true), + Path::new("/home/u/.local/share/tabularis") + ); +} + +#[test] +fn unnested_app_dir_keeps_path_when_not_stripping() { + let dir = Path::new("/home/u/.local/share/tabularis"); + assert_eq!(unnested_app_dir(dir, false), dir.to_path_buf()); +} + +#[test] +fn unnested_app_dir_keeps_root_when_no_parent() { + let dir = Path::new("/"); + assert_eq!(unnested_app_dir(dir, true), dir.to_path_buf()); +} + +#[test] +fn config_and_data_dirs_share_the_tabularis_folder_name() { + assert_eq!( + get_app_config_dir().file_name().and_then(|n| n.to_str()), + Some("tabularis") + ); + assert_eq!( + get_app_data_dir().file_name().and_then(|n| n.to_str()), + Some("tabularis") + ); +} diff --git a/src-tauri/src/plugins/installer.rs b/src-tauri/src/plugins/installer.rs index 861c98b3..e941bfec 100644 --- a/src-tauri/src/plugins/installer.rs +++ b/src-tauri/src/plugins/installer.rs @@ -22,9 +22,7 @@ struct InstalledPluginManifest { } pub fn get_plugins_dir() -> Result { - let proj_dirs = ProjectDirs::from("com", "debba", "tabularis") - .ok_or_else(|| "Could not determine project directories".to_string())?; - let plugins_dir = proj_dirs.data_dir().join("plugins"); + let plugins_dir = crate::paths::get_app_data_dir().join("plugins"); if !plugins_dir.exists() { fs::create_dir_all(&plugins_dir) .map_err(|e| format!("Failed to create plugins directory: {}", e))?; @@ -32,6 +30,80 @@ pub fn get_plugins_dir() -> Result { Ok(plugins_dir) } +/// Plugins directory used by builds before the project dirs were unified under +/// `tabularis` (the old `com.debba.tabularis` identifier). On Linux this equals +/// the current directory, so callers must guard against migrating onto itself. +fn legacy_plugins_dir() -> Option { + ProjectDirs::from("com", "debba", "tabularis").map(|pd| pd.data_dir().join("plugins")) +} + +fn dir_has_entries(dir: &Path) -> bool { + fs::read_dir(dir) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) +} + +/// Move plugin folders from `legacy` into `target`. No-op when there is nothing +/// to move, when the paths are identical (Linux), or when `target` already holds +/// plugins (never clobber an existing install). Returns the number of folders +/// moved. Best-effort: per-entry failures are logged and skipped. +pub(crate) fn migrate_plugins_between(legacy: &Path, target: &Path) -> usize { + if legacy == target || !legacy.is_dir() || dir_has_entries(target) { + return 0; + } + + let entries = match fs::read_dir(legacy) { + Ok(entries) => entries, + Err(e) => { + log::error!("Plugin migration: failed to read {:?}: {}", legacy, e); + return 0; + } + }; + if let Err(e) = fs::create_dir_all(target) { + log::error!("Plugin migration: failed to create {:?}: {}", target, e); + return 0; + } + + let mut moved = 0; + for entry in entries.flatten() { + let dest = target.join(entry.file_name()); + if dest.exists() { + continue; + } + match fs::rename(entry.path(), &dest) { + Ok(()) => moved += 1, + Err(e) => log::error!( + "Plugin migration: failed to move {:?} -> {:?}: {}", + entry.path(), + dest, + e + ), + } + } + // Best-effort cleanup of the now-empty legacy directory. + let _ = fs::remove_dir(legacy); + moved +} + +/// One-time migration: relocate plugins from the legacy `com.debba.tabularis` +/// project dir into the unified `tabularis` data dir. Safe to call on every +/// startup — it only does work the first time after upgrading. +pub fn migrate_legacy_plugins_dir() { + let Some(legacy) = legacy_plugins_dir() else { + return; + }; + let target = crate::paths::get_app_data_dir().join("plugins"); + let moved = migrate_plugins_between(&legacy, &target); + if moved > 0 { + log::info!( + "Migrated {} plugin(s) from legacy directory {:?} to {:?}", + moved, + legacy, + target + ); + } +} + pub(crate) fn read_plugin_info_from_dir(path: &Path) -> Result { let manifest_path = path.join("manifest.json"); let manifest_str = fs::read_to_string(&manifest_path) diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index f5e4af31..fc8da440 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -3,7 +3,6 @@ use std::fs; use std::path::Path; use std::sync::Mutex; -use directories::ProjectDirs; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use tauri::AppHandle; @@ -66,19 +65,13 @@ pub async fn load_plugins(app: &AppHandle, enabled_ids: Op .plugins .unwrap_or_default(); - let proj_dirs = match ProjectDirs::from("com", "debba", "tabularis") { - Some(d) => d, - None => return, - }; - - let plugins_dir = proj_dirs.data_dir().join("plugins"); - - if !plugins_dir.exists() { - if let Err(e) = fs::create_dir_all(&plugins_dir) { - log::error!("Failed to create plugins directory: {}", e); + let plugins_dir = match crate::plugins::installer::get_plugins_dir() { + Ok(dir) => dir, + Err(e) => { + log::error!("{}", e); return; } - } + }; let entries = match fs::read_dir(&plugins_dir) { Ok(e) => e, diff --git a/src-tauri/src/plugins/tests.rs b/src-tauri/src/plugins/tests.rs index d04ab2d9..57ab5bc9 100644 --- a/src-tauri/src/plugins/tests.rs +++ b/src-tauri/src/plugins/tests.rs @@ -2,7 +2,7 @@ use std::fs; use tempfile::tempdir; -use super::installer::read_plugin_info_from_dir; +use super::installer::{migrate_plugins_between, read_plugin_info_from_dir}; #[test] fn reads_installed_plugin_info_from_manifest() { @@ -37,3 +37,58 @@ fn returns_error_for_invalid_manifest() { assert!(error.contains("Failed to parse plugin manifest")); } + +#[test] +fn migrates_plugin_folders_into_empty_target() { + let root = tempdir().expect("temp dir"); + let legacy = root.path().join("legacy/plugins"); + let target = root.path().join("new/plugins"); + fs::create_dir_all(legacy.join("my-plugin")).expect("legacy plugin dir"); + fs::write(legacy.join("my-plugin/manifest.json"), "{}").expect("manifest"); + + let moved = migrate_plugins_between(&legacy, &target); + + assert_eq!(moved, 1); + assert!(target.join("my-plugin/manifest.json").exists()); + assert!(!legacy.exists(), "empty legacy dir should be removed"); +} + +#[test] +fn skips_migration_when_target_already_populated() { + let root = tempdir().expect("temp dir"); + let legacy = root.path().join("legacy/plugins"); + let target = root.path().join("new/plugins"); + fs::create_dir_all(legacy.join("old")).expect("legacy"); + fs::create_dir_all(target.join("already-there")).expect("target"); + + let moved = migrate_plugins_between(&legacy, &target); + + assert_eq!(moved, 0); + assert!(legacy.join("old").exists(), "legacy left untouched"); + assert!(!target.join("old").exists()); +} + +#[test] +fn migration_is_a_no_op_when_legacy_equals_target() { + let root = tempdir().expect("temp dir"); + let same = root.path().join("plugins"); + fs::create_dir_all(same.join("p")).expect("dir"); + + let moved = migrate_plugins_between(&same, &same); + + assert_eq!(moved, 0); + assert!(same.join("p").exists()); +} + +#[test] +fn migration_is_a_no_op_when_legacy_missing() { + let root = tempdir().expect("temp dir"); + let legacy = root.path().join("does-not-exist"); + let target = root.path().join("new"); + + assert_eq!(migrate_plugins_between(&legacy, &target), 0); + assert!( + !target.exists(), + "target not created when nothing to migrate" + ); +}