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