Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
45 changes: 33 additions & 12 deletions src-tauri/src/paths.rs
Original file line number Diff line number Diff line change
@@ -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> {
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"),
}
}
35 changes: 35 additions & 0 deletions src-tauri/src/paths_tests.rs
Original file line number Diff line number Diff line change
@@ -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")
);
}
78 changes: 75 additions & 3 deletions src-tauri/src/plugins/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,88 @@ struct InstalledPluginManifest {
}

pub fn get_plugins_dir() -> Result<PathBuf, String> {
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))?;
}
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<PathBuf> {
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<InstalledPluginInfo, String> {
let manifest_path = path.join("manifest.json");
let manifest_str = fs::read_to_string(&manifest_path)
Expand Down
17 changes: 5 additions & 12 deletions src-tauri/src/plugins/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,19 +65,13 @@ pub async fn load_plugins<R: tauri::Runtime>(app: &AppHandle<R>, 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,
Expand Down
57 changes: 56 additions & 1 deletion src-tauri/src/plugins/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
);
}