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