1use std::ffi::OsStr;
2use std::path::{Component, Path, PathBuf};
3
4use crate::config::{
5 Config, FileOperationSettings, FileOperationSettingsInput, RawMetadataField,
6 RuntimeOptionOverrides, effective_ignore_patterns, normalize_file_operation_settings,
7};
8use crate::context;
9use crate::{
10 ActionPlan, ActionPlanOptions, EnvironmentInput, Error, ExecuteOptions, Executor,
11 FileOperation, FileOperationKind, MetadataField, OutputEvent, PlanOrigin, Reporter, Result,
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 env_options = RuntimeOptionOverrides::from_environment(&environment)?;
275 let pre_config_strict = env_options.pre_config_strict(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 = env_options.resolve(&config_options, strict);
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 ActionPlanOptions::from(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
556 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
557 let id = SystemTime::now()
558 .duration_since(UNIX_EPOCH)
559 .expect("clock should be after Unix epoch")
560 .as_nanos();
561 let base = std::env::temp_dir().join(format!("treeboot-manual-{name}-{id}"));
562 let root = base.join("root");
563 let worktree = base.join("worktree");
564
565 std::fs::create_dir_all(&root).expect("root should be created");
566 std::fs::create_dir_all(&worktree).expect("worktree should be created");
567
568 (root, worktree)
569 }
570
571 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
572 Worktree {
573 root_path: root_path.to_path_buf(),
574 worktree_path: worktree_path.to_path_buf(),
575 default_branch: "main".to_owned(),
576 environment: BTreeMap::from([(
577 "TREEBOOT_ROOT_PATH".to_owned(),
578 OsString::from(root_path),
579 )]),
580 }
581 }
582
583 fn options(operation: FileOperationKind, sources: &[&str]) -> ManualFileOperationOptions {
584 ManualFileOperationOptions {
585 operation,
586 sources: sources.iter().map(PathBuf::from).collect(),
587 target: None,
588 required: false,
589 symlinks: None,
590 compare: None,
591 delete: None,
592 ignore: Vec::new(),
593 ignore_metadata: Vec::new(),
594 }
595 }
596
597 #[test]
598 fn manual_operations_should_map_single_source_to_same_target() {
599 let (root, worktree) = temp_workspace("single-default-target");
600 let context = context(&root, &worktree);
601 let options = options(FileOperationKind::Copy, &[".env"]);
602 let operations = FileOperation::from_manual_options(&context, options)
603 .expect("operation should normalize");
604
605 assert_eq!(operations[0].source, PathBuf::from(".env"));
606 assert_eq!(operations[0].target, PathBuf::from(".env"));
607 assert_eq!(operations[0].source_path, root.join(".env"));
608 assert_eq!(operations[0].target_path, worktree.join(".env"));
609 }
610
611 #[test]
612 fn manual_operations_should_map_single_source_to_exact_target() {
613 let (root, worktree) = temp_workspace("single-exact-target");
614 let context = context(&root, &worktree);
615 let mut options = options(FileOperationKind::Copy, &[".env"]);
616 options.target = Some(PathBuf::from("local/.env"));
617
618 let operations = FileOperation::from_manual_options(&context, options)
619 .expect("operation should normalize");
620
621 assert_eq!(operations[0].target, PathBuf::from("local/.env"));
622 assert_eq!(operations[0].target_path, worktree.join("local/.env"));
623 }
624
625 #[test]
626 fn manual_operations_should_map_multiple_sources_to_default_targets() {
627 let (root, worktree) = temp_workspace("multi-default-target");
628 let context = context(&root, &worktree);
629 let options = options(FileOperationKind::Copy, &[".env", ".npmrc"]);
630 let operations = FileOperation::from_manual_options(&context, options)
631 .expect("operation should normalize");
632
633 assert_eq!(operations[0].target_path, worktree.join(".env"));
634 assert_eq!(operations[1].target_path, worktree.join(".npmrc"));
635 }
636
637 #[test]
638 fn manual_operations_should_map_multiple_sources_under_target_prefix() {
639 let (root, worktree) = temp_workspace("multi-target-prefix");
640 let context = context(&root, &worktree);
641 let mut options = options(FileOperationKind::Copy, &["a", "nested/c"]);
642 options.target = Some(PathBuf::from("local"));
643
644 let operations = FileOperation::from_manual_options(&context, options)
645 .expect("operation should normalize");
646
647 assert_eq!(operations[0].source_path, root.join("a"));
648 assert_eq!(operations[0].target_path, worktree.join("local/a"));
649 assert_eq!(operations[1].source_path, root.join("nested/c"));
650 assert_eq!(operations[1].target_path, worktree.join("local/nested/c"));
651 }
652
653 #[test]
654 fn manual_operations_should_map_multiple_absolute_sources_by_name() {
655 let (root, worktree) = temp_workspace("multi-absolute-target-prefix");
656 let context = context(&root, &worktree);
657 let source = root.join("a");
658 let mut options = ManualFileOperationOptions {
659 operation: FileOperationKind::Copy,
660 sources: vec![source.clone()],
661 target: None,
662 required: false,
663 symlinks: None,
664 compare: None,
665 delete: None,
666 ignore: Vec::new(),
667 ignore_metadata: Vec::new(),
668 };
669 options.sources.push(root.join("b"));
670 options.target = Some(PathBuf::from("local"));
671
672 let operations = FileOperation::from_manual_options(&context, options)
673 .expect("operation should normalize");
674
675 assert_eq!(operations[0].source_path, source);
676 assert_eq!(operations[0].target_path, worktree.join("local/a"));
677 assert_eq!(operations[1].target_path, worktree.join("local/b"));
678 }
679
680 #[test]
681 fn manual_target_should_reject_absolute_source_without_file_name() {
682 let temp_dir = std::env::temp_dir();
683 let root_source = temp_dir
684 .ancestors()
685 .last()
686 .expect("temp dir should have a filesystem root");
687 assert!(root_source.is_absolute());
688 assert!(root_source.file_name().is_none());
689
690 let error = manual_target(
691 FileOperationKind::Copy,
692 root_source,
693 Some(Path::new("local")),
694 true,
695 )
696 .expect_err("root path should not have a file name");
697
698 assert!(error.to_string().contains("cannot derive target"));
699 }
700
701 #[test]
702 fn validate_manual_options_should_reject_symlink_mode_for_symlink() {
703 let mut options = options(FileOperationKind::Symlink, &["link"]);
704 options.symlinks = Some(SymlinkMode::Preserve);
705
706 let error = validate_manual_options(
707 options.operation,
708 &options.sources,
709 options.symlinks,
710 options.compare,
711 options.delete,
712 &options.ignore,
713 &options.ignore_metadata,
714 )
715 .expect_err("symlinks should fail");
716
717 assert!(error.to_string().contains("invalid symlink file operation"));
718 assert!(error.to_string().contains("only valid for copy and sync"));
719 }
720
721 #[test]
722 fn validate_manual_options_should_reject_compare_for_copy() {
723 let mut options = options(FileOperationKind::Copy, &["file"]);
724 options.compare = Some(SyncCompare::Checksum);
725
726 let error = validate_manual_options(
727 options.operation,
728 &options.sources,
729 options.symlinks,
730 options.compare,
731 options.delete,
732 &options.ignore,
733 &options.ignore_metadata,
734 )
735 .expect_err("compare should fail");
736
737 assert!(
738 error
739 .to_string()
740 .contains("`compare` is only valid for sync")
741 );
742 }
743
744 #[test]
745 fn validate_manual_options_should_reject_delete_for_copy() {
746 let mut options = options(FileOperationKind::Copy, &["file"]);
747 options.delete = Some(true);
748
749 let error = validate_manual_options(
750 options.operation,
751 &options.sources,
752 options.symlinks,
753 options.compare,
754 options.delete,
755 &options.ignore,
756 &options.ignore_metadata,
757 )
758 .expect_err("delete should fail");
759
760 assert!(
761 error
762 .to_string()
763 .contains("`delete` is only valid for sync")
764 );
765 }
766
767 #[test]
768 fn validate_manual_options_should_reject_compare_for_symlink() {
769 let mut options = options(FileOperationKind::Symlink, &["file"]);
770 options.compare = Some(SyncCompare::Metadata);
771
772 let error = validate_manual_options(
773 options.operation,
774 &options.sources,
775 options.symlinks,
776 options.compare,
777 options.delete,
778 &options.ignore,
779 &options.ignore_metadata,
780 )
781 .expect_err("compare should fail");
782
783 assert!(
784 error
785 .to_string()
786 .contains("`compare` is only valid for sync")
787 );
788 }
789
790 #[test]
791 fn validate_manual_options_should_reject_delete_for_symlink() {
792 let mut options = options(FileOperationKind::Symlink, &["file"]);
793 options.delete = Some(false);
794
795 let error = validate_manual_options(
796 options.operation,
797 &options.sources,
798 options.symlinks,
799 options.compare,
800 options.delete,
801 &options.ignore,
802 &options.ignore_metadata,
803 )
804 .expect_err("delete should fail");
805
806 assert!(
807 error
808 .to_string()
809 .contains("`delete` is only valid for sync")
810 );
811 }
812
813 #[test]
814 fn validate_manual_options_should_reject_empty_sources() {
815 let options = options(FileOperationKind::Copy, &[]);
816 let error = validate_manual_options(
817 options.operation,
818 &options.sources,
819 options.symlinks,
820 options.compare,
821 options.delete,
822 &options.ignore,
823 &options.ignore_metadata,
824 )
825 .expect_err("empty sources should fail");
826
827 assert!(
828 error
829 .to_string()
830 .contains("at least one source is required")
831 );
832 }
833
834 #[test]
835 fn manual_operations_should_preserve_explicit_sync_options() {
836 let (root, worktree) = temp_workspace("sync-options");
837 let context = context(&root, &worktree);
838 let mut options = options(FileOperationKind::Sync, &["shared"]);
839 options.compare = Some(SyncCompare::Checksum);
840 options.delete = Some(true);
841 options.symlinks = Some(SymlinkMode::Preserve);
842 options.ignore = vec!["**/vendor/**".to_owned(), "!**/vendor/keep/**".to_owned()];
843 options.ignore_metadata = vec![MetadataField::Owner, MetadataField::Group];
844
845 let operations = FileOperation::from_manual_options(&context, options)
846 .expect("operation should normalize");
847
848 assert_eq!(operations[0].compare, Some(SyncCompare::Checksum));
849 assert_eq!(operations[0].delete, Some(true));
850 assert_eq!(operations[0].symlinks, Some(SymlinkMode::Preserve));
851 assert_eq!(
852 operations[0].ignore,
853 vec!["**/vendor/**", "!**/vendor/keep/**"]
854 );
855 assert_eq!(
856 operations[0].ignore_metadata,
857 vec![MetadataField::Owner, MetadataField::Group]
858 );
859 }
860
861 #[test]
862 fn validate_manual_options_should_reject_ignore_for_symlink() {
863 let mut options = options(FileOperationKind::Symlink, &["file"]);
864 options.ignore = vec!["**/tmp/**".to_owned()];
865
866 let error = validate_manual_options(
867 options.operation,
868 &options.sources,
869 options.symlinks,
870 options.compare,
871 options.delete,
872 &options.ignore,
873 &options.ignore_metadata,
874 )
875 .expect_err("ignore should fail");
876
877 assert!(
878 error
879 .to_string()
880 .contains("`ignore` is only valid for copy and sync")
881 );
882 }
883
884 #[test]
885 fn validate_manual_options_should_reject_ignored_metadata_for_symlink() {
886 let mut options = options(FileOperationKind::Symlink, &["file"]);
887 options.ignore_metadata = vec![MetadataField::Permissions];
888
889 let error = validate_manual_options(
890 options.operation,
891 &options.sources,
892 options.symlinks,
893 options.compare,
894 options.delete,
895 &options.ignore,
896 &options.ignore_metadata,
897 )
898 .expect_err("ignore_metadata should fail");
899
900 assert!(
901 error
902 .to_string()
903 .contains("`ignore_metadata` is only valid for copy and sync")
904 );
905 }
906
907 #[test]
908 fn source_candidates_should_list_root_relative_files_and_dirs() {
909 let (root, _worktree) = temp_workspace("source-candidates");
910 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("file should be written");
911 std::fs::create_dir_all(root.join("shared/nested")).expect("dir should be created");
912
913 assert_eq!(
914 source_candidates(&root, Path::new("")),
915 vec![
916 ".env".to_owned(),
917 format!("shared{}", std::path::MAIN_SEPARATOR)
918 ]
919 );
920 assert_eq!(
921 source_candidates(&root, Path::new("shared/")),
922 vec![format!("shared/nested{}", std::path::MAIN_SEPARATOR)]
923 );
924 }
925
926 #[test]
927 fn source_candidates_should_fail_quietly_for_missing_prefix() {
928 let (root, _worktree) = temp_workspace("source-candidates-missing");
929
930 assert!(source_candidates(&root, Path::new("missing/")).is_empty());
931 }
932
933 #[test]
934 fn source_candidates_should_fail_quietly_for_absolute_current_value() {
935 let (root, _worktree) = temp_workspace("source-candidates-absolute");
936
937 assert!(source_candidates(&root, Path::new("/tmp")).is_empty());
938 }
939
940 #[test]
941 fn source_candidates_should_not_escape_root_with_parent_segments() {
942 let (root, _worktree) = temp_workspace("source-candidates-parent");
943 std::fs::write(root.join("inside"), "ok\n").expect("file should be written");
944
945 assert!(source_candidates(&root, Path::new("../")).is_empty());
946 assert!(source_candidates(&root, Path::new("nested/../../")).is_empty());
947 }
948
949 #[test]
950 fn file_operation_source_candidates_should_fail_quietly_outside_git() {
951 let (root, _worktree) = temp_workspace("completion-outside-git");
952
953 assert!(
954 file_operation_source_candidates(FileOperationCompletionOptions {
955 cwd: Some(root),
956 root: None,
957 environment: EnvironmentInput::empty(),
958 current: PathBuf::new(),
959 })
960 .is_empty()
961 );
962 }
963
964 #[test]
965 fn manual_validation_error_should_not_look_like_config_error() {
966 let (root, worktree) = temp_workspace("manual-error-origin");
967 let error = ActionPlan::from_file_operations(
968 &context(&root, &worktree),
969 PlanOrigin::Manual {
970 operation: FileOperationKind::Copy,
971 },
972 &[FileOperation {
973 operation: FileOperationKind::Copy,
974 source: PathBuf::from("../outside"),
975 target: PathBuf::from("outside"),
976 source_path: root.join("../outside"),
977 target_path: worktree.join("outside"),
978 required: false,
979 compare: None,
980 delete: None,
981 symlinks: Some(SymlinkMode::Preserve),
982 ignore: Vec::new(),
983 ignore_metadata: Vec::new(),
984 declaration: manual_span(),
985 }],
986 ActionPlanOptions::default(),
987 )
988 .expect_err("outside source should fail");
989
990 assert!(error.to_string().contains("invalid copy file operation"));
991 assert!(!error.to_string().contains("invalid config"));
992 assert!(!error.to_string().contains("line"));
993 assert!(!error.to_string().contains(".treeboot.toml"));
994 }
995
996 #[test]
997 fn strict_manual_sync_should_fail_before_side_effects() {
998 let (root, worktree) = temp_workspace("strict-sync");
999 std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1000 let error = ActionPlan::from_file_operations(
1001 &context(&root, &worktree),
1002 PlanOrigin::Manual {
1003 operation: FileOperationKind::Sync,
1004 },
1005 &[FileOperation {
1006 operation: FileOperationKind::Sync,
1007 source: PathBuf::from("shared"),
1008 target: PathBuf::from("shared"),
1009 source_path: root.join("shared"),
1010 target_path: worktree.join("shared"),
1011 required: false,
1012 compare: Some(SyncCompare::Metadata),
1013 delete: Some(false),
1014 symlinks: Some(SymlinkMode::Preserve),
1015 ignore: Vec::new(),
1016 ignore_metadata: Vec::new(),
1017 declaration: manual_span(),
1018 }],
1019 ActionPlanOptions {
1020 strict: true,
1021 ..ActionPlanOptions::default()
1022 },
1023 )
1024 .expect_err("strict sync should fail");
1025
1026 assert!(error.to_string().contains("cannot be used with sync"));
1027 assert!(!worktree.join("shared").exists());
1028 }
1029}