1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5pub const MIGRATION_MARKER: &str = ".migrated-to-xdg-config";
6
7const CONFIG_FILES: &[&str] = &[
11 "config.toml",
12 "daemon.toml",
13 "routes.at",
14 "on_session_start.at",
15 "on_session_end.at",
16 "atman.toml",
17];
18
19const CONFIG_DIRS: &[&str] = &["commands"];
20
21pub const MIGRATION_STATE: &str = ".config-migration-state.json";
22const MANIFEST_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ArtifactOutcomeKind {
27 NoSource,
28 Moved,
29 Conflict,
30 CommittedSourceRetained,
31 RejectedFileType,
32 FailedBeforePublish,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct ArtifactOutcome {
37 pub path: String,
38 pub sensitive: bool,
39 pub kind: ArtifactOutcomeKind,
40 pub error: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct MigrationReport {
45 pub moved: Vec<String>,
46 pub skipped_conflicts: Vec<String>,
47 pub from: PathBuf,
48 pub to: PathBuf,
49 pub artifacts: Vec<ArtifactOutcome>,
50}
51
52#[derive(serde::Serialize, serde::Deserialize)]
53struct MigrationState {
54 version: u32,
55 artifacts: Vec<ArtifactOutcome>,
56}
57
58pub fn migrate_legacy_config_if_needed(
59 config_dir: &Path,
60 data_dir: &Path,
61) -> Result<Option<MigrationReport>> {
62 crate::config_hub::ConfigHub::from_config_dir(config_dir)
63 .migrate_legacy_layout(data_dir)
64 .map_err(anyhow::Error::from)
65}
66
67pub(crate) fn relocate_legacy_layout(
68 config_dir: &Path,
69 daemon_config_path: Option<&Path>,
70 data_dir: &Path,
71) -> Result<Option<MigrationReport>> {
72 if data_dir == config_dir || !data_dir.exists() {
73 return Ok(None);
74 }
75 let previous = load_state(data_dir);
76 let mut artifacts = Vec::new();
77 for name in CONFIG_FILES {
78 let destination = if *name == "daemon.toml" {
79 daemon_config_path
80 .map(Path::to_path_buf)
81 .unwrap_or_else(|| config_dir.join(name))
82 } else {
83 config_dir.join(name)
84 };
85 artifacts.push(relocate_file(
86 &data_dir.join(name),
87 &destination,
88 name,
89 *name == "daemon.toml",
90 ));
91 }
92 for dir in CONFIG_DIRS {
93 relocate_dir(
94 &data_dir.join(dir),
95 &config_dir.join(dir),
96 dir,
97 &mut artifacts,
98 )?;
99 }
100
101 let state = MigrationState {
102 version: MANIFEST_VERSION,
103 artifacts: artifacts.clone(),
104 };
105 write_state(data_dir, &state)?;
106
107 let moved = artifacts
108 .iter()
109 .filter(|item| item.kind == ArtifactOutcomeKind::Moved)
110 .map(|item| item.path.clone())
111 .collect::<Vec<_>>();
112 let skipped_conflicts = artifacts
113 .iter()
114 .filter(|item| item.kind == ArtifactOutcomeKind::Conflict)
115 .filter(|item| {
116 previous.as_ref().is_none_or(|state| {
117 !state
118 .artifacts
119 .iter()
120 .any(|old| old.path == item.path && old.kind == ArtifactOutcomeKind::Conflict)
121 })
122 })
123 .map(|item| item.path.clone())
124 .collect::<Vec<_>>();
125 let reportable = artifacts.iter().any(|item| {
126 matches!(
127 item.kind,
128 ArtifactOutcomeKind::Moved
129 | ArtifactOutcomeKind::CommittedSourceRetained
130 | ArtifactOutcomeKind::RejectedFileType
131 | ArtifactOutcomeKind::FailedBeforePublish
132 )
133 }) || !skipped_conflicts.is_empty();
134 if !reportable {
135 return Ok(None);
136 }
137 Ok(Some(MigrationReport {
138 moved,
139 skipped_conflicts,
140 from: data_dir.to_path_buf(),
141 to: config_dir.to_path_buf(),
142 artifacts,
143 }))
144}
145
146fn relocate_dir(
147 src: &Path,
148 dst: &Path,
149 relative: &str,
150 outcomes: &mut Vec<ArtifactOutcome>,
151) -> Result<()> {
152 let metadata = match std::fs::symlink_metadata(src) {
153 Ok(metadata) => metadata,
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
155 Err(error) => return Err(error.into()),
156 };
157 if !metadata.file_type().is_dir() {
158 outcomes.push(outcome(
159 relative,
160 false,
161 ArtifactOutcomeKind::RejectedFileType,
162 None,
163 ));
164 return Ok(());
165 }
166 match std::fs::symlink_metadata(dst) {
167 Ok(metadata) if metadata.file_type().is_dir() => {}
168 Ok(_) => {
169 outcomes.push(outcome(
170 relative,
171 false,
172 ArtifactOutcomeKind::RejectedFileType,
173 None,
174 ));
175 return Ok(());
176 }
177 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
178 std::fs::create_dir(dst)?;
179 }
180 Err(error) => return Err(error.into()),
181 }
182 for entry in std::fs::read_dir(src)? {
183 let entry = entry?;
184 let name = entry.file_name();
185 let child_relative = format!("{relative}/{}", name.to_string_lossy());
186 let child_src = entry.path();
187 let child_dst = dst.join(&name);
188 let child_type = std::fs::symlink_metadata(&child_src)?.file_type();
189 if child_type.is_dir() {
190 relocate_dir(&child_src, &child_dst, &child_relative, outcomes)?;
191 } else if child_type.is_file() {
192 outcomes.push(relocate_file(
193 &child_src,
194 &child_dst,
195 &child_relative,
196 false,
197 ));
198 } else {
199 outcomes.push(outcome(
200 &child_relative,
201 false,
202 ArtifactOutcomeKind::RejectedFileType,
203 None,
204 ));
205 }
206 }
207 let _ = std::fs::remove_dir(src);
208 Ok(())
209}
210
211fn relocate_file(src: &Path, dst: &Path, relative: &str, sensitive: bool) -> ArtifactOutcome {
212 let metadata = match std::fs::symlink_metadata(src) {
213 Ok(metadata) => metadata,
214 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
215 return outcome(relative, sensitive, ArtifactOutcomeKind::NoSource, None);
216 }
217 Err(error) => {
218 return outcome(
219 relative,
220 sensitive,
221 ArtifactOutcomeKind::FailedBeforePublish,
222 Some(error.to_string()),
223 );
224 }
225 };
226 if !metadata.file_type().is_file() {
227 return outcome(
228 relative,
229 sensitive,
230 ArtifactOutcomeKind::RejectedFileType,
231 None,
232 );
233 }
234 match std::fs::symlink_metadata(dst) {
235 Ok(metadata) if metadata.file_type().is_file() => {
236 return outcome(relative, sensitive, ArtifactOutcomeKind::Conflict, None);
237 }
238 Ok(_) => {
239 return outcome(
240 relative,
241 sensitive,
242 ArtifactOutcomeKind::RejectedFileType,
243 None,
244 );
245 }
246 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
247 Err(error) => {
248 return outcome(
249 relative,
250 sensitive,
251 ArtifactOutcomeKind::FailedBeforePublish,
252 Some(error.to_string()),
253 );
254 }
255 }
256 match copy_publish_no_clobber(src, dst, sensitive, &metadata) {
257 Ok(()) => match std::fs::remove_file(src) {
258 Ok(()) => outcome(relative, sensitive, ArtifactOutcomeKind::Moved, None),
259 Err(error) => outcome(
260 relative,
261 sensitive,
262 ArtifactOutcomeKind::CommittedSourceRetained,
263 Some(error.to_string()),
264 ),
265 },
266 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
267 outcome(relative, sensitive, ArtifactOutcomeKind::Conflict, None)
268 }
269 Err(error) => outcome(
270 relative,
271 sensitive,
272 ArtifactOutcomeKind::FailedBeforePublish,
273 Some(error.to_string()),
274 ),
275 }
276}
277
278fn copy_publish_no_clobber(
279 src: &Path,
280 dst: &Path,
281 sensitive: bool,
282 metadata: &std::fs::Metadata,
283) -> std::io::Result<()> {
284 use std::io::{Read, Write};
285 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
286 std::fs::create_dir_all(parent)?;
287 let filename = dst
288 .file_name()
289 .and_then(|name| name.to_str())
290 .unwrap_or("config");
291 let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
292 let result = (|| {
293 let mut options = std::fs::OpenOptions::new();
294 options.write(true).create_new(true);
295 #[cfg(unix)]
296 {
297 use std::os::unix::fs::OpenOptionsExt;
298 use std::os::unix::fs::PermissionsExt;
299 options.mode(if sensitive {
300 0o600
301 } else {
302 metadata.permissions().mode() & 0o777
303 });
304 }
305 let mut output = options.open(&tmp)?;
306 let mut input = std::fs::File::open(src)?;
307 let mut bytes = Vec::new();
308 input.read_to_end(&mut bytes)?;
309 output.write_all(&bytes)?;
310 output.sync_all()?;
311 drop(output);
312 std::fs::hard_link(&tmp, dst)?;
313 let published = std::fs::read(dst);
314 if !published.is_ok_and(|published| published == bytes) {
315 return Err(std::io::Error::other(
316 "published config verification failed",
317 ));
318 }
319 let _ = std::fs::File::open(parent).and_then(|dir| dir.sync_all());
320 Ok(())
321 })();
322 let _ = std::fs::remove_file(&tmp);
323 result
324}
325
326fn outcome(
327 path: &str,
328 sensitive: bool,
329 kind: ArtifactOutcomeKind,
330 error: Option<String>,
331) -> ArtifactOutcome {
332 ArtifactOutcome {
333 path: path.to_string(),
334 sensitive,
335 kind,
336 error,
337 }
338}
339
340fn load_state(data_dir: &Path) -> Option<MigrationState> {
341 let bytes = std::fs::read(data_dir.join(MIGRATION_STATE)).ok()?;
342 serde_json::from_slice(&bytes).ok()
343}
344
345fn write_state(data_dir: &Path, state: &MigrationState) -> Result<()> {
346 std::fs::create_dir_all(data_dir)?;
347 let path = data_dir.join(MIGRATION_STATE);
348 let tmp = data_dir.join(format!(
349 ".config-migration-state.{}.tmp",
350 uuid::Uuid::new_v4().simple()
351 ));
352 let result = (|| -> Result<()> {
353 let bytes = serde_json::to_vec_pretty(state)?;
354 std::fs::write(&tmp, bytes)?;
355 std::fs::rename(&tmp, path)?;
356 Ok(())
357 })();
358 if result.is_err() {
359 let _ = std::fs::remove_file(tmp);
360 }
361 result
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use tempfile::TempDir;
368
369 fn write(p: &Path, body: &str) {
370 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
371 std::fs::write(p, body).unwrap();
372 }
373
374 #[test]
375 fn no_data_dir_returns_none_without_creating_marker() {
376 let cfg = TempDir::new().unwrap();
377 let data = cfg.path().join("does-not-exist");
378 let out = migrate_legacy_config_if_needed(cfg.path(), &data).unwrap();
379 assert!(out.is_none());
380 assert!(!data.exists());
381 }
382
383 #[test]
384 fn same_dir_is_noop() {
385 let dir = TempDir::new().unwrap();
386 write(&dir.path().join("config.toml"), "x");
387 let out = migrate_legacy_config_if_needed(dir.path(), dir.path()).unwrap();
388 assert!(out.is_none());
389 assert!(dir.path().join("config.toml").exists());
391 assert!(!dir.path().join(MIGRATION_MARKER).exists());
392 }
393
394 #[test]
395 fn moves_config_files_and_writes_marker() {
396 let cfg = TempDir::new().unwrap();
397 let data = TempDir::new().unwrap();
398 write(&data.path().join("config.toml"), "cfg");
399 write(&data.path().join("daemon.toml"), "d");
400 write(&data.path().join("routes.at"), "r");
401 write(&data.path().join("routes.toml"), "legacy");
402 write(&data.path().join("sessions").join("keep"), "s");
404
405 let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
406 .unwrap()
407 .expect("expected report");
408 assert_eq!(rep.moved.len(), 3);
409 assert!(rep.skipped_conflicts.is_empty());
410 assert!(cfg.path().join("config.toml").exists());
411 assert!(cfg.path().join("daemon.toml").exists());
412 assert!(cfg.path().join("routes.at").exists());
413 assert!(!cfg.path().join("routes.toml").exists());
414 assert!(data.path().join("routes.toml").exists());
415 assert!(!data.path().join("config.toml").exists());
416 assert!(data.path().join("sessions").join("keep").exists());
418 assert!(data.path().join(MIGRATION_STATE).exists());
419 }
420
421 #[test]
422 fn moves_commands_directory() {
423 let cfg = TempDir::new().unwrap();
424 let data = TempDir::new().unwrap();
425 write(&data.path().join("commands").join("hello.at"), "greet");
426
427 let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
428 .unwrap()
429 .unwrap();
430 assert!(rep.moved.iter().any(|path| path == "commands/hello.at"));
431 assert!(cfg.path().join("commands").join("hello.at").exists());
432 assert!(!data.path().join("commands").join("hello.at").exists());
433 }
434
435 #[test]
436 fn existing_commands_directory_merges_without_overwriting() {
437 let cfg = TempDir::new().unwrap();
438 let data = TempDir::new().unwrap();
439 write(&cfg.path().join("commands/keep.at"), "new");
440 write(&data.path().join("commands/keep.at"), "old");
441 write(&data.path().join("commands/move.at"), "move");
442
443 let report = migrate_legacy_config_if_needed(cfg.path(), data.path())
444 .unwrap()
445 .unwrap();
446
447 assert!(report.moved.iter().any(|path| path == "commands/move.at"));
448 assert!(
449 report
450 .skipped_conflicts
451 .iter()
452 .any(|path| path == "commands/keep.at")
453 );
454 assert_eq!(
455 std::fs::read_to_string(cfg.path().join("commands/keep.at")).unwrap(),
456 "new"
457 );
458 assert!(data.path().join("commands/keep.at").exists());
459 }
460
461 #[cfg(unix)]
462 #[test]
463 fn symlink_is_rejected_and_sensitive_file_becomes_owner_only() {
464 use std::os::unix::fs::{PermissionsExt, symlink};
465 let cfg = TempDir::new().unwrap();
466 let data = TempDir::new().unwrap();
467 write(&data.path().join("outside"), "outside");
468 symlink(data.path().join("outside"), data.path().join("config.toml")).unwrap();
469 write(
470 &data.path().join("daemon.toml"),
471 "auth_token = \"secret\"\n",
472 );
473 std::fs::set_permissions(
474 data.path().join("daemon.toml"),
475 std::fs::Permissions::from_mode(0o644),
476 )
477 .unwrap();
478
479 let report = migrate_legacy_config_if_needed(cfg.path(), data.path())
480 .unwrap()
481 .unwrap();
482
483 assert!(report.artifacts.iter().any(|item| {
484 item.path == "config.toml" && item.kind == ArtifactOutcomeKind::RejectedFileType
485 }));
486 assert!(data.path().join("config.toml").exists());
487 let mode = std::fs::metadata(cfg.path().join("daemon.toml"))
488 .unwrap()
489 .permissions()
490 .mode()
491 & 0o777;
492 assert_eq!(mode, 0o600);
493 }
494
495 #[test]
496 fn conflict_leaves_config_dir_version_untouched() {
497 let cfg = TempDir::new().unwrap();
498 let data = TempDir::new().unwrap();
499 write(&cfg.path().join("config.toml"), "new");
501 write(&data.path().join("config.toml"), "old");
503
504 let rep = migrate_legacy_config_if_needed(cfg.path(), data.path())
505 .unwrap()
506 .unwrap();
507 assert!(rep.moved.is_empty());
508 assert_eq!(rep.skipped_conflicts, vec!["config.toml".to_string()]);
509 assert_eq!(
510 std::fs::read_to_string(cfg.path().join("config.toml")).unwrap(),
511 "new"
512 );
513 assert!(data.path().join("config.toml").exists());
515 }
516
517 #[test]
518 fn versioned_state_does_not_hide_new_legacy_artifacts() {
519 let cfg = TempDir::new().unwrap();
520 let data = TempDir::new().unwrap();
521 write(&data.path().join("config.toml"), "first");
522
523 let first = migrate_legacy_config_if_needed(cfg.path(), data.path())
524 .unwrap()
525 .unwrap();
526 assert_eq!(first.moved, vec!["config.toml".to_string()]);
527
528 write(&data.path().join("daemon.toml"), "later");
529 let second = migrate_legacy_config_if_needed(cfg.path(), data.path())
530 .unwrap()
531 .unwrap();
532 assert_eq!(second.moved, vec!["daemon.toml".to_string()]);
533 assert!(!data.path().join("daemon.toml").exists());
534 assert!(cfg.path().join("daemon.toml").exists());
535 }
536
537 #[test]
538 fn noop_sweep_writes_versioned_state_but_returns_none() {
539 let cfg = TempDir::new().unwrap();
540 let data = TempDir::new().unwrap();
541 write(&data.path().join("index.db"), "sqlite");
543
544 let out = migrate_legacy_config_if_needed(cfg.path(), data.path()).unwrap();
545 assert!(out.is_none());
546 assert!(data.path().join(MIGRATION_STATE).exists());
547 assert!(data.path().join("index.db").exists());
548 }
549}