kimun_notes/settings/
config_migration.rs1use color_eyre::eyre;
8
9use super::AppSettings;
10use super::workspace_config::{WorkspaceConfig, WorkspaceEntry};
11
12pub const CURRENT_CONFIG_VERSION: u32 = 2;
14
15pub struct ConfigMigration;
18
19impl ConfigMigration {
20 pub fn run(settings: &mut AppSettings) -> eyre::Result<bool> {
23 let mut migrated = false;
24
25 if settings.workspace_dir.is_some() {
27 Self::migrate_workspace_dir(settings)?;
28 migrated = true;
29 }
30
31 if let Some(ref mut wc) = settings.workspace_config
33 && !wc.global.current_workspace.is_empty()
34 && !wc.workspaces.contains_key(&wc.global.current_workspace)
35 {
36 let first = wc.workspaces.keys().next().cloned().unwrap_or_default();
37 tracing::warn!(
38 "current_workspace '{}' does not exist, resetting to '{}'",
39 wc.global.current_workspace,
40 first
41 );
42 wc.global.current_workspace = first;
43 migrated = true;
44 }
45
46 if migrated {
50 settings.config_version = CURRENT_CONFIG_VERSION;
51 }
52
53 Ok(migrated)
54 }
55
56 fn migrate_workspace_dir(settings: &mut AppSettings) -> eyre::Result<()> {
65 let Some(workspace_dir) = settings.workspace_dir.take() else {
66 return Ok(());
67 };
68
69 if settings.workspace_config.is_none() {
70 if !workspace_dir.exists() {
72 return Err(eyre::eyre!(
73 "Cannot migrate: workspace directory {} no longer exists",
74 workspace_dir.display()
75 ));
76 }
77 tracing::info!("Migrating Phase 1 config to Phase 2 format");
78 let last_paths: Vec<String> =
79 settings.last_paths.iter().map(|p| p.to_string()).collect();
80
81 settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
82 workspace_dir,
83 last_paths,
84 ));
85 } else if let Some(ref mut wc) = settings.workspace_config {
87 let already_exists = wc
89 .workspaces
90 .values()
91 .any(|e| *e.effective_path() == workspace_dir);
92 if !already_exists && !workspace_dir.exists() {
93 tracing::warn!(
94 "Dropping orphaned workspace_dir {:?} (directory no longer exists)",
95 workspace_dir
96 );
97 } else if !already_exists && workspace_dir.exists() {
98 tracing::info!(
99 "Migrating orphaned workspace_dir into workspace_config as 'default'"
100 );
101 let name = Self::unique_workspace_name(wc, "default");
102 let last_paths: Vec<String> =
103 settings.last_paths.iter().map(|p| p.to_string()).collect();
104 let entry = WorkspaceEntry {
105 path: workspace_dir,
106 last_paths,
107 created: chrono::Utc::now(),
108 quick_note_path: None,
109 inbox_path: None,
110 resolved_path: None,
111 };
112 wc.workspaces.insert(name, entry);
113 }
114 }
115
116 settings.last_paths.clear();
117 Ok(())
118 }
119
120 fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
123 if !wc.workspaces.contains_key(base) {
124 return base.to_string();
125 }
126 let mut n = 2;
127 loop {
128 let candidate = format!("{}-{}", base, n);
129 if !wc.workspaces.contains_key(&candidate) {
130 return candidate;
131 }
132 n += 1;
133 }
134 }
135}
136
137#[cfg(test)]
138#[allow(clippy::field_reassign_with_default)]
139mod tests {
140 use super::*;
141 use std::path::PathBuf;
142
143 fn settings_with_workspace_dir(path: &str) -> AppSettings {
144 let mut s = AppSettings::default();
145 s.workspace_dir = Some(PathBuf::from(path));
146 s.theme = "gruvbox_dark".to_string();
147 s
148 }
149
150 #[test]
151 fn full_phase1_migration_creates_default_workspace() {
152 let dir = tempfile::TempDir::new().unwrap();
153 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
154
155 let migrated = ConfigMigration::run(&mut settings).unwrap();
156
157 assert!(migrated);
158 assert!(settings.workspace_dir.is_none());
159 assert!(settings.last_paths.is_empty());
160 assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
161 let wc = settings.workspace_config.as_ref().unwrap();
162 assert!(wc.workspaces.contains_key("default"));
163 assert_eq!(wc.global.current_workspace, "default");
164 }
165
166 #[test]
167 fn full_phase1_migration_fails_for_missing_dir() {
168 let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
169 let result = ConfigMigration::run(&mut settings);
170 assert!(result.is_err());
171 assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
172 }
173
174 #[test]
175 fn orphaned_workspace_dir_migrated_into_existing_config() {
176 let dir = tempfile::TempDir::new().unwrap();
177 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
178
179 let other_dir = tempfile::TempDir::new().unwrap();
181 let mut wc = WorkspaceConfig::new_empty();
182 wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
183 .unwrap();
184 wc.global.current_workspace = "production".to_string();
185 settings.workspace_config = Some(wc);
186
187 let migrated = ConfigMigration::run(&mut settings).unwrap();
188
189 assert!(migrated);
190 assert!(settings.workspace_dir.is_none());
191 let wc = settings.workspace_config.as_ref().unwrap();
192 assert!(wc.workspaces.contains_key("default"));
193 assert!(wc.workspaces.contains_key("production"));
194 assert_eq!(wc.global.current_workspace, "production"); }
196
197 #[test]
198 fn orphaned_workspace_dir_skipped_if_same_path_exists() {
199 let dir = tempfile::TempDir::new().unwrap();
200 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
201
202 let mut wc = WorkspaceConfig::new_empty();
204 wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
205 .unwrap();
206 wc.global.current_workspace = "existing".to_string();
207 settings.workspace_config = Some(wc);
208
209 ConfigMigration::run(&mut settings).unwrap();
210
211 let wc = settings.workspace_config.as_ref().unwrap();
212 assert_eq!(wc.workspaces.len(), 1); assert!(wc.workspaces.contains_key("existing"));
214 }
215
216 #[test]
217 fn unique_name_avoids_collision() {
218 let mut wc = WorkspaceConfig::new_empty();
219 let dir = tempfile::TempDir::new().unwrap();
220 wc.add_workspace("default".to_string(), dir.path().to_path_buf())
221 .unwrap();
222
223 let name = ConfigMigration::unique_workspace_name(&wc, "default");
224 assert_eq!(name, "default-2");
225 }
226
227 #[test]
228 fn no_migration_when_no_legacy_fields() {
229 let mut settings = AppSettings::default();
230 settings.workspace_config = Some(WorkspaceConfig::new_empty());
231
232 let migrated = ConfigMigration::run(&mut settings).unwrap();
233 assert!(!migrated);
234 }
235}