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 ignored by copy and sync.
34    pub ignore: Vec<String>,
35    /// Metadata fields ignored by copy and sync.
36    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    /// Creates manual copy operation options for the given sources.
57    #[must_use]
58    pub fn copy(sources: Vec<PathBuf>) -> Self {
59        Self::new(FileOperationKind::Copy, sources)
60    }
61
62    /// Creates manual symlink operation options for the given sources.
63    #[must_use]
64    pub fn symlink(sources: Vec<PathBuf>) -> Self {
65        Self::new(FileOperationKind::Symlink, sources)
66    }
67
68    /// Creates manual sync operation options for the given sources.
69    #[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    /// Builds normalized manual file operation specs for an action plan.
85    ///
86    /// This applies the same target derivation and option validation used by
87    /// `treeboot copy`, `treeboot symlink`, and `treeboot sync`.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error when the manual operation has no sources, when an
92    /// option is not valid for the selected operation kind, or when a target
93    /// cannot be derived for an absolute source.
94    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/// Options for running one manual file operation command.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct FileOperationOptions {
114    /// Directory from which the operation starts. Defaults to the process cwd.
115    pub cwd: Option<PathBuf>,
116    /// Overrides the root checkout used as the source base.
117    pub root: Option<PathBuf>,
118    /// Explicit environment input used for compatibility discovery and options.
119    pub environment: EnvironmentInput,
120    /// File operation kind to run.
121    pub operation: FileOperationKind,
122    /// Source paths resolved from the root checkout.
123    pub sources: Vec<PathBuf>,
124    /// Optional target path resolved from the current worktree.
125    pub target: Option<PathBuf>,
126    /// Fails when a source is missing.
127    pub required: bool,
128    /// How copy and sync should treat source symlinks.
129    pub symlinks: Option<SymlinkMode>,
130    /// Sync comparison mode.
131    pub compare: Option<SyncCompare>,
132    /// Whether sync should delete target-only files.
133    pub delete: Option<bool>,
134    /// Source-relative path patterns ignored by copy and sync.
135    pub ignore: Vec<String>,
136    /// Metadata fields ignored by copy and sync.
137    pub ignore_metadata: Vec<MetadataField>,
138    /// Fails on stricter file-operation conflicts.
139    pub strict: bool,
140    /// Replaces existing file-operation targets where supported.
141    pub force: bool,
142    /// Prints planned work without changing files.
143    pub dry_run: bool,
144    /// Prints detailed file-operation actions instead of compact summaries.
145    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    /// 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        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/// Returns source completion candidates relative to the resolved root checkout.
334///
335/// Errors during context resolution or directory scanning are intentionally
336/// quiet, making this suitable for shell completion hooks.
337#[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}