Skip to main content

treeboot_core/
validation.rs

1use std::collections::BTreeMap;
2use std::path::{Component, Path, PathBuf};
3
4use crate::ignore_rules::PathIgnoreRules;
5use crate::{
6    CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
7    FileOperationKind, MetadataField, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
8};
9
10/// Options that affect declarative run planning.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub struct ActionPlanOptions {
13    /// Rejects sync operations and other strict-mode conflicts.
14    pub strict: bool,
15    /// Allows file operation sources outside the root checkout.
16    pub dangerously_allow_sources_outside_root: bool,
17    /// Allows file operation targets outside the current worktree.
18    pub dangerously_allow_targets_outside_worktree: bool,
19}
20
21impl From<ConfigRuntimeOptions> for ActionPlanOptions {
22    fn from(options: ConfigRuntimeOptions) -> Self {
23        Self {
24            strict: options.strict,
25            dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
26            dangerously_allow_targets_outside_worktree: options
27                .dangerously_allow_targets_outside_worktree,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy)]
33pub(super) enum FilePlanOrigin<'a> {
34    Config(&'a Path),
35    Manual { operation: FileOperationKind },
36}
37
38/// Source of a validated action plan.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PlanOrigin {
41    /// Plan was built from a treeboot manifest.
42    Manifest {
43        /// Manifest path.
44        path: PathBuf,
45    },
46    /// Plan was built from a manual file operation command.
47    Manual {
48        /// Manual operation kind.
49        operation: FileOperationKind,
50    },
51}
52
53/// A validated set of file operations and commands ready for execution.
54///
55/// Plans can only be built through validation constructors. Callers may inspect
56/// plans through accessor methods, but cannot construct or mutate planned work
57/// directly.
58///
59/// ```compile_fail
60/// # use treeboot_core::ActionPlan;
61/// # fn cannot_construct() {
62/// ActionPlan {
63///     context: todo!(),
64///     origin: todo!(),
65///     config_path: None,
66///     files: Vec::new(),
67///     commands: Vec::new(),
68/// };
69/// # }
70/// ```
71///
72/// ```compile_fail
73/// # use treeboot_core::ActionPlan;
74/// # fn cannot_mutate(plan: &mut ActionPlan) {
75/// plan.commands = Vec::new();
76/// # }
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ActionPlan {
80    /// Runtime context used while building the plan.
81    context: Worktree,
82    /// Origin of this plan.
83    origin: PlanOrigin,
84    /// Config file used for this plan, when it came from a manifest.
85    config_path: Option<PathBuf>,
86    /// Planned file operations.
87    files: Vec<PlannedFileOperation>,
88    /// Planned command operations.
89    commands: Vec<PlannedCommand>,
90}
91
92impl ActionPlan {
93    /// Returns the runtime context used while building the plan.
94    #[must_use]
95    pub const fn context(&self) -> &Worktree {
96        &self.context
97    }
98
99    /// Returns the origin of this plan.
100    #[must_use]
101    pub const fn origin(&self) -> &PlanOrigin {
102        &self.origin
103    }
104
105    /// Returns the config file used for this plan, when it came from a manifest.
106    #[must_use]
107    pub fn config_path(&self) -> Option<&Path> {
108        self.config_path.as_deref()
109    }
110
111    /// Returns the planned file operations.
112    #[must_use]
113    pub fn files(&self) -> &[PlannedFileOperation] {
114        &self.files
115    }
116
117    /// Returns the planned command operations.
118    #[must_use]
119    pub fn commands(&self) -> &[PlannedCommand] {
120        &self.commands
121    }
122
123    /// Builds a validated action plan from a parsed treeboot manifest.
124    ///
125    /// This does not apply file operations or execute commands. It normalizes
126    /// paths that may not exist yet, rejects invalid declarative behavior, and
127    /// marks optional missing-source file operations as skipped.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if manifest validation fails.
132    pub fn from_manifest(
133        path: &Path,
134        manifest: &Config,
135        context: &Worktree,
136        options: ActionPlanOptions,
137    ) -> Result<Self> {
138        let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
139            invalid_config_error(
140                path,
141                None,
142                format!("failed to resolve worktree path: {source}"),
143            )
144        })?;
145        let files = plan_file_operations(
146            FilePlanOrigin::Config(path),
147            &manifest.files,
148            context,
149            options,
150        )?;
151        let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
152
153        Ok(Self {
154            context: context.clone(),
155            origin: PlanOrigin::Manifest {
156                path: path.to_path_buf(),
157            },
158            config_path: Some(path.to_path_buf()),
159            files,
160            commands,
161        })
162    }
163
164    /// Builds a validated action plan from explicit file operations.
165    ///
166    /// This is intended for manual commands and other callers that already
167    /// have a discovered worktree context and operation list.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if file operation validation fails.
172    pub fn from_file_operations(
173        context: &Worktree,
174        origin: PlanOrigin,
175        files: &[FileOperation],
176        options: ActionPlanOptions,
177    ) -> Result<Self> {
178        let file_origin = match &origin {
179            PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
180            PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
181                operation: *operation,
182            },
183        };
184        let files = plan_file_operations(file_origin, files, context, options)?;
185        let config_path = match &origin {
186            PlanOrigin::Manifest { path } => Some(path.clone()),
187            PlanOrigin::Manual { .. } => None,
188        };
189
190        Ok(Self {
191            context: context.clone(),
192            origin,
193            config_path,
194            files,
195            commands: Vec::new(),
196        })
197    }
198
199    #[cfg(test)]
200    pub(crate) fn from_parts_unchecked(
201        context: Worktree,
202        origin: PlanOrigin,
203        config_path: Option<PathBuf>,
204        files: Vec<PlannedFileOperation>,
205        commands: Vec<PlannedCommand>,
206    ) -> Self {
207        Self {
208            context,
209            origin,
210            config_path,
211            files,
212            commands,
213        }
214    }
215}
216
217/// A validated file operation ready for execution.
218///
219/// ```compile_fail
220/// # use treeboot_core::PlannedFileOperation;
221/// # fn cannot_mutate(operation: &mut PlannedFileOperation) {
222/// operation.target_path = std::path::PathBuf::from("outside");
223/// # }
224/// ```
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct PlannedFileOperation {
227    /// File operation kind.
228    operation: FileOperationKind,
229    /// Declared source path.
230    source: PathBuf,
231    /// Declared target path.
232    target: PathBuf,
233    /// Normalized source path.
234    source_path: PathBuf,
235    /// Normalized target path.
236    target_path: PathBuf,
237    /// Whether a missing source should fail validation.
238    required: bool,
239    /// Sync comparison mode.
240    compare: Option<SyncCompare>,
241    /// Whether sync should delete target-only files.
242    delete: Option<bool>,
243    /// How copy and sync should treat source symlinks.
244    symlinks: Option<SymlinkMode>,
245    /// Source-relative path patterns ignored by copy and sync.
246    ignore: Vec<String>,
247    /// Metadata fields ignored by copy and sync.
248    ignore_metadata: Vec<MetadataField>,
249    /// Whether this operation should execute.
250    status: PlannedFileStatus,
251    /// Source location for the operation declaration.
252    declaration: SourceSpan,
253}
254
255impl PlannedFileOperation {
256    /// Returns the file operation kind.
257    #[must_use]
258    pub const fn operation(&self) -> FileOperationKind {
259        self.operation
260    }
261
262    /// Returns the declared source path.
263    #[must_use]
264    pub fn source(&self) -> &Path {
265        &self.source
266    }
267
268    /// Returns the declared target path.
269    #[must_use]
270    pub fn target(&self) -> &Path {
271        &self.target
272    }
273
274    /// Returns the normalized source path.
275    #[must_use]
276    pub fn source_path(&self) -> &Path {
277        &self.source_path
278    }
279
280    /// Returns the normalized target path.
281    #[must_use]
282    pub fn target_path(&self) -> &Path {
283        &self.target_path
284    }
285
286    /// Returns whether a missing source should fail validation.
287    #[must_use]
288    pub const fn required(&self) -> bool {
289        self.required
290    }
291
292    /// Returns the sync comparison mode.
293    #[must_use]
294    pub const fn compare(&self) -> Option<SyncCompare> {
295        self.compare
296    }
297
298    /// Returns whether sync should delete target-only files.
299    #[must_use]
300    pub const fn delete(&self) -> Option<bool> {
301        self.delete
302    }
303
304    /// Returns how copy and sync should treat source symlinks.
305    #[must_use]
306    pub const fn symlinks(&self) -> Option<SymlinkMode> {
307        self.symlinks
308    }
309
310    /// Returns source-relative path patterns ignored by copy and sync.
311    #[must_use]
312    pub fn ignore(&self) -> &[String] {
313        &self.ignore
314    }
315
316    /// Returns metadata fields ignored by copy and sync.
317    #[must_use]
318    pub fn ignore_metadata(&self) -> &[MetadataField] {
319        &self.ignore_metadata
320    }
321
322    /// Returns whether this operation should execute.
323    #[must_use]
324    pub const fn status(&self) -> PlannedFileStatus {
325        self.status
326    }
327
328    /// Returns the source location for the operation declaration.
329    #[must_use]
330    pub const fn declaration(&self) -> SourceSpan {
331        self.declaration
332    }
333
334    #[cfg(test)]
335    pub(crate) fn from_raw_parts_unchecked(parts: PlannedFileOperationParts) -> Self {
336        Self {
337            operation: parts.operation,
338            source: parts.source,
339            target: parts.target,
340            source_path: parts.source_path,
341            target_path: parts.target_path,
342            required: parts.required,
343            compare: parts.compare,
344            delete: parts.delete,
345            symlinks: parts.symlinks,
346            ignore: parts.ignore,
347            ignore_metadata: parts.ignore_metadata,
348            status: parts.status,
349            declaration: parts.declaration,
350        }
351    }
352
353    #[cfg(test)]
354    pub(crate) const fn with_compare(mut self, compare: Option<SyncCompare>) -> Self {
355        self.compare = compare;
356        self
357    }
358
359    #[cfg(test)]
360    pub(crate) const fn with_delete(mut self, delete: Option<bool>) -> Self {
361        self.delete = delete;
362        self
363    }
364
365    #[cfg(test)]
366    pub(crate) fn with_ignore(mut self, ignore: Vec<String>) -> Self {
367        self.ignore = ignore;
368        self
369    }
370
371    #[cfg(test)]
372    pub(crate) fn with_ignore_metadata(mut self, ignore_metadata: Vec<MetadataField>) -> Self {
373        self.ignore_metadata = ignore_metadata;
374        self
375    }
376}
377
378#[cfg(test)]
379pub(crate) struct PlannedFileOperationParts {
380    pub(crate) operation: FileOperationKind,
381    pub(crate) source: PathBuf,
382    pub(crate) target: PathBuf,
383    pub(crate) source_path: PathBuf,
384    pub(crate) target_path: PathBuf,
385    pub(crate) required: bool,
386    pub(crate) compare: Option<SyncCompare>,
387    pub(crate) delete: Option<bool>,
388    pub(crate) symlinks: Option<SymlinkMode>,
389    pub(crate) ignore: Vec<String>,
390    pub(crate) ignore_metadata: Vec<MetadataField>,
391    pub(crate) status: PlannedFileStatus,
392    pub(crate) declaration: SourceSpan,
393}
394
395/// Execution status for a planned file operation.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum PlannedFileStatus {
398    /// The operation has an existing source and should run.
399    Ready,
400    /// The operation has an optional missing source and should be skipped.
401    SkippedMissingSource,
402}
403
404/// A validated command operation ready for execution.
405///
406/// ```compile_fail
407/// # use treeboot_core::PlannedCommand;
408/// # fn cannot_mutate(command: &mut PlannedCommand) {
409/// command.cwd_path = std::path::PathBuf::from("outside");
410/// # }
411/// ```
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct PlannedCommand {
414    /// Optional display name.
415    name: Option<String>,
416    /// Command invocation.
417    command: CommandKind,
418    /// Declared working directory.
419    cwd: Option<PathBuf>,
420    /// Normalized working directory.
421    cwd_path: PathBuf,
422    /// Extra environment variables for this command.
423    env: BTreeMap<String, String>,
424    /// Whether a non-zero exit status should be non-fatal.
425    allow_failure: bool,
426    /// Source location for the command declaration.
427    declaration: SourceSpan,
428}
429
430impl PlannedCommand {
431    /// Returns the optional display name.
432    #[must_use]
433    pub fn name(&self) -> Option<&str> {
434        self.name.as_deref()
435    }
436
437    /// Returns the command invocation.
438    #[must_use]
439    pub const fn command(&self) -> &CommandKind {
440        &self.command
441    }
442
443    /// Returns the declared working directory.
444    #[must_use]
445    pub fn cwd(&self) -> Option<&Path> {
446        self.cwd.as_deref()
447    }
448
449    /// Returns the normalized working directory.
450    #[must_use]
451    pub fn cwd_path(&self) -> &Path {
452        &self.cwd_path
453    }
454
455    /// Returns extra environment variables for this command.
456    #[must_use]
457    pub const fn env(&self) -> &BTreeMap<String, String> {
458        &self.env
459    }
460
461    /// Returns whether a non-zero exit status should be non-fatal.
462    #[must_use]
463    pub const fn allow_failure(&self) -> bool {
464        self.allow_failure
465    }
466
467    /// Returns the source location for the command declaration.
468    #[must_use]
469    pub const fn declaration(&self) -> SourceSpan {
470        self.declaration
471    }
472
473    #[cfg(test)]
474    pub(crate) fn from_raw_parts_unchecked(parts: PlannedCommandParts) -> Self {
475        Self {
476            name: parts.name,
477            command: parts.command,
478            cwd: parts.cwd,
479            cwd_path: parts.cwd_path,
480            env: parts.env,
481            allow_failure: parts.allow_failure,
482            declaration: parts.declaration,
483        }
484    }
485}
486
487#[cfg(test)]
488#[derive(Clone)]
489pub(crate) struct PlannedCommandParts {
490    pub(crate) name: Option<String>,
491    pub(crate) command: CommandKind,
492    pub(crate) cwd: Option<PathBuf>,
493    pub(crate) cwd_path: PathBuf,
494    pub(crate) env: BTreeMap<String, String>,
495    pub(crate) allow_failure: bool,
496    pub(crate) declaration: SourceSpan,
497}
498
499pub(super) fn plan_file_operations(
500    origin: FilePlanOrigin<'_>,
501    files: &[FileOperation],
502    context: &Worktree,
503    options: ActionPlanOptions,
504) -> Result<Vec<PlannedFileOperation>> {
505    let root_path = normalize_existing(&context.root_path).map_err(|source| {
506        file_plan_error(
507            origin,
508            None,
509            format!("failed to resolve root path: {source}"),
510        )
511    })?;
512    let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
513        file_plan_error(
514            origin,
515            None,
516            format!("failed to resolve worktree path: {source}"),
517        )
518    })?;
519
520    let target_paths = normalize_target_paths(origin, files)?;
521    validate_target_conflicts(origin, files, &target_paths)?;
522    validate_strict_sync(origin, files, options.strict)?;
523
524    build_file_operations(
525        origin,
526        files,
527        options,
528        &target_paths,
529        root_path.as_path(),
530        worktree_path.as_path(),
531    )
532}
533
534fn normalize_target_paths(
535    origin: FilePlanOrigin<'_>,
536    files: &[FileOperation],
537) -> Result<Vec<PathBuf>> {
538    files
539        .iter()
540        .map(|operation| {
541            normalize_maybe_existing(&operation.target_path).map_err(|source| {
542                file_plan_error(
543                    origin,
544                    Some(operation.declaration),
545                    format!(
546                        "failed to resolve target {}: {source}",
547                        operation.target.display()
548                    ),
549                )
550            })
551        })
552        .collect()
553}
554
555fn validate_target_conflicts(
556    origin: FilePlanOrigin<'_>,
557    files: &[FileOperation],
558    target_paths: &[PathBuf],
559) -> Result<()> {
560    validate_duplicate_targets(origin, files, target_paths)?;
561    validate_overlapping_targets(origin, files, target_paths)
562}
563
564fn validate_duplicate_targets(
565    origin: FilePlanOrigin<'_>,
566    files: &[FileOperation],
567    target_paths: &[PathBuf],
568) -> Result<()> {
569    let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
570
571    for (operation, target_path) in files.iter().zip(target_paths) {
572        targets
573            .entry(target_path.as_path())
574            .or_default()
575            .push(operation);
576    }
577
578    let duplicates = targets
579        .into_iter()
580        .filter(|(_, operations)| operations.len() > 1)
581        .collect::<Vec<_>>();
582
583    if duplicates.is_empty() {
584        return Ok(());
585    }
586
587    let details = duplicates
588        .iter()
589        .flat_map(|(target, operations)| {
590            operations.iter().map(move |operation| {
591                format!(
592                    "{}: {}",
593                    target.display(),
594                    operation_summary(origin, operation)
595                )
596            })
597        })
598        .collect::<Vec<_>>()
599        .join("; ");
600
601    let message = match origin {
602        FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
603        FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
604    };
605
606    Err(file_plan_error(origin, None, message))
607}
608
609fn validate_overlapping_targets(
610    origin: FilePlanOrigin<'_>,
611    files: &[FileOperation],
612    target_paths: &[PathBuf],
613) -> Result<()> {
614    let mut overlaps = Vec::new();
615
616    for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
617        for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
618            if target_path == other_target_path {
619                continue;
620            }
621
622            let Some((ancestor_path, ancestor, descendant_path, descendant)) =
623                overlapping_targets(target_path, operation, other_target_path, other_operation)
624            else {
625                continue;
626            };
627
628            overlaps.push(format!(
629                "{} contains {}: {}; {}",
630                ancestor_path.display(),
631                descendant_path.display(),
632                operation_summary(origin, ancestor),
633                operation_summary(origin, descendant)
634            ));
635        }
636    }
637
638    if overlaps.is_empty() {
639        return Ok(());
640    }
641
642    let message = match origin {
643        FilePlanOrigin::Config(_) => {
644            format!("overlapping configured targets: {}", overlaps.join("; "))
645        }
646        FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
647    };
648
649    Err(file_plan_error(origin, None, message))
650}
651
652fn overlapping_targets<'a>(
653    target_path: &'a Path,
654    operation: &'a FileOperation,
655    other_target_path: &'a Path,
656    other_operation: &'a FileOperation,
657) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
658    if other_target_path.starts_with(target_path) {
659        return Some((target_path, operation, other_target_path, other_operation));
660    }
661
662    if target_path.starts_with(other_target_path) {
663        return Some((other_target_path, other_operation, target_path, operation));
664    }
665
666    None
667}
668
669fn validate_strict_sync(
670    origin: FilePlanOrigin<'_>,
671    files: &[FileOperation],
672    strict: bool,
673) -> Result<()> {
674    if !strict {
675        return Ok(());
676    }
677
678    if let Some(operation) = files
679        .iter()
680        .find(|operation| operation.operation == FileOperationKind::Sync)
681    {
682        return invalid_file_plan(
683            origin,
684            Some(operation.declaration),
685            format!(
686                "`--strict` cannot be used with sync file operation {}",
687                operation_summary(origin, operation)
688            ),
689        );
690    }
691
692    Ok(())
693}
694
695fn build_file_operations(
696    origin: FilePlanOrigin<'_>,
697    files: &[FileOperation],
698    options: ActionPlanOptions,
699    target_paths: &[PathBuf],
700    root_path: &Path,
701    worktree_path: &Path,
702) -> Result<Vec<PlannedFileOperation>> {
703    let mut planned = Vec::with_capacity(files.len());
704
705    for (operation, target_path) in files.iter().zip(target_paths) {
706        validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
707
708        let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
709            file_plan_error(
710                origin,
711                Some(operation.declaration),
712                format!(
713                    "failed to resolve source {}: {source}",
714                    operation.source.display()
715                ),
716            )
717        })?;
718        validate_source_boundary(origin, options, operation, &source_path, root_path)?;
719        let ignore_rules = operation_ignore_rules(origin, operation, &source_path)?;
720
721        let status = match source_exists(origin, operation, source_path.as_path())? {
722            true => {
723                if matches!(
724                    operation.operation,
725                    FileOperationKind::Copy | FileOperationKind::Sync
726                ) {
727                    validate_source_symlinks(
728                        origin,
729                        operation,
730                        source_path.as_path(),
731                        root_path,
732                        ignore_rules.as_ref(),
733                    )?;
734                }
735
736                PlannedFileStatus::Ready
737            }
738            false if operation.required => {
739                return invalid_file_plan(
740                    origin,
741                    Some(operation.declaration),
742                    format!(
743                        "required source does not exist for {}",
744                        operation_summary(origin, operation)
745                    ),
746                );
747            }
748            false => PlannedFileStatus::SkippedMissingSource,
749        };
750
751        planned.push(PlannedFileOperation {
752            operation: operation.operation,
753            source: operation.source.clone(),
754            target: operation.target.clone(),
755            source_path,
756            target_path: target_path.clone(),
757            required: operation.required,
758            compare: operation.compare,
759            delete: operation.delete,
760            symlinks: operation.symlinks,
761            ignore: operation.ignore.clone(),
762            ignore_metadata: operation.ignore_metadata.clone(),
763            status,
764            declaration: operation.declaration,
765        });
766    }
767
768    Ok(planned)
769}
770
771fn validate_target_boundary(
772    origin: FilePlanOrigin<'_>,
773    options: ActionPlanOptions,
774    operation: &FileOperation,
775    target_path: &Path,
776    worktree_path: &Path,
777) -> Result<()> {
778    if options.dangerously_allow_targets_outside_worktree {
779        return Ok(());
780    }
781
782    if !is_within(target_path, worktree_path) {
783        return invalid_file_plan(
784            origin,
785            Some(operation.declaration),
786            format!(
787                "target resolves outside worktree for {}",
788                operation_summary(origin, operation)
789            ),
790        );
791    }
792
793    Ok(())
794}
795
796fn validate_source_boundary(
797    origin: FilePlanOrigin<'_>,
798    options: ActionPlanOptions,
799    operation: &FileOperation,
800    source_path: &Path,
801    root_path: &Path,
802) -> Result<()> {
803    if options.dangerously_allow_sources_outside_root {
804        return Ok(());
805    }
806
807    if !is_within(source_path, root_path) {
808        return invalid_file_plan(
809            origin,
810            Some(operation.declaration),
811            format!(
812                "source resolves outside root for {}",
813                operation_summary(origin, operation)
814            ),
815        );
816    }
817
818    Ok(())
819}
820
821fn operation_ignore_rules(
822    origin: FilePlanOrigin<'_>,
823    operation: &FileOperation,
824    source_path: &Path,
825) -> Result<Option<PathIgnoreRules>> {
826    if !matches!(
827        operation.operation,
828        FileOperationKind::Copy | FileOperationKind::Sync
829    ) || operation.ignore.is_empty()
830    {
831        return Ok(None);
832    }
833
834    PathIgnoreRules::new(source_path, &operation.ignore)
835        .map(Some)
836        .map_err(|source| {
837            file_plan_error(
838                origin,
839                Some(operation.declaration),
840                format!(
841                    "invalid ignore pattern for {}: {source}",
842                    operation_summary(origin, operation)
843                ),
844            )
845        })
846}
847
848fn plan_commands(
849    path: &Path,
850    commands: &[CommandOperation],
851    context: &Worktree,
852    worktree_path: &Path,
853) -> Result<Vec<PlannedCommand>> {
854    let mut planned = Vec::with_capacity(commands.len());
855
856    for command in commands {
857        let cwd_path = command
858            .cwd_path
859            .as_ref()
860            .map_or_else(
861                || Ok(worktree_path.to_path_buf()),
862                |cwd_path| normalize_maybe_existing(cwd_path),
863            )
864            .map_err(|source| {
865                invalid_config_error(
866                    path,
867                    Some(command.declaration),
868                    format!("failed to resolve command cwd: {source}"),
869                )
870            })?;
871
872        if !is_within(&cwd_path, worktree_path) {
873            return invalid_config(
874                path,
875                Some(command.declaration),
876                "command cwd resolves outside worktree",
877            );
878        }
879
880        for key in command.env.keys() {
881            if context.environment.contains_key(key) {
882                return invalid_config(
883                    path,
884                    Some(command.declaration),
885                    format!("command env overrides treeboot-owned variable `{key}`"),
886                );
887            }
888        }
889
890        planned.push(PlannedCommand {
891            name: command.name.clone(),
892            command: command.command.clone(),
893            cwd: command.cwd.clone(),
894            cwd_path,
895            env: command.env.clone(),
896            allow_failure: command.allow_failure,
897            declaration: command.declaration,
898        });
899    }
900
901    Ok(planned)
902}
903
904fn validate_source_symlinks(
905    origin: FilePlanOrigin<'_>,
906    operation: &FileOperation,
907    source_path: &Path,
908    root_path: &Path,
909    ignore_rules: Option<&PathIgnoreRules>,
910) -> Result<()> {
911    validate_source_symlink_path(
912        origin,
913        operation,
914        source_path,
915        source_path,
916        root_path,
917        ignore_rules,
918    )
919}
920
921fn validate_source_symlink_path(
922    origin: FilePlanOrigin<'_>,
923    operation: &FileOperation,
924    source_root: &Path,
925    path: &Path,
926    root_path: &Path,
927    ignore_rules: Option<&PathIgnoreRules>,
928) -> Result<()> {
929    let metadata = std::fs::symlink_metadata(path).map_err(|source| {
930        file_plan_error(
931            origin,
932            Some(operation.declaration),
933            format!(
934                "failed to inspect source {}: {source}",
935                operation.source.display()
936            ),
937        )
938    })?;
939
940    if metadata.file_type().is_symlink() {
941        let target = normalize_existing(path).map_err(|source| {
942            file_plan_error(
943                origin,
944                Some(operation.declaration),
945                format!(
946                    "failed to resolve source symlink {}: {source}",
947                    path.display()
948                ),
949            )
950        })?;
951
952        if !is_within(&target, root_path) {
953            return invalid_file_plan(
954                origin,
955                Some(operation.declaration),
956                format!(
957                    "copy or sync source contains unsafe symlink {}",
958                    path.display()
959                ),
960            );
961        }
962
963        return Ok(());
964    }
965
966    if !metadata.is_dir() {
967        return Ok(());
968    }
969
970    for entry in std::fs::read_dir(path).map_err(|source| {
971        file_plan_error(
972            origin,
973            Some(operation.declaration),
974            format!(
975                "failed to inspect source directory {}: {source}",
976                path.display()
977            ),
978        )
979    })? {
980        let entry = entry.map_err(|source| {
981            file_plan_error(
982                origin,
983                Some(operation.declaration),
984                format!(
985                    "failed to inspect source directory {}: {source}",
986                    path.display()
987                ),
988            )
989        })?;
990        let path = entry.path();
991        let metadata = std::fs::symlink_metadata(&path).map_err(|source| {
992            file_plan_error(
993                origin,
994                Some(operation.declaration),
995                format!(
996                    "failed to inspect source directory {}: {source}",
997                    path.display()
998                ),
999            )
1000        })?;
1001
1002        if ignored_source_path(source_root, &path, &metadata, ignore_rules) {
1003            if metadata.is_dir()
1004                && ignore_rules
1005                    .map(PathIgnoreRules::has_negation)
1006                    .unwrap_or(false)
1007            {
1008                validate_source_symlink_path(
1009                    origin,
1010                    operation,
1011                    source_root,
1012                    &path,
1013                    root_path,
1014                    ignore_rules,
1015                )?;
1016            }
1017            continue;
1018        }
1019
1020        validate_source_symlink_path(
1021            origin,
1022            operation,
1023            source_root,
1024            &path,
1025            root_path,
1026            ignore_rules,
1027        )?;
1028    }
1029
1030    Ok(())
1031}
1032
1033fn ignored_source_path(
1034    source_root: &Path,
1035    path: &Path,
1036    metadata: &std::fs::Metadata,
1037    ignore_rules: Option<&PathIgnoreRules>,
1038) -> bool {
1039    ignore_rules
1040        .zip(path.strip_prefix(source_root).ok())
1041        .is_some_and(|(rules, relative)| rules.is_ignored(relative, metadata.is_dir()))
1042}
1043
1044fn source_exists(
1045    origin: FilePlanOrigin<'_>,
1046    operation: &FileOperation,
1047    source_path: &Path,
1048) -> Result<bool> {
1049    match std::fs::symlink_metadata(source_path) {
1050        Ok(_) => Ok(true),
1051        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
1052        Err(source) => Err(file_plan_error(
1053            origin,
1054            Some(operation.declaration),
1055            format!(
1056                "failed to inspect source {}: {source}",
1057                operation.source.display()
1058            ),
1059        )),
1060    }
1061}
1062
1063fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
1064    let summary = format!(
1065        "{} {} -> {}",
1066        operation.operation,
1067        operation.source.display(),
1068        operation.target.display()
1069    );
1070
1071    match origin {
1072        FilePlanOrigin::Config(_) => format!(
1073            "{} at line {}, column {}",
1074            summary, operation.declaration.line, operation.declaration.column
1075        ),
1076        FilePlanOrigin::Manual { .. } => summary,
1077    }
1078}
1079
1080fn invalid_config<T>(
1081    path: &Path,
1082    span: Option<SourceSpan>,
1083    message: impl Into<String>,
1084) -> Result<T> {
1085    Err(invalid_config_error(path, span, message))
1086}
1087
1088fn invalid_file_plan<T>(
1089    origin: FilePlanOrigin<'_>,
1090    span: Option<SourceSpan>,
1091    message: impl Into<String>,
1092) -> Result<T> {
1093    Err(file_plan_error(origin, span, message))
1094}
1095
1096fn invalid_config_error(
1097    path: &Path,
1098    span: Option<SourceSpan>,
1099    message: impl Into<String>,
1100) -> Error {
1101    let message = match span {
1102        Some(span) => format!(
1103            "{} at line {}, column {}",
1104            message.into(),
1105            span.line,
1106            span.column
1107        ),
1108        None => message.into(),
1109    };
1110
1111    Error::ConfigInvalid {
1112        path: path.to_path_buf(),
1113        message,
1114    }
1115}
1116
1117fn file_plan_error(
1118    origin: FilePlanOrigin<'_>,
1119    span: Option<SourceSpan>,
1120    message: impl Into<String>,
1121) -> Error {
1122    match origin {
1123        FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
1124        FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
1125            operation: operation.as_str(),
1126            message: message.into(),
1127        },
1128    }
1129}
1130
1131fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
1132    std::fs::canonicalize(path)
1133}
1134
1135fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
1136    match normalize_existing(path) {
1137        Ok(path) => return Ok(path),
1138        Err(source) if source.kind() != std::io::ErrorKind::NotFound => {
1139            return Err(source);
1140        }
1141        Err(_) => {}
1142    }
1143
1144    let mut missing = Vec::new();
1145    let mut ancestor = path;
1146
1147    while !ancestor.exists() {
1148        if let Some(name) = ancestor.file_name() {
1149            missing.push(name.to_owned());
1150        }
1151
1152        let Some(parent) = ancestor.parent() else {
1153            break;
1154        };
1155        ancestor = parent;
1156    }
1157
1158    let mut normalized = if ancestor.exists() {
1159        normalize_existing(ancestor)?
1160    } else {
1161        PathBuf::new()
1162    };
1163
1164    for component in missing.iter().rev() {
1165        normalized.push(component);
1166    }
1167
1168    Ok(normalize_lexical(&normalized))
1169}
1170
1171fn normalize_lexical(path: &Path) -> PathBuf {
1172    let mut normalized = PathBuf::new();
1173
1174    for component in path.components() {
1175        match component {
1176            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1177            Component::RootDir => normalized.push(component.as_os_str()),
1178            Component::CurDir => {}
1179            Component::ParentDir => {
1180                if !normalized.pop() && !normalized.has_root() {
1181                    normalized.push(component.as_os_str());
1182                }
1183            }
1184            Component::Normal(part) => normalized.push(part),
1185        }
1186    }
1187
1188    normalized
1189}
1190
1191fn is_within(path: &Path, boundary: &Path) -> bool {
1192    path == boundary || path.starts_with(boundary)
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use std::collections::BTreeMap;
1198    use std::ffi::OsString;
1199    use std::time::{SystemTime, UNIX_EPOCH};
1200
1201    use super::*;
1202
1203    fn span() -> SourceSpan {
1204        SourceSpan {
1205            start: 0,
1206            end: 1,
1207            line: 1,
1208            column: 1,
1209        }
1210    }
1211
1212    fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
1213        let id = SystemTime::now()
1214            .duration_since(UNIX_EPOCH)
1215            .expect("clock should be after Unix epoch")
1216            .as_nanos();
1217        let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
1218        let root = base.join("root");
1219        let worktree = base.join("worktree");
1220
1221        std::fs::create_dir_all(&root).expect("root should be created");
1222        std::fs::create_dir_all(&worktree).expect("worktree should be created");
1223
1224        (root, worktree)
1225    }
1226
1227    fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
1228        Worktree {
1229            root_path: root_path.to_path_buf(),
1230            worktree_path: worktree_path.to_path_buf(),
1231            default_branch: "main".to_owned(),
1232            environment: BTreeMap::from([(
1233                "TREEBOOT_ROOT_PATH".to_owned(),
1234                OsString::from(root_path),
1235            )]),
1236        }
1237    }
1238
1239    fn empty_config() -> Config {
1240        Config {
1241            options: Default::default(),
1242            files: Vec::new(),
1243            commands: Vec::new(),
1244        }
1245    }
1246
1247    fn file_operation(
1248        operation: FileOperationKind,
1249        root: &Path,
1250        worktree: &Path,
1251        source: &str,
1252        target: &str,
1253    ) -> FileOperation {
1254        FileOperation {
1255            operation,
1256            source: PathBuf::from(source),
1257            target: PathBuf::from(target),
1258            source_path: root.join(source),
1259            target_path: worktree.join(target),
1260            required: false,
1261            compare: match operation {
1262                FileOperationKind::Sync => Some(SyncCompare::Metadata),
1263                FileOperationKind::Copy | FileOperationKind::Symlink => None,
1264            },
1265            delete: match operation {
1266                FileOperationKind::Sync => Some(false),
1267                FileOperationKind::Copy | FileOperationKind::Symlink => None,
1268            },
1269            symlinks: match operation {
1270                FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
1271                FileOperationKind::Symlink => None,
1272            },
1273            ignore: Vec::new(),
1274            ignore_metadata: Vec::new(),
1275            declaration: span(),
1276        }
1277    }
1278
1279    fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
1280        ActionPlan::from_manifest(
1281            Path::new(".treeboot.toml"),
1282            config,
1283            &context(root, worktree),
1284            ActionPlanOptions::default(),
1285        )
1286    }
1287
1288    #[test]
1289    fn normalize_lexical_should_resolve_parent_components() {
1290        assert_eq!(
1291            normalize_lexical(Path::new("/repo/worktree/../outside")),
1292            PathBuf::from("/repo/outside")
1293        );
1294    }
1295
1296    #[test]
1297    fn is_within_should_not_match_partial_component_prefixes() {
1298        assert!(!is_within(
1299            Path::new("/repo-worktree-other/file"),
1300            Path::new("/repo-worktree")
1301        ));
1302    }
1303
1304    #[test]
1305    fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
1306        let (root, worktree) = temp_workspace("missing-source");
1307        let config = Config {
1308            options: Default::default(),
1309            files: vec![FileOperation {
1310                operation: FileOperationKind::Copy,
1311                source: PathBuf::from("missing"),
1312                target: PathBuf::from("missing"),
1313                source_path: root.join("missing"),
1314                target_path: worktree.join("missing"),
1315                required: false,
1316                compare: None,
1317                delete: None,
1318                symlinks: Some(SymlinkMode::Preserve),
1319                ignore: Vec::new(),
1320                ignore_metadata: Vec::new(),
1321                declaration: span(),
1322            }],
1323            commands: Vec::new(),
1324        };
1325
1326        let plan = ActionPlan::from_manifest(
1327            Path::new(".treeboot.toml"),
1328            &config,
1329            &context(&root, &worktree),
1330            ActionPlanOptions::default(),
1331        )
1332        .expect("optional missing source should plan");
1333
1334        assert_eq!(
1335            plan.files[0].status,
1336            PlannedFileStatus::SkippedMissingSource
1337        );
1338    }
1339
1340    #[test]
1341    fn action_plan_from_manifest_should_build_ready_file_operation() {
1342        let (root, worktree) = temp_workspace("ready-file");
1343        std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
1344        let config = Config {
1345            options: Default::default(),
1346            files: vec![file_operation(
1347                FileOperationKind::Copy,
1348                &root,
1349                &worktree,
1350                ".env",
1351                ".env",
1352            )],
1353            commands: Vec::new(),
1354        };
1355
1356        let plan = plan(&config, &root, &worktree).expect("file should plan");
1357
1358        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1359    }
1360
1361    #[test]
1362    fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
1363        let (root, worktree) = temp_workspace("overlapping-targets");
1364        let mut sync = file_operation(
1365            FileOperationKind::Sync,
1366            &root,
1367            &worktree,
1368            "shared",
1369            "shared",
1370        );
1371        sync.delete = Some(true);
1372        let config = Config {
1373            options: Default::default(),
1374            files: vec![
1375                file_operation(
1376                    FileOperationKind::Copy,
1377                    &root,
1378                    &worktree,
1379                    "child",
1380                    "shared/child",
1381                ),
1382                sync,
1383            ],
1384            commands: Vec::new(),
1385        };
1386
1387        let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
1388
1389        assert!(error.to_string().contains("overlapping configured targets"));
1390        assert!(error.to_string().contains("shared"));
1391        assert!(error.to_string().contains("shared/child"));
1392    }
1393
1394    #[test]
1395    fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
1396        let (root, worktree) = temp_workspace("manual-overlapping-targets");
1397        let mut sync = file_operation(
1398            FileOperationKind::Sync,
1399            &root,
1400            &worktree,
1401            "shared",
1402            "shared",
1403        );
1404        sync.delete = Some(true);
1405        let operations = vec![
1406            sync,
1407            file_operation(
1408                FileOperationKind::Sync,
1409                &root,
1410                &worktree,
1411                "shared/nested",
1412                "shared/nested",
1413            ),
1414        ];
1415
1416        let error = ActionPlan::from_file_operations(
1417            &context(&root, &worktree),
1418            PlanOrigin::Manual {
1419                operation: FileOperationKind::Sync,
1420            },
1421            &operations,
1422            ActionPlanOptions::default(),
1423        )
1424        .expect_err("overlapping targets should fail");
1425
1426        assert!(error.to_string().contains("invalid sync file operation"));
1427        assert!(error.to_string().contains("overlapping targets"));
1428    }
1429
1430    #[test]
1431    fn action_plan_from_manifest_should_build_command_metadata() {
1432        let (root, worktree) = temp_workspace("command-metadata");
1433        let app_dir = worktree.join("app");
1434        std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1435        let config = Config {
1436            options: Default::default(),
1437            files: Vec::new(),
1438            commands: vec![CommandOperation {
1439                name: Some("Install".to_owned()),
1440                command: CommandKind::Direct {
1441                    program: "npm".to_owned(),
1442                    args: vec!["install".to_owned()],
1443                },
1444                cwd: Some(PathBuf::from("app")),
1445                cwd_path: Some(app_dir.clone()),
1446                env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1447                allow_failure: true,
1448                declaration: span(),
1449            }],
1450        };
1451
1452        let plan = plan(&config, &root, &worktree).expect("command should plan");
1453
1454        assert_eq!(
1455            plan.commands[0].cwd_path,
1456            std::fs::canonicalize(app_dir).expect("app dir should canonicalize")
1457        );
1458        assert!(plan.commands[0].allow_failure);
1459    }
1460
1461    #[test]
1462    fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1463        let (root, worktree) = temp_workspace("boundary-escapes");
1464        let outside_source = root
1465            .parent()
1466            .expect("root should have parent")
1467            .join("outside-source");
1468        let outside_target = worktree
1469            .parent()
1470            .expect("worktree should have parent")
1471            .join("outside-target");
1472        std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1473        let config = Config {
1474            options: Default::default(),
1475            files: vec![FileOperation {
1476                operation: FileOperationKind::Copy,
1477                source: outside_source.clone(),
1478                target: outside_target.clone(),
1479                source_path: outside_source,
1480                target_path: outside_target,
1481                required: false,
1482                compare: None,
1483                delete: None,
1484                symlinks: Some(SymlinkMode::Preserve),
1485                ignore: Vec::new(),
1486                ignore_metadata: Vec::new(),
1487                declaration: span(),
1488            }],
1489            commands: Vec::new(),
1490        };
1491
1492        let plan = ActionPlan::from_manifest(
1493            Path::new(".treeboot.toml"),
1494            &config,
1495            &context(&root, &worktree),
1496            ActionPlanOptions {
1497                dangerously_allow_sources_outside_root: true,
1498                dangerously_allow_targets_outside_worktree: true,
1499                ..ActionPlanOptions::default()
1500            },
1501        )
1502        .expect("escaped paths should plan");
1503
1504        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1505    }
1506
1507    #[test]
1508    fn action_plan_from_manifest_should_reject_missing_root_path() {
1509        let (_root, worktree) = temp_workspace("missing-root");
1510        let missing_root = worktree.join("missing-root");
1511        let error = ActionPlan::from_manifest(
1512            Path::new(".treeboot.toml"),
1513            &empty_config(),
1514            &context(&missing_root, &worktree),
1515            ActionPlanOptions::default(),
1516        )
1517        .expect_err("missing root should fail");
1518
1519        assert!(error.to_string().contains("failed to resolve root path"));
1520    }
1521
1522    #[test]
1523    fn action_plan_from_manifest_should_reject_missing_worktree_path() {
1524        let (root, worktree) = temp_workspace("missing-worktree");
1525        let missing_worktree = worktree.join("missing-worktree");
1526        let error = ActionPlan::from_manifest(
1527            Path::new(".treeboot.toml"),
1528            &empty_config(),
1529            &context(&root, &missing_worktree),
1530            ActionPlanOptions::default(),
1531        )
1532        .expect_err("missing worktree should fail");
1533
1534        assert!(
1535            error
1536                .to_string()
1537                .contains("failed to resolve worktree path")
1538        );
1539    }
1540
1541    #[test]
1542    fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
1543        let (root, worktree) = temp_workspace("strict-no-sync");
1544
1545        let plan = ActionPlan::from_manifest(
1546            Path::new(".treeboot.toml"),
1547            &empty_config(),
1548            &context(&root, &worktree),
1549            ActionPlanOptions {
1550                strict: true,
1551                ..ActionPlanOptions::default()
1552            },
1553        )
1554        .expect("strict mode should allow configs without sync");
1555
1556        assert!(plan.files.is_empty());
1557    }
1558
1559    #[test]
1560    fn action_plan_from_manifest_should_walk_source_directories() {
1561        let (root, worktree) = temp_workspace("source-directory");
1562        let source_dir = root.join("shared");
1563        std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1564        std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
1565        let config = Config {
1566            options: Default::default(),
1567            files: vec![file_operation(
1568                FileOperationKind::Copy,
1569                &root,
1570                &worktree,
1571                "shared",
1572                "shared",
1573            )],
1574            commands: Vec::new(),
1575        };
1576
1577        let plan = plan(&config, &root, &worktree).expect("directory source should plan");
1578
1579        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1580    }
1581
1582    #[test]
1583    fn action_plan_from_manifest_should_preserve_sync_options() {
1584        let (root, worktree) = temp_workspace("sync-options");
1585        let source_dir = root.join("shared");
1586        std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1587        let mut operation = file_operation(
1588            FileOperationKind::Sync,
1589            &root,
1590            &worktree,
1591            "shared",
1592            "shared",
1593        );
1594        operation.delete = Some(true);
1595
1596        let config = Config {
1597            options: Default::default(),
1598            files: vec![operation],
1599            commands: Vec::new(),
1600        };
1601
1602        let plan = plan(&config, &root, &worktree).expect("sync should plan");
1603
1604        assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
1605        assert_eq!(plan.files[0].delete, Some(true));
1606        assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
1607    }
1608
1609    #[cfg(unix)]
1610    #[test]
1611    fn action_plan_from_manifest_should_allow_safe_source_symlink() {
1612        let (root, worktree) = temp_workspace("safe-symlink");
1613        std::fs::write(root.join("source"), "value\n").expect("source should be written");
1614        std::os::unix::fs::symlink(root.join("source"), root.join("link"))
1615            .expect("safe source symlink should be created");
1616        let config = Config {
1617            options: Default::default(),
1618            files: vec![file_operation(
1619                FileOperationKind::Copy,
1620                &root,
1621                &worktree,
1622                "link",
1623                "link",
1624            )],
1625            commands: Vec::new(),
1626        };
1627
1628        let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
1629
1630        assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1631    }
1632
1633    #[cfg(unix)]
1634    #[test]
1635    fn action_plan_from_manifest_should_reject_broken_source_symlink() {
1636        let (root, worktree) = temp_workspace("broken-symlink");
1637        std::os::unix::fs::symlink(root.join("missing"), root.join("link"))
1638            .expect("broken source symlink should be created");
1639        let config = Config {
1640            options: Default::default(),
1641            files: vec![file_operation(
1642                FileOperationKind::Copy,
1643                &root,
1644                &worktree,
1645                "link",
1646                "link",
1647            )],
1648            commands: Vec::new(),
1649        };
1650
1651        let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
1652
1653        assert!(
1654            error
1655                .to_string()
1656                .contains("failed to resolve source symlink")
1657        );
1658    }
1659
1660    #[test]
1661    fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
1662        let (root, worktree) = temp_workspace("command-cwd");
1663        let config = Config {
1664            options: Default::default(),
1665            files: Vec::new(),
1666            commands: vec![CommandOperation {
1667                name: None,
1668                command: CommandKind::Shell {
1669                    run: "pwd".to_owned(),
1670                },
1671                cwd: None,
1672                cwd_path: None,
1673                env: BTreeMap::new(),
1674                allow_failure: false,
1675                declaration: span(),
1676            }],
1677        };
1678
1679        let plan = ActionPlan::from_manifest(
1680            Path::new(".treeboot.toml"),
1681            &config,
1682            &context(&root, &worktree),
1683            ActionPlanOptions::default(),
1684        )
1685        .expect("command should plan");
1686
1687        assert_eq!(
1688            plan.commands[0].cwd_path,
1689            std::fs::canonicalize(worktree).expect("worktree should canonicalize")
1690        );
1691    }
1692}