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, effective_ignore_patterns, 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    /// 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 env_options = RuntimeOptionOverrides::from_environment(&environment)?;
275    let pre_config_strict = env_options.pre_config_strict(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 = env_options.resolve(&config_options, strict);
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        ActionPlanOptions::from(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
556    fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
557        let id = SystemTime::now()
558            .duration_since(UNIX_EPOCH)
559            .expect("clock should be after Unix epoch")
560            .as_nanos();
561        let base = std::env::temp_dir().join(format!("treeboot-manual-{name}-{id}"));
562        let root = base.join("root");
563        let worktree = base.join("worktree");
564
565        std::fs::create_dir_all(&root).expect("root should be created");
566        std::fs::create_dir_all(&worktree).expect("worktree should be created");
567
568        (root, worktree)
569    }
570
571    fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
572        Worktree {
573            root_path: root_path.to_path_buf(),
574            worktree_path: worktree_path.to_path_buf(),
575            default_branch: "main".to_owned(),
576            environment: BTreeMap::from([(
577                "TREEBOOT_ROOT_PATH".to_owned(),
578                OsString::from(root_path),
579            )]),
580        }
581    }
582
583    fn options(operation: FileOperationKind, sources: &[&str]) -> ManualFileOperationOptions {
584        ManualFileOperationOptions {
585            operation,
586            sources: sources.iter().map(PathBuf::from).collect(),
587            target: None,
588            required: false,
589            symlinks: None,
590            compare: None,
591            delete: None,
592            ignore: Vec::new(),
593            ignore_metadata: Vec::new(),
594        }
595    }
596
597    #[test]
598    fn manual_operations_should_map_single_source_to_same_target() {
599        let (root, worktree) = temp_workspace("single-default-target");
600        let context = context(&root, &worktree);
601        let options = options(FileOperationKind::Copy, &[".env"]);
602        let operations = FileOperation::from_manual_options(&context, options)
603            .expect("operation should normalize");
604
605        assert_eq!(operations[0].source, PathBuf::from(".env"));
606        assert_eq!(operations[0].target, PathBuf::from(".env"));
607        assert_eq!(operations[0].source_path, root.join(".env"));
608        assert_eq!(operations[0].target_path, worktree.join(".env"));
609    }
610
611    #[test]
612    fn manual_operations_should_map_single_source_to_exact_target() {
613        let (root, worktree) = temp_workspace("single-exact-target");
614        let context = context(&root, &worktree);
615        let mut options = options(FileOperationKind::Copy, &[".env"]);
616        options.target = Some(PathBuf::from("local/.env"));
617
618        let operations = FileOperation::from_manual_options(&context, options)
619            .expect("operation should normalize");
620
621        assert_eq!(operations[0].target, PathBuf::from("local/.env"));
622        assert_eq!(operations[0].target_path, worktree.join("local/.env"));
623    }
624
625    #[test]
626    fn manual_operations_should_map_multiple_sources_to_default_targets() {
627        let (root, worktree) = temp_workspace("multi-default-target");
628        let context = context(&root, &worktree);
629        let options = options(FileOperationKind::Copy, &[".env", ".npmrc"]);
630        let operations = FileOperation::from_manual_options(&context, options)
631            .expect("operation should normalize");
632
633        assert_eq!(operations[0].target_path, worktree.join(".env"));
634        assert_eq!(operations[1].target_path, worktree.join(".npmrc"));
635    }
636
637    #[test]
638    fn manual_operations_should_map_multiple_sources_under_target_prefix() {
639        let (root, worktree) = temp_workspace("multi-target-prefix");
640        let context = context(&root, &worktree);
641        let mut options = options(FileOperationKind::Copy, &["a", "nested/c"]);
642        options.target = Some(PathBuf::from("local"));
643
644        let operations = FileOperation::from_manual_options(&context, options)
645            .expect("operation should normalize");
646
647        assert_eq!(operations[0].source_path, root.join("a"));
648        assert_eq!(operations[0].target_path, worktree.join("local/a"));
649        assert_eq!(operations[1].source_path, root.join("nested/c"));
650        assert_eq!(operations[1].target_path, worktree.join("local/nested/c"));
651    }
652
653    #[test]
654    fn manual_operations_should_map_multiple_absolute_sources_by_name() {
655        let (root, worktree) = temp_workspace("multi-absolute-target-prefix");
656        let context = context(&root, &worktree);
657        let source = root.join("a");
658        let mut options = ManualFileOperationOptions {
659            operation: FileOperationKind::Copy,
660            sources: vec![source.clone()],
661            target: None,
662            required: false,
663            symlinks: None,
664            compare: None,
665            delete: None,
666            ignore: Vec::new(),
667            ignore_metadata: Vec::new(),
668        };
669        options.sources.push(root.join("b"));
670        options.target = Some(PathBuf::from("local"));
671
672        let operations = FileOperation::from_manual_options(&context, options)
673            .expect("operation should normalize");
674
675        assert_eq!(operations[0].source_path, source);
676        assert_eq!(operations[0].target_path, worktree.join("local/a"));
677        assert_eq!(operations[1].target_path, worktree.join("local/b"));
678    }
679
680    #[test]
681    fn manual_target_should_reject_absolute_source_without_file_name() {
682        let temp_dir = std::env::temp_dir();
683        let root_source = temp_dir
684            .ancestors()
685            .last()
686            .expect("temp dir should have a filesystem root");
687        assert!(root_source.is_absolute());
688        assert!(root_source.file_name().is_none());
689
690        let error = manual_target(
691            FileOperationKind::Copy,
692            root_source,
693            Some(Path::new("local")),
694            true,
695        )
696        .expect_err("root path should not have a file name");
697
698        assert!(error.to_string().contains("cannot derive target"));
699    }
700
701    #[test]
702    fn validate_manual_options_should_reject_symlink_mode_for_symlink() {
703        let mut options = options(FileOperationKind::Symlink, &["link"]);
704        options.symlinks = Some(SymlinkMode::Preserve);
705
706        let error = validate_manual_options(
707            options.operation,
708            &options.sources,
709            options.symlinks,
710            options.compare,
711            options.delete,
712            &options.ignore,
713            &options.ignore_metadata,
714        )
715        .expect_err("symlinks should fail");
716
717        assert!(error.to_string().contains("invalid symlink file operation"));
718        assert!(error.to_string().contains("only valid for copy and sync"));
719    }
720
721    #[test]
722    fn validate_manual_options_should_reject_compare_for_copy() {
723        let mut options = options(FileOperationKind::Copy, &["file"]);
724        options.compare = Some(SyncCompare::Checksum);
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,
733            &options.ignore_metadata,
734        )
735        .expect_err("compare should fail");
736
737        assert!(
738            error
739                .to_string()
740                .contains("`compare` is only valid for sync")
741        );
742    }
743
744    #[test]
745    fn validate_manual_options_should_reject_delete_for_copy() {
746        let mut options = options(FileOperationKind::Copy, &["file"]);
747        options.delete = Some(true);
748
749        let error = validate_manual_options(
750            options.operation,
751            &options.sources,
752            options.symlinks,
753            options.compare,
754            options.delete,
755            &options.ignore,
756            &options.ignore_metadata,
757        )
758        .expect_err("delete should fail");
759
760        assert!(
761            error
762                .to_string()
763                .contains("`delete` is only valid for sync")
764        );
765    }
766
767    #[test]
768    fn validate_manual_options_should_reject_compare_for_symlink() {
769        let mut options = options(FileOperationKind::Symlink, &["file"]);
770        options.compare = Some(SyncCompare::Metadata);
771
772        let error = validate_manual_options(
773            options.operation,
774            &options.sources,
775            options.symlinks,
776            options.compare,
777            options.delete,
778            &options.ignore,
779            &options.ignore_metadata,
780        )
781        .expect_err("compare should fail");
782
783        assert!(
784            error
785                .to_string()
786                .contains("`compare` is only valid for sync")
787        );
788    }
789
790    #[test]
791    fn validate_manual_options_should_reject_delete_for_symlink() {
792        let mut options = options(FileOperationKind::Symlink, &["file"]);
793        options.delete = Some(false);
794
795        let error = validate_manual_options(
796            options.operation,
797            &options.sources,
798            options.symlinks,
799            options.compare,
800            options.delete,
801            &options.ignore,
802            &options.ignore_metadata,
803        )
804        .expect_err("delete should fail");
805
806        assert!(
807            error
808                .to_string()
809                .contains("`delete` is only valid for sync")
810        );
811    }
812
813    #[test]
814    fn validate_manual_options_should_reject_empty_sources() {
815        let options = options(FileOperationKind::Copy, &[]);
816        let error = validate_manual_options(
817            options.operation,
818            &options.sources,
819            options.symlinks,
820            options.compare,
821            options.delete,
822            &options.ignore,
823            &options.ignore_metadata,
824        )
825        .expect_err("empty sources should fail");
826
827        assert!(
828            error
829                .to_string()
830                .contains("at least one source is required")
831        );
832    }
833
834    #[test]
835    fn manual_operations_should_preserve_explicit_sync_options() {
836        let (root, worktree) = temp_workspace("sync-options");
837        let context = context(&root, &worktree);
838        let mut options = options(FileOperationKind::Sync, &["shared"]);
839        options.compare = Some(SyncCompare::Checksum);
840        options.delete = Some(true);
841        options.symlinks = Some(SymlinkMode::Preserve);
842        options.ignore = vec!["**/vendor/**".to_owned(), "!**/vendor/keep/**".to_owned()];
843        options.ignore_metadata = vec![MetadataField::Owner, MetadataField::Group];
844
845        let operations = FileOperation::from_manual_options(&context, options)
846            .expect("operation should normalize");
847
848        assert_eq!(operations[0].compare, Some(SyncCompare::Checksum));
849        assert_eq!(operations[0].delete, Some(true));
850        assert_eq!(operations[0].symlinks, Some(SymlinkMode::Preserve));
851        assert_eq!(
852            operations[0].ignore,
853            vec!["**/vendor/**", "!**/vendor/keep/**"]
854        );
855        assert_eq!(
856            operations[0].ignore_metadata,
857            vec![MetadataField::Owner, MetadataField::Group]
858        );
859    }
860
861    #[test]
862    fn validate_manual_options_should_reject_ignore_for_symlink() {
863        let mut options = options(FileOperationKind::Symlink, &["file"]);
864        options.ignore = vec!["**/tmp/**".to_owned()];
865
866        let error = validate_manual_options(
867            options.operation,
868            &options.sources,
869            options.symlinks,
870            options.compare,
871            options.delete,
872            &options.ignore,
873            &options.ignore_metadata,
874        )
875        .expect_err("ignore should fail");
876
877        assert!(
878            error
879                .to_string()
880                .contains("`ignore` is only valid for copy and sync")
881        );
882    }
883
884    #[test]
885    fn validate_manual_options_should_reject_ignored_metadata_for_symlink() {
886        let mut options = options(FileOperationKind::Symlink, &["file"]);
887        options.ignore_metadata = vec![MetadataField::Permissions];
888
889        let error = validate_manual_options(
890            options.operation,
891            &options.sources,
892            options.symlinks,
893            options.compare,
894            options.delete,
895            &options.ignore,
896            &options.ignore_metadata,
897        )
898        .expect_err("ignore_metadata should fail");
899
900        assert!(
901            error
902                .to_string()
903                .contains("`ignore_metadata` is only valid for copy and sync")
904        );
905    }
906
907    #[test]
908    fn source_candidates_should_list_root_relative_files_and_dirs() {
909        let (root, _worktree) = temp_workspace("source-candidates");
910        std::fs::write(root.join(".env"), "TOKEN=1\n").expect("file should be written");
911        std::fs::create_dir_all(root.join("shared/nested")).expect("dir should be created");
912
913        assert_eq!(
914            source_candidates(&root, Path::new("")),
915            vec![
916                ".env".to_owned(),
917                format!("shared{}", std::path::MAIN_SEPARATOR)
918            ]
919        );
920        assert_eq!(
921            source_candidates(&root, Path::new("shared/")),
922            vec![format!("shared/nested{}", std::path::MAIN_SEPARATOR)]
923        );
924    }
925
926    #[test]
927    fn source_candidates_should_fail_quietly_for_missing_prefix() {
928        let (root, _worktree) = temp_workspace("source-candidates-missing");
929
930        assert!(source_candidates(&root, Path::new("missing/")).is_empty());
931    }
932
933    #[test]
934    fn source_candidates_should_fail_quietly_for_absolute_current_value() {
935        let (root, _worktree) = temp_workspace("source-candidates-absolute");
936
937        assert!(source_candidates(&root, Path::new("/tmp")).is_empty());
938    }
939
940    #[test]
941    fn source_candidates_should_not_escape_root_with_parent_segments() {
942        let (root, _worktree) = temp_workspace("source-candidates-parent");
943        std::fs::write(root.join("inside"), "ok\n").expect("file should be written");
944
945        assert!(source_candidates(&root, Path::new("../")).is_empty());
946        assert!(source_candidates(&root, Path::new("nested/../../")).is_empty());
947    }
948
949    #[test]
950    fn file_operation_source_candidates_should_fail_quietly_outside_git() {
951        let (root, _worktree) = temp_workspace("completion-outside-git");
952
953        assert!(
954            file_operation_source_candidates(FileOperationCompletionOptions {
955                cwd: Some(root),
956                root: None,
957                environment: EnvironmentInput::empty(),
958                current: PathBuf::new(),
959            })
960            .is_empty()
961        );
962    }
963
964    #[test]
965    fn manual_validation_error_should_not_look_like_config_error() {
966        let (root, worktree) = temp_workspace("manual-error-origin");
967        let error = ActionPlan::from_file_operations(
968            &context(&root, &worktree),
969            PlanOrigin::Manual {
970                operation: FileOperationKind::Copy,
971            },
972            &[FileOperation {
973                operation: FileOperationKind::Copy,
974                source: PathBuf::from("../outside"),
975                target: PathBuf::from("outside"),
976                source_path: root.join("../outside"),
977                target_path: worktree.join("outside"),
978                required: false,
979                compare: None,
980                delete: None,
981                symlinks: Some(SymlinkMode::Preserve),
982                ignore: Vec::new(),
983                ignore_metadata: Vec::new(),
984                declaration: manual_span(),
985            }],
986            ActionPlanOptions::default(),
987        )
988        .expect_err("outside source should fail");
989
990        assert!(error.to_string().contains("invalid copy file operation"));
991        assert!(!error.to_string().contains("invalid config"));
992        assert!(!error.to_string().contains("line"));
993        assert!(!error.to_string().contains(".treeboot.toml"));
994    }
995
996    #[test]
997    fn strict_manual_sync_should_fail_before_side_effects() {
998        let (root, worktree) = temp_workspace("strict-sync");
999        std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1000        let error = ActionPlan::from_file_operations(
1001            &context(&root, &worktree),
1002            PlanOrigin::Manual {
1003                operation: FileOperationKind::Sync,
1004            },
1005            &[FileOperation {
1006                operation: FileOperationKind::Sync,
1007                source: PathBuf::from("shared"),
1008                target: PathBuf::from("shared"),
1009                source_path: root.join("shared"),
1010                target_path: worktree.join("shared"),
1011                required: false,
1012                compare: Some(SyncCompare::Metadata),
1013                delete: Some(false),
1014                symlinks: Some(SymlinkMode::Preserve),
1015                ignore: Vec::new(),
1016                ignore_metadata: Vec::new(),
1017                declaration: manual_span(),
1018            }],
1019            ActionPlanOptions {
1020                strict: true,
1021                ..ActionPlanOptions::default()
1022            },
1023        )
1024        .expect_err("strict sync should fail");
1025
1026        assert!(error.to_string().contains("cannot be used with sync"));
1027        assert!(!worktree.join("shared").exists());
1028    }
1029}