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