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