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 = 3;
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 settings.config_version < 3 {
48 Self::migrate_to_v3(settings)?;
49 migrated = true;
50 }
51
52 if migrated {
56 settings.config_version = CURRENT_CONFIG_VERSION;
57 }
58
59 Ok(migrated)
60 }
61
62 fn migrate_to_v3(settings: &mut AppSettings) -> eyre::Result<()> {
71 let Some(ref wc) = settings.workspace_config else {
72 return Ok(());
73 };
74
75 let mut invalid = Vec::new();
76 for name in wc.workspaces.keys() {
77 if let Err(e) = kimun_core::nfs::filename::validate_filename(name) {
78 invalid.push(format!("{e}"));
79 }
80 }
81 if !invalid.is_empty() {
82 return Err(eyre::eyre!(
83 "Cannot migrate to v3: invalid workspace names:\n - {}",
84 invalid.join("\n - ")
85 ));
86 }
87
88 if let Some(ref cfg_path) = settings.config_file {
89 let bak_path = cfg_path.with_extension("toml.bak.v2");
90 if !bak_path.exists() {
91 std::fs::copy(cfg_path, &bak_path).map_err(|e| {
92 eyre::eyre!("failed to back up config to {:?}: {}", bak_path, e)
93 })?;
94 tracing::info!("backed up v2 config to {:?}", bak_path);
95 }
96 }
97
98 let cache_dir = settings
99 .cache_dir_resolved()
100 .map(|p| p.to_path_buf())
101 .unwrap_or_else(|| settings.cache_dir.clone());
102 let history_dir = settings
103 .history_dir_resolved()
104 .map(|p| p.to_path_buf())
105 .unwrap_or_else(|| settings.history_dir.clone());
106
107 let work: Vec<(String, std::path::PathBuf, Vec<String>)> = wc
108 .workspaces
109 .iter()
110 .map(|(name, entry)| {
111 (
112 name.clone(),
113 entry.effective_path().clone(),
114 entry.last_paths.clone(),
115 )
116 })
117 .collect();
118
119 for (name, ws_path, last_paths) in work {
120 let old_db = ws_path.join("kimun.sqlite");
121 let new_db = cache_dir.join(format!("{name}.kimuncache"));
122 if old_db.exists() {
123 if new_db.exists() {
124 tracing::warn!(
125 "destination cache {:?} already exists, leaving old DB at {:?}",
126 new_db,
127 old_db
128 );
129 } else {
130 std::fs::create_dir_all(&cache_dir).map_err(|e| {
131 eyre::eyre!("failed to create cache dir {:?}: {}", cache_dir, e)
132 })?;
133 if let Err(rename_err) = std::fs::rename(&old_db, &new_db) {
134 if rename_err.raw_os_error() == Some(libc_exdev_code()) {
137 std::fs::copy(&old_db, &new_db)?;
138 std::fs::remove_file(&old_db)?;
139 } else {
140 return Err(eyre::eyre!(
141 "failed to move {:?} -> {:?}: {}",
142 old_db,
143 new_db,
144 rename_err
145 ));
146 }
147 }
148 tracing::info!("migrated {:?} -> {:?}", old_db, new_db);
149 }
150 }
151
152 if !last_paths.is_empty() {
153 let hist_path = history_dir.join(format!("{name}.txt"));
154 if !hist_path.exists() {
155 std::fs::create_dir_all(&history_dir)?;
156 let body = last_paths.join("\n") + "\n";
157 std::fs::write(&hist_path, body)?;
158 }
159 }
160 }
161
162 if let Some(ref mut wc) = settings.workspace_config {
163 for entry in wc.workspaces.values_mut() {
164 entry.last_paths.clear();
165 }
166 }
167
168 Ok(())
169 }
170
171 fn migrate_workspace_dir(settings: &mut AppSettings) -> eyre::Result<()> {
180 let Some(workspace_dir) = settings.workspace_dir.take() else {
181 return Ok(());
182 };
183
184 if settings.workspace_config.is_none() {
185 if !workspace_dir.exists() {
187 return Err(eyre::eyre!(
188 "Cannot migrate: workspace directory {} no longer exists",
189 workspace_dir.display()
190 ));
191 }
192 tracing::info!("Migrating Phase 1 config to Phase 2 format");
193 let last_paths: Vec<String> =
194 settings.last_paths.iter().map(|p| p.to_string()).collect();
195
196 settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
197 workspace_dir,
198 last_paths,
199 ));
200 } else if let Some(ref mut wc) = settings.workspace_config {
202 let already_exists = wc
204 .workspaces
205 .values()
206 .any(|e| *e.effective_path() == workspace_dir);
207 if !already_exists && !workspace_dir.exists() {
208 tracing::warn!(
209 "Dropping orphaned workspace_dir {:?} (directory no longer exists)",
210 workspace_dir
211 );
212 } else if !already_exists && workspace_dir.exists() {
213 tracing::info!(
214 "Migrating orphaned workspace_dir into workspace_config as 'default'"
215 );
216 let name = Self::unique_workspace_name(wc, "default");
217 let last_paths: Vec<String> =
218 settings.last_paths.iter().map(|p| p.to_string()).collect();
219 let entry = WorkspaceEntry {
220 path: workspace_dir,
221 last_paths,
222 created: chrono::Utc::now(),
223 quick_note_path: None,
224 inbox_path: None,
225 resolved_path: None,
226 };
227 wc.workspaces.insert(name, entry);
228 }
229 }
230
231 settings.last_paths.clear();
232 Ok(())
233 }
234
235 fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
238 if !wc.workspaces.contains_key(base) {
239 return base.to_string();
240 }
241 let mut n = 2;
242 loop {
243 let candidate = format!("{}-{}", base, n);
244 if !wc.workspaces.contains_key(&candidate) {
245 return candidate;
246 }
247 n += 1;
248 }
249 }
250}
251
252#[cfg(unix)]
253fn libc_exdev_code() -> i32 {
254 18 }
256#[cfg(not(unix))]
257fn libc_exdev_code() -> i32 {
258 -1
259}
260
261#[cfg(test)]
262#[allow(clippy::field_reassign_with_default)]
263mod tests {
264 use super::*;
265 use std::path::PathBuf;
266
267 fn settings_with_workspace_dir(path: &str) -> AppSettings {
268 let mut s = AppSettings::default();
269 s.workspace_dir = Some(PathBuf::from(path));
270 s.theme = "gruvbox_dark".to_string();
271 s
272 }
273
274 #[test]
275 fn full_phase1_migration_creates_default_workspace() {
276 let dir = tempfile::TempDir::new().unwrap();
277 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
278
279 let migrated = ConfigMigration::run(&mut settings).unwrap();
280
281 assert!(migrated);
282 assert!(settings.workspace_dir.is_none());
283 assert!(settings.last_paths.is_empty());
284 assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
285 let wc = settings.workspace_config.as_ref().unwrap();
286 assert!(wc.workspaces.contains_key("default"));
287 assert_eq!(wc.global.current_workspace, "default");
288 }
289
290 #[test]
291 fn full_phase1_migration_fails_for_missing_dir() {
292 let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
293 let result = ConfigMigration::run(&mut settings);
294 assert!(result.is_err());
295 assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
296 }
297
298 #[test]
299 fn orphaned_workspace_dir_migrated_into_existing_config() {
300 let dir = tempfile::TempDir::new().unwrap();
301 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
302
303 let other_dir = tempfile::TempDir::new().unwrap();
305 let mut wc = WorkspaceConfig::new_empty();
306 wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
307 .unwrap();
308 wc.global.current_workspace = "production".to_string();
309 settings.workspace_config = Some(wc);
310
311 let migrated = ConfigMigration::run(&mut settings).unwrap();
312
313 assert!(migrated);
314 assert!(settings.workspace_dir.is_none());
315 let wc = settings.workspace_config.as_ref().unwrap();
316 assert!(wc.workspaces.contains_key("default"));
317 assert!(wc.workspaces.contains_key("production"));
318 assert_eq!(wc.global.current_workspace, "production"); }
320
321 #[test]
322 fn orphaned_workspace_dir_skipped_if_same_path_exists() {
323 let dir = tempfile::TempDir::new().unwrap();
324 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
325
326 let mut wc = WorkspaceConfig::new_empty();
328 wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
329 .unwrap();
330 wc.global.current_workspace = "existing".to_string();
331 settings.workspace_config = Some(wc);
332
333 ConfigMigration::run(&mut settings).unwrap();
334
335 let wc = settings.workspace_config.as_ref().unwrap();
336 assert_eq!(wc.workspaces.len(), 1); assert!(wc.workspaces.contains_key("existing"));
338 }
339
340 #[test]
341 fn unique_name_avoids_collision() {
342 let mut wc = WorkspaceConfig::new_empty();
343 let dir = tempfile::TempDir::new().unwrap();
344 wc.add_workspace("default".to_string(), dir.path().to_path_buf())
345 .unwrap();
346
347 let name = ConfigMigration::unique_workspace_name(&wc, "default");
348 assert_eq!(name, "default-2");
349 }
350
351 #[test]
352 fn no_migration_when_no_legacy_fields() {
353 let mut settings = AppSettings::default();
354 settings.config_version = CURRENT_CONFIG_VERSION;
355 settings.workspace_config = Some(WorkspaceConfig::new_empty());
356
357 let migrated = ConfigMigration::run(&mut settings).unwrap();
358 assert!(!migrated);
359 }
360}