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