Skip to main content

treeboot_core/
validation.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use std::fmt;
5
6use crate::config::include_with_delete_message;
7use crate::file_system::{TargetAncestorIssue, inspect_target_ancestors, matching_target_anchor};
8use crate::path_filter::{
9    PathIgnoreRules, PathIncludeRules, include_matches_any_source_path, invalid_include_pattern,
10};
11use crate::paths;
12use crate::{
13    CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
14    FileOperationKind, MetadataField, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
15};
16
17/// Options that affect declarative run planning.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct ActionPlanOptions {
20    /// Rejects sync operations and other strict-mode conflicts.
21    pub strict: bool,
22    /// Allows file operation sources outside the root checkout.
23    pub dangerously_allow_sources_outside_root: bool,
24    /// Allows file operation targets outside the current worktree.
25    pub dangerously_allow_targets_outside_worktree: bool,
26}
27
28impl From<ConfigRuntimeOptions> for ActionPlanOptions {
29    fn from(options: ConfigRuntimeOptions) -> Self {
30        Self {
31            strict: options.strict,
32            dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
33            dangerously_allow_targets_outside_worktree: options
34                .dangerously_allow_targets_outside_worktree,
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy)]
40pub(super) enum FilePlanOrigin<'a> {
41    Config(&'a Path),
42    Manual { operation: FileOperationKind },
43}
44
45/// Source of a validated action plan.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PlanOrigin {
48    /// Plan was built from a treeboot manifest.
49    Manifest {
50        /// Manifest path.
51        path: PathBuf,
52    },
53    /// Plan was built from a manual file operation command.
54    Manual {
55        /// Manual operation kind.
56        operation: FileOperationKind,
57    },
58}
59
60/// A validated set of file operations and commands ready for execution.
61///
62/// Plans can only be built through validation constructors. Callers may inspect
63/// plans through accessor methods, but cannot construct or mutate planned work
64/// directly.
65///
66/// ```compile_fail
67/// # use treeboot_core::ActionPlan;
68/// # fn cannot_construct() {
69/// ActionPlan {
70///     context: todo!(),
71///     origin: todo!(),
72///     config_path: None,
73///     files: Vec::new(),
74///     commands: Vec::new(),
75/// };
76/// # }
77/// ```
78///
79/// ```compile_fail
80/// # use treeboot_core::ActionPlan;
81/// # fn cannot_mutate(plan: &mut ActionPlan) {
82/// plan.commands = Vec::new();
83/// # }
84/// ```
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ActionPlan {
87    /// Runtime context used while building the plan.
88    context: Worktree,
89    /// Origin of this plan.
90    origin: PlanOrigin,
91    /// Config file used for this plan, when it came from a manifest.
92    config_path: Option<PathBuf>,
93    /// Planned file operations.
94    files: Vec<PlannedFileOperation>,
95    /// Planned command operations.
96    commands: Vec<PlannedCommand>,
97    /// Non-fatal warnings produced while building the plan.
98    warnings: Vec<PlanWarning>,
99}
100
101/// Non-fatal warning produced while building an action plan.
102///
103/// Warnings never fail validation. `treeboot check` and `treeboot config`
104/// surface them; `run` intentionally does not report them.
105#[derive(Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum PlanWarning {
108    /// A non-empty include list matched no source paths before ignore
109    /// filtering.
110    IncludeMatchedNothing {
111        /// File operation kind.
112        operation: FileOperationKind,
113        /// Declared source path.
114        source: PathBuf,
115        /// Declared target path.
116        target: PathBuf,
117    },
118}
119
120impl fmt::Display for PlanWarning {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::IncludeMatchedNothing {
124                operation,
125                source,
126                target,
127            } => write!(
128                formatter,
129                "include patterns match no source paths for {} {} -> {}",
130                operation,
131                source.display(),
132                target.display()
133            ),
134        }
135    }
136}
137
138impl ActionPlan {
139    /// Returns the runtime context used while building the plan.
140    #[must_use]
141    pub const fn context(&self) -> &Worktree {
142        &self.context
143    }
144
145    /// Returns non-fatal warnings produced while building the plan.
146    #[must_use]
147    pub fn warnings(&self) -> &[PlanWarning] {
148        &self.warnings
149    }
150
151    /// Returns the origin of this plan.
152    #[must_use]
153    pub const fn origin(&self) -> &PlanOrigin {
154        &self.origin
155    }
156
157    /// Returns the config file used for this plan, when it came from a manifest.
158    #[must_use]
159    pub fn config_path(&self) -> Option<&Path> {
160        self.config_path.as_deref()
161    }
162
163    /// Returns the planned file operations.
164    #[must_use]
165    pub fn files(&self) -> &[PlannedFileOperation] {
166        &self.files
167    }
168
169    /// Returns the planned command operations.
170    #[must_use]
171    pub fn commands(&self) -> &[PlannedCommand] {
172        &self.commands
173    }
174
175    /// Builds a validated action plan from a parsed treeboot manifest.
176    ///
177    /// This does not apply file operations or execute commands. It normalizes
178    /// paths that may not exist yet, rejects invalid declarative behavior, and
179    /// marks optional missing-source file operations as skipped.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if manifest validation fails.
184    pub fn from_manifest(
185        path: &Path,
186        manifest: &Config,
187        context: &Worktree,
188        options: ActionPlanOptions,
189    ) -> Result<Self> {
190        let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
191            invalid_config_error(
192                path,
193                None,
194                format!("failed to resolve worktree path: {source}"),
195            )
196        })?;
197        let (files, warnings) = plan_file_operations(
198            FilePlanOrigin::Config(path),
199            &manifest.files,
200            context,
201            options,
202        )?;
203        let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
204
205        Ok(Self {
206            context: context.clone(),
207            origin: PlanOrigin::Manifest {
208                path: path.to_path_buf(),
209            },
210            config_path: Some(path.to_path_buf()),
211            files,
212            commands,
213            warnings,
214        })
215    }
216
217    /// Builds a validated action plan from explicit file operations.
218    ///
219    /// This is intended for manual commands and other callers that already
220    /// have a discovered worktree context and operation list.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if file operation validation fails.
225    pub fn from_file_operations(
226        context: &Worktree,
227        origin: PlanOrigin,
228        files: &[FileOperation],
229        options: ActionPlanOptions,
230    ) -> Result<Self> {
231        let file_origin = match &origin {
232            PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
233            PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
234                operation: *operation,
235            },
236        };
237        let (files, warnings) = plan_file_operations(file_origin, files, context, options)?;
238        let config_path = match &origin {
239            PlanOrigin::Manifest { path } => Some(path.clone()),
240            PlanOrigin::Manual { .. } => None,
241        };
242
243        Ok(Self {
244            context: context.clone(),
245            origin,
246            config_path,
247            files,
248            commands: Vec::new(),
249            warnings,
250        })
251    }
252
253    #[cfg(test)]
254    pub(crate) fn from_parts_unchecked(
255        context: Worktree,
256        origin: PlanOrigin,
257        config_path: Option<PathBuf>,
258        files: Vec<PlannedFileOperation>,
259        commands: Vec<PlannedCommand>,
260    ) -> Self {
261        Self {
262            context,
263            origin,
264            config_path,
265            files,
266            commands,
267            warnings: Vec::new(),
268        }
269    }
270}
271
272/// A validated file operation ready for execution.
273///
274/// ```compile_fail
275/// # use treeboot_core::PlannedFileOperation;
276/// # fn cannot_mutate(operation: &mut PlannedFileOperation) {
277/// operation.target_path = std::path::PathBuf::from("outside");
278/// # }
279/// ```
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct PlannedFileOperation {
282    /// File operation kind.
283    operation: FileOperationKind,
284    /// Declared source path.
285    source: PathBuf,
286    /// Declared target path.
287    target: PathBuf,
288    /// Normalized source path.
289    source_path: PathBuf,
290    /// Normalized target path.
291    target_path: PathBuf,
292    /// Whether a missing source should fail validation.
293    required: bool,
294    /// Sync comparison mode.
295    compare: Option<SyncCompare>,
296    /// Whether sync should delete target-only files.
297    delete: Option<bool>,
298    /// How copy and sync should treat source symlinks.
299    symlinks: Option<SymlinkMode>,
300    /// Source-relative path patterns that narrow copy and sync traversal.
301    include: Vec<String>,
302    /// Source-relative path patterns ignored by copy and sync.
303    ignore: Vec<String>,
304    /// Metadata fields ignored by copy and sync.
305    ignore_metadata: Vec<MetadataField>,
306    /// Whether this operation should execute.
307    status: PlannedFileStatus,
308    /// Source location for the operation declaration.
309    declaration: SourceSpan,
310}
311
312impl PlannedFileOperation {
313    /// Returns the file operation kind.
314    #[must_use]
315    pub const fn operation(&self) -> FileOperationKind {
316        self.operation
317    }
318
319    /// Returns the declared source path.
320    #[must_use]
321    pub fn source(&self) -> &Path {
322        &self.source
323    }
324
325    /// Returns the declared target path.
326    #[must_use]
327    pub fn target(&self) -> &Path {
328        &self.target
329    }
330
331    /// Returns the normalized source path.
332    #[must_use]
333    pub fn source_path(&self) -> &Path {
334        &self.source_path
335    }
336
337    /// Returns the normalized target path.
338    #[must_use]
339    pub fn target_path(&self) -> &Path {
340        &self.target_path
341    }
342
343    /// Returns whether a missing source should fail validation.
344    #[must_use]
345    pub const fn required(&self) -> bool {
346        self.required
347    }
348
349    /// Returns the sync comparison mode.
350    #[must_use]
351    pub const fn compare(&self) -> Option<SyncCompare> {
352        self.compare
353    }
354
355    /// Returns whether sync should delete target-only files.
356    #[must_use]
357    pub const fn delete(&self) -> Option<bool> {
358        self.delete
359    }
360
361    /// Returns how copy and sync should treat source symlinks.
362    #[must_use]
363    pub const fn symlinks(&self) -> Option<SymlinkMode> {
364        self.symlinks
365    }
366
367    /// Returns source-relative path patterns that narrow copy and sync
368    /// directory traversal to matching source paths.
369    #[must_use]
370    pub fn include(&self) -> &[String] {
371        &self.include
372    }
373
374    /// Returns source-relative path patterns ignored by copy and sync.
375    #[must_use]
376    pub fn ignore(&self) -> &[String] {
377        &self.ignore
378    }
379
380    /// Returns metadata fields ignored by copy and sync.
381    #[must_use]
382    pub fn ignore_metadata(&self) -> &[MetadataField] {
383        &self.ignore_metadata
384    }
385
386    /// Returns whether this operation should execute.
387    #[must_use]
388    pub const fn status(&self) -> PlannedFileStatus {
389        self.status
390    }
391
392    /// Returns the source location for the operation declaration.
393    #[must_use]
394    pub const fn declaration(&self) -> SourceSpan {
395        self.declaration
396    }
397
398    #[cfg(test)]
399    pub(crate) fn from_raw_parts_unchecked(parts: PlannedFileOperationParts) -> Self {
400        Self {
401            operation: parts.operation,
402            source: parts.source,
403            target: parts.target,
404            source_path: parts.source_path,
405            target_path: parts.target_path,
406            required: parts.required,
407            compare: parts.compare,
408            delete: parts.delete,
409            symlinks: parts.symlinks,
410            include: parts.include,
411            ignore: parts.ignore,
412            ignore_metadata: parts.ignore_metadata,
413            status: parts.status,
414            declaration: parts.declaration,
415        }
416    }
417
418    #[cfg(test)]
419    pub(crate) const fn with_compare(mut self, compare: Option<SyncCompare>) -> Self {
420        self.compare = compare;
421        self
422    }
423
424    #[cfg(test)]
425    pub(crate) const fn with_delete(mut self, delete: Option<bool>) -> Self {
426        self.delete = delete;
427        self
428    }
429
430    #[cfg(test)]
431    pub(crate) fn with_include(mut self, include: Vec<String>) -> Self {
432        self.include = include;
433        self
434    }
435
436    #[cfg(test)]
437    pub(crate) fn with_ignore(mut self, ignore: Vec<String>) -> Self {
438        self.ignore = ignore;
439        self
440    }
441
442    #[cfg(test)]
443    pub(crate) fn with_ignore_metadata(mut self, ignore_metadata: Vec<MetadataField>) -> Self {
444        self.ignore_metadata = ignore_metadata;
445        self
446    }
447}
448
449#[cfg(test)]
450pub(crate) struct PlannedFileOperationParts {
451    pub(crate) operation: FileOperationKind,
452    pub(crate) source: PathBuf,
453    pub(crate) target: PathBuf,
454    pub(crate) source_path: PathBuf,
455    pub(crate) target_path: PathBuf,
456    pub(crate) required: bool,
457    pub(crate) compare: Option<SyncCompare>,
458    pub(crate) delete: Option<bool>,
459    pub(crate) symlinks: Option<SymlinkMode>,
460    pub(crate) include: Vec<String>,
461    pub(crate) ignore: Vec<String>,
462    pub(crate) ignore_metadata: Vec<MetadataField>,
463    pub(crate) status: PlannedFileStatus,
464    pub(crate) declaration: SourceSpan,
465}
466
467/// Execution status for a planned file operation.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum PlannedFileStatus {
470    /// The operation has an existing source and should run.
471    Ready,
472    /// The operation has an optional missing source and should be skipped.
473    SkippedMissingSource,
474}
475
476/// A validated command operation ready for execution.
477///
478/// ```compile_fail
479/// # use treeboot_core::PlannedCommand;
480/// # fn cannot_mutate(command: &mut PlannedCommand) {
481/// command.cwd_path = std::path::PathBuf::from("outside");
482/// # }
483/// ```
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct PlannedCommand {
486    /// Optional display name.
487    name: Option<String>,
488    /// Command invocation.
489    command: CommandKind,
490    /// Declared working directory.
491    cwd: Option<PathBuf>,
492    /// Normalized working directory.
493    cwd_path: PathBuf,
494    /// Extra environment variables for this command.
495    env: BTreeMap<String, String>,
496    /// Whether a non-zero exit status should be non-fatal.
497    allow_failure: bool,
498    /// Source location for the command declaration.
499    declaration: SourceSpan,
500}
501
502impl PlannedCommand {
503    /// Returns the optional display name.
504    #[must_use]
505    pub fn name(&self) -> Option<&str> {
506        self.name.as_deref()
507    }
508
509    /// Returns the command invocation.
510    #[must_use]
511    pub const fn command(&self) -> &CommandKind {
512        &self.command
513    }
514
515    /// Returns the declared working directory.
516    #[must_use]
517    pub fn cwd(&self) -> Option<&Path> {
518        self.cwd.as_deref()
519    }
520
521    /// Returns the normalized working directory.
522    #[must_use]
523    pub fn cwd_path(&self) -> &Path {
524        &self.cwd_path
525    }
526
527    /// Returns extra environment variables for this command.
528    #[must_use]
529    pub const fn env(&self) -> &BTreeMap<String, String> {
530        &self.env
531    }
532
533    /// Returns whether a non-zero exit status should be non-fatal.
534    #[must_use]
535    pub const fn allow_failure(&self) -> bool {
536        self.allow_failure
537    }
538
539    /// Returns the source location for the command declaration.
540    #[must_use]
541    pub const fn declaration(&self) -> SourceSpan {
542        self.declaration
543    }
544
545    #[cfg(test)]
546    pub(crate) fn from_raw_parts_unchecked(parts: PlannedCommandParts) -> Self {
547        Self {
548            name: parts.name,
549            command: parts.command,
550            cwd: parts.cwd,
551            cwd_path: parts.cwd_path,
552            env: parts.env,
553            allow_failure: parts.allow_failure,
554            declaration: parts.declaration,
555        }
556    }
557}
558
559#[cfg(test)]
560#[derive(Clone)]
561pub(crate) struct PlannedCommandParts {
562    pub(crate) name: Option<String>,
563    pub(crate) command: CommandKind,
564    pub(crate) cwd: Option<PathBuf>,
565    pub(crate) cwd_path: PathBuf,
566    pub(crate) env: BTreeMap<String, String>,
567    pub(crate) allow_failure: bool,
568    pub(crate) declaration: SourceSpan,
569}
570
571pub(super) fn plan_file_operations(
572    origin: FilePlanOrigin<'_>,
573    files: &[FileOperation],
574    context: &Worktree,
575    options: ActionPlanOptions,
576) -> Result<(Vec<PlannedFileOperation>, Vec<PlanWarning>)> {
577    let root_path = normalize_existing(&context.root_path).map_err(|source| {
578        file_plan_error(
579            origin,
580            None,
581            format!("failed to resolve root path: {source}"),
582        )
583    })?;
584    let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
585        file_plan_error(
586            origin,
587            None,
588            format!("failed to resolve worktree path: {source}"),
589        )
590    })?;
591
592    let target_paths = normalize_target_paths(
593        origin,
594        files,
595        context.worktree_path.as_path(),
596        worktree_path.as_path(),
597    )?;
598    validate_target_conflicts(origin, files, &target_paths)?;
599    validate_strict_sync(origin, files, options.strict)?;
600
601    build_file_operations(
602        origin,
603        files,
604        options,
605        &target_paths,
606        root_path.as_path(),
607        worktree_path.as_path(),
608    )
609}
610
611fn normalize_target_paths(
612    origin: FilePlanOrigin<'_>,
613    files: &[FileOperation],
614    worktree_path: &Path,
615    normalized_worktree_path: &Path,
616) -> Result<Vec<PathBuf>> {
617    files
618        .iter()
619        .map(|operation| {
620            let inspected_parent =
621                validate_target_parent_components(origin, operation, worktree_path)?;
622            let target_path = normalize_target_path(&operation.target_path).map_err(|source| {
623                file_plan_error(
624                    origin,
625                    Some(operation.declaration),
626                    format!(
627                        "failed to resolve target {}: {source}",
628                        operation.target.display()
629                    ),
630                )
631            })?;
632
633            if !inspected_parent && is_within(&target_path, normalized_worktree_path) {
634                return invalid_file_plan(
635                    origin,
636                    Some(operation.declaration),
637                    format!(
638                        "cannot create target for {}; target parent could not be inspected",
639                        operation_label(operation),
640                    ),
641                );
642            }
643
644            Ok(target_path)
645        })
646        .collect()
647}
648
649fn validate_target_parent_components(
650    origin: FilePlanOrigin<'_>,
651    operation: &FileOperation,
652    worktree_path: &Path,
653) -> Result<bool> {
654    let parent = operation.target_path.parent().unwrap_or(worktree_path);
655    let Some(anchor) = matching_target_anchor(parent, worktree_path) else {
656        return Ok(false);
657    };
658
659    match inspect_target_ancestors(parent, anchor.as_ref(), false) {
660        Ok(()) | Err(TargetAncestorIssue::OutsideWorktree { .. }) => Ok(true),
661        Err(TargetAncestorIssue::Symlink { path }) => invalid_file_plan(
662            origin,
663            Some(operation.declaration),
664            format!(
665                "cannot create target for {}; target parent {} is a symlink",
666                operation_label(operation),
667                path.display()
668            ),
669        ),
670        Err(TargetAncestorIssue::NotDirectory { path }) => invalid_file_plan(
671            origin,
672            Some(operation.declaration),
673            format!(
674                "cannot create target for {}; target parent {} is not a directory",
675                operation_label(operation),
676                path.display()
677            ),
678        ),
679        Err(TargetAncestorIssue::Io { path, source }) => Err(file_plan_error(
680            origin,
681            Some(operation.declaration),
682            format!(
683                "failed to inspect target parent {}: {source}",
684                path.display()
685            ),
686        )),
687    }
688}
689
690fn validate_target_conflicts(
691    origin: FilePlanOrigin<'_>,
692    files: &[FileOperation],
693    target_paths: &[PathBuf],
694) -> Result<()> {
695    validate_duplicate_targets(origin, files, target_paths)?;
696    validate_overlapping_targets(origin, files, target_paths)
697}
698
699fn validate_duplicate_targets(
700    origin: FilePlanOrigin<'_>,
701    files: &[FileOperation],
702    target_paths: &[PathBuf],
703) -> Result<()> {
704    let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
705
706    for (operation, target_path) in files.iter().zip(target_paths) {
707        targets
708            .entry(target_path.as_path())
709            .or_default()
710            .push(operation);
711    }
712
713    let duplicates = targets
714        .into_iter()
715        .filter(|(_, operations)| operations.len() > 1)
716        .collect::<Vec<_>>();
717
718    if duplicates.is_empty() {
719        return Ok(());
720    }
721
722    let details = duplicates
723        .iter()
724        .flat_map(|(target, operations)| {
725            operations.iter().map(move |operation| {
726                format!(
727                    "{}: {}",
728                    target.display(),
729                    operation_summary(origin, operation)
730                )
731            })
732        })
733        .collect::<Vec<_>>()
734        .join("; ");
735
736    let message = match origin {
737        FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
738        FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
739    };
740
741    Err(file_plan_error(origin, None, message))
742}
743
744fn validate_overlapping_targets(
745    origin: FilePlanOrigin<'_>,
746    files: &[FileOperation],
747    target_paths: &[PathBuf],
748) -> Result<()> {
749    let mut overlaps = Vec::new();
750
751    for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
752        for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
753            if target_path == other_target_path {
754                continue;
755            }
756
757            let Some((ancestor_path, ancestor, descendant_path, descendant)) =
758                overlapping_targets(target_path, operation, other_target_path, other_operation)
759            else {
760                continue;
761            };
762
763            overlaps.push(format!(
764                "{} contains {}: {}; {}",
765                ancestor_path.display(),
766                descendant_path.display(),
767                operation_summary(origin, ancestor),
768                operation_summary(origin, descendant)
769            ));
770        }
771    }
772
773    if overlaps.is_empty() {
774        return Ok(());
775    }
776
777    let message = match origin {
778        FilePlanOrigin::Config(_) => {
779            format!("overlapping configured targets: {}", overlaps.join("; "))
780        }
781        FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
782    };
783
784    Err(file_plan_error(origin, None, message))
785}
786
787fn overlapping_targets<'a>(
788    target_path: &'a Path,
789    operation: &'a FileOperation,
790    other_target_path: &'a Path,
791    other_operation: &'a FileOperation,
792) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
793    if other_target_path.starts_with(target_path) {
794        return Some((target_path, operation, other_target_path, other_operation));
795    }
796
797    if target_path.starts_with(other_target_path) {
798        return Some((other_target_path, other_operation, target_path, operation));
799    }
800
801    None
802}
803
804fn validate_strict_sync(
805    origin: FilePlanOrigin<'_>,
806    files: &[FileOperation],
807    strict: bool,
808) -> Result<()> {
809    if !strict {
810        return Ok(());
811    }
812
813    if let Some(operation) = files
814        .iter()
815        .find(|operation| operation.operation == FileOperationKind::Sync)
816    {
817        return invalid_file_plan(
818            origin,
819            Some(operation.declaration),
820            format!(
821                "strict mode cannot be used with sync file operation {}",
822                operation_summary(origin, operation)
823            ),
824        );
825    }
826
827    Ok(())
828}
829
830fn build_file_operations(
831    origin: FilePlanOrigin<'_>,
832    files: &[FileOperation],
833    options: ActionPlanOptions,
834    target_paths: &[PathBuf],
835    root_path: &Path,
836    worktree_path: &Path,
837) -> Result<(Vec<PlannedFileOperation>, Vec<PlanWarning>)> {
838    let mut planned = Vec::with_capacity(files.len());
839    let mut warnings = Vec::new();
840
841    for (operation, target_path) in files.iter().zip(target_paths) {
842        validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
843
844        let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
845            file_plan_error(
846                origin,
847                Some(operation.declaration),
848                format!(
849                    "failed to resolve source {}: {source}",
850                    operation.source.display()
851                ),
852            )
853        })?;
854        validate_source_boundary(origin, options, operation, &source_path, root_path)?;
855        let ignore_rules = operation_ignore_rules(origin, operation, &source_path)?;
856        let include_rules = operation_include_rules(origin, operation, &source_path)?;
857
858        let status = match source_exists(origin, operation, source_path.as_path())? {
859            true => {
860                if matches!(
861                    operation.operation,
862                    FileOperationKind::Copy | FileOperationKind::Sync
863                ) {
864                    validate_source_symlinks(
865                        origin,
866                        operation,
867                        source_path.as_path(),
868                        root_path,
869                        ignore_rules.as_ref(),
870                        include_rules.as_ref(),
871                    )?;
872
873                    // Gate the zero-match warning on the raw declared source:
874                    // normalization resolves symlinks, but a source symlink is
875                    // planned as a symlink and include never applies to it.
876                    if let Some(include) = include_rules.as_ref()
877                        && std::fs::symlink_metadata(&operation.source_path)
878                            .is_ok_and(|metadata| metadata.is_dir())
879                        && !include_matches_any_source_path(&source_path, include)
880                    {
881                        warnings.push(PlanWarning::IncludeMatchedNothing {
882                            operation: operation.operation,
883                            source: operation.source.clone(),
884                            target: operation.target.clone(),
885                        });
886                    }
887                }
888
889                PlannedFileStatus::Ready
890            }
891            false if operation.required => {
892                return invalid_file_plan(
893                    origin,
894                    Some(operation.declaration),
895                    format!(
896                        "required source does not exist for {}",
897                        operation_summary(origin, operation)
898                    ),
899                );
900            }
901            false => PlannedFileStatus::SkippedMissingSource,
902        };
903
904        planned.push(PlannedFileOperation {
905            operation: operation.operation,
906            source: operation.source.clone(),
907            target: operation.target.clone(),
908            source_path,
909            target_path: target_path.clone(),
910            required: operation.required,
911            compare: operation.compare,
912            delete: operation.delete,
913            symlinks: operation.symlinks,
914            include: operation.include.clone(),
915            ignore: operation.ignore.clone(),
916            ignore_metadata: operation.ignore_metadata.clone(),
917            status,
918            declaration: operation.declaration,
919        });
920    }
921
922    Ok((planned, warnings))
923}
924
925fn validate_target_boundary(
926    origin: FilePlanOrigin<'_>,
927    options: ActionPlanOptions,
928    operation: &FileOperation,
929    target_path: &Path,
930    worktree_path: &Path,
931) -> Result<()> {
932    if options.dangerously_allow_targets_outside_worktree {
933        return Ok(());
934    }
935
936    if !is_within(target_path, worktree_path) {
937        return invalid_file_plan(
938            origin,
939            Some(operation.declaration),
940            format!(
941                "target resolves outside worktree for {}",
942                operation_summary(origin, operation)
943            ),
944        );
945    }
946
947    Ok(())
948}
949
950fn validate_source_boundary(
951    origin: FilePlanOrigin<'_>,
952    options: ActionPlanOptions,
953    operation: &FileOperation,
954    source_path: &Path,
955    root_path: &Path,
956) -> Result<()> {
957    if options.dangerously_allow_sources_outside_root {
958        return Ok(());
959    }
960
961    if !is_within(source_path, root_path) {
962        return invalid_file_plan(
963            origin,
964            Some(operation.declaration),
965            format!(
966                "source resolves outside root for {}",
967                operation_summary(origin, operation)
968            ),
969        );
970    }
971
972    Ok(())
973}
974
975fn operation_ignore_rules(
976    origin: FilePlanOrigin<'_>,
977    operation: &FileOperation,
978    source_path: &Path,
979) -> Result<Option<PathIgnoreRules>> {
980    if !matches!(
981        operation.operation,
982        FileOperationKind::Copy | FileOperationKind::Sync
983    ) || operation.ignore.is_empty()
984    {
985        return Ok(None);
986    }
987
988    PathIgnoreRules::new(source_path, &operation.ignore)
989        .map(Some)
990        .map_err(|source| {
991            file_plan_error(
992                origin,
993                Some(operation.declaration),
994                format!(
995                    "invalid ignore pattern for {}: {source}",
996                    operation_summary(origin, operation)
997                ),
998            )
999        })
1000}
1001
1002fn operation_include_rules(
1003    origin: FilePlanOrigin<'_>,
1004    operation: &FileOperation,
1005    source_path: &Path,
1006) -> Result<Option<PathIncludeRules>> {
1007    if !matches!(
1008        operation.operation,
1009        FileOperationKind::Copy | FileOperationKind::Sync
1010    ) || operation.include.is_empty()
1011    {
1012        return Ok(None);
1013    }
1014
1015    // Config parsing and manual option validation reject these earlier;
1016    // revalidate here so directly constructed file operations get the same
1017    // contract.
1018    if operation.operation == FileOperationKind::Sync && operation.delete == Some(true) {
1019        return invalid_file_plan(
1020            origin,
1021            Some(operation.declaration),
1022            format!(
1023                "{} for {}",
1024                include_with_delete_message(),
1025                operation_summary(origin, operation)
1026            ),
1027        );
1028    }
1029    for pattern in &operation.include {
1030        if let Some(issue) = invalid_include_pattern(pattern) {
1031            return invalid_file_plan(
1032                origin,
1033                Some(operation.declaration),
1034                format!(
1035                    "{} for {}",
1036                    issue.message(pattern),
1037                    operation_summary(origin, operation)
1038                ),
1039            );
1040        }
1041    }
1042
1043    PathIncludeRules::new(source_path, &operation.include)
1044        .map(Some)
1045        .map_err(|source| {
1046            file_plan_error(
1047                origin,
1048                Some(operation.declaration),
1049                format!(
1050                    "invalid include pattern for {}: {source}",
1051                    operation_summary(origin, operation)
1052                ),
1053            )
1054        })
1055}
1056
1057fn plan_commands(
1058    path: &Path,
1059    commands: &[CommandOperation],
1060    context: &Worktree,
1061    worktree_path: &Path,
1062) -> Result<Vec<PlannedCommand>> {
1063    let mut planned = Vec::with_capacity(commands.len());
1064
1065    for command in commands {
1066        let cwd_path = command
1067            .cwd_path
1068            .as_ref()
1069            .map_or_else(
1070                || Ok(worktree_path.to_path_buf()),
1071                |cwd_path| normalize_maybe_existing(cwd_path),
1072            )
1073            .map_err(|source| {
1074                invalid_config_error(
1075                    path,
1076                    Some(command.declaration),
1077                    format!("failed to resolve command cwd: {source}"),
1078                )
1079            })?;
1080
1081        if !is_within(&cwd_path, worktree_path) {
1082            return invalid_config(
1083                path,
1084                Some(command.declaration),
1085                "command cwd resolves outside worktree",
1086            );
1087        }
1088
1089        for key in command.env.keys() {
1090            if context.environment.contains_key(key) {
1091                return invalid_config(
1092                    path,
1093                    Some(command.declaration),
1094                    format!("command env overrides treeboot-owned variable `{key}`"),
1095                );
1096            }
1097        }
1098
1099        planned.push(PlannedCommand {
1100            name: command.name.clone(),
1101            command: command.command.clone(),
1102            cwd: command.cwd.clone(),
1103            cwd_path,
1104            env: command.env.clone(),
1105            allow_failure: command.allow_failure,
1106            declaration: command.declaration,
1107        });
1108    }
1109
1110    Ok(planned)
1111}
1112
1113fn validate_source_symlinks(
1114    origin: FilePlanOrigin<'_>,
1115    operation: &FileOperation,
1116    source_path: &Path,
1117    root_path: &Path,
1118    ignore_rules: Option<&PathIgnoreRules>,
1119    include_rules: Option<&PathIncludeRules>,
1120) -> Result<()> {
1121    validate_source_symlink_path(
1122        origin,
1123        operation,
1124        source_root_context(source_path, ignore_rules, include_rules),
1125        source_path,
1126        root_path,
1127        false,
1128    )
1129}
1130
1131#[derive(Debug, Clone, Copy)]
1132struct SourceFilterContext<'a> {
1133    source_root: &'a Path,
1134    ignore_rules: Option<&'a PathIgnoreRules>,
1135    include_rules: Option<&'a PathIncludeRules>,
1136}
1137
1138const fn source_root_context<'a>(
1139    source_root: &'a Path,
1140    ignore_rules: Option<&'a PathIgnoreRules>,
1141    include_rules: Option<&'a PathIncludeRules>,
1142) -> SourceFilterContext<'a> {
1143    SourceFilterContext {
1144        source_root,
1145        ignore_rules,
1146        include_rules,
1147    }
1148}
1149
1150fn validate_source_symlink_path(
1151    origin: FilePlanOrigin<'_>,
1152    operation: &FileOperation,
1153    filter: SourceFilterContext<'_>,
1154    path: &Path,
1155    root_path: &Path,
1156    ancestor_included: bool,
1157) -> Result<()> {
1158    let metadata = std::fs::symlink_metadata(path).map_err(|source| {
1159        file_plan_error(
1160            origin,
1161            Some(operation.declaration),
1162            format!(
1163                "failed to inspect source {}: {source}",
1164                operation.source.display()
1165            ),
1166        )
1167    })?;
1168
1169    if metadata.file_type().is_symlink() {
1170        let target = normalize_existing(path).map_err(|source| {
1171            file_plan_error(
1172                origin,
1173                Some(operation.declaration),
1174                format!(
1175                    "failed to resolve source symlink {}: {source}",
1176                    path.display()
1177                ),
1178            )
1179        })?;
1180
1181        if !is_within(&target, root_path) {
1182            return invalid_file_plan(
1183                origin,
1184                Some(operation.declaration),
1185                format!(
1186                    "copy or sync source contains unsafe symlink {}",
1187                    path.display()
1188                ),
1189            );
1190        }
1191
1192        return Ok(());
1193    }
1194
1195    if !metadata.is_dir() {
1196        return Ok(());
1197    }
1198
1199    for entry in std::fs::read_dir(path).map_err(|source| {
1200        file_plan_error(
1201            origin,
1202            Some(operation.declaration),
1203            format!(
1204                "failed to inspect source directory {}: {source}",
1205                path.display()
1206            ),
1207        )
1208    })? {
1209        let entry = entry.map_err(|source| {
1210            file_plan_error(
1211                origin,
1212                Some(operation.declaration),
1213                format!(
1214                    "failed to inspect source directory {}: {source}",
1215                    path.display()
1216                ),
1217            )
1218        })?;
1219        let path = entry.path();
1220        let metadata = std::fs::symlink_metadata(&path).map_err(|source| {
1221            file_plan_error(
1222                origin,
1223                Some(operation.declaration),
1224                format!(
1225                    "failed to inspect source directory {}: {source}",
1226                    path.display()
1227                ),
1228            )
1229        })?;
1230
1231        let entry_included = ancestor_included || entry_matches_include(filter, &path, &metadata);
1232
1233        if ignored_source_path(filter.source_root, &path, &metadata, filter.ignore_rules) {
1234            // Ignored directories are only traversed for re-included
1235            // descendants, and only when the include gate could still pass
1236            // somewhere underneath.
1237            if metadata.is_dir()
1238                && filter
1239                    .ignore_rules
1240                    .map(PathIgnoreRules::has_negation)
1241                    .unwrap_or(false)
1242                && (entry_included || dir_may_contain_included(filter, &path))
1243            {
1244                validate_source_symlink_path(
1245                    origin,
1246                    operation,
1247                    filter,
1248                    &path,
1249                    root_path,
1250                    entry_included,
1251                )?;
1252            }
1253            continue;
1254        }
1255
1256        // Non-included paths are skipped before unsafe symlink validation,
1257        // mirroring ignored paths. Non-included directories are still
1258        // traversed while a descendant could match include rules.
1259        if filter.include_rules.is_some() && !entry_included {
1260            if metadata.is_dir() && dir_may_contain_included(filter, &path) {
1261                validate_source_symlink_path(origin, operation, filter, &path, root_path, false)?;
1262            }
1263            continue;
1264        }
1265
1266        validate_source_symlink_path(origin, operation, filter, &path, root_path, entry_included)?;
1267    }
1268
1269    Ok(())
1270}
1271
1272fn entry_matches_include(
1273    filter: SourceFilterContext<'_>,
1274    path: &Path,
1275    metadata: &std::fs::Metadata,
1276) -> bool {
1277    match filter.include_rules {
1278        None => true,
1279        Some(include) => path
1280            .strip_prefix(filter.source_root)
1281            .is_ok_and(|relative| include.is_included(relative, metadata.is_dir())),
1282    }
1283}
1284
1285fn dir_may_contain_included(filter: SourceFilterContext<'_>, path: &Path) -> bool {
1286    filter.include_rules.is_none_or(|include| {
1287        path.strip_prefix(filter.source_root)
1288            .is_ok_and(|relative| include.dir_may_contain_matches(relative))
1289    })
1290}
1291
1292fn ignored_source_path(
1293    source_root: &Path,
1294    path: &Path,
1295    metadata: &std::fs::Metadata,
1296    ignore_rules: Option<&PathIgnoreRules>,
1297) -> bool {
1298    ignore_rules
1299        .zip(path.strip_prefix(source_root).ok())
1300        .is_some_and(|(rules, relative)| rules.is_ignored(relative, metadata.is_dir()))
1301}
1302
1303fn source_exists(
1304    origin: FilePlanOrigin<'_>,
1305    operation: &FileOperation,
1306    source_path: &Path,
1307) -> Result<bool> {
1308    match std::fs::symlink_metadata(source_path) {
1309        Ok(_) => Ok(true),
1310        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
1311        Err(source) => Err(file_plan_error(
1312            origin,
1313            Some(operation.declaration),
1314            format!(
1315                "failed to inspect source {}: {source}",
1316                operation.source.display()
1317            ),
1318        )),
1319    }
1320}
1321
1322fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
1323    let summary = operation_label(operation);
1324
1325    match origin {
1326        FilePlanOrigin::Config(_) => format!(
1327            "{} at line {}, column {}",
1328            summary, operation.declaration.line, operation.declaration.column
1329        ),
1330        FilePlanOrigin::Manual { .. } => summary,
1331    }
1332}
1333
1334fn operation_label(operation: &FileOperation) -> String {
1335    format!(
1336        "{} {} -> {}",
1337        operation.operation,
1338        operation.source.display(),
1339        operation.target.display()
1340    )
1341}
1342
1343fn invalid_config<T>(
1344    path: &Path,
1345    span: Option<SourceSpan>,
1346    message: impl Into<String>,
1347) -> Result<T> {
1348    Err(invalid_config_error(path, span, message))
1349}
1350
1351fn invalid_file_plan<T>(
1352    origin: FilePlanOrigin<'_>,
1353    span: Option<SourceSpan>,
1354    message: impl Into<String>,
1355) -> Result<T> {
1356    Err(file_plan_error(origin, span, message))
1357}
1358
1359fn invalid_config_error(
1360    path: &Path,
1361    span: Option<SourceSpan>,
1362    message: impl Into<String>,
1363) -> Error {
1364    let message = match span {
1365        Some(span) => format!(
1366            "{} at line {}, column {}",
1367            message.into(),
1368            span.line,
1369            span.column
1370        ),
1371        None => message.into(),
1372    };
1373
1374    Error::ConfigInvalid {
1375        path: path.to_path_buf(),
1376        message,
1377    }
1378}
1379
1380fn file_plan_error(
1381    origin: FilePlanOrigin<'_>,
1382    span: Option<SourceSpan>,
1383    message: impl Into<String>,
1384) -> Error {
1385    match origin {
1386        FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
1387        FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
1388            operation: operation.as_str(),
1389            message: message.into(),
1390        },
1391    }
1392}
1393
1394fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
1395    paths::canonicalize(path)
1396}
1397
1398fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
1399    paths::normalize_maybe_existing(path)
1400}
1401
1402fn normalize_target_path(path: &Path) -> std::io::Result<PathBuf> {
1403    let Some(name) = path.file_name() else {
1404        return normalize_maybe_existing(path);
1405    };
1406
1407    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1408    let mut normalized = normalize_maybe_existing(parent)?;
1409    normalized.push(name);
1410
1411    Ok(paths::normalize_lexical(&normalized))
1412}
1413
1414fn is_within(path: &Path, boundary: &Path) -> bool {
1415    path == boundary || path.starts_with(boundary)
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use std::collections::BTreeMap;
1421    use std::ffi::OsString;
1422    use std::time::{SystemTime, UNIX_EPOCH};
1423
1424    use super::*;
1425    use crate::test_support::{symlink_dir, symlink_file};
1426
1427    fn span() -> SourceSpan {
1428        SourceSpan {
1429            start: 0,
1430            end: 1,
1431            line: 1,
1432            column: 1,
1433        }
1434    }
1435
1436    fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
1437        let id = SystemTime::now()
1438            .duration_since(UNIX_EPOCH)
1439            .expect("clock should be after Unix epoch")
1440            .as_nanos();
1441        let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
1442        let root = base.join("root");
1443        let worktree = base.join("worktree");
1444
1445        std::fs::create_dir_all(&root).expect("root should be created");
1446        std::fs::create_dir_all(&worktree).expect("worktree should be created");
1447
1448        (root, worktree)
1449    }
1450
1451    fn aliased_workspace(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1452        let (root, worktree) = temp_workspace(name);
1453        let base = root.parent().expect("root should have parent");
1454        let alias = base.join("alias");
1455        symlink_dir(base, &alias).expect("workspace alias should be created");
1456
1457        let alias_root = alias.join("root");
1458        let alias_worktree = alias.join("worktree");
1459
1460        (root, worktree, alias_root, alias_worktree)
1461    }
1462
1463    fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
1464        Worktree {
1465            root_path: root_path.to_path_buf(),
1466            worktree_path: worktree_path.to_path_buf(),
1467            default_branch: "main".to_owned(),
1468            environment: BTreeMap::from([(
1469                "TREEBOOT_ROOT_PATH".to_owned(),
1470                OsString::from(root_path),
1471            )]),
1472        }
1473    }
1474
1475    fn empty_config() -> Config {
1476        Config {
1477            options: Default::default(),
1478            files: Vec::new(),
1479            commands: Vec::new(),
1480        }
1481    }
1482
1483    fn file_operation(
1484        operation: FileOperationKind,
1485        root: &Path,
1486        worktree: &Path,
1487        source: &str,
1488        target: &str,
1489    ) -> FileOperation {
1490        FileOperation {
1491            operation,
1492            source: PathBuf::from(source),
1493            target: PathBuf::from(target),
1494            source_path: root.join(source),
1495            target_path: worktree.join(target),
1496            required: false,
1497            compare: match operation {
1498                FileOperationKind::Sync => Some(SyncCompare::Metadata),
1499                FileOperationKind::Copy | FileOperationKind::Symlink => None,
1500            },
1501            delete: match operation {
1502                FileOperationKind::Sync => Some(false),
1503                FileOperationKind::Copy | FileOperationKind::Symlink => None,
1504            },
1505            symlinks: match operation {
1506                FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
1507                FileOperationKind::Symlink => None,
1508            },
1509            include: Vec::new(),
1510            ignore: Vec::new(),
1511            ignore_metadata: Vec::new(),
1512            declaration: span(),
1513        }
1514    }
1515
1516    fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
1517        ActionPlan::from_manifest(
1518            Path::new(".treeboot.toml"),
1519            config,
1520            &context(root, worktree),
1521            ActionPlanOptions::default(),
1522        )
1523    }
1524
1525    fn include_operation(
1526        operation: FileOperationKind,
1527        root: &Path,
1528        worktree: &Path,
1529        source: &str,
1530        include: &[&str],
1531    ) -> FileOperation {
1532        FileOperation {
1533            include: include.iter().map(ToString::to_string).collect(),
1534            ..file_operation(operation, root, worktree, source, source)
1535        }
1536    }
1537
1538    #[test]
1539    fn plan_should_warn_when_include_matches_no_source_paths() {
1540        let (root, worktree) = temp_workspace("include-zero-match");
1541        std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1542        std::fs::write(root.join("shared/file.txt"), "data\n").expect("file should be written");
1543        let config = Config {
1544            options: Default::default(),
1545            files: vec![include_operation(
1546                FileOperationKind::Copy,
1547                &root,
1548                &worktree,
1549                "shared",
1550                &["docs/**"],
1551            )],
1552            commands: Vec::new(),
1553        };
1554
1555        let plan = plan(&config, &root, &worktree).expect("plan should build");
1556
1557        assert_eq!(plan.warnings().len(), 1);
1558        let message = plan.warnings()[0].to_string();
1559        assert!(message.contains("include patterns match no source paths"));
1560        assert!(message.contains("copy shared -> shared"));
1561    }
1562
1563    #[test]
1564    fn plan_should_not_warn_when_include_matches_before_ignore_filtering() {
1565        let (root, worktree) = temp_workspace("include-match-before-ignore");
1566        std::fs::create_dir_all(root.join("shared/docs")).expect("source should be created");
1567        std::fs::write(root.join("shared/docs/guide.md"), "guide\n")
1568            .expect("file should be written");
1569        let mut operation = include_operation(
1570            FileOperationKind::Copy,
1571            &root,
1572            &worktree,
1573            "shared",
1574            &["docs/**"],
1575        );
1576        // Ignore removes every included path, but the include list still
1577        // matched source paths, so no zero-match warning is emitted.
1578        operation.ignore = vec!["docs/**".to_owned()];
1579        let config = Config {
1580            options: Default::default(),
1581            files: vec![operation],
1582            commands: Vec::new(),
1583        };
1584
1585        let plan = plan(&config, &root, &worktree).expect("plan should build");
1586
1587        assert!(plan.warnings().is_empty());
1588    }
1589
1590    #[test]
1591    fn plan_should_not_warn_for_missing_or_file_sources_with_include() {
1592        let (root, worktree) = temp_workspace("include-no-warning-sources");
1593        std::fs::write(root.join(".env"), "TOKEN=1\n").expect("file should be written");
1594        let config = Config {
1595            options: Default::default(),
1596            files: vec![
1597                include_operation(
1598                    FileOperationKind::Copy,
1599                    &root,
1600                    &worktree,
1601                    "missing",
1602                    &["docs/**"],
1603                ),
1604                include_operation(
1605                    FileOperationKind::Copy,
1606                    &root,
1607                    &worktree,
1608                    ".env",
1609                    &["docs/**"],
1610                ),
1611            ],
1612            commands: Vec::new(),
1613        };
1614
1615        let plan = plan(&config, &root, &worktree).expect("plan should build");
1616
1617        assert!(plan.warnings().is_empty());
1618    }
1619
1620    #[cfg(unix)]
1621    #[test]
1622    fn plan_should_not_warn_for_symlink_directory_sources_with_include() {
1623        let (root, worktree) = temp_workspace("include-symlink-dir-source");
1624        std::fs::create_dir_all(root.join("real")).expect("real dir should be created");
1625        std::fs::write(root.join("real/file.txt"), "data\n").expect("file should be written");
1626        symlink_dir(root.join("real"), root.join("linked"))
1627            .expect("source symlink should be created");
1628        let config = Config {
1629            options: Default::default(),
1630            files: vec![include_operation(
1631                FileOperationKind::Copy,
1632                &root,
1633                &worktree,
1634                "linked",
1635                &["nomatch/**"],
1636            )],
1637            commands: Vec::new(),
1638        };
1639
1640        let plan = plan(&config, &root, &worktree).expect("plan should build");
1641
1642        assert!(plan.warnings().is_empty());
1643    }
1644
1645    #[cfg(unix)]
1646    #[test]
1647    fn plan_should_skip_unsafe_symlinks_excluded_by_include() {
1648        let (root, worktree) = temp_workspace("include-unsafe-symlink");
1649        std::fs::create_dir_all(root.join("shared/docs")).expect("source should be created");
1650        std::fs::write(root.join("shared/docs/guide.md"), "guide\n")
1651            .expect("file should be written");
1652        let outside = root.parent().expect("root should have parent").join("out");
1653        std::fs::create_dir_all(&outside).expect("outside dir should be created");
1654        symlink_dir(&outside, root.join("shared/unsafe-link"))
1655            .expect("unsafe symlink should be created");
1656
1657        let filtered = Config {
1658            options: Default::default(),
1659            files: vec![include_operation(
1660                FileOperationKind::Copy,
1661                &root,
1662                &worktree,
1663                "shared",
1664                &["docs/**"],
1665            )],
1666            commands: Vec::new(),
1667        };
1668        plan(&filtered, &root, &worktree)
1669            .expect("non-included unsafe symlink should not fail validation");
1670
1671        let unfiltered = Config {
1672            options: Default::default(),
1673            files: vec![file_operation(
1674                FileOperationKind::Copy,
1675                &root,
1676                &worktree,
1677                "shared",
1678                "shared",
1679            )],
1680            commands: Vec::new(),
1681        };
1682        let error = plan(&unfiltered, &root, &worktree)
1683            .expect_err("unfiltered unsafe symlink should fail validation");
1684        assert!(error.to_string().contains("unsafe symlink"));
1685    }
1686
1687    #[test]
1688    fn plan_should_reject_include_with_sync_delete_for_direct_file_operations() {
1689        let (root, worktree) = temp_workspace("include-delete-direct");
1690        std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1691        let mut operation = include_operation(
1692            FileOperationKind::Sync,
1693            &root,
1694            &worktree,
1695            "shared",
1696            &["docs/**"],
1697        );
1698        operation.delete = Some(true);
1699
1700        let error = ActionPlan::from_file_operations(
1701            &context(&root, &worktree),
1702            PlanOrigin::Manual {
1703                operation: FileOperationKind::Sync,
1704            },
1705            &[operation],
1706            ActionPlanOptions::default(),
1707        )
1708        .expect_err("include with delete should fail");
1709
1710        assert!(
1711            error
1712                .to_string()
1713                .contains("`include` cannot be combined with `delete = true`")
1714        );
1715    }
1716
1717    #[test]
1718    fn plan_should_reject_inert_include_patterns_for_direct_file_operations() {
1719        let (root, worktree) = temp_workspace("include-inert-direct");
1720        std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1721        let operation = include_operation(
1722            FileOperationKind::Copy,
1723            &root,
1724            &worktree,
1725            "shared",
1726            &["!docs"],
1727        );
1728
1729        let error = ActionPlan::from_file_operations(
1730            &context(&root, &worktree),
1731            PlanOrigin::Manual {
1732                operation: FileOperationKind::Copy,
1733            },
1734            &[operation],
1735            ActionPlanOptions::default(),
1736        )
1737        .expect_err("negated include should fail");
1738
1739        assert!(error.to_string().contains("uses `!` negation"));
1740    }
1741
1742    #[test]
1743    fn is_within_should_not_match_partial_component_prefixes() {
1744        assert!(!is_within(
1745            Path::new("/repo-worktree-other/file"),
1746            Path::new("/repo-worktree")
1747        ));
1748    }
1749
1750    #[test]
1751    fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
1752        let (root, worktree) = temp_workspace("missing-source");
1753        let config = Config {
1754            options: Default::default(),
1755            files: vec![FileOperation {
1756                operation: FileOperationKind::Copy,
1757                source: PathBuf::from("missing"),
1758                target: PathBuf::from("missing"),
1759                source_path: root.join("missing"),
1760                target_path: worktree.join("missing"),
1761                required: false,
1762                compare: None,
1763                delete: None,
1764                symlinks: Some(SymlinkMode::Preserve),
1765                include: Vec::new(),
1766                ignore: Vec::new(),
1767                ignore_metadata: Vec::new(),
1768                declaration: span(),
1769            }],
1770            commands: Vec::new(),
1771        };
1772
1773        let plan = ActionPlan::from_manifest(
1774            Path::new(".treeboot.toml"),
1775            &config,
1776            &context(&root, &worktree),
1777            ActionPlanOptions::default(),
1778        )
1779        .expect("optional missing source should plan");
1780
1781        assert_eq!(
1782            plan.files[0].status,
1783            PlannedFileStatus::SkippedMissingSource
1784        );
1785    }
1786
1787    #[test]
1788    fn action_plan_from_manifest_should_build_ready_file_operation() {
1789        let (root, worktree) = temp_workspace("ready-file");
1790        std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
1791        let config = Config {
1792            options: Default::default(),
1793            files: vec![file_operation(
1794                FileOperationKind::Copy,
1795                &root,
1796                &worktree,
1797                ".env",
1798                ".env",
1799            )],
1800            commands: Vec::new(),
1801        };
1802
1803        let plan = plan(&config, &root, &worktree).expect("file should plan");
1804
1805        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1806    }
1807
1808    #[test]
1809    fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
1810        let (root, worktree) = temp_workspace("overlapping-targets");
1811        let mut sync = file_operation(
1812            FileOperationKind::Sync,
1813            &root,
1814            &worktree,
1815            "shared",
1816            "shared",
1817        );
1818        sync.delete = Some(true);
1819        let config = Config {
1820            options: Default::default(),
1821            files: vec![
1822                file_operation(
1823                    FileOperationKind::Copy,
1824                    &root,
1825                    &worktree,
1826                    "child",
1827                    "shared/child",
1828                ),
1829                sync,
1830            ],
1831            commands: Vec::new(),
1832        };
1833
1834        let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
1835
1836        assert!(error.to_string().contains("overlapping configured targets"));
1837        assert!(error.to_string().contains("shared"));
1838        assert!(error.to_string().contains("shared/child"));
1839    }
1840
1841    #[test]
1842    fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
1843        let (root, worktree) = temp_workspace("manual-overlapping-targets");
1844        let mut sync = file_operation(
1845            FileOperationKind::Sync,
1846            &root,
1847            &worktree,
1848            "shared",
1849            "shared",
1850        );
1851        sync.delete = Some(true);
1852        let operations = vec![
1853            sync,
1854            file_operation(
1855                FileOperationKind::Sync,
1856                &root,
1857                &worktree,
1858                "shared/nested",
1859                "shared/nested",
1860            ),
1861        ];
1862
1863        let error = ActionPlan::from_file_operations(
1864            &context(&root, &worktree),
1865            PlanOrigin::Manual {
1866                operation: FileOperationKind::Sync,
1867            },
1868            &operations,
1869            ActionPlanOptions::default(),
1870        )
1871        .expect_err("overlapping targets should fail");
1872
1873        assert!(error.to_string().contains("invalid sync file operation"));
1874        assert!(error.to_string().contains("overlapping targets"));
1875    }
1876
1877    #[test]
1878    fn action_plan_from_manifest_should_build_command_metadata() {
1879        let (root, worktree) = temp_workspace("command-metadata");
1880        let app_dir = worktree.join("app");
1881        std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1882        let config = Config {
1883            options: Default::default(),
1884            files: Vec::new(),
1885            commands: vec![CommandOperation {
1886                name: Some("Install".to_owned()),
1887                command: CommandKind::Direct {
1888                    program: "npm".to_owned(),
1889                    args: vec!["install".to_owned()],
1890                },
1891                cwd: Some(PathBuf::from("app")),
1892                cwd_path: Some(app_dir.clone()),
1893                env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1894                allow_failure: true,
1895                declaration: span(),
1896            }],
1897        };
1898
1899        let plan = plan(&config, &root, &worktree).expect("command should plan");
1900
1901        assert_eq!(
1902            plan.commands[0].cwd_path,
1903            paths::canonicalize(&app_dir).expect("app dir should canonicalize")
1904        );
1905        assert!(plan.commands[0].allow_failure);
1906    }
1907
1908    #[test]
1909    fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1910        let (root, worktree) = temp_workspace("boundary-escapes");
1911        let outside_source = root
1912            .parent()
1913            .expect("root should have parent")
1914            .join("outside-source");
1915        let outside_target = worktree
1916            .parent()
1917            .expect("worktree should have parent")
1918            .join("outside-target");
1919        std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1920        let config = Config {
1921            options: Default::default(),
1922            files: vec![FileOperation {
1923                operation: FileOperationKind::Copy,
1924                source: outside_source.clone(),
1925                target: outside_target.clone(),
1926                source_path: outside_source,
1927                target_path: outside_target,
1928                required: false,
1929                compare: None,
1930                delete: None,
1931                symlinks: Some(SymlinkMode::Preserve),
1932                include: Vec::new(),
1933                ignore: Vec::new(),
1934                ignore_metadata: Vec::new(),
1935                declaration: span(),
1936            }],
1937            commands: Vec::new(),
1938        };
1939
1940        let plan = ActionPlan::from_manifest(
1941            Path::new(".treeboot.toml"),
1942            &config,
1943            &context(&root, &worktree),
1944            ActionPlanOptions {
1945                dangerously_allow_sources_outside_root: true,
1946                dangerously_allow_targets_outside_worktree: true,
1947                ..ActionPlanOptions::default()
1948            },
1949        )
1950        .expect("escaped paths should plan");
1951
1952        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1953    }
1954
1955    #[test]
1956    fn action_plan_from_manifest_should_allow_missing_target_parents_for_all_file_operations() {
1957        for operation in [
1958            FileOperationKind::Copy,
1959            FileOperationKind::Symlink,
1960            FileOperationKind::Sync,
1961        ] {
1962            let (root, worktree) = temp_workspace(&format!("missing-target-parent-{operation}"));
1963            std::fs::write(root.join("source"), "value\n").expect("source should be written");
1964            let config = Config {
1965                options: Default::default(),
1966                files: vec![file_operation(
1967                    operation,
1968                    &root,
1969                    &worktree,
1970                    "source",
1971                    "nested/config/source",
1972                )],
1973                commands: Vec::new(),
1974            };
1975
1976            let plan = plan(&config, &root, &worktree)
1977                .unwrap_or_else(|error| panic!("{operation} should plan: {error}"));
1978
1979            assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1980        }
1981    }
1982
1983    #[test]
1984    fn action_plan_from_manifest_should_allow_final_symlink_target_to_root_source() {
1985        let (root, worktree) = temp_workspace("final-symlink-target-to-root");
1986        let source = root.join("config/master.key");
1987        let target = worktree.join("config/master.key");
1988        std::fs::create_dir_all(source.parent().unwrap()).expect("source parent should exist");
1989        std::fs::create_dir_all(target.parent().unwrap()).expect("target parent should exist");
1990        std::fs::write(&source, "secret\n").expect("source should be written");
1991        symlink_file(&source, &target).expect("target symlink should be created");
1992        let config = Config {
1993            options: Default::default(),
1994            files: vec![file_operation(
1995                FileOperationKind::Symlink,
1996                &root,
1997                &worktree,
1998                "config/master.key",
1999                "config/master.key",
2000            )],
2001            commands: Vec::new(),
2002        };
2003
2004        let plan = plan(&config, &root, &worktree)
2005            .expect("final target symlink to root source should plan");
2006
2007        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2008        assert_eq!(
2009            plan.files[0].target_path,
2010            normalize_target_path(&target).expect("target should normalize")
2011        );
2012    }
2013
2014    #[test]
2015    fn action_plan_from_manifest_should_reject_target_parent_symlink_for_all_file_operations() {
2016        for operation in [
2017            FileOperationKind::Copy,
2018            FileOperationKind::Symlink,
2019            FileOperationKind::Sync,
2020        ] {
2021            let (root, worktree) = temp_workspace(&format!("target-parent-symlink-{operation}"));
2022            let linked = root.join("config");
2023            std::fs::create_dir_all(&linked).expect("linked directory should be created");
2024            std::fs::write(root.join("source"), "value\n").expect("source should be written");
2025            symlink_dir(&linked, worktree.join("config"))
2026                .expect("target parent symlink should be created");
2027            let config = Config {
2028                options: Default::default(),
2029                files: vec![file_operation(
2030                    operation,
2031                    &root,
2032                    &worktree,
2033                    "source",
2034                    "config/source",
2035                )],
2036                commands: Vec::new(),
2037            };
2038
2039            let error = match plan(&config, &root, &worktree) {
2040                Ok(_) => panic!("{operation} should reject symlink parent"),
2041                Err(error) => error,
2042            };
2043            let message = error.to_string();
2044            assert!(
2045                message.contains(&format!("cannot create target for {operation}")),
2046                "{operation} error should name operation: {message}"
2047            );
2048            assert!(
2049                message.contains("target parent") && message.contains("is a symlink"),
2050                "{operation} error should describe symlink parent: {message}"
2051            );
2052        }
2053    }
2054
2055    #[test]
2056    fn action_plan_from_manifest_should_reject_target_parent_file_for_all_file_operations() {
2057        for operation in [
2058            FileOperationKind::Copy,
2059            FileOperationKind::Symlink,
2060            FileOperationKind::Sync,
2061        ] {
2062            let (root, worktree) = temp_workspace(&format!("target-parent-file-{operation}"));
2063            std::fs::write(root.join("source"), "value\n").expect("source should be written");
2064            std::fs::write(worktree.join("config"), "not a directory\n")
2065                .expect("target parent file should be written");
2066            let config = Config {
2067                options: Default::default(),
2068                files: vec![file_operation(
2069                    operation,
2070                    &root,
2071                    &worktree,
2072                    "source",
2073                    "config/source",
2074                )],
2075                commands: Vec::new(),
2076            };
2077
2078            let error = match plan(&config, &root, &worktree) {
2079                Ok(_) => panic!("{operation} should reject file parent"),
2080                Err(error) => error,
2081            };
2082            let message = error.to_string();
2083            assert!(
2084                message.contains(&format!("cannot create target for {operation}")),
2085                "{operation} error should name operation: {message}"
2086            );
2087            assert!(
2088                message.contains("target parent") && message.contains("is not a directory"),
2089                "{operation} error should describe file parent: {message}"
2090            );
2091        }
2092    }
2093
2094    #[test]
2095    fn action_plan_from_manifest_should_reject_target_parent_file_with_worktree_alias() {
2096        let (_root, _worktree, alias_root, alias_worktree) =
2097            aliased_workspace("target-parent-file-alias");
2098        std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2099        std::fs::write(alias_worktree.join("config"), "not a directory\n")
2100            .expect("target parent file should be written");
2101        let config = Config {
2102            options: Default::default(),
2103            files: vec![file_operation(
2104                FileOperationKind::Copy,
2105                &alias_root,
2106                &alias_worktree,
2107                "source",
2108                "config/source",
2109            )],
2110            commands: Vec::new(),
2111        };
2112
2113        let error = plan(&config, &alias_root, &alias_worktree)
2114            .expect_err("aliased worktree should still reject file parent");
2115
2116        assert!(error.to_string().contains("target parent"));
2117        assert!(error.to_string().contains("is not a directory"));
2118    }
2119
2120    #[test]
2121    fn action_plan_from_manifest_should_reject_target_parent_symlink_with_worktree_alias() {
2122        let (root, _worktree, alias_root, alias_worktree) =
2123            aliased_workspace("target-parent-symlink-alias");
2124        let linked = root.join("config");
2125        std::fs::create_dir_all(&linked).expect("linked directory should be created");
2126        std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2127        symlink_dir(&linked, alias_worktree.join("config"))
2128            .expect("target parent symlink should be created");
2129        let config = Config {
2130            options: Default::default(),
2131            files: vec![file_operation(
2132                FileOperationKind::Copy,
2133                &alias_root,
2134                &alias_worktree,
2135                "source",
2136                "config/source",
2137            )],
2138            commands: Vec::new(),
2139        };
2140
2141        let error = plan(&config, &alias_root, &alias_worktree)
2142            .expect_err("aliased worktree should still reject symlink parent");
2143
2144        assert!(error.to_string().contains("target parent"));
2145        assert!(error.to_string().contains("is a symlink"));
2146    }
2147
2148    #[test]
2149    fn action_plan_from_manifest_should_reject_canonical_absolute_target_parent_symlink() {
2150        let (root, worktree, alias_root, alias_worktree) =
2151            aliased_workspace("absolute-target-parent-symlink");
2152        let linked = root.join("config");
2153        std::fs::create_dir_all(&linked).expect("linked directory should be created");
2154        std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2155        symlink_dir(&linked, worktree.join("config"))
2156            .expect("target parent symlink should be created");
2157        let target_path = paths::canonicalize(&worktree)
2158            .expect("worktree should canonicalize")
2159            .join("config/source");
2160        let mut operation = file_operation(
2161            FileOperationKind::Copy,
2162            &alias_root,
2163            &alias_worktree,
2164            "source",
2165            "config/source",
2166        );
2167        operation.target = target_path.clone();
2168        operation.target_path = target_path;
2169        let config = Config {
2170            options: Default::default(),
2171            files: vec![operation],
2172            commands: Vec::new(),
2173        };
2174
2175        let error = plan(&config, &alias_root, &alias_worktree)
2176            .expect_err("canonical absolute target should reject symlink parent");
2177
2178        assert!(error.to_string().contains("target parent"));
2179        assert!(error.to_string().contains("is a symlink"));
2180    }
2181
2182    #[test]
2183    fn action_plan_from_manifest_should_reject_absolute_alias_target_parent_symlink() {
2184        let (root, worktree, _alias_root, alias_worktree) =
2185            aliased_workspace("absolute-alias-target-parent-symlink");
2186        let linked = worktree.join("real-config");
2187        std::fs::create_dir_all(&linked).expect("linked directory should be created");
2188        std::fs::write(root.join("source"), "value\n").expect("source should be written");
2189        symlink_dir(&linked, worktree.join("config"))
2190            .expect("target parent symlink should be created");
2191        let target_path = alias_worktree.join("config/source");
2192        let mut operation = file_operation(
2193            FileOperationKind::Copy,
2194            &root,
2195            &worktree,
2196            "source",
2197            "config/source",
2198        );
2199        operation.target = target_path.clone();
2200        operation.target_path = target_path;
2201        let config = Config {
2202            options: Default::default(),
2203            files: vec![operation],
2204            commands: Vec::new(),
2205        };
2206
2207        let error = plan(&config, &root, &worktree)
2208            .expect_err("absolute alias target should reject symlink parent");
2209
2210        assert!(error.to_string().contains("target parent"));
2211        assert!(error.to_string().contains("is a symlink"));
2212    }
2213
2214    #[test]
2215    fn action_plan_from_manifest_should_keep_input_context_for_worktree_alias() {
2216        let (_root, _worktree, alias_root, alias_worktree) = aliased_workspace("context-alias");
2217        let plan = ActionPlan::from_manifest(
2218            Path::new(".treeboot.toml"),
2219            &empty_config(),
2220            &context(&alias_root, &alias_worktree),
2221            ActionPlanOptions::default(),
2222        )
2223        .expect("empty plan should build");
2224
2225        assert_eq!(plan.context().root_path, alias_root);
2226        assert_eq!(plan.context().worktree_path, alias_worktree);
2227    }
2228
2229    #[test]
2230    fn action_plan_from_manifest_should_reject_missing_root_path() {
2231        let (_root, worktree) = temp_workspace("missing-root");
2232        let missing_root = worktree.join("missing-root");
2233        let error = ActionPlan::from_manifest(
2234            Path::new(".treeboot.toml"),
2235            &empty_config(),
2236            &context(&missing_root, &worktree),
2237            ActionPlanOptions::default(),
2238        )
2239        .expect_err("missing root should fail");
2240
2241        assert!(error.to_string().contains("failed to resolve root path"));
2242    }
2243
2244    #[test]
2245    fn action_plan_from_manifest_should_reject_missing_worktree_path() {
2246        let (root, worktree) = temp_workspace("missing-worktree");
2247        let missing_worktree = worktree.join("missing-worktree");
2248        let error = ActionPlan::from_manifest(
2249            Path::new(".treeboot.toml"),
2250            &empty_config(),
2251            &context(&root, &missing_worktree),
2252            ActionPlanOptions::default(),
2253        )
2254        .expect_err("missing worktree should fail");
2255
2256        assert!(
2257            error
2258                .to_string()
2259                .contains("failed to resolve worktree path")
2260        );
2261    }
2262
2263    #[test]
2264    fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
2265        let (root, worktree) = temp_workspace("strict-no-sync");
2266
2267        let plan = ActionPlan::from_manifest(
2268            Path::new(".treeboot.toml"),
2269            &empty_config(),
2270            &context(&root, &worktree),
2271            ActionPlanOptions {
2272                strict: true,
2273                ..ActionPlanOptions::default()
2274            },
2275        )
2276        .expect("strict mode should allow configs without sync");
2277
2278        assert!(plan.files.is_empty());
2279    }
2280
2281    #[test]
2282    fn action_plan_from_manifest_should_walk_source_directories() {
2283        let (root, worktree) = temp_workspace("source-directory");
2284        let source_dir = root.join("shared");
2285        std::fs::create_dir_all(&source_dir).expect("source dir should be created");
2286        std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
2287        let config = Config {
2288            options: Default::default(),
2289            files: vec![file_operation(
2290                FileOperationKind::Copy,
2291                &root,
2292                &worktree,
2293                "shared",
2294                "shared",
2295            )],
2296            commands: Vec::new(),
2297        };
2298
2299        let plan = plan(&config, &root, &worktree).expect("directory source should plan");
2300
2301        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2302    }
2303
2304    #[test]
2305    fn action_plan_from_manifest_should_preserve_sync_options() {
2306        let (root, worktree) = temp_workspace("sync-options");
2307        let source_dir = root.join("shared");
2308        std::fs::create_dir_all(&source_dir).expect("source dir should be created");
2309        let mut operation = file_operation(
2310            FileOperationKind::Sync,
2311            &root,
2312            &worktree,
2313            "shared",
2314            "shared",
2315        );
2316        operation.delete = Some(true);
2317
2318        let config = Config {
2319            options: Default::default(),
2320            files: vec![operation],
2321            commands: Vec::new(),
2322        };
2323
2324        let plan = plan(&config, &root, &worktree).expect("sync should plan");
2325
2326        assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
2327        assert_eq!(plan.files[0].delete, Some(true));
2328        assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
2329    }
2330
2331    #[test]
2332    fn action_plan_from_manifest_should_allow_safe_source_symlink() {
2333        let (root, worktree) = temp_workspace("safe-symlink");
2334        std::fs::write(root.join("source"), "value\n").expect("source should be written");
2335        symlink_file(root.join("source"), root.join("link"))
2336            .expect("safe source symlink should be created");
2337        let config = Config {
2338            options: Default::default(),
2339            files: vec![file_operation(
2340                FileOperationKind::Copy,
2341                &root,
2342                &worktree,
2343                "link",
2344                "link",
2345            )],
2346            commands: Vec::new(),
2347        };
2348
2349        let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
2350
2351        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2352    }
2353
2354    #[test]
2355    fn action_plan_from_manifest_should_reject_broken_source_symlink() {
2356        let (root, worktree) = temp_workspace("broken-symlink");
2357        symlink_file(root.join("missing"), root.join("link"))
2358            .expect("broken source symlink should be created");
2359        let config = Config {
2360            options: Default::default(),
2361            files: vec![file_operation(
2362                FileOperationKind::Copy,
2363                &root,
2364                &worktree,
2365                "link",
2366                "link",
2367            )],
2368            commands: Vec::new(),
2369        };
2370
2371        let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
2372
2373        assert!(
2374            error
2375                .to_string()
2376                .contains("failed to resolve source symlink")
2377        );
2378    }
2379
2380    #[test]
2381    fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
2382        let (root, worktree) = temp_workspace("command-cwd");
2383        let config = Config {
2384            options: Default::default(),
2385            files: Vec::new(),
2386            commands: vec![CommandOperation {
2387                name: None,
2388                command: CommandKind::Shell {
2389                    run: "pwd".to_owned(),
2390                },
2391                cwd: None,
2392                cwd_path: None,
2393                env: BTreeMap::new(),
2394                allow_failure: false,
2395                declaration: span(),
2396            }],
2397        };
2398
2399        let plan = ActionPlan::from_manifest(
2400            Path::new(".treeboot.toml"),
2401            &config,
2402            &context(&root, &worktree),
2403            ActionPlanOptions::default(),
2404        )
2405        .expect("command should plan");
2406
2407        assert_eq!(
2408            plan.commands[0].cwd_path,
2409            paths::canonicalize(&worktree).expect("worktree should canonicalize")
2410        );
2411    }
2412}