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