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);
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) {
309 let marker_path = target_marker_path_from(target_root, harness);
310 let value = match fs::read(&marker_path) {
311 Ok(bytes) => match serde_json::from_slice::<Marker>(&bytes) {
312 Ok(marker) => serde_json::json!({
313 "harness": harness.as_str(),
314 "target_root": target_root.display().to_string(),
315 "migrated": true,
316 "marker_path": marker_path.display().to_string(),
317 "migrated_at": marker.timestamp,
318 "source_path": marker.source_path,
319 "aft_version": marker.aft_version,
320 }),
321 Err(_) => serde_json::json!({
322 "harness": harness.as_str(),
323 "target_root": target_root.display().to_string(),
324 "migrated": true,
325 "marker_path": marker_path.display().to_string(),
326 }),
327 },
328 Err(error) if error.kind() == io::ErrorKind::NotFound => serde_json::json!({
329 "harness": harness.as_str(),
330 "target_root": target_root.display().to_string(),
331 "migrated": false,
332 }),
333 Err(_) => serde_json::json!({
334 "harness": harness.as_str(),
335 "target_root": target_root.display().to_string(),
336 "migrated": false,
337 }),
338 };
339
340 let mut stdout = io::stdout().lock();
341 let _ = serde_json::to_writer(&mut stdout, &value);
342 let _ = stdout.write_all(b"\n");
343}
344
345pub fn cleanup_staging_dirs(target_root: &Path, harness: Harness) -> io::Result<usize> {
348 let harness_dir = target_root.join(harness.as_str());
349 if !harness_dir.exists() {
350 return Ok(0);
351 }
352
353 let mut removed = 0;
354 for entry in fs::read_dir(&harness_dir)? {
355 let entry = entry?;
356 let name = entry.file_name();
357 let Some(s) = name.to_str() else { continue };
358 if !s.starts_with("staging-") {
359 continue;
360 }
361 let path = entry.path();
362 if fs::remove_dir_all(&path).is_ok() {
363 removed += 1;
364 }
365 }
366
367 Ok(removed)
368}
369
370fn log_complete(log: &mut JsonLogger, started: SystemTime) {
371 let duration_ms = SystemTime::now()
372 .duration_since(started)
373 .unwrap_or(Duration::ZERO)
374 .as_millis();
375 log.write(serde_json::json!({
376 "level": "info",
377 "step": "complete",
378 "status": "ok",
379 "duration_ms": duration_ms,
380 }));
381}
382
383#[derive(Clone, Copy)]
384struct MigrationItem {
385 name: &'static str,
386 source_name: &'static str,
387 target: TargetKind,
388 entry: EntryKind,
389 merge: MergeKind,
390}
391
392#[derive(Clone, Copy, Eq, PartialEq)]
393enum TargetKind {
394 Harness,
395 Root,
396}
397
398#[derive(Clone, Copy, Eq, PartialEq)]
399enum EntryKind {
400 Directory,
401 File,
402}
403
404#[derive(Clone, Copy, Eq, PartialEq)]
405enum MergeKind {
406 Whole,
407 ChildUnion,
408 TrustJson,
409}
410
411fn migration_items() -> &'static [MigrationItem] {
412 &[
413 MigrationItem {
414 name: "bash-tasks",
415 source_name: "bash-tasks",
416 target: TargetKind::Harness,
417 entry: EntryKind::Directory,
418 merge: MergeKind::Whole,
419 },
420 MigrationItem {
421 name: "backups",
422 source_name: "backups",
423 target: TargetKind::Harness,
424 entry: EntryKind::Directory,
425 merge: MergeKind::Whole,
426 },
427 MigrationItem {
428 name: "filters",
429 source_name: "filters",
430 target: TargetKind::Harness,
431 entry: EntryKind::Directory,
432 merge: MergeKind::ChildUnion,
433 },
434 MigrationItem {
435 name: "index",
436 source_name: "index",
437 target: TargetKind::Root,
438 entry: EntryKind::Directory,
439 merge: MergeKind::ChildUnion,
440 },
441 MigrationItem {
442 name: "last_announced_version",
443 source_name: "last_announced_version",
444 target: TargetKind::Harness,
445 entry: EntryKind::File,
446 merge: MergeKind::Whole,
447 },
448 MigrationItem {
453 name: "onnxruntime",
454 source_name: "onnxruntime",
455 target: TargetKind::Root,
456 entry: EntryKind::Directory,
457 merge: MergeKind::ChildUnion,
458 },
459 MigrationItem {
460 name: "last-update-check.json",
461 source_name: "last-update-check.json",
462 target: TargetKind::Harness,
463 entry: EntryKind::File,
464 merge: MergeKind::Whole,
465 },
466 MigrationItem {
467 name: "semantic",
468 source_name: "semantic",
469 target: TargetKind::Root,
470 entry: EntryKind::Directory,
471 merge: MergeKind::ChildUnion,
472 },
473 MigrationItem {
474 name: "symbols",
475 source_name: "symbols",
476 target: TargetKind::Root,
477 entry: EntryKind::Directory,
478 merge: MergeKind::ChildUnion,
479 },
480 MigrationItem {
481 name: "trusted-filter-projects.json",
482 source_name: "trusted-filter-projects.json",
483 target: TargetKind::Root,
484 entry: EntryKind::File,
485 merge: MergeKind::TrustJson,
486 },
487 MigrationItem {
488 name: "warned_tools.json",
489 source_name: "warned_tools.json",
490 target: TargetKind::Harness,
491 entry: EntryKind::File,
492 merge: MergeKind::Whole,
493 },
494 ]
495}
496
497fn migrate_item(args: &MigrationArgs, item: MigrationItem, log: &mut JsonLogger) -> io::Result<()> {
498 let source = args.from.join(item.source_name);
499 if !source.exists() {
500 log.write(serde_json::json!({
501 "level": "info",
502 "step": "copy",
503 "subtree": item.name,
504 "status": "missing",
505 "action": "skipped",
506 }));
507 return Ok(());
508 }
509
510 match item.merge {
511 MergeKind::Whole => migrate_whole(args, item, &source, log),
512 MergeKind::ChildUnion => migrate_child_union(args, item, &source, log),
513 MergeKind::TrustJson => merge_trust_file(args, &source, log),
514 }
515}
516
517fn migrate_whole(
518 args: &MigrationArgs,
519 item: MigrationItem,
520 source: &Path,
521 log: &mut JsonLogger,
522) -> io::Result<()> {
523 let final_path = target_path(args, item);
524 if final_path.exists() {
525 log.write(serde_json::json!({
526 "level": "warn",
527 "step": "copy",
528 "subtree": item.name,
529 "status": "already_exists",
530 "action": "skipped",
531 }));
532 return Ok(());
533 }
534
535 let staging = staging_path(&final_path, item.name);
536 if let Some(parent) = staging.parent() {
537 fs::create_dir_all(parent)?;
538 }
539
540 let copy_result = match item.entry {
541 EntryKind::Directory => copy_dir_recursive(source, &staging),
542 EntryKind::File => copy_file(source, &staging).map(|_| ()),
543 };
544 if let Err(error) = copy_result {
545 return Err(error);
546 }
547
548 fs::rename(&staging, &final_path)?;
549 let bytes = source_size(source)?;
550 log.write(serde_json::json!({
551 "level": "info",
552 "step": "copy",
553 "subtree": item.name,
554 "status": "ok",
555 "bytes": bytes,
556 }));
557 Ok(())
558}
559
560fn migrate_child_union(
561 args: &MigrationArgs,
562 item: MigrationItem,
563 source: &Path,
564 log: &mut JsonLogger,
565) -> io::Result<()> {
566 let final_dir = target_path(args, item);
567 fs::create_dir_all(&final_dir)?;
568 let mut copied_bytes = 0_u64;
569 let mut failed = false;
570
571 for entry in sorted_read_dir(source)? {
572 let name = entry.file_name();
573 let child_source = entry.path();
574 let child_final = final_dir.join(&name);
575 if child_final.exists() {
576 log.write(serde_json::json!({
577 "level": "warn",
578 "step": "copy",
579 "subtree": item.name,
580 "path": child_final,
581 "status": "already_exists",
582 "action": "skipped",
583 }));
584 continue;
585 }
586 let child_staging = staging_path(&child_final, item.name);
587 let result = if child_source.is_dir() {
588 copy_dir_recursive(&child_source, &child_staging)
589 } else {
590 copy_file(&child_source, &child_staging).map(|_| ())
591 };
592 match result {
593 Ok(()) => {
594 fs::rename(&child_staging, &child_final)?;
595 copied_bytes = copied_bytes.saturating_add(source_size(&child_final)?);
596 }
597 Err(error) => {
598 failed = true;
599 log.write(serde_json::json!({
600 "level": "error",
601 "step": "copy",
602 "subtree": item.name,
603 "path": child_source,
604 "status": "error",
605 "error": error.to_string(),
606 }));
607 }
608 }
609 }
610
611 if failed {
612 return Err(io::Error::other("one or more children failed to copy"));
613 }
614
615 log.write(serde_json::json!({
616 "level": "info",
617 "step": "copy",
618 "subtree": item.name,
619 "status": "ok",
620 "bytes": copied_bytes,
621 }));
622 Ok(())
623}
624
625fn merge_trust_file(args: &MigrationArgs, source: &Path, log: &mut JsonLogger) -> io::Result<()> {
626 let target = args.to.join("trusted-filter-projects.json");
627 let mut paths = Vec::new();
628 let mut seen = BTreeSet::new();
629 for value in [
630 read_json_string_array(&target)?,
631 read_json_string_array(source)?,
632 ] {
633 for item in value {
634 if seen.insert(item.clone()) {
635 paths.push(item);
636 }
637 }
638 }
639 atomic_write_json(&target, &paths)?;
640 log.write(serde_json::json!({
641 "level": "info",
642 "step": "copy",
643 "subtree": "trusted-filter-projects.json",
644 "status": "ok",
645 "entries": paths.len(),
646 }));
647 Ok(())
648}
649
650fn read_json_string_array(path: &Path) -> io::Result<Vec<String>> {
651 match fs::read(path) {
652 Ok(bytes) => serde_json::from_slice::<Vec<String>>(&bytes).map_err(io::Error::other),
653 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
654 Err(error) => Err(error),
655 }
656}
657
658fn target_path(args: &MigrationArgs, item: MigrationItem) -> PathBuf {
659 match item.target {
660 TargetKind::Harness => args.to.join(args.harness.as_str()).join(item.name),
661 TargetKind::Root => args.to.join(item.name),
662 }
663}
664
665fn staging_path(final_path: &Path, subtree: &str) -> PathBuf {
666 let final_name = final_path
667 .file_name()
668 .and_then(|name| name.to_str())
669 .unwrap_or(subtree);
670 final_path.with_file_name(format!(
671 "staging-{subtree}-{final_name}-{}-{}",
672 std::process::id(),
673 now_millis()
674 ))
675}
676
677fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
678 fs::create_dir_all(target)?;
679 for entry in sorted_read_dir(source)? {
680 let source_path = entry.path();
681 let target_path = target.join(entry.file_name());
682 if source_path.is_dir() {
683 copy_dir_recursive(&source_path, &target_path)?;
684 } else {
685 copy_file(&source_path, &target_path)?;
686 }
687 }
688 sync_path(target);
689 Ok(())
690}
691
692fn copy_file(source: &Path, target: &Path) -> io::Result<u64> {
693 if let Some(parent) = target.parent() {
694 fs::create_dir_all(parent)?;
695 }
696 let bytes = fs::copy(source, target)?;
697 sync_path(target);
698 Ok(bytes)
699}
700
701fn sorted_read_dir(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
702 let mut entries = fs::read_dir(path)?.collect::<io::Result<Vec<_>>>()?;
703 entries.sort_by_key(|entry| entry.file_name());
704 Ok(entries)
705}
706
707fn source_size(path: &Path) -> io::Result<u64> {
708 if !path.exists() {
709 return Ok(0);
710 }
711 let metadata = fs::metadata(path)?;
712 if metadata.is_file() {
713 return Ok(metadata.len());
714 }
715 let mut total = 0_u64;
716 for entry in fs::read_dir(path)? {
717 let entry = entry?;
718 total = total.saturating_add(source_size(&entry.path())?);
719 }
720 Ok(total)
721}
722
723#[cfg(unix)]
724fn free_bytes_at(path: &Path) -> io::Result<u64> {
725 use std::ffi::CString;
726 use std::os::unix::ffi::OsStrExt;
727
728 let probe = existing_ancestor(path);
729 let c_path = CString::new(probe.as_os_str().as_bytes())
730 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
731 let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
732 let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
733 if result != 0 {
734 return Err(io::Error::last_os_error());
735 }
736 let stat = unsafe { stat.assume_init() };
737 Ok((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
738}
739
740#[cfg(windows)]
741fn free_bytes_at(_path: &Path) -> io::Result<u64> {
742 Ok(u64::MAX)
745}
746
747fn existing_ancestor(path: &Path) -> &Path {
748 let mut current = path;
749 while !current.exists() {
750 if let Some(parent) = current.parent() {
751 current = parent;
752 } else {
753 break;
754 }
755 }
756 current
757}
758
759#[derive(Serialize, Deserialize)]
760struct Marker {
761 timestamp: String,
762 source_path: String,
763 target_path: String,
764 harness: String,
765 aft_version: String,
766}
767
768fn marker(args: &MigrationArgs) -> Marker {
769 Marker {
770 timestamp: iso_timestamp_now(),
771 source_path: args.from.display().to_string(),
772 target_path: args.to.display().to_string(),
773 harness: args.harness.as_str().to_string(),
774 aft_version: env!("CARGO_PKG_VERSION").to_string(),
775 }
776}
777
778fn write_source_marker(args: &MigrationArgs) -> io::Result<()> {
779 atomic_write_json(&source_marker_path(args), &marker(args))
780}
781
782fn write_target_marker(args: &MigrationArgs) -> io::Result<()> {
783 fs::create_dir_all(args.to.join(args.harness.as_str()))?;
784 atomic_write_json(&target_marker_path(args), &marker(args))
785}
786
787fn source_marker_path(args: &MigrationArgs) -> PathBuf {
788 args.from.join(SOURCE_MARKER)
789}
790
791fn target_marker_path(args: &MigrationArgs) -> PathBuf {
792 target_marker_path_from(&args.to, args.harness)
793}
794
795fn target_marker_path_from(target_root: &Path, harness: Harness) -> PathBuf {
796 target_root.join(harness.as_str()).join(TARGET_MARKER)
797}
798
799fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
800 if let Some(parent) = path.parent() {
801 fs::create_dir_all(parent)?;
802 }
803 let tmp = path.with_file_name(format!(
804 ".{}.tmp.{}.{}",
805 path.file_name()
806 .and_then(|name| name.to_str())
807 .unwrap_or("marker"),
808 std::process::id(),
809 now_millis()
810 ));
811 let result = (|| {
812 let mut file = File::create(&tmp)?;
813 serde_json::to_writer(&mut file, value).map_err(io::Error::other)?;
814 file.write_all(b"\n")?;
815 file.sync_all()?;
816 drop(file);
817 fs::rename(&tmp, path)?;
818 if let Some(parent) = path.parent() {
819 sync_path(parent);
820 }
821 Ok(())
822 })();
823 if result.is_err() {
824 let _ = fs::remove_file(&tmp);
825 }
826 result
827}
828
829fn sync_path(path: &Path) {
830 if let Ok(file) = File::open(path) {
831 let _ = file.sync_all();
832 }
833}
834
835pub fn parse_cli_args<I, S>(args: I) -> Result<Args, String>
836where
837 I: IntoIterator<Item = S>,
838 S: Into<OsString>,
839{
840 let mut from = None;
841 let mut to = None;
842 let mut harness = None;
843 let mut log = None;
844 let mut status = false;
845 let mut iter = args.into_iter().map(Into::into);
846 while let Some(arg) = iter.next() {
847 let arg = arg.to_string_lossy().to_string();
848 if arg == "--status" {
849 status = true;
850 continue;
851 }
852 let value = match arg.as_str() {
853 "--from" | "--to" | "--harness" | "--log" => iter
854 .next()
855 .ok_or_else(|| format!("missing value for {arg}"))?,
856 "--help" | "-h" => return Err(help_text()),
857 other => return Err(format!("unknown argument: {other}\n\n{}", help_text())),
858 };
859 match arg.as_str() {
860 "--from" => from = Some(PathBuf::from(value)),
861 "--to" => to = Some(PathBuf::from(value)),
862 "--harness" => {
863 let value = value.to_string_lossy();
864 harness = Some(
865 value
866 .parse::<Harness>()
867 .map_err(|err| format!("invalid harness: {err}\n\n{}", help_text()))?,
868 );
869 }
870 "--log" => log = Some(PathBuf::from(value)),
871 _ => unreachable!(),
872 }
873 }
874
875 let to = to.ok_or_else(|| format!("missing required --to\n\n{}", help_text()))?;
876 let harness =
877 harness.ok_or_else(|| format!("missing required --harness\n\n{}", help_text()))?;
878 if status {
879 return Ok(Args {
880 from,
881 to,
882 harness,
883 log,
884 status,
885 });
886 }
887
888 Ok(Args {
889 from: Some(from.ok_or_else(|| format!("missing required --from\n\n{}", help_text()))?),
890 to,
891 harness,
892 log: Some(log.ok_or_else(|| format!("missing required --log\n\n{}", help_text()))?),
893 status,
894 })
895}
896pub fn help_text() -> String {
897 "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\
898Blocking one-shot migration from legacy AFT storage into the CortexKit-rooted layout.\n\n\
899Exit 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"
900 .to_string()
901}