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