use color_eyre::eyre;
use super::AppSettings;
use super::workspace_config::{WorkspaceConfig, WorkspaceEntry};
pub const CURRENT_CONFIG_VERSION: u32 = 3;
pub struct ConfigMigration;
impl ConfigMigration {
pub fn run(settings: &mut AppSettings) -> eyre::Result<bool> {
let mut migrated = false;
if settings.workspace_dir.is_some() {
Self::migrate_workspace_dir(settings)?;
migrated = true;
}
if let Some(ref mut wc) = settings.workspace_config
&& !wc.global.current_workspace.is_empty()
&& !wc.workspaces.contains_key(&wc.global.current_workspace)
{
let first = wc.workspaces.keys().next().cloned().unwrap_or_default();
tracing::warn!(
"current_workspace '{}' does not exist, resetting to '{}'",
wc.global.current_workspace,
first
);
wc.global.current_workspace = first;
migrated = true;
}
if settings.config_version < 3 {
Self::migrate_to_v3(settings)?;
migrated = true;
}
if migrated {
settings.config_version = CURRENT_CONFIG_VERSION;
}
Ok(migrated)
}
fn migrate_to_v3(settings: &mut AppSettings) -> eyre::Result<()> {
let Some(ref wc) = settings.workspace_config else {
return Ok(());
};
let mut invalid = Vec::new();
for name in wc.workspaces.keys() {
if let Err(e) = kimun_core::nfs::filename::validate_filename(name) {
invalid.push(format!("{e}"));
}
}
if !invalid.is_empty() {
return Err(eyre::eyre!(
"Cannot migrate to v3: invalid workspace names:\n - {}",
invalid.join("\n - ")
));
}
if let Some(ref cfg_path) = settings.config_file {
let bak_path = cfg_path.with_extension("toml.bak.v2");
if !bak_path.exists() {
std::fs::copy(cfg_path, &bak_path).map_err(|e| {
eyre::eyre!("failed to back up config to {:?}: {}", bak_path, e)
})?;
tracing::info!("backed up v2 config to {:?}", bak_path);
}
}
let cache_dir = settings
.cache_dir_resolved()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| settings.cache_dir.clone());
let history_dir = settings
.history_dir_resolved()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| settings.history_dir.clone());
let work: Vec<(String, std::path::PathBuf, Vec<String>)> = wc
.workspaces
.iter()
.map(|(name, entry)| {
(
name.clone(),
entry.effective_path().clone(),
entry.last_paths.clone(),
)
})
.collect();
for (name, ws_path, last_paths) in work {
let old_db = ws_path.join("kimun.sqlite");
let new_db = cache_dir.join(format!("{name}.kimuncache"));
if old_db.exists() {
if new_db.exists() {
tracing::warn!(
"destination cache {:?} already exists, leaving old DB at {:?}",
new_db,
old_db
);
} else {
std::fs::create_dir_all(&cache_dir).map_err(|e| {
eyre::eyre!("failed to create cache dir {:?}: {}", cache_dir, e)
})?;
if let Err(rename_err) = std::fs::rename(&old_db, &new_db) {
if rename_err.raw_os_error() == Some(libc_exdev_code()) {
std::fs::copy(&old_db, &new_db)?;
std::fs::remove_file(&old_db)?;
} else {
return Err(eyre::eyre!(
"failed to move {:?} -> {:?}: {}",
old_db,
new_db,
rename_err
));
}
}
tracing::info!("migrated {:?} -> {:?}", old_db, new_db);
}
}
if !last_paths.is_empty() {
let hist_path = history_dir.join(format!("{name}.txt"));
if !hist_path.exists() {
std::fs::create_dir_all(&history_dir)?;
let body = last_paths.join("\n") + "\n";
std::fs::write(&hist_path, body)?;
}
}
}
if let Some(ref mut wc) = settings.workspace_config {
for entry in wc.workspaces.values_mut() {
entry.last_paths.clear();
}
}
Ok(())
}
fn migrate_workspace_dir(settings: &mut AppSettings) -> eyre::Result<()> {
let Some(workspace_dir) = settings.workspace_dir.take() else {
return Ok(());
};
if settings.workspace_config.is_none() {
if !workspace_dir.exists() {
return Err(eyre::eyre!(
"Cannot migrate: workspace directory {} no longer exists",
workspace_dir.display()
));
}
tracing::info!("Migrating Phase 1 config to Phase 2 format");
let last_paths: Vec<String> =
settings.last_paths.iter().map(|p| p.to_string()).collect();
settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
workspace_dir,
last_paths,
));
} else if let Some(ref mut wc) = settings.workspace_config {
let already_exists = wc
.workspaces
.values()
.any(|e| *e.effective_path() == workspace_dir);
if !already_exists && !workspace_dir.exists() {
tracing::warn!(
"Dropping orphaned workspace_dir {:?} (directory no longer exists)",
workspace_dir
);
} else if !already_exists && workspace_dir.exists() {
tracing::info!(
"Migrating orphaned workspace_dir into workspace_config as 'default'"
);
let name = Self::unique_workspace_name(wc, "default");
let last_paths: Vec<String> =
settings.last_paths.iter().map(|p| p.to_string()).collect();
let entry = WorkspaceEntry {
path: workspace_dir,
last_paths,
created: chrono::Utc::now(),
quick_note_path: None,
inbox_path: None,
resolved_path: None,
};
wc.workspaces.insert(name, entry);
}
}
settings.last_paths.clear();
Ok(())
}
fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
if !wc.workspaces.contains_key(base) {
return base.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{}-{}", base, n);
if !wc.workspaces.contains_key(&candidate) {
return candidate;
}
n += 1;
}
}
}
#[cfg(unix)]
fn libc_exdev_code() -> i32 {
18 }
#[cfg(not(unix))]
fn libc_exdev_code() -> i32 {
-1
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use std::path::PathBuf;
fn settings_with_workspace_dir(path: &str) -> AppSettings {
let mut s = AppSettings::default();
s.workspace_dir = Some(PathBuf::from(path));
s.theme = "gruvbox_dark".to_string();
s
}
#[test]
fn full_phase1_migration_creates_default_workspace() {
let dir = tempfile::TempDir::new().unwrap();
let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
let migrated = ConfigMigration::run(&mut settings).unwrap();
assert!(migrated);
assert!(settings.workspace_dir.is_none());
assert!(settings.last_paths.is_empty());
assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
let wc = settings.workspace_config.as_ref().unwrap();
assert!(wc.workspaces.contains_key("default"));
assert_eq!(wc.global.current_workspace, "default");
}
#[test]
fn full_phase1_migration_fails_for_missing_dir() {
let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
let result = ConfigMigration::run(&mut settings);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
}
#[test]
fn orphaned_workspace_dir_migrated_into_existing_config() {
let dir = tempfile::TempDir::new().unwrap();
let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
let other_dir = tempfile::TempDir::new().unwrap();
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
.unwrap();
wc.global.current_workspace = "production".to_string();
settings.workspace_config = Some(wc);
let migrated = ConfigMigration::run(&mut settings).unwrap();
assert!(migrated);
assert!(settings.workspace_dir.is_none());
let wc = settings.workspace_config.as_ref().unwrap();
assert!(wc.workspaces.contains_key("default"));
assert!(wc.workspaces.contains_key("production"));
assert_eq!(wc.global.current_workspace, "production"); }
#[test]
fn orphaned_workspace_dir_skipped_if_same_path_exists() {
let dir = tempfile::TempDir::new().unwrap();
let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
let mut wc = WorkspaceConfig::new_empty();
wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
.unwrap();
wc.global.current_workspace = "existing".to_string();
settings.workspace_config = Some(wc);
ConfigMigration::run(&mut settings).unwrap();
let wc = settings.workspace_config.as_ref().unwrap();
assert_eq!(wc.workspaces.len(), 1); assert!(wc.workspaces.contains_key("existing"));
}
#[test]
fn unique_name_avoids_collision() {
let mut wc = WorkspaceConfig::new_empty();
let dir = tempfile::TempDir::new().unwrap();
wc.add_workspace("default".to_string(), dir.path().to_path_buf())
.unwrap();
let name = ConfigMigration::unique_workspace_name(&wc, "default");
assert_eq!(name, "default-2");
}
#[test]
fn no_migration_when_no_legacy_fields() {
let mut settings = AppSettings::default();
settings.config_version = CURRENT_CONFIG_VERSION;
settings.workspace_config = Some(WorkspaceConfig::new_empty());
let migrated = ConfigMigration::run(&mut settings).unwrap();
assert!(!migrated);
}
}