Skip to main content

treeboot_core/
manual.rs

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/// Options for building manual file operation specs.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ManualFileOperationOptions {
19    /// File operation kind to build.
20    pub operation: FileOperationKind,
21    /// Source paths resolved from the root checkout.
22    pub sources: Vec<PathBuf>,
23    /// Optional target path resolved from the current worktree.
24    pub target: Option<PathBuf>,
25    /// Fails when a source is missing.
26    pub required: bool,
27    /// How copy and sync should treat source symlinks.
28    pub symlinks: Option<SymlinkMode>,
29    /// Sync comparison mode.
30    pub compare: Option<SyncCompare>,
31    /// Whether sync should delete target-only files.
32    pub delete: Option<bool>,
33    /// Source-relative path patterns that narrow copy and sync directory
34    /// traversal to matching source paths. Empty means no include filtering.
35    pub include: Vec<String>,
36    /// Source-relative path patterns ignored by copy and sync.
37    pub ignore: Vec<String>,
38    /// Metadata fields ignored by copy and sync.
39    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    /// Creates manual copy operation options for the given sources.
61    #[must_use]
62    pub fn copy(sources: Vec<PathBuf>) -> Self {
63        Self::new(FileOperationKind::Copy, sources)
64    }
65
66    /// Creates manual symlink operation options for the given sources.
67    #[must_use]
68    pub fn symlink(sources: Vec<PathBuf>) -> Self {
69        Self::new(FileOperationKind::Symlink, sources)
70    }
71
72    /// Creates manual sync operation options for the given sources.
73    #[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    /// Builds normalized manual file operation specs for an action plan.
89    ///
90    /// This applies the same target derivation and option validation used by
91    /// `treeboot copy`, `treeboot symlink`, and `treeboot sync`.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error when the manual operation has no sources, when an
96    /// option is not valid for the selected operation kind, or when a target
97    /// cannot be derived for an absolute source.
98    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/// Options for running one manual file operation command.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct FileOperationOptions {
110    /// Directory from which the operation starts. Defaults to the process cwd.
111    pub cwd: Option<PathBuf>,
112    /// Overrides the root checkout used as the source base.
113    pub root: Option<PathBuf>,
114    /// Explicit environment input used for compatibility discovery and options.
115    pub environment: EnvironmentInput,
116    /// File operation kind to run.
117    pub operation: FileOperationKind,
118    /// Source paths resolved from the root checkout.
119    pub sources: Vec<PathBuf>,
120    /// Optional target path resolved from the current worktree.
121    pub target: Option<PathBuf>,
122    /// Fails when a source is missing.
123    pub required: bool,
124    /// How copy and sync should treat source symlinks.
125    pub symlinks: Option<SymlinkMode>,
126    /// Sync comparison mode.
127    pub compare: Option<SyncCompare>,
128    /// Whether sync should delete target-only files.
129    pub delete: Option<bool>,
130    /// Source-relative path patterns that narrow copy and sync directory
131    /// traversal to matching source paths. Empty means no include filtering.
132    pub include: Vec<String>,
133    /// Source-relative path patterns ignored by copy and sync.
134    pub ignore: Vec<String>,
135    /// Metadata fields ignored by copy and sync.
136    pub ignore_metadata: Vec<MetadataField>,
137    /// Fails on stricter file-operation conflicts.
138    pub strict: bool,
139    /// Replaces existing file-operation targets where supported.
140    pub force: bool,
141    /// Prints planned work without changing files.
142    pub dry_run: bool,
143    /// Prints detailed file-operation actions instead of compact summaries.
144    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    /// Creates copy command options for the given sources.
173    #[must_use]
174    pub fn copy(sources: Vec<PathBuf>) -> Self {
175        Self::new(FileOperationKind::Copy, sources)
176    }
177
178    /// Creates symlink command options for the given sources.
179    #[must_use]
180    pub fn symlink(sources: Vec<PathBuf>) -> Self {
181        Self::new(FileOperationKind::Symlink, sources)
182    }
183
184    /// Creates sync command options for the given sources.
185    #[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/// Completed action for a manual file operation invocation.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum FileOperationAction {
202    /// The command started from the root checkout and had no work to do.
203    RootWorktreeSkipped,
204    /// File operations were planned and applied.
205    Applied,
206}
207
208/// Result summary for a manual file operation invocation.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct FileOperationReport {
211    /// Runtime context used by the operation.
212    pub context: Worktree,
213    /// File operation kind that ran.
214    pub operation: FileOperationKind,
215    /// Completed action.
216    pub action: FileOperationAction,
217    /// Number of file actions that were applied or reported.
218    pub action_count: usize,
219}
220
221/// Options for root-relative source completion.
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
223pub struct FileOperationCompletionOptions {
224    /// Directory from which completion starts. Defaults to the process cwd.
225    pub cwd: Option<PathBuf>,
226    /// Overrides the root checkout used as the completion base.
227    pub root: Option<PathBuf>,
228    /// Explicit environment input used for compatibility discovery.
229    pub environment: EnvironmentInput,
230    /// Current partial value being completed.
231    pub current: PathBuf,
232}
233
234/// Runs a manual copy, symlink, or sync file operation.
235///
236/// # Errors
237///
238/// Returns an error if context discovery fails, options are invalid,
239/// validation rejects the operation, output reporting fails, or applying the
240/// file operation fails.
241pub 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/// Returns source completion candidates relative to the resolved root checkout.
336///
337/// Errors during context resolution or directory scanning are intentionally
338/// quiet, making this suitable for shell completion hooks.
339#[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}