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