1use std::ffi::OsStr;
2use std::path::{Component, Path, PathBuf};
3
4use crate::config::{
5 Config, FileOperationSettings, FileOperationSettingsInput, RuntimeOptionOverrides,
6 normalize_file_operation_settings,
7};
8use crate::context;
9use crate::{
10 ActionPlan, ActionPlanOptions, Error, ExecuteOptions, Executor, FileOperation,
11 FileOperationKind, OutputEvent, PlanOrigin, Reporter, Result, SourceSpan, SymlinkMode,
12 SyncCompare, Worktree, WorktreeOptions,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ManualFileOperationOptions {
18 pub operation: FileOperationKind,
20 pub sources: Vec<PathBuf>,
22 pub target: Option<PathBuf>,
24 pub required: bool,
26 pub symlinks: Option<SymlinkMode>,
28 pub compare: Option<SyncCompare>,
30 pub delete: Option<bool>,
32}
33
34impl Default for ManualFileOperationOptions {
35 fn default() -> Self {
36 Self {
37 operation: FileOperationKind::Copy,
38 sources: Vec::new(),
39 target: None,
40 required: false,
41 symlinks: None,
42 compare: None,
43 delete: None,
44 }
45 }
46}
47
48impl FileOperation {
49 pub fn from_manual_options(
60 context: &Worktree,
61 options: ManualFileOperationOptions,
62 ) -> Result<Vec<Self>> {
63 let settings = validate_manual_options(
64 options.operation,
65 &options.sources,
66 options.symlinks,
67 options.compare,
68 options.delete,
69 )?;
70 manual_operations(options, context, settings)
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FileOperationOptions {
77 pub cwd: Option<PathBuf>,
79 pub root: Option<PathBuf>,
81 pub operation: FileOperationKind,
83 pub sources: Vec<PathBuf>,
85 pub target: Option<PathBuf>,
87 pub required: bool,
89 pub symlinks: Option<SymlinkMode>,
91 pub compare: Option<SyncCompare>,
93 pub delete: Option<bool>,
95 pub strict: bool,
97 pub force: bool,
99 pub dry_run: bool,
101}
102
103impl Default for FileOperationOptions {
104 fn default() -> Self {
105 Self {
106 cwd: None,
107 root: None,
108 operation: FileOperationKind::Copy,
109 sources: Vec::new(),
110 target: None,
111 required: false,
112 symlinks: None,
113 compare: None,
114 delete: None,
115 strict: false,
116 force: false,
117 dry_run: false,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum FileOperationAction {
125 RootWorktreeSkipped,
127 Applied,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct FileOperationReport {
134 pub context: Worktree,
136 pub operation: FileOperationKind,
138 pub action: FileOperationAction,
140 pub action_count: usize,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct FileOperationCompletionOptions {
147 pub cwd: Option<PathBuf>,
149 pub root: Option<PathBuf>,
151 pub current: PathBuf,
153}
154
155pub fn run_file_operation(
163 options: FileOperationOptions,
164 reporter: &mut dyn Reporter,
165) -> Result<FileOperationReport> {
166 let FileOperationOptions {
167 cwd,
168 root,
169 operation,
170 sources,
171 target,
172 required,
173 symlinks,
174 compare,
175 delete,
176 strict,
177 force,
178 dry_run,
179 } = options;
180 let manual_options = ManualFileOperationOptions {
181 operation,
182 sources,
183 target,
184 required,
185 symlinks,
186 compare,
187 delete,
188 };
189
190 let env_options = RuntimeOptionOverrides::from_env()?;
191 let pre_config_strict = env_options.pre_config_strict(strict);
192 let context = context::resolve(&WorktreeOptions { cwd, root })?;
193
194 if context.root_path == context.worktree_path {
195 report(reporter, OutputEvent::RootWorktreeDetected)?;
196
197 if pre_config_strict {
198 return Err(Error::RootWorktreeStrict);
199 }
200
201 return Ok(FileOperationReport {
202 context,
203 operation,
204 action: FileOperationAction::RootWorktreeSkipped,
205 action_count: 0,
206 });
207 }
208
209 let config_options = Config::load_discovered(&context, None)?
210 .map(|loaded| loaded.config.options)
211 .unwrap_or_default();
212 let plan_options = env_options.resolve(&config_options, strict);
213 let operations = FileOperation::from_manual_options(&context, manual_options)?;
214 let plan = ActionPlan::from_file_operations(
215 &context,
216 PlanOrigin::Manual { operation },
217 &operations,
218 ActionPlanOptions::from(plan_options),
219 )?;
220 let report = Executor::new(ExecuteOptions {
221 strict: plan_options.strict,
222 force,
223 dry_run,
224 skip_commands: true,
225 })
226 .execute_files(&plan, reporter)?;
227 let context = plan.context;
228
229 Ok(FileOperationReport {
230 context,
231 operation,
232 action: FileOperationAction::Applied,
233 action_count: report.file_action_count,
234 })
235}
236
237#[must_use]
242pub fn file_operation_source_candidates(options: FileOperationCompletionOptions) -> Vec<String> {
243 let Ok(context) = context::resolve(&WorktreeOptions {
244 cwd: options.cwd,
245 root: options.root,
246 }) else {
247 return Vec::new();
248 };
249
250 source_candidates(&context.root_path, &options.current)
251}
252
253fn validate_manual_options(
254 operation: FileOperationKind,
255 sources: &[PathBuf],
256 symlinks: Option<SymlinkMode>,
257 compare: Option<SyncCompare>,
258 delete: Option<bool>,
259) -> Result<FileOperationSettings> {
260 if sources.is_empty() {
261 return invalid_manual(operation, "at least one source is required");
262 }
263
264 normalize_file_operation_settings(
265 operation,
266 FileOperationSettingsInput {
267 compare,
268 delete,
269 symlinks,
270 },
271 )
272 .map_err(|field| Error::FileOperationInvalid {
273 operation: operation.as_str(),
274 message: format!(
275 "`{}` is only valid for {}",
276 field.name(),
277 field.allowed_operations()
278 ),
279 })
280}
281
282fn manual_operations(
283 options: ManualFileOperationOptions,
284 context: &Worktree,
285 settings: FileOperationSettings,
286) -> Result<Vec<FileOperation>> {
287 let ManualFileOperationOptions {
288 operation,
289 sources,
290 target,
291 required,
292 ..
293 } = options;
294 let multiple_sources = sources.len() > 1;
295 sources
296 .into_iter()
297 .map(|source| {
298 let target = manual_target(operation, &source, target.as_deref(), multiple_sources)?;
299 Ok(FileOperation {
300 operation,
301 source_path: resolve_path(&context.root_path, &source),
302 target_path: resolve_path(&context.worktree_path, &target),
303 source,
304 target,
305 required,
306 compare: settings.compare,
307 delete: settings.delete,
308 symlinks: settings.symlinks,
309 declaration: manual_span(),
310 })
311 })
312 .collect()
313}
314
315fn manual_target(
316 operation: FileOperationKind,
317 source: &Path,
318 target: Option<&Path>,
319 multiple_sources: bool,
320) -> Result<PathBuf> {
321 match (target, multiple_sources) {
322 (None, _) => Ok(source.to_path_buf()),
323 (Some(target), false) => Ok(target.to_path_buf()),
324 (Some(target), true) => {
325 if source.is_absolute() {
326 let Some(name) = source.file_name() else {
327 return invalid_manual(
328 operation,
329 format!("cannot derive target for source {}", source.display()),
330 );
331 };
332 return Ok(target.join(name));
333 }
334
335 Ok(target.join(source))
336 }
337 }
338}
339
340fn source_candidates(root: &Path, current: &Path) -> Vec<String> {
341 if current.is_absolute()
342 || current.components().any(|component| {
343 matches!(
344 component,
345 Component::ParentDir | Component::RootDir | Component::Prefix(_)
346 )
347 })
348 {
349 return Vec::new();
350 }
351
352 let (search_prefix, needle) = split_candidate(current);
353 let search_root = root.join(search_prefix);
354 let Ok(entries) = std::fs::read_dir(search_root) else {
355 return Vec::new();
356 };
357 let needle = needle.to_string_lossy();
358 let mut candidates = entries
359 .filter_map(|entry| {
360 let entry = entry.ok()?;
361 let name = entry.file_name();
362 let name_lossy = name.to_string_lossy();
363 if !name_lossy.starts_with(needle.as_ref()) {
364 return None;
365 }
366
367 let mut candidate = search_prefix.to_path_buf();
368 candidate.push(&name);
369 let mut value = candidate.to_string_lossy().into_owned();
370 if entry.file_type().ok()?.is_dir() {
371 value.push(std::path::MAIN_SEPARATOR);
372 }
373 Some(value)
374 })
375 .collect::<Vec<_>>();
376
377 candidates.sort();
378 candidates
379}
380
381fn split_candidate(path: &Path) -> (&Path, &OsStr) {
382 if path.as_os_str().is_empty() {
383 return (Path::new(""), OsStr::new(""));
384 }
385
386 if has_trailing_separator(path) {
387 return (path, OsStr::new(""));
388 }
389
390 (
391 path.parent().unwrap_or_else(|| Path::new("")),
392 path.file_name().unwrap_or_else(|| OsStr::new("")),
393 )
394}
395
396fn has_trailing_separator(path: &Path) -> bool {
397 path.as_os_str().to_string_lossy().ends_with(['/', '\\'])
398}
399
400fn resolve_path(base: &Path, path: &Path) -> PathBuf {
401 if path.is_absolute() {
402 path.to_path_buf()
403 } else {
404 base.join(path)
405 }
406}
407
408const fn manual_span() -> SourceSpan {
409 SourceSpan {
410 start: 0,
411 end: 0,
412 line: 0,
413 column: 0,
414 }
415}
416
417fn invalid_manual<T>(operation: FileOperationKind, message: impl Into<String>) -> Result<T> {
418 Err(Error::FileOperationInvalid {
419 operation: operation.as_str(),
420 message: message.into(),
421 })
422}
423
424fn report(reporter: &mut dyn Reporter, event: OutputEvent) -> Result<()> {
425 reporter
426 .report(event)
427 .map_err(|source| Error::Output { source })
428}
429
430#[cfg(test)]
431mod tests {
432 use std::collections::BTreeMap;
433 use std::ffi::OsString;
434 use std::time::{SystemTime, UNIX_EPOCH};
435
436 use super::*;
437
438 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
439 let id = SystemTime::now()
440 .duration_since(UNIX_EPOCH)
441 .expect("clock should be after Unix epoch")
442 .as_nanos();
443 let base = std::env::temp_dir().join(format!("treeboot-manual-{name}-{id}"));
444 let root = base.join("root");
445 let worktree = base.join("worktree");
446
447 std::fs::create_dir_all(&root).expect("root should be created");
448 std::fs::create_dir_all(&worktree).expect("worktree should be created");
449
450 (root, worktree)
451 }
452
453 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
454 Worktree {
455 root_path: root_path.to_path_buf(),
456 worktree_path: worktree_path.to_path_buf(),
457 default_branch: "main".to_owned(),
458 environment: BTreeMap::from([(
459 "TREEBOOT_ROOT_PATH".to_owned(),
460 OsString::from(root_path),
461 )]),
462 }
463 }
464
465 fn options(operation: FileOperationKind, sources: &[&str]) -> ManualFileOperationOptions {
466 ManualFileOperationOptions {
467 operation,
468 sources: sources.iter().map(PathBuf::from).collect(),
469 target: None,
470 required: false,
471 symlinks: None,
472 compare: None,
473 delete: None,
474 }
475 }
476
477 #[test]
478 fn manual_operations_should_map_single_source_to_same_target() {
479 let (root, worktree) = temp_workspace("single-default-target");
480 let context = context(&root, &worktree);
481 let options = options(FileOperationKind::Copy, &[".env"]);
482 let operations = FileOperation::from_manual_options(&context, options)
483 .expect("operation should normalize");
484
485 assert_eq!(operations[0].source, PathBuf::from(".env"));
486 assert_eq!(operations[0].target, PathBuf::from(".env"));
487 assert_eq!(operations[0].source_path, root.join(".env"));
488 assert_eq!(operations[0].target_path, worktree.join(".env"));
489 }
490
491 #[test]
492 fn manual_operations_should_map_single_source_to_exact_target() {
493 let (root, worktree) = temp_workspace("single-exact-target");
494 let context = context(&root, &worktree);
495 let mut options = options(FileOperationKind::Copy, &[".env"]);
496 options.target = Some(PathBuf::from("local/.env"));
497
498 let operations = FileOperation::from_manual_options(&context, options)
499 .expect("operation should normalize");
500
501 assert_eq!(operations[0].target, PathBuf::from("local/.env"));
502 assert_eq!(operations[0].target_path, worktree.join("local/.env"));
503 }
504
505 #[test]
506 fn manual_operations_should_map_multiple_sources_to_default_targets() {
507 let (root, worktree) = temp_workspace("multi-default-target");
508 let context = context(&root, &worktree);
509 let options = options(FileOperationKind::Copy, &[".env", ".npmrc"]);
510 let operations = FileOperation::from_manual_options(&context, options)
511 .expect("operation should normalize");
512
513 assert_eq!(operations[0].target_path, worktree.join(".env"));
514 assert_eq!(operations[1].target_path, worktree.join(".npmrc"));
515 }
516
517 #[test]
518 fn manual_operations_should_map_multiple_sources_under_target_prefix() {
519 let (root, worktree) = temp_workspace("multi-target-prefix");
520 let context = context(&root, &worktree);
521 let mut options = options(FileOperationKind::Copy, &["a", "nested/c"]);
522 options.target = Some(PathBuf::from("local"));
523
524 let operations = FileOperation::from_manual_options(&context, options)
525 .expect("operation should normalize");
526
527 assert_eq!(operations[0].source_path, root.join("a"));
528 assert_eq!(operations[0].target_path, worktree.join("local/a"));
529 assert_eq!(operations[1].source_path, root.join("nested/c"));
530 assert_eq!(operations[1].target_path, worktree.join("local/nested/c"));
531 }
532
533 #[test]
534 fn manual_operations_should_map_multiple_absolute_sources_by_name() {
535 let (root, worktree) = temp_workspace("multi-absolute-target-prefix");
536 let context = context(&root, &worktree);
537 let source = root.join("a");
538 let mut options = ManualFileOperationOptions {
539 operation: FileOperationKind::Copy,
540 sources: vec![source.clone()],
541 target: None,
542 required: false,
543 symlinks: None,
544 compare: None,
545 delete: None,
546 };
547 options.sources.push(root.join("b"));
548 options.target = Some(PathBuf::from("local"));
549
550 let operations = FileOperation::from_manual_options(&context, options)
551 .expect("operation should normalize");
552
553 assert_eq!(operations[0].source_path, source);
554 assert_eq!(operations[0].target_path, worktree.join("local/a"));
555 assert_eq!(operations[1].target_path, worktree.join("local/b"));
556 }
557
558 #[test]
559 fn manual_target_should_reject_absolute_source_without_file_name() {
560 let temp_dir = std::env::temp_dir();
561 let root_source = temp_dir
562 .ancestors()
563 .last()
564 .expect("temp dir should have a filesystem root");
565 assert!(root_source.is_absolute());
566 assert!(root_source.file_name().is_none());
567
568 let error = manual_target(
569 FileOperationKind::Copy,
570 root_source,
571 Some(Path::new("local")),
572 true,
573 )
574 .expect_err("root path should not have a file name");
575
576 assert!(error.to_string().contains("cannot derive target"));
577 }
578
579 #[test]
580 fn validate_manual_options_should_reject_symlink_mode_for_symlink() {
581 let mut options = options(FileOperationKind::Symlink, &["link"]);
582 options.symlinks = Some(SymlinkMode::Preserve);
583
584 let error = validate_manual_options(
585 options.operation,
586 &options.sources,
587 options.symlinks,
588 options.compare,
589 options.delete,
590 )
591 .expect_err("symlinks should fail");
592
593 assert!(error.to_string().contains("invalid symlink file operation"));
594 assert!(error.to_string().contains("only valid for copy and sync"));
595 }
596
597 #[test]
598 fn validate_manual_options_should_reject_compare_for_copy() {
599 let mut options = options(FileOperationKind::Copy, &["file"]);
600 options.compare = Some(SyncCompare::Checksum);
601
602 let error = validate_manual_options(
603 options.operation,
604 &options.sources,
605 options.symlinks,
606 options.compare,
607 options.delete,
608 )
609 .expect_err("compare should fail");
610
611 assert!(
612 error
613 .to_string()
614 .contains("`compare` is only valid for sync")
615 );
616 }
617
618 #[test]
619 fn validate_manual_options_should_reject_delete_for_copy() {
620 let mut options = options(FileOperationKind::Copy, &["file"]);
621 options.delete = Some(true);
622
623 let error = validate_manual_options(
624 options.operation,
625 &options.sources,
626 options.symlinks,
627 options.compare,
628 options.delete,
629 )
630 .expect_err("delete should fail");
631
632 assert!(
633 error
634 .to_string()
635 .contains("`delete` is only valid for sync")
636 );
637 }
638
639 #[test]
640 fn validate_manual_options_should_reject_compare_for_symlink() {
641 let mut options = options(FileOperationKind::Symlink, &["file"]);
642 options.compare = Some(SyncCompare::Metadata);
643
644 let error = validate_manual_options(
645 options.operation,
646 &options.sources,
647 options.symlinks,
648 options.compare,
649 options.delete,
650 )
651 .expect_err("compare should fail");
652
653 assert!(
654 error
655 .to_string()
656 .contains("`compare` is only valid for sync")
657 );
658 }
659
660 #[test]
661 fn validate_manual_options_should_reject_delete_for_symlink() {
662 let mut options = options(FileOperationKind::Symlink, &["file"]);
663 options.delete = Some(false);
664
665 let error = validate_manual_options(
666 options.operation,
667 &options.sources,
668 options.symlinks,
669 options.compare,
670 options.delete,
671 )
672 .expect_err("delete should fail");
673
674 assert!(
675 error
676 .to_string()
677 .contains("`delete` is only valid for sync")
678 );
679 }
680
681 #[test]
682 fn validate_manual_options_should_reject_empty_sources() {
683 let options = options(FileOperationKind::Copy, &[]);
684 let error = validate_manual_options(
685 options.operation,
686 &options.sources,
687 options.symlinks,
688 options.compare,
689 options.delete,
690 )
691 .expect_err("empty sources should fail");
692
693 assert!(
694 error
695 .to_string()
696 .contains("at least one source is required")
697 );
698 }
699
700 #[test]
701 fn manual_operations_should_preserve_explicit_sync_options() {
702 let (root, worktree) = temp_workspace("sync-options");
703 let context = context(&root, &worktree);
704 let mut options = options(FileOperationKind::Sync, &["shared"]);
705 options.compare = Some(SyncCompare::Checksum);
706 options.delete = Some(true);
707 options.symlinks = Some(SymlinkMode::Preserve);
708
709 let operations = FileOperation::from_manual_options(&context, options)
710 .expect("operation should normalize");
711
712 assert_eq!(operations[0].compare, Some(SyncCompare::Checksum));
713 assert_eq!(operations[0].delete, Some(true));
714 assert_eq!(operations[0].symlinks, Some(SymlinkMode::Preserve));
715 }
716
717 #[test]
718 fn source_candidates_should_list_root_relative_files_and_dirs() {
719 let (root, _worktree) = temp_workspace("source-candidates");
720 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("file should be written");
721 std::fs::create_dir_all(root.join("shared/nested")).expect("dir should be created");
722
723 assert_eq!(
724 source_candidates(&root, Path::new("")),
725 vec![
726 ".env".to_owned(),
727 format!("shared{}", std::path::MAIN_SEPARATOR)
728 ]
729 );
730 assert_eq!(
731 source_candidates(&root, Path::new("shared/")),
732 vec![format!("shared/nested{}", std::path::MAIN_SEPARATOR)]
733 );
734 }
735
736 #[test]
737 fn source_candidates_should_fail_quietly_for_missing_prefix() {
738 let (root, _worktree) = temp_workspace("source-candidates-missing");
739
740 assert!(source_candidates(&root, Path::new("missing/")).is_empty());
741 }
742
743 #[test]
744 fn source_candidates_should_fail_quietly_for_absolute_current_value() {
745 let (root, _worktree) = temp_workspace("source-candidates-absolute");
746
747 assert!(source_candidates(&root, Path::new("/tmp")).is_empty());
748 }
749
750 #[test]
751 fn source_candidates_should_not_escape_root_with_parent_segments() {
752 let (root, _worktree) = temp_workspace("source-candidates-parent");
753 std::fs::write(root.join("inside"), "ok\n").expect("file should be written");
754
755 assert!(source_candidates(&root, Path::new("../")).is_empty());
756 assert!(source_candidates(&root, Path::new("nested/../../")).is_empty());
757 }
758
759 #[test]
760 fn file_operation_source_candidates_should_fail_quietly_outside_git() {
761 let (root, _worktree) = temp_workspace("completion-outside-git");
762
763 assert!(
764 file_operation_source_candidates(FileOperationCompletionOptions {
765 cwd: Some(root),
766 root: None,
767 current: PathBuf::new(),
768 })
769 .is_empty()
770 );
771 }
772
773 #[test]
774 fn manual_validation_error_should_not_look_like_config_error() {
775 let (root, worktree) = temp_workspace("manual-error-origin");
776 let error = ActionPlan::from_file_operations(
777 &context(&root, &worktree),
778 PlanOrigin::Manual {
779 operation: FileOperationKind::Copy,
780 },
781 &[FileOperation {
782 operation: FileOperationKind::Copy,
783 source: PathBuf::from("../outside"),
784 target: PathBuf::from("outside"),
785 source_path: root.join("../outside"),
786 target_path: worktree.join("outside"),
787 required: false,
788 compare: None,
789 delete: None,
790 symlinks: Some(SymlinkMode::Preserve),
791 declaration: manual_span(),
792 }],
793 ActionPlanOptions::default(),
794 )
795 .expect_err("outside source should fail");
796
797 assert!(error.to_string().contains("invalid copy file operation"));
798 assert!(!error.to_string().contains("invalid config"));
799 assert!(!error.to_string().contains("line"));
800 assert!(!error.to_string().contains(".treeboot.toml"));
801 }
802
803 #[test]
804 fn strict_manual_sync_should_fail_before_side_effects() {
805 let (root, worktree) = temp_workspace("strict-sync");
806 std::fs::create_dir_all(root.join("shared")).expect("source should be created");
807 let error = ActionPlan::from_file_operations(
808 &context(&root, &worktree),
809 PlanOrigin::Manual {
810 operation: FileOperationKind::Sync,
811 },
812 &[FileOperation {
813 operation: FileOperationKind::Sync,
814 source: PathBuf::from("shared"),
815 target: PathBuf::from("shared"),
816 source_path: root.join("shared"),
817 target_path: worktree.join("shared"),
818 required: false,
819 compare: Some(SyncCompare::Metadata),
820 delete: Some(false),
821 symlinks: Some(SymlinkMode::Preserve),
822 declaration: manual_span(),
823 }],
824 ActionPlanOptions {
825 strict: true,
826 ..ActionPlanOptions::default()
827 },
828 )
829 .expect_err("strict sync should fail");
830
831 assert!(error.to_string().contains("cannot be used with sync"));
832 assert!(!worktree.join("shared").exists());
833 }
834}