Skip to main content

atman_runtime/
config_migration.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5// Sentinel in data_dir. Presence means we already ran (or attempted) the
6// legacy sweep so we don't repeat rename syscalls on every startup.
7pub const MIGRATION_MARKER: &str = ".migrated-to-xdg-config";
8
9// Files historically written straight into data_dir but conceptually belong
10// beside the user's other tool configs. Anything not in this list stays in
11// data_dir (sessions/, indexes/, projects/, tools/, ...).
12const CONFIG_FILES: &[&str] = &[
13    "config.toml",
14    "daemon.toml",
15    "routes.at",
16    "routes.toml",
17    "on_session_start.at",
18    "on_session_end.at",
19    "atman.toml",
20];
21
22const CONFIG_DIRS: &[&str] = &["commands"];
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct MigrationReport {
26    pub moved: Vec<String>,
27    pub skipped_conflicts: Vec<String>,
28    pub from: PathBuf,
29    pub to: PathBuf,
30}
31
32pub fn migrate_legacy_config_if_needed(
33    config_dir: &Path,
34    data_dir: &Path,
35) -> Result<Option<MigrationReport>> {
36    // User pointed both dirs at the same place — nothing to relocate.
37    if data_dir == config_dir {
38        return Ok(None);
39    }
40    if !data_dir.exists() {
41        return Ok(None);
42    }
43    let marker = data_dir.join(MIGRATION_MARKER);
44    if marker.exists() {
45        return Ok(None);
46    }
47
48    let mut moved = Vec::new();
49    let mut skipped = Vec::new();
50
51    for name in CONFIG_FILES {
52        let src = data_dir.join(name);
53        if !src.is_file() {
54            continue;
55        }
56        let dst = config_dir.join(name);
57        if dst.exists() {
58            skipped.push((*name).to_string());
59            continue;
60        }
61        std::fs::create_dir_all(config_dir)
62            .with_context(|| format!("mkdir {}", config_dir.display()))?;
63        std::fs::rename(&src, &dst)
64            .with_context(|| format!("move {} → {}", src.display(), dst.display()))?;
65        moved.push((*name).to_string());
66    }
67
68    for dir in CONFIG_DIRS {
69        let src = data_dir.join(dir);
70        if !src.is_dir() {
71            continue;
72        }
73        let dst = config_dir.join(dir);
74        if dst.exists() {
75            skipped.push((*dir).to_string());
76            continue;
77        }
78        std::fs::create_dir_all(config_dir)
79            .with_context(|| format!("mkdir {}", config_dir.display()))?;
80        std::fs::rename(&src, &dst)
81            .with_context(|| format!("move {} → {}", src.display(), dst.display()))?;
82        moved.push((*dir).to_string());
83    }
84
85    // Marker always written after a successful sweep — even a pure no-op
86    // sweep counts as "done" so subsequent runs skip immediately.
87    std::fs::create_dir_all(data_dir).with_context(|| format!("mkdir {}", data_dir.display()))?;
88    std::fs::write(&marker, marker_contents(&moved, &skipped))
89        .with_context(|| format!("write {}", marker.display()))?;
90
91    if moved.is_empty() && skipped.is_empty() {
92        return Ok(None);
93    }
94    Ok(Some(MigrationReport {
95        moved,
96        skipped_conflicts: skipped,
97        from: data_dir.to_path_buf(),
98        to: config_dir.to_path_buf(),
99    }))
100}
101
102fn marker_contents(moved: &[String], skipped: &[String]) -> String {
103    if moved.is_empty() && skipped.is_empty() {
104        return "no-op\n".into();
105    }
106    let mut s = String::new();
107    if !moved.is_empty() {
108        s.push_str("moved:\n");
109        for m in moved {
110            s.push_str(&format!("  {m}\n"));
111        }
112    }
113    if !skipped.is_empty() {
114        s.push_str("skipped (destination already existed):\n");
115        for k in skipped {
116            s.push_str(&format!("  {k}\n"));
117        }
118    }
119    s
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use tempfile::TempDir;
126
127    fn write(p: &Path, body: &str) {
128        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
129        std::fs::write(p, body).unwrap();
130    }
131
132    #[test]
133    fn no_data_dir_returns_none_without_creating_marker() {
134        let cfg = TempDir::new().unwrap();
135        let data = cfg.path().join("does-not-exist");
136        let out = migrate_legacy_config_if_needed(cfg.path(), &data).unwrap();
137        assert!(out.is_none());
138        assert!(!data.exists());
139    }
140
141    #[test]
142    fn same_dir_is_noop() {
143        let dir = TempDir::new().unwrap();
144        write(&dir.path().join("config.toml"), "x");
145        let out = migrate_legacy_config_if_needed(dir.path(), dir.path()).unwrap();
146        assert!(out.is_none());
147        // config.toml stayed, marker never written.
148        assert!(dir.path().join("config.toml").exists());
149        assert!(!dir.path().join(MIGRATION_MARKER).exists());
150    }
151
152    #[test]
153    fn moves_config_files_and_writes_marker() {
154        let cfg = TempDir::new().unwrap();
155        let data = TempDir::new().unwrap();
156        write(&data.path().join("config.toml"), "cfg");
157        write(&data.path().join("daemon.toml"), "d");
158        write(&data.path().join("routes.at"), "r");
159        // Non-config file must stay put.
160        write(&data.path().join("sessions").join("keep"), "s");
161
162        let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
163            .unwrap()
164            .expect("expected report");
165        assert_eq!(rep.moved.len(), 3);
166        assert!(rep.skipped_conflicts.is_empty());
167        assert!(cfg.path().join("config.toml").exists());
168        assert!(cfg.path().join("daemon.toml").exists());
169        assert!(cfg.path().join("routes.at").exists());
170        assert!(!data.path().join("config.toml").exists());
171        // sessions/ never touched.
172        assert!(data.path().join("sessions").join("keep").exists());
173        // Marker written.
174        assert!(data.path().join(MIGRATION_MARKER).exists());
175    }
176
177    #[test]
178    fn moves_commands_directory() {
179        let cfg = TempDir::new().unwrap();
180        let data = TempDir::new().unwrap();
181        write(&data.path().join("commands").join("hello.at"), "greet");
182
183        let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
184            .unwrap()
185            .unwrap();
186        assert!(rep.moved.contains(&"commands".to_string()));
187        assert!(cfg.path().join("commands").join("hello.at").exists());
188        assert!(!data.path().join("commands").exists());
189    }
190
191    #[test]
192    fn conflict_leaves_config_dir_version_untouched() {
193        let cfg = TempDir::new().unwrap();
194        let data = TempDir::new().unwrap();
195        // User already customized config.toml at the new location.
196        write(&cfg.path().join("config.toml"), "new");
197        // Old copy from legacy dir must not clobber it.
198        write(&data.path().join("config.toml"), "old");
199
200        let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
201            .unwrap()
202            .unwrap();
203        assert!(rep.moved.is_empty());
204        assert_eq!(rep.skipped_conflicts, vec!["config.toml".to_string()]);
205        assert_eq!(
206            std::fs::read_to_string(cfg.path().join("config.toml")).unwrap(),
207            "new"
208        );
209        // Legacy copy left in place so the user can inspect it manually.
210        assert!(data.path().join("config.toml").exists());
211    }
212
213    #[test]
214    fn marker_short_circuits_second_run() {
215        let cfg = TempDir::new().unwrap();
216        let data = TempDir::new().unwrap();
217        write(&data.path().join("config.toml"), "first");
218
219        let first = migrate_legacy_config_if_needed(cfg.path(), data.path())
220            .unwrap()
221            .unwrap();
222        assert_eq!(first.moved, vec!["config.toml".to_string()]);
223
224        // Drop a fresh legacy file — marker should still block a rerun.
225        write(&data.path().join("daemon.toml"), "later");
226        let second = migrate_legacy_config_if_needed(cfg.path(), data.path()).unwrap();
227        assert!(second.is_none());
228        assert!(data.path().join("daemon.toml").exists());
229        assert!(!cfg.path().join("daemon.toml").exists());
230    }
231
232    #[test]
233    fn noop_sweep_still_writes_marker_but_returns_none() {
234        let cfg = TempDir::new().unwrap();
235        let data = TempDir::new().unwrap();
236        // data_dir exists but contains only non-config artifacts.
237        write(&data.path().join("index.db"), "sqlite");
238
239        let out = migrate_legacy_config_if_needed(cfg.path(), data.path()).unwrap();
240        assert!(out.is_none());
241        assert!(data.path().join(MIGRATION_MARKER).exists());
242        assert!(data.path().join("index.db").exists());
243    }
244}