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
355pub 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 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 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}