Skip to main content

aft/
migrate_storage.rs

1use std::collections::BTreeSet;
2use std::ffi::OsString;
3use std::fs::{self, File};
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::ExitCode;
7use std::time::{Duration, SystemTime};
8
9use serde::{Deserialize, Serialize};
10
11use crate::fs_lock;
12use crate::harness::Harness;
13
14mod log;
15
16use self::log::{iso_timestamp_now, now_millis, JsonLogger};
17
18const SOURCE_MARKER: &str = ".migrated_to_cortexkit";
19const TARGET_MARKER: &str = ".migrated_from_legacy";
20const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
21
22#[derive(Clone, Debug)]
23pub struct Args {
24    pub from: Option<PathBuf>,
25    pub to: PathBuf,
26    pub harness: Harness,
27    pub log: Option<PathBuf>,
28    pub status: bool,
29}
30
31#[derive(Clone, Debug)]
32struct MigrationArgs {
33    from: PathBuf,
34    to: PathBuf,
35    harness: Harness,
36    log: PathBuf,
37}
38
39#[derive(Clone, Copy, Debug)]
40pub struct Options {
41    pub lock_timeout: Duration,
42    pub disk_free_override: Option<u64>,
43}
44
45impl Default for Options {
46    fn default() -> Self {
47        Self {
48            lock_timeout: LOCK_TIMEOUT,
49            disk_free_override: None,
50        }
51    }
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum ExitStatus {
56    Success = 0,
57    SourceUnreadable = 1,
58    InsufficientDisk = 2,
59    LockContention = 3,
60    PartialState = 4,
61    MigrationFailed = 5,
62}
63
64impl ExitStatus {
65    pub fn code(self) -> u8 {
66        self as u8
67    }
68
69    fn exit_code(self) -> ExitCode {
70        ExitCode::from(self.code())
71    }
72}
73
74pub fn run(args: Args) -> ExitCode {
75    run_with_options(args, Options::default()).exit_code()
76}
77
78pub fn run_with_options(args: Args, options: Options) -> ExitStatus {
79    if args.status {
80        write_status(&args.to, args.harness, args.from.as_deref());
81        return ExitStatus::Success;
82    }
83
84    let Some(from) = args.from else {
85        return ExitStatus::MigrationFailed;
86    };
87    let Some(log_path) = args.log else {
88        return ExitStatus::MigrationFailed;
89    };
90    let args = MigrationArgs {
91        from,
92        to: args.to,
93        harness: args.harness,
94        log: log_path,
95    };
96
97    let target_root_error = fs::create_dir_all(&args.to).err();
98    let mut log = JsonLogger::open(&args.log, args.harness);
99    let started = SystemTime::now();
100    log.write(serde_json::json!({
101        "step": "start",
102        "level": "info",
103        "from": args.from,
104        "to": args.to,
105        "harness": args.harness.as_str(),
106    }));
107
108    if let Some(error) = target_root_error {
109        log.write(serde_json::json!({
110            "level": "error",
111            "step": "create_target_root",
112            "path": args.to,
113            "status": "error",
114            "error": error.to_string(),
115        }));
116        return ExitStatus::MigrationFailed;
117    }
118
119    let lock_dir = args.to.join(".aft");
120    if let Err(error) = fs::create_dir_all(&lock_dir) {
121        log.write(serde_json::json!({
122            "level": "error",
123            "step": "create_lock_dir",
124            "path": lock_dir,
125            "status": "error",
126            "error": error.to_string(),
127        }));
128        return ExitStatus::MigrationFailed;
129    }
130
131    let target_harness = args.to.join(args.harness.as_str());
132    let target_marker = target_marker_path(&args);
133    let source_marker = source_marker_path(&args);
134
135    if source_marker.exists() && target_marker.exists() {
136        log.write(serde_json::json!({
137            "level": "info",
138            "step": "marker_check",
139            "status": "already_migrated",
140        }));
141        return ExitStatus::Success;
142    }
143
144    let source_bytes = match source_size(&args.from) {
145        Ok(bytes) => bytes,
146        Err(error) => {
147            log.write(serde_json::json!({
148                "level": "error",
149                "step": "preflight",
150                "path": args.from,
151                "status": "source_unreadable",
152                "error": error.to_string(),
153            }));
154            return ExitStatus::SourceUnreadable;
155        }
156    };
157
158    let free_bytes = match options.disk_free_override {
159        Some(bytes) => Ok(bytes),
160        None => free_bytes_at(&args.to),
161    };
162    let free_bytes = match free_bytes {
163        Ok(bytes) => bytes,
164        Err(error) => {
165            log.write(serde_json::json!({
166                "level": "error",
167                "step": "preflight",
168                "path": args.to,
169                "status": "disk_check_failed",
170                "bytes_source": source_bytes,
171                "error": error.to_string(),
172            }));
173            return ExitStatus::MigrationFailed;
174        }
175    };
176    let required = source_bytes.saturating_mul(2);
177    let has_space = free_bytes >= required;
178    log.write(serde_json::json!({
179        "level": if has_space { "info" } else { "error" },
180        "step": "preflight",
181        "bytes_source": source_bytes,
182        "bytes_free": free_bytes,
183        "bytes_required": required,
184        "ok": has_space,
185    }));
186    if !has_space {
187        return ExitStatus::InsufficientDisk;
188    }
189
190    let lock_path = lock_dir.join("migration.lock");
191    let _guard = match fs_lock::try_acquire(&lock_path, options.lock_timeout) {
192        Ok(guard) => guard,
193        Err(fs_lock::AcquireError::Timeout) => {
194            log.write(serde_json::json!({
195                "level": "error",
196                "step": "lock",
197                "path": lock_path,
198                "status": "timeout",
199            }));
200            return ExitStatus::LockContention;
201        }
202        Err(error) => {
203            log.write(serde_json::json!({
204                "level": "error",
205                "step": "lock",
206                "path": lock_path,
207                "status": "error",
208                "error": error.to_string(),
209            }));
210            return ExitStatus::MigrationFailed;
211        }
212    };
213
214    if source_marker.exists() && target_marker.exists() {
215        log.write(serde_json::json!({
216            "level": "info",
217            "step": "marker_check_locked",
218            "status": "already_migrated",
219        }));
220        return ExitStatus::Success;
221    }
222
223    if !args.from.exists() {
224        if let Err(error) = fs::create_dir_all(&target_harness) {
225            log.write(serde_json::json!({
226                "level": "error",
227                "step": "create_harness_dir",
228                "path": target_harness,
229                "status": "error",
230                "error": error.to_string(),
231            }));
232            return ExitStatus::MigrationFailed;
233        }
234        if let Err(error) = write_target_marker(&args) {
235            log.write(serde_json::json!({
236                "level": "error",
237                "step": "marker",
238                "path": target_marker,
239                "status": "error",
240                "error": error.to_string(),
241            }));
242            return ExitStatus::MigrationFailed;
243        }
244        log_complete(&mut log, started);
245        return ExitStatus::Success;
246    }
247
248    if let Err(error) = fs::create_dir_all(&target_harness) {
249        log.write(serde_json::json!({
250            "level": "error",
251            "step": "create_harness_dir",
252            "path": target_harness,
253            "status": "error",
254            "error": error.to_string(),
255        }));
256        return ExitStatus::MigrationFailed;
257    }
258
259    let mut failed = false;
260    for &item in migration_items() {
261        if let Err(error) = migrate_item(&args, item, &mut log) {
262            failed = true;
263            log.write(serde_json::json!({
264                "level": "error",
265                "step": "copy",
266                "subtree": item.name,
267                "status": "error",
268                "error": error.to_string(),
269            }));
270        }
271    }
272
273    if failed {
274        log.write(serde_json::json!({
275            "level": "error",
276            "step": "complete",
277            "status": "failed",
278        }));
279        return ExitStatus::MigrationFailed;
280    }
281
282    if let Err(error) = write_source_marker(&args) {
283        log.write(serde_json::json!({
284            "level": "error",
285            "step": "marker",
286            "path": source_marker,
287            "status": "error",
288            "error": error.to_string(),
289        }));
290        return ExitStatus::MigrationFailed;
291    }
292
293    if let Err(error) = write_target_marker(&args) {
294        log.write(serde_json::json!({
295            "level": "error",
296            "step": "marker",
297            "path": target_marker,
298            "status": "error",
299            "error": error.to_string(),
300        }));
301        return ExitStatus::PartialState;
302    }
303
304    log_complete(&mut log, started);
305    ExitStatus::Success
306}
307
308fn write_status(target_root: &Path, harness: Harness, source_root: Option<&Path>) {
309    let marker_path = target_marker_path_from(target_root, harness);
310    let source_marker_path = source_root.map(|root| root.join(SOURCE_MARKER));
311    let source_marker_present = source_marker_path
312        .as_ref()
313        .is_some_and(|path| path.exists());
314    let mut value = match fs::read(&marker_path) {
315        Ok(bytes) => match serde_json::from_slice::<Marker>(&bytes) {
316            Ok(marker) => serde_json::json!({
317                "harness": harness.as_str(),
318                "target_root": target_root.display().to_string(),
319                "migrated": true,
320                "marker_path": marker_path.display().to_string(),
321                "migrated_at": marker.timestamp,
322                "source_path": marker.source_path,
323                "aft_version": marker.aft_version,
324            }),
325            Err(_) => serde_json::json!({
326                "harness": harness.as_str(),
327                "target_root": target_root.display().to_string(),
328                "migrated": true,
329                "marker_path": marker_path.display().to_string(),
330            }),
331        },
332        Err(error) if error.kind() == io::ErrorKind::NotFound => serde_json::json!({
333            "harness": harness.as_str(),
334            "target_root": target_root.display().to_string(),
335            "migrated": false,
336        }),
337        Err(_) => serde_json::json!({
338            "harness": harness.as_str(),
339            "target_root": target_root.display().to_string(),
340            "migrated": false,
341        }),
342    };
343
344    if let Some(source_marker_path) = source_marker_path {
345        value["source_marker_path"] = serde_json::json!(source_marker_path.display().to_string());
346        value["source_marker_present"] = serde_json::json!(source_marker_present);
347        value["partial_state"] = serde_json::json!(source_marker_present && !marker_path.exists());
348    }
349
350    let mut stdout = io::stdout().lock();
351    let _ = serde_json::to_writer(&mut stdout, &value);
352    let _ = stdout.write_all(b"\n");
353}
354
355/// Sweep `staging-*` files and directories from target parents used by migration.
356/// Idempotent. Returns the number of staging entries removed.
357pub fn cleanup_staging_dirs(target_root: &Path, harness: Harness) -> io::Result<usize> {
358    let mut parents = BTreeSet::new();
359    for &item in migration_items() {
360        parents.insert(staging_parent_from_root(target_root, harness, item));
361    }
362
363    let mut removed = 0;
364    for parent in parents {
365        let entries = match fs::read_dir(&parent) {
366            Ok(entries) => entries,
367            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
368            Err(error) => return Err(error),
369        };
370
371        for entry in entries {
372            let entry = entry?;
373            let name = entry.file_name();
374            let Some(s) = name.to_str() else { continue };
375            if !s.starts_with("staging-") {
376                continue;
377            }
378
379            remove_staging_path(&entry.path())?;
380            removed += 1;
381        }
382    }
383
384    Ok(removed)
385}
386
387fn staging_parent_from_root(target_root: &Path, harness: Harness, item: MigrationItem) -> PathBuf {
388    let final_path = target_path_from_root(target_root, harness, item);
389    if item.merge == MergeKind::ChildUnion {
390        final_path
391    } else {
392        final_path
393            .parent()
394            .map(Path::to_path_buf)
395            .unwrap_or_else(|| target_root.to_path_buf())
396    }
397}
398
399fn remove_staging_path(path: &Path) -> io::Result<()> {
400    let metadata = fs::symlink_metadata(path)?;
401    if metadata.is_dir() {
402        fs::remove_dir_all(path)
403    } else {
404        fs::remove_file(path)
405    }
406}
407
408fn log_complete(log: &mut JsonLogger, started: SystemTime) {
409    let duration_ms = SystemTime::now()
410        .duration_since(started)
411        .unwrap_or(Duration::ZERO)
412        .as_millis();
413    log.write(serde_json::json!({
414        "level": "info",
415        "step": "complete",
416        "status": "ok",
417        "duration_ms": duration_ms,
418    }));
419}
420
421#[derive(Clone, Copy)]
422struct MigrationItem {
423    name: &'static str,
424    source_name: &'static str,
425    target: TargetKind,
426    entry: EntryKind,
427    merge: MergeKind,
428}
429
430#[derive(Clone, Copy, Eq, PartialEq)]
431enum TargetKind {
432    Harness,
433    Root,
434}
435
436#[derive(Clone, Copy, Eq, PartialEq)]
437enum EntryKind {
438    Directory,
439    File,
440}
441
442#[derive(Clone, Copy, Eq, PartialEq)]
443enum MergeKind {
444    Whole,
445    ChildUnion,
446    TrustJson,
447}
448
449fn migration_items() -> &'static [MigrationItem] {
450    &[
451        MigrationItem {
452            name: "bash-tasks",
453            source_name: "bash-tasks",
454            target: TargetKind::Harness,
455            entry: EntryKind::Directory,
456            merge: MergeKind::Whole,
457        },
458        MigrationItem {
459            name: "backups",
460            source_name: "backups",
461            target: TargetKind::Harness,
462            entry: EntryKind::Directory,
463            merge: MergeKind::Whole,
464        },
465        MigrationItem {
466            name: "filters",
467            source_name: "filters",
468            target: TargetKind::Harness,
469            entry: EntryKind::Directory,
470            merge: MergeKind::ChildUnion,
471        },
472        MigrationItem {
473            name: "index",
474            source_name: "index",
475            target: TargetKind::Root,
476            entry: EntryKind::Directory,
477            merge: MergeKind::ChildUnion,
478        },
479        MigrationItem {
480            name: "last_announced_version",
481            source_name: "last_announced_version",
482            target: TargetKind::Harness,
483            entry: EntryKind::File,
484            merge: MergeKind::Whole,
485        },
486        // ONNX runtime is host-global (shared by all harnesses). Moving it
487        // avoids forcing every user to re-download the ~200MB runtime after
488        // upgrading. ChildUnion lets a later second-harness migration coexist
489        // if a partial migration somehow ran a different runtime version.
490        MigrationItem {
491            name: "onnxruntime",
492            source_name: "onnxruntime",
493            target: TargetKind::Root,
494            entry: EntryKind::Directory,
495            merge: MergeKind::ChildUnion,
496        },
497        MigrationItem {
498            name: "last-update-check.json",
499            source_name: "last-update-check.json",
500            target: TargetKind::Harness,
501            entry: EntryKind::File,
502            merge: MergeKind::Whole,
503        },
504        MigrationItem {
505            name: "semantic",
506            source_name: "semantic",
507            target: TargetKind::Root,
508            entry: EntryKind::Directory,
509            merge: MergeKind::ChildUnion,
510        },
511        MigrationItem {
512            name: "symbols",
513            source_name: "symbols",
514            target: TargetKind::Root,
515            entry: EntryKind::Directory,
516            merge: MergeKind::ChildUnion,
517        },
518        MigrationItem {
519            name: "trusted-filter-projects.json",
520            source_name: "trusted-filter-projects.json",
521            target: TargetKind::Root,
522            entry: EntryKind::File,
523            merge: MergeKind::TrustJson,
524        },
525        MigrationItem {
526            name: "warned_tools.json",
527            source_name: "warned_tools.json",
528            target: TargetKind::Harness,
529            entry: EntryKind::File,
530            merge: MergeKind::Whole,
531        },
532    ]
533}
534
535fn migrate_item(args: &MigrationArgs, item: MigrationItem, log: &mut JsonLogger) -> io::Result<()> {
536    let source = args.from.join(item.source_name);
537    if !source.exists() {
538        log.write(serde_json::json!({
539            "level": "info",
540            "step": "copy",
541            "subtree": item.name,
542            "status": "missing",
543            "action": "skipped",
544        }));
545        return Ok(());
546    }
547
548    match item.merge {
549        MergeKind::Whole => migrate_whole(args, item, &source, log),
550        MergeKind::ChildUnion => migrate_child_union(args, item, &source, log),
551        MergeKind::TrustJson => merge_trust_file(args, &source, log),
552    }
553}
554
555fn migrate_whole(
556    args: &MigrationArgs,
557    item: MigrationItem,
558    source: &Path,
559    log: &mut JsonLogger,
560) -> io::Result<()> {
561    let final_path = target_path(args, item);
562    if final_path.exists() {
563        log.write(serde_json::json!({
564            "level": "warn",
565            "step": "copy",
566            "subtree": item.name,
567            "status": "already_exists",
568            "action": "skipped",
569        }));
570        return Ok(());
571    }
572
573    let staging = staging_path(&final_path, item.name);
574    if let Some(parent) = staging.parent() {
575        fs::create_dir_all(parent)?;
576    }
577
578    let copy_result = match item.entry {
579        EntryKind::Directory => copy_dir_recursive(source, &staging),
580        EntryKind::File => copy_file(source, &staging).map(|_| ()),
581    };
582    if let Err(error) = copy_result {
583        return Err(error);
584    }
585
586    fs::rename(&staging, &final_path)?;
587    let bytes = source_size(source)?;
588    log.write(serde_json::json!({
589        "level": "info",
590        "step": "copy",
591        "subtree": item.name,
592        "status": "ok",
593        "bytes": bytes,
594    }));
595    Ok(())
596}
597
598fn migrate_child_union(
599    args: &MigrationArgs,
600    item: MigrationItem,
601    source: &Path,
602    log: &mut JsonLogger,
603) -> io::Result<()> {
604    let final_dir = target_path(args, item);
605    fs::create_dir_all(&final_dir)?;
606    let mut copied_bytes = 0_u64;
607    let mut failed = false;
608
609    for entry in sorted_read_dir(source)? {
610        let name = entry.file_name();
611        let child_source = entry.path();
612        let child_final = final_dir.join(&name);
613        if child_final.exists() {
614            log.write(serde_json::json!({
615                "level": "warn",
616                "step": "copy",
617                "subtree": item.name,
618                "path": child_final,
619                "status": "already_exists",
620                "action": "skipped",
621            }));
622            continue;
623        }
624        let child_staging = staging_path(&child_final, item.name);
625        let result = if child_source.is_dir() {
626            copy_dir_recursive(&child_source, &child_staging)
627        } else {
628            copy_file(&child_source, &child_staging).map(|_| ())
629        };
630        match result {
631            Ok(()) => {
632                fs::rename(&child_staging, &child_final)?;
633                copied_bytes = copied_bytes.saturating_add(source_size(&child_final)?);
634            }
635            Err(error) => {
636                failed = true;
637                log.write(serde_json::json!({
638                    "level": "error",
639                    "step": "copy",
640                    "subtree": item.name,
641                    "path": child_source,
642                    "status": "error",
643                    "error": error.to_string(),
644                }));
645            }
646        }
647    }
648
649    if failed {
650        return Err(io::Error::other("one or more children failed to copy"));
651    }
652
653    log.write(serde_json::json!({
654        "level": "info",
655        "step": "copy",
656        "subtree": item.name,
657        "status": "ok",
658        "bytes": copied_bytes,
659    }));
660    Ok(())
661}
662
663fn merge_trust_file(args: &MigrationArgs, source: &Path, log: &mut JsonLogger) -> io::Result<()> {
664    let target = args.to.join("trusted-filter-projects.json");
665    let mut paths = Vec::new();
666    let mut seen = BTreeSet::new();
667    for value in [
668        read_json_string_array(&target)?,
669        read_json_string_array(source)?,
670    ] {
671        for item in value {
672            if seen.insert(item.clone()) {
673                paths.push(item);
674            }
675        }
676    }
677    atomic_write_json(&target, &paths)?;
678    log.write(serde_json::json!({
679        "level": "info",
680        "step": "copy",
681        "subtree": "trusted-filter-projects.json",
682        "status": "ok",
683        "entries": paths.len(),
684    }));
685    Ok(())
686}
687
688fn read_json_string_array(path: &Path) -> io::Result<Vec<String>> {
689    match fs::read(path) {
690        Ok(bytes) => serde_json::from_slice::<Vec<String>>(&bytes).map_err(io::Error::other),
691        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
692        Err(error) => Err(error),
693    }
694}
695
696fn target_path(args: &MigrationArgs, item: MigrationItem) -> PathBuf {
697    target_path_from_root(&args.to, args.harness, item)
698}
699
700fn target_path_from_root(target_root: &Path, harness: Harness, item: MigrationItem) -> PathBuf {
701    match item.target {
702        TargetKind::Harness => target_root.join(harness.as_str()).join(item.name),
703        TargetKind::Root => target_root.join(item.name),
704    }
705}
706
707fn staging_path(final_path: &Path, subtree: &str) -> PathBuf {
708    let final_name = final_path
709        .file_name()
710        .and_then(|name| name.to_str())
711        .unwrap_or(subtree);
712    final_path.with_file_name(format!(
713        "staging-{subtree}-{final_name}-{}-{}",
714        std::process::id(),
715        now_millis()
716    ))
717}
718
719fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
720    fs::create_dir_all(target)?;
721    for entry in sorted_read_dir(source)? {
722        let source_path = entry.path();
723        let target_path = target.join(entry.file_name());
724        if source_path.is_dir() {
725            copy_dir_recursive(&source_path, &target_path)?;
726        } else {
727            copy_file(&source_path, &target_path)?;
728        }
729    }
730    sync_path(target);
731    Ok(())
732}
733
734fn copy_file(source: &Path, target: &Path) -> io::Result<u64> {
735    if let Some(parent) = target.parent() {
736        fs::create_dir_all(parent)?;
737    }
738    let bytes = fs::copy(source, target)?;
739    sync_path(target);
740    Ok(bytes)
741}
742
743fn sorted_read_dir(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
744    let mut entries = fs::read_dir(path)?.collect::<io::Result<Vec<_>>>()?;
745    entries.sort_by_key(|entry| entry.file_name());
746    Ok(entries)
747}
748
749fn source_size(path: &Path) -> io::Result<u64> {
750    if !path.exists() {
751        return Ok(0);
752    }
753    let metadata = fs::metadata(path)?;
754    if metadata.is_file() {
755        return Ok(metadata.len());
756    }
757    let mut total = 0_u64;
758    for entry in fs::read_dir(path)? {
759        let entry = entry?;
760        total = total.saturating_add(source_size(&entry.path())?);
761    }
762    Ok(total)
763}
764
765#[cfg(unix)]
766fn free_bytes_at(path: &Path) -> io::Result<u64> {
767    use std::ffi::CString;
768    use std::os::unix::ffi::OsStrExt;
769
770    let probe = existing_ancestor(path);
771    let c_path = CString::new(probe.as_os_str().as_bytes())
772        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
773    let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
774    let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
775    if result != 0 {
776        return Err(io::Error::last_os_error());
777    }
778    let stat = unsafe { stat.assume_init() };
779    Ok((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
780}
781
782#[cfg(windows)]
783fn free_bytes_at(_path: &Path) -> io::Result<u64> {
784    // v0.27 is Unix-prioritized. Windows disk preflight should be wired to
785    // GetDiskFreeSpaceExW through windows-sys in a follow-up.
786    Ok(u64::MAX)
787}
788
789fn existing_ancestor(path: &Path) -> &Path {
790    let mut current = path;
791    while !current.exists() {
792        if let Some(parent) = current.parent() {
793            current = parent;
794        } else {
795            break;
796        }
797    }
798    current
799}
800
801#[derive(Serialize, Deserialize)]
802struct Marker {
803    timestamp: String,
804    source_path: String,
805    target_path: String,
806    harness: String,
807    aft_version: String,
808}
809
810fn marker(args: &MigrationArgs) -> Marker {
811    Marker {
812        timestamp: iso_timestamp_now(),
813        source_path: args.from.display().to_string(),
814        target_path: args.to.display().to_string(),
815        harness: args.harness.as_str().to_string(),
816        aft_version: env!("CARGO_PKG_VERSION").to_string(),
817    }
818}
819
820fn write_source_marker(args: &MigrationArgs) -> io::Result<()> {
821    atomic_write_json(&source_marker_path(args), &marker(args))
822}
823
824fn write_target_marker(args: &MigrationArgs) -> io::Result<()> {
825    fs::create_dir_all(args.to.join(args.harness.as_str()))?;
826    atomic_write_json(&target_marker_path(args), &marker(args))
827}
828
829fn source_marker_path(args: &MigrationArgs) -> PathBuf {
830    args.from.join(SOURCE_MARKER)
831}
832
833fn target_marker_path(args: &MigrationArgs) -> PathBuf {
834    target_marker_path_from(&args.to, args.harness)
835}
836
837fn target_marker_path_from(target_root: &Path, harness: Harness) -> PathBuf {
838    target_root.join(harness.as_str()).join(TARGET_MARKER)
839}
840
841fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
842    if let Some(parent) = path.parent() {
843        fs::create_dir_all(parent)?;
844    }
845    let tmp = path.with_file_name(format!(
846        ".{}.tmp.{}.{}",
847        path.file_name()
848            .and_then(|name| name.to_str())
849            .unwrap_or("marker"),
850        std::process::id(),
851        now_millis()
852    ));
853    let result = (|| {
854        let mut file = File::create(&tmp)?;
855        serde_json::to_writer(&mut file, value).map_err(io::Error::other)?;
856        file.write_all(b"\n")?;
857        file.sync_all()?;
858        drop(file);
859        fs::rename(&tmp, path)?;
860        if let Some(parent) = path.parent() {
861            sync_path(parent);
862        }
863        Ok(())
864    })();
865    if result.is_err() {
866        let _ = fs::remove_file(&tmp);
867    }
868    result
869}
870
871fn sync_path(path: &Path) {
872    if let Ok(file) = File::open(path) {
873        let _ = file.sync_all();
874    }
875}
876
877pub fn parse_cli_args<I, S>(args: I) -> Result<Args, String>
878where
879    I: IntoIterator<Item = S>,
880    S: Into<OsString>,
881{
882    let mut from = None;
883    let mut to = None;
884    let mut harness = None;
885    let mut log = None;
886    let mut status = false;
887    let mut iter = args.into_iter().map(Into::into);
888    while let Some(arg) = iter.next() {
889        let arg = arg.to_string_lossy().to_string();
890        if arg == "--status" {
891            status = true;
892            continue;
893        }
894        let value = match arg.as_str() {
895            "--from" | "--to" | "--harness" | "--log" => iter
896                .next()
897                .ok_or_else(|| format!("missing value for {arg}"))?,
898            "--help" | "-h" => return Err(help_text()),
899            other => return Err(format!("unknown argument: {other}\n\n{}", help_text())),
900        };
901        match arg.as_str() {
902            "--from" => from = Some(PathBuf::from(value)),
903            "--to" => to = Some(PathBuf::from(value)),
904            "--harness" => {
905                let value = value.to_string_lossy();
906                harness = Some(
907                    value
908                        .parse::<Harness>()
909                        .map_err(|err| format!("invalid harness: {err}\n\n{}", help_text()))?,
910                );
911            }
912            "--log" => log = Some(PathBuf::from(value)),
913            _ => unreachable!(),
914        }
915    }
916
917    let to = to.ok_or_else(|| format!("missing required --to\n\n{}", help_text()))?;
918    let harness =
919        harness.ok_or_else(|| format!("missing required --harness\n\n{}", help_text()))?;
920    if status {
921        return Ok(Args {
922            from,
923            to,
924            harness,
925            log,
926            status,
927        });
928    }
929
930    Ok(Args {
931        from: Some(from.ok_or_else(|| format!("missing required --from\n\n{}", help_text()))?),
932        to,
933        harness,
934        log: Some(log.ok_or_else(|| format!("missing required --log\n\n{}", help_text()))?),
935        status,
936    })
937}
938pub fn help_text() -> String {
939    "Usage: aft migrate-storage --from <legacy_root> --to <new_root> --harness <opencode|pi> --log <log_file>\n       aft migrate-storage --status --to <new_root> --harness <opencode|pi>\n\n\
940Blocking one-shot migration from legacy AFT storage into the CortexKit-rooted layout.\n\n\
941Exit codes:\n  0  success (including idempotent already-migrated/no-op; missing legacy source is a no-op)\n  1  source unreadable\n  2  insufficient disk space during preflight\n  3  migration lock contention\n  4  migration in progress / partial marker state\n  5  migration failed; inspect the log file"
942        .to_string()
943}