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