Skip to main content

atman_runtime/
config_migration.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5pub const MIGRATION_MARKER: &str = ".migrated-to-xdg-config";
6
7// Files historically written straight into data_dir but conceptually belong
8// beside the user's other tool configs. Anything not in this list stays in
9// data_dir (sessions/, indexes/, projects/, tools/, ...).
10const 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        // config.toml stayed, marker never written.
390        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        // Non-config file must stay put.
403        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        // sessions/ never touched.
417        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        // User already customized config.toml at the new location.
500        write(&cfg.path().join("config.toml"), "new");
501        // Old copy from legacy dir must not clobber it.
502        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        // Legacy copy left in place so the user can inspect it manually.
514        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        // data_dir exists but contains only non-config artifacts.
542        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}