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