atman_runtime/
config_migration.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5pub const MIGRATION_MARKER: &str = ".migrated-to-xdg-config";
8
9const 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 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 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 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 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 assert!(data.path().join("sessions").join("keep").exists());
173 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 write(&cfg.path().join("config.toml"), "new");
197 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 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 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 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}