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