1use std::collections::BTreeMap;
2use std::path::{Component, Path, PathBuf};
3
4use crate::{
5 CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
6 FileOperationKind, MetadataField, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
7};
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub struct ActionPlanOptions {
12 pub strict: bool,
14 pub dangerously_allow_sources_outside_root: bool,
16 pub dangerously_allow_targets_outside_worktree: bool,
18}
19
20impl From<ConfigRuntimeOptions> for ActionPlanOptions {
21 fn from(options: ConfigRuntimeOptions) -> Self {
22 Self {
23 strict: options.strict,
24 dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
25 dangerously_allow_targets_outside_worktree: options
26 .dangerously_allow_targets_outside_worktree,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy)]
32pub(super) enum FilePlanOrigin<'a> {
33 Config(&'a Path),
34 Manual { operation: FileOperationKind },
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PlanOrigin {
40 Manifest {
42 path: PathBuf,
44 },
45 Manual {
47 operation: FileOperationKind,
49 },
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ActionPlan {
55 pub context: Worktree,
57 pub origin: PlanOrigin,
59 pub config_path: Option<PathBuf>,
61 pub files: Vec<PlannedFileOperation>,
63 pub commands: Vec<PlannedCommand>,
65}
66
67impl ActionPlan {
68 pub fn from_manifest(
78 path: &Path,
79 manifest: &Config,
80 context: &Worktree,
81 options: ActionPlanOptions,
82 ) -> Result<Self> {
83 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
84 invalid_config_error(
85 path,
86 None,
87 format!("failed to resolve worktree path: {source}"),
88 )
89 })?;
90 let files = plan_file_operations(
91 FilePlanOrigin::Config(path),
92 &manifest.files,
93 context,
94 options,
95 )?;
96 let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
97
98 Ok(Self {
99 context: context.clone(),
100 origin: PlanOrigin::Manifest {
101 path: path.to_path_buf(),
102 },
103 config_path: Some(path.to_path_buf()),
104 files,
105 commands,
106 })
107 }
108
109 pub fn from_file_operations(
118 context: &Worktree,
119 origin: PlanOrigin,
120 files: &[FileOperation],
121 options: ActionPlanOptions,
122 ) -> Result<Self> {
123 let file_origin = match &origin {
124 PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
125 PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
126 operation: *operation,
127 },
128 };
129 let files = plan_file_operations(file_origin, files, context, options)?;
130 let config_path = match &origin {
131 PlanOrigin::Manifest { path } => Some(path.clone()),
132 PlanOrigin::Manual { .. } => None,
133 };
134
135 Ok(Self {
136 context: context.clone(),
137 origin,
138 config_path,
139 files,
140 commands: Vec::new(),
141 })
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct PlannedFileOperation {
148 pub operation: FileOperationKind,
150 pub source: PathBuf,
152 pub target: PathBuf,
154 pub source_path: PathBuf,
156 pub target_path: PathBuf,
158 pub required: bool,
160 pub compare: Option<SyncCompare>,
162 pub delete: Option<bool>,
164 pub symlinks: Option<SymlinkMode>,
166 pub ignore_metadata: Vec<MetadataField>,
168 pub status: PlannedFileStatus,
170 pub declaration: SourceSpan,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum PlannedFileStatus {
177 Ready,
179 SkippedMissingSource,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct PlannedCommand {
186 pub name: Option<String>,
188 pub command: CommandKind,
190 pub cwd: Option<PathBuf>,
192 pub cwd_path: PathBuf,
194 pub env: BTreeMap<String, String>,
196 pub allow_failure: bool,
198 pub declaration: SourceSpan,
200}
201
202pub(super) fn plan_file_operations(
203 origin: FilePlanOrigin<'_>,
204 files: &[FileOperation],
205 context: &Worktree,
206 options: ActionPlanOptions,
207) -> Result<Vec<PlannedFileOperation>> {
208 let root_path = normalize_existing(&context.root_path).map_err(|source| {
209 file_plan_error(
210 origin,
211 None,
212 format!("failed to resolve root path: {source}"),
213 )
214 })?;
215 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
216 file_plan_error(
217 origin,
218 None,
219 format!("failed to resolve worktree path: {source}"),
220 )
221 })?;
222
223 let target_paths = normalize_target_paths(origin, files)?;
224 validate_target_conflicts(origin, files, &target_paths)?;
225 validate_strict_sync(origin, files, options.strict)?;
226
227 build_file_operations(
228 origin,
229 files,
230 options,
231 &target_paths,
232 root_path.as_path(),
233 worktree_path.as_path(),
234 )
235}
236
237fn normalize_target_paths(
238 origin: FilePlanOrigin<'_>,
239 files: &[FileOperation],
240) -> Result<Vec<PathBuf>> {
241 files
242 .iter()
243 .map(|operation| {
244 normalize_maybe_existing(&operation.target_path).map_err(|source| {
245 file_plan_error(
246 origin,
247 Some(operation.declaration),
248 format!(
249 "failed to resolve target {}: {source}",
250 operation.target.display()
251 ),
252 )
253 })
254 })
255 .collect()
256}
257
258fn validate_target_conflicts(
259 origin: FilePlanOrigin<'_>,
260 files: &[FileOperation],
261 target_paths: &[PathBuf],
262) -> Result<()> {
263 validate_duplicate_targets(origin, files, target_paths)?;
264 validate_overlapping_targets(origin, files, target_paths)
265}
266
267fn validate_duplicate_targets(
268 origin: FilePlanOrigin<'_>,
269 files: &[FileOperation],
270 target_paths: &[PathBuf],
271) -> Result<()> {
272 let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
273
274 for (operation, target_path) in files.iter().zip(target_paths) {
275 targets
276 .entry(target_path.as_path())
277 .or_default()
278 .push(operation);
279 }
280
281 let duplicates = targets
282 .into_iter()
283 .filter(|(_, operations)| operations.len() > 1)
284 .collect::<Vec<_>>();
285
286 if duplicates.is_empty() {
287 return Ok(());
288 }
289
290 let details = duplicates
291 .iter()
292 .flat_map(|(target, operations)| {
293 operations.iter().map(move |operation| {
294 format!(
295 "{}: {}",
296 target.display(),
297 operation_summary(origin, operation)
298 )
299 })
300 })
301 .collect::<Vec<_>>()
302 .join("; ");
303
304 let message = match origin {
305 FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
306 FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
307 };
308
309 Err(file_plan_error(origin, None, message))
310}
311
312fn validate_overlapping_targets(
313 origin: FilePlanOrigin<'_>,
314 files: &[FileOperation],
315 target_paths: &[PathBuf],
316) -> Result<()> {
317 let mut overlaps = Vec::new();
318
319 for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
320 for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
321 if target_path == other_target_path {
322 continue;
323 }
324
325 let Some((ancestor_path, ancestor, descendant_path, descendant)) =
326 overlapping_targets(target_path, operation, other_target_path, other_operation)
327 else {
328 continue;
329 };
330
331 overlaps.push(format!(
332 "{} contains {}: {}; {}",
333 ancestor_path.display(),
334 descendant_path.display(),
335 operation_summary(origin, ancestor),
336 operation_summary(origin, descendant)
337 ));
338 }
339 }
340
341 if overlaps.is_empty() {
342 return Ok(());
343 }
344
345 let message = match origin {
346 FilePlanOrigin::Config(_) => {
347 format!("overlapping configured targets: {}", overlaps.join("; "))
348 }
349 FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
350 };
351
352 Err(file_plan_error(origin, None, message))
353}
354
355fn overlapping_targets<'a>(
356 target_path: &'a Path,
357 operation: &'a FileOperation,
358 other_target_path: &'a Path,
359 other_operation: &'a FileOperation,
360) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
361 if other_target_path.starts_with(target_path) {
362 return Some((target_path, operation, other_target_path, other_operation));
363 }
364
365 if target_path.starts_with(other_target_path) {
366 return Some((other_target_path, other_operation, target_path, operation));
367 }
368
369 None
370}
371
372fn validate_strict_sync(
373 origin: FilePlanOrigin<'_>,
374 files: &[FileOperation],
375 strict: bool,
376) -> Result<()> {
377 if !strict {
378 return Ok(());
379 }
380
381 if let Some(operation) = files
382 .iter()
383 .find(|operation| operation.operation == FileOperationKind::Sync)
384 {
385 return invalid_file_plan(
386 origin,
387 Some(operation.declaration),
388 format!(
389 "`--strict` cannot be used with sync file operation {}",
390 operation_summary(origin, operation)
391 ),
392 );
393 }
394
395 Ok(())
396}
397
398fn build_file_operations(
399 origin: FilePlanOrigin<'_>,
400 files: &[FileOperation],
401 options: ActionPlanOptions,
402 target_paths: &[PathBuf],
403 root_path: &Path,
404 worktree_path: &Path,
405) -> Result<Vec<PlannedFileOperation>> {
406 let mut planned = Vec::with_capacity(files.len());
407
408 for (operation, target_path) in files.iter().zip(target_paths) {
409 validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
410
411 let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
412 file_plan_error(
413 origin,
414 Some(operation.declaration),
415 format!(
416 "failed to resolve source {}: {source}",
417 operation.source.display()
418 ),
419 )
420 })?;
421 validate_source_boundary(origin, options, operation, &source_path, root_path)?;
422
423 let status = match source_exists(origin, operation, source_path.as_path())? {
424 true => {
425 if matches!(
426 operation.operation,
427 FileOperationKind::Copy | FileOperationKind::Sync
428 ) {
429 validate_source_symlinks(origin, operation, source_path.as_path(), root_path)?;
430 }
431
432 PlannedFileStatus::Ready
433 }
434 false if operation.required => {
435 return invalid_file_plan(
436 origin,
437 Some(operation.declaration),
438 format!(
439 "required source does not exist for {}",
440 operation_summary(origin, operation)
441 ),
442 );
443 }
444 false => PlannedFileStatus::SkippedMissingSource,
445 };
446
447 planned.push(PlannedFileOperation {
448 operation: operation.operation,
449 source: operation.source.clone(),
450 target: operation.target.clone(),
451 source_path,
452 target_path: target_path.clone(),
453 required: operation.required,
454 compare: operation.compare,
455 delete: operation.delete,
456 symlinks: operation.symlinks,
457 ignore_metadata: operation.ignore_metadata.clone(),
458 status,
459 declaration: operation.declaration,
460 });
461 }
462
463 Ok(planned)
464}
465
466fn validate_target_boundary(
467 origin: FilePlanOrigin<'_>,
468 options: ActionPlanOptions,
469 operation: &FileOperation,
470 target_path: &Path,
471 worktree_path: &Path,
472) -> Result<()> {
473 if options.dangerously_allow_targets_outside_worktree {
474 return Ok(());
475 }
476
477 if !is_within(target_path, worktree_path) {
478 return invalid_file_plan(
479 origin,
480 Some(operation.declaration),
481 format!(
482 "target resolves outside worktree for {}",
483 operation_summary(origin, operation)
484 ),
485 );
486 }
487
488 Ok(())
489}
490
491fn validate_source_boundary(
492 origin: FilePlanOrigin<'_>,
493 options: ActionPlanOptions,
494 operation: &FileOperation,
495 source_path: &Path,
496 root_path: &Path,
497) -> Result<()> {
498 if options.dangerously_allow_sources_outside_root {
499 return Ok(());
500 }
501
502 if !is_within(source_path, root_path) {
503 return invalid_file_plan(
504 origin,
505 Some(operation.declaration),
506 format!(
507 "source resolves outside root for {}",
508 operation_summary(origin, operation)
509 ),
510 );
511 }
512
513 Ok(())
514}
515
516fn plan_commands(
517 path: &Path,
518 commands: &[CommandOperation],
519 context: &Worktree,
520 worktree_path: &Path,
521) -> Result<Vec<PlannedCommand>> {
522 let mut planned = Vec::with_capacity(commands.len());
523
524 for command in commands {
525 let cwd_path = command
526 .cwd_path
527 .as_ref()
528 .map_or_else(
529 || Ok(worktree_path.to_path_buf()),
530 |cwd_path| normalize_maybe_existing(cwd_path),
531 )
532 .map_err(|source| {
533 invalid_config_error(
534 path,
535 Some(command.declaration),
536 format!("failed to resolve command cwd: {source}"),
537 )
538 })?;
539
540 if !is_within(&cwd_path, worktree_path) {
541 return invalid_config(
542 path,
543 Some(command.declaration),
544 "command cwd resolves outside worktree",
545 );
546 }
547
548 for key in command.env.keys() {
549 if context.environment.contains_key(key) {
550 return invalid_config(
551 path,
552 Some(command.declaration),
553 format!("command env overrides treeboot-owned variable `{key}`"),
554 );
555 }
556 }
557
558 planned.push(PlannedCommand {
559 name: command.name.clone(),
560 command: command.command.clone(),
561 cwd: command.cwd.clone(),
562 cwd_path,
563 env: command.env.clone(),
564 allow_failure: command.allow_failure,
565 declaration: command.declaration,
566 });
567 }
568
569 Ok(planned)
570}
571
572fn validate_source_symlinks(
573 origin: FilePlanOrigin<'_>,
574 operation: &FileOperation,
575 source_path: &Path,
576 root_path: &Path,
577) -> Result<()> {
578 validate_source_symlink_path(origin, operation, source_path, root_path)
579}
580
581fn validate_source_symlink_path(
582 origin: FilePlanOrigin<'_>,
583 operation: &FileOperation,
584 path: &Path,
585 root_path: &Path,
586) -> Result<()> {
587 let metadata = std::fs::symlink_metadata(path).map_err(|source| {
588 file_plan_error(
589 origin,
590 Some(operation.declaration),
591 format!(
592 "failed to inspect source {}: {source}",
593 operation.source.display()
594 ),
595 )
596 })?;
597
598 if metadata.file_type().is_symlink() {
599 let target = normalize_existing(path).map_err(|source| {
600 file_plan_error(
601 origin,
602 Some(operation.declaration),
603 format!(
604 "failed to resolve source symlink {}: {source}",
605 path.display()
606 ),
607 )
608 })?;
609
610 if !is_within(&target, root_path) {
611 return invalid_file_plan(
612 origin,
613 Some(operation.declaration),
614 format!(
615 "copy or sync source contains unsafe symlink {}",
616 path.display()
617 ),
618 );
619 }
620
621 return Ok(());
622 }
623
624 if !metadata.is_dir() {
625 return Ok(());
626 }
627
628 for entry in std::fs::read_dir(path).map_err(|source| {
629 file_plan_error(
630 origin,
631 Some(operation.declaration),
632 format!(
633 "failed to inspect source directory {}: {source}",
634 path.display()
635 ),
636 )
637 })? {
638 let entry = entry.map_err(|source| {
639 file_plan_error(
640 origin,
641 Some(operation.declaration),
642 format!(
643 "failed to inspect source directory {}: {source}",
644 path.display()
645 ),
646 )
647 })?;
648 validate_source_symlink_path(origin, operation, &entry.path(), root_path)?;
649 }
650
651 Ok(())
652}
653
654fn source_exists(
655 origin: FilePlanOrigin<'_>,
656 operation: &FileOperation,
657 source_path: &Path,
658) -> Result<bool> {
659 match std::fs::symlink_metadata(source_path) {
660 Ok(_) => Ok(true),
661 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
662 Err(source) => Err(file_plan_error(
663 origin,
664 Some(operation.declaration),
665 format!(
666 "failed to inspect source {}: {source}",
667 operation.source.display()
668 ),
669 )),
670 }
671}
672
673fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
674 let summary = format!(
675 "{} {} -> {}",
676 operation.operation,
677 operation.source.display(),
678 operation.target.display()
679 );
680
681 match origin {
682 FilePlanOrigin::Config(_) => format!(
683 "{} at line {}, column {}",
684 summary, operation.declaration.line, operation.declaration.column
685 ),
686 FilePlanOrigin::Manual { .. } => summary,
687 }
688}
689
690fn invalid_config<T>(
691 path: &Path,
692 span: Option<SourceSpan>,
693 message: impl Into<String>,
694) -> Result<T> {
695 Err(invalid_config_error(path, span, message))
696}
697
698fn invalid_file_plan<T>(
699 origin: FilePlanOrigin<'_>,
700 span: Option<SourceSpan>,
701 message: impl Into<String>,
702) -> Result<T> {
703 Err(file_plan_error(origin, span, message))
704}
705
706fn invalid_config_error(
707 path: &Path,
708 span: Option<SourceSpan>,
709 message: impl Into<String>,
710) -> Error {
711 let message = match span {
712 Some(span) => format!(
713 "{} at line {}, column {}",
714 message.into(),
715 span.line,
716 span.column
717 ),
718 None => message.into(),
719 };
720
721 Error::ConfigInvalid {
722 path: path.to_path_buf(),
723 message,
724 }
725}
726
727fn file_plan_error(
728 origin: FilePlanOrigin<'_>,
729 span: Option<SourceSpan>,
730 message: impl Into<String>,
731) -> Error {
732 match origin {
733 FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
734 FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
735 operation: operation.as_str(),
736 message: message.into(),
737 },
738 }
739}
740
741fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
742 std::fs::canonicalize(path)
743}
744
745fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
746 match normalize_existing(path) {
747 Ok(path) => return Ok(path),
748 Err(source) if source.kind() != std::io::ErrorKind::NotFound => {
749 return Err(source);
750 }
751 Err(_) => {}
752 }
753
754 let mut missing = Vec::new();
755 let mut ancestor = path;
756
757 while !ancestor.exists() {
758 if let Some(name) = ancestor.file_name() {
759 missing.push(name.to_owned());
760 }
761
762 let Some(parent) = ancestor.parent() else {
763 break;
764 };
765 ancestor = parent;
766 }
767
768 let mut normalized = if ancestor.exists() {
769 normalize_existing(ancestor)?
770 } else {
771 PathBuf::new()
772 };
773
774 for component in missing.iter().rev() {
775 normalized.push(component);
776 }
777
778 Ok(normalize_lexical(&normalized))
779}
780
781fn normalize_lexical(path: &Path) -> PathBuf {
782 let mut normalized = PathBuf::new();
783
784 for component in path.components() {
785 match component {
786 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
787 Component::RootDir => normalized.push(component.as_os_str()),
788 Component::CurDir => {}
789 Component::ParentDir => {
790 if !normalized.pop() && !normalized.has_root() {
791 normalized.push(component.as_os_str());
792 }
793 }
794 Component::Normal(part) => normalized.push(part),
795 }
796 }
797
798 normalized
799}
800
801fn is_within(path: &Path, boundary: &Path) -> bool {
802 path == boundary || path.starts_with(boundary)
803}
804
805#[cfg(test)]
806mod tests {
807 use std::collections::BTreeMap;
808 use std::ffi::OsString;
809 use std::time::{SystemTime, UNIX_EPOCH};
810
811 use super::*;
812
813 fn span() -> SourceSpan {
814 SourceSpan {
815 start: 0,
816 end: 1,
817 line: 1,
818 column: 1,
819 }
820 }
821
822 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
823 let id = SystemTime::now()
824 .duration_since(UNIX_EPOCH)
825 .expect("clock should be after Unix epoch")
826 .as_nanos();
827 let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
828 let root = base.join("root");
829 let worktree = base.join("worktree");
830
831 std::fs::create_dir_all(&root).expect("root should be created");
832 std::fs::create_dir_all(&worktree).expect("worktree should be created");
833
834 (root, worktree)
835 }
836
837 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
838 Worktree {
839 root_path: root_path.to_path_buf(),
840 worktree_path: worktree_path.to_path_buf(),
841 default_branch: "main".to_owned(),
842 environment: BTreeMap::from([(
843 "TREEBOOT_ROOT_PATH".to_owned(),
844 OsString::from(root_path),
845 )]),
846 }
847 }
848
849 fn empty_config() -> Config {
850 Config {
851 options: Default::default(),
852 files: Vec::new(),
853 commands: Vec::new(),
854 }
855 }
856
857 fn file_operation(
858 operation: FileOperationKind,
859 root: &Path,
860 worktree: &Path,
861 source: &str,
862 target: &str,
863 ) -> FileOperation {
864 FileOperation {
865 operation,
866 source: PathBuf::from(source),
867 target: PathBuf::from(target),
868 source_path: root.join(source),
869 target_path: worktree.join(target),
870 required: false,
871 compare: match operation {
872 FileOperationKind::Sync => Some(SyncCompare::Metadata),
873 FileOperationKind::Copy | FileOperationKind::Symlink => None,
874 },
875 delete: match operation {
876 FileOperationKind::Sync => Some(false),
877 FileOperationKind::Copy | FileOperationKind::Symlink => None,
878 },
879 symlinks: match operation {
880 FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
881 FileOperationKind::Symlink => None,
882 },
883 ignore_metadata: Vec::new(),
884 declaration: span(),
885 }
886 }
887
888 fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
889 ActionPlan::from_manifest(
890 Path::new(".treeboot.toml"),
891 config,
892 &context(root, worktree),
893 ActionPlanOptions::default(),
894 )
895 }
896
897 #[test]
898 fn normalize_lexical_should_resolve_parent_components() {
899 assert_eq!(
900 normalize_lexical(Path::new("/repo/worktree/../outside")),
901 PathBuf::from("/repo/outside")
902 );
903 }
904
905 #[test]
906 fn is_within_should_not_match_partial_component_prefixes() {
907 assert!(!is_within(
908 Path::new("/repo-worktree-other/file"),
909 Path::new("/repo-worktree")
910 ));
911 }
912
913 #[test]
914 fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
915 let (root, worktree) = temp_workspace("missing-source");
916 let config = Config {
917 options: Default::default(),
918 files: vec![FileOperation {
919 operation: FileOperationKind::Copy,
920 source: PathBuf::from("missing"),
921 target: PathBuf::from("missing"),
922 source_path: root.join("missing"),
923 target_path: worktree.join("missing"),
924 required: false,
925 compare: None,
926 delete: None,
927 symlinks: Some(SymlinkMode::Preserve),
928 ignore_metadata: Vec::new(),
929 declaration: span(),
930 }],
931 commands: Vec::new(),
932 };
933
934 let plan = ActionPlan::from_manifest(
935 Path::new(".treeboot.toml"),
936 &config,
937 &context(&root, &worktree),
938 ActionPlanOptions::default(),
939 )
940 .expect("optional missing source should plan");
941
942 assert_eq!(
943 plan.files[0].status,
944 PlannedFileStatus::SkippedMissingSource
945 );
946 }
947
948 #[test]
949 fn action_plan_from_manifest_should_build_ready_file_operation() {
950 let (root, worktree) = temp_workspace("ready-file");
951 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
952 let config = Config {
953 options: Default::default(),
954 files: vec![file_operation(
955 FileOperationKind::Copy,
956 &root,
957 &worktree,
958 ".env",
959 ".env",
960 )],
961 commands: Vec::new(),
962 };
963
964 let plan = plan(&config, &root, &worktree).expect("file should plan");
965
966 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
967 }
968
969 #[test]
970 fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
971 let (root, worktree) = temp_workspace("overlapping-targets");
972 let mut sync = file_operation(
973 FileOperationKind::Sync,
974 &root,
975 &worktree,
976 "shared",
977 "shared",
978 );
979 sync.delete = Some(true);
980 let config = Config {
981 options: Default::default(),
982 files: vec![
983 file_operation(
984 FileOperationKind::Copy,
985 &root,
986 &worktree,
987 "child",
988 "shared/child",
989 ),
990 sync,
991 ],
992 commands: Vec::new(),
993 };
994
995 let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
996
997 assert!(error.to_string().contains("overlapping configured targets"));
998 assert!(error.to_string().contains("shared"));
999 assert!(error.to_string().contains("shared/child"));
1000 }
1001
1002 #[test]
1003 fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
1004 let (root, worktree) = temp_workspace("manual-overlapping-targets");
1005 let mut sync = file_operation(
1006 FileOperationKind::Sync,
1007 &root,
1008 &worktree,
1009 "shared",
1010 "shared",
1011 );
1012 sync.delete = Some(true);
1013 let operations = vec![
1014 sync,
1015 file_operation(
1016 FileOperationKind::Sync,
1017 &root,
1018 &worktree,
1019 "shared/nested",
1020 "shared/nested",
1021 ),
1022 ];
1023
1024 let error = ActionPlan::from_file_operations(
1025 &context(&root, &worktree),
1026 PlanOrigin::Manual {
1027 operation: FileOperationKind::Sync,
1028 },
1029 &operations,
1030 ActionPlanOptions::default(),
1031 )
1032 .expect_err("overlapping targets should fail");
1033
1034 assert!(error.to_string().contains("invalid sync file operation"));
1035 assert!(error.to_string().contains("overlapping targets"));
1036 }
1037
1038 #[test]
1039 fn action_plan_from_manifest_should_build_command_metadata() {
1040 let (root, worktree) = temp_workspace("command-metadata");
1041 let app_dir = worktree.join("app");
1042 std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1043 let config = Config {
1044 options: Default::default(),
1045 files: Vec::new(),
1046 commands: vec![CommandOperation {
1047 name: Some("Install".to_owned()),
1048 command: CommandKind::Direct {
1049 program: "npm".to_owned(),
1050 args: vec!["install".to_owned()],
1051 },
1052 cwd: Some(PathBuf::from("app")),
1053 cwd_path: Some(app_dir.clone()),
1054 env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1055 allow_failure: true,
1056 declaration: span(),
1057 }],
1058 };
1059
1060 let plan = plan(&config, &root, &worktree).expect("command should plan");
1061
1062 assert_eq!(
1063 plan.commands[0].cwd_path,
1064 std::fs::canonicalize(app_dir).expect("app dir should canonicalize")
1065 );
1066 assert!(plan.commands[0].allow_failure);
1067 }
1068
1069 #[test]
1070 fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1071 let (root, worktree) = temp_workspace("boundary-escapes");
1072 let outside_source = root
1073 .parent()
1074 .expect("root should have parent")
1075 .join("outside-source");
1076 let outside_target = worktree
1077 .parent()
1078 .expect("worktree should have parent")
1079 .join("outside-target");
1080 std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1081 let config = Config {
1082 options: Default::default(),
1083 files: vec![FileOperation {
1084 operation: FileOperationKind::Copy,
1085 source: outside_source.clone(),
1086 target: outside_target.clone(),
1087 source_path: outside_source,
1088 target_path: outside_target,
1089 required: false,
1090 compare: None,
1091 delete: None,
1092 symlinks: Some(SymlinkMode::Preserve),
1093 ignore_metadata: Vec::new(),
1094 declaration: span(),
1095 }],
1096 commands: Vec::new(),
1097 };
1098
1099 let plan = ActionPlan::from_manifest(
1100 Path::new(".treeboot.toml"),
1101 &config,
1102 &context(&root, &worktree),
1103 ActionPlanOptions {
1104 dangerously_allow_sources_outside_root: true,
1105 dangerously_allow_targets_outside_worktree: true,
1106 ..ActionPlanOptions::default()
1107 },
1108 )
1109 .expect("escaped paths should plan");
1110
1111 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1112 }
1113
1114 #[test]
1115 fn action_plan_from_manifest_should_reject_missing_root_path() {
1116 let (_root, worktree) = temp_workspace("missing-root");
1117 let missing_root = worktree.join("missing-root");
1118 let error = ActionPlan::from_manifest(
1119 Path::new(".treeboot.toml"),
1120 &empty_config(),
1121 &context(&missing_root, &worktree),
1122 ActionPlanOptions::default(),
1123 )
1124 .expect_err("missing root should fail");
1125
1126 assert!(error.to_string().contains("failed to resolve root path"));
1127 }
1128
1129 #[test]
1130 fn action_plan_from_manifest_should_reject_missing_worktree_path() {
1131 let (root, worktree) = temp_workspace("missing-worktree");
1132 let missing_worktree = worktree.join("missing-worktree");
1133 let error = ActionPlan::from_manifest(
1134 Path::new(".treeboot.toml"),
1135 &empty_config(),
1136 &context(&root, &missing_worktree),
1137 ActionPlanOptions::default(),
1138 )
1139 .expect_err("missing worktree should fail");
1140
1141 assert!(
1142 error
1143 .to_string()
1144 .contains("failed to resolve worktree path")
1145 );
1146 }
1147
1148 #[test]
1149 fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
1150 let (root, worktree) = temp_workspace("strict-no-sync");
1151
1152 let plan = ActionPlan::from_manifest(
1153 Path::new(".treeboot.toml"),
1154 &empty_config(),
1155 &context(&root, &worktree),
1156 ActionPlanOptions {
1157 strict: true,
1158 ..ActionPlanOptions::default()
1159 },
1160 )
1161 .expect("strict mode should allow configs without sync");
1162
1163 assert!(plan.files.is_empty());
1164 }
1165
1166 #[test]
1167 fn action_plan_from_manifest_should_walk_source_directories() {
1168 let (root, worktree) = temp_workspace("source-directory");
1169 let source_dir = root.join("shared");
1170 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1171 std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
1172 let config = Config {
1173 options: Default::default(),
1174 files: vec![file_operation(
1175 FileOperationKind::Copy,
1176 &root,
1177 &worktree,
1178 "shared",
1179 "shared",
1180 )],
1181 commands: Vec::new(),
1182 };
1183
1184 let plan = plan(&config, &root, &worktree).expect("directory source should plan");
1185
1186 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1187 }
1188
1189 #[test]
1190 fn action_plan_from_manifest_should_preserve_sync_options() {
1191 let (root, worktree) = temp_workspace("sync-options");
1192 let source_dir = root.join("shared");
1193 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1194 let mut operation = file_operation(
1195 FileOperationKind::Sync,
1196 &root,
1197 &worktree,
1198 "shared",
1199 "shared",
1200 );
1201 operation.delete = Some(true);
1202
1203 let config = Config {
1204 options: Default::default(),
1205 files: vec![operation],
1206 commands: Vec::new(),
1207 };
1208
1209 let plan = plan(&config, &root, &worktree).expect("sync should plan");
1210
1211 assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
1212 assert_eq!(plan.files[0].delete, Some(true));
1213 assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
1214 }
1215
1216 #[cfg(unix)]
1217 #[test]
1218 fn action_plan_from_manifest_should_allow_safe_source_symlink() {
1219 let (root, worktree) = temp_workspace("safe-symlink");
1220 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1221 std::os::unix::fs::symlink(root.join("source"), root.join("link"))
1222 .expect("safe source symlink should be created");
1223 let config = Config {
1224 options: Default::default(),
1225 files: vec![file_operation(
1226 FileOperationKind::Copy,
1227 &root,
1228 &worktree,
1229 "link",
1230 "link",
1231 )],
1232 commands: Vec::new(),
1233 };
1234
1235 let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
1236
1237 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1238 }
1239
1240 #[cfg(unix)]
1241 #[test]
1242 fn action_plan_from_manifest_should_reject_broken_source_symlink() {
1243 let (root, worktree) = temp_workspace("broken-symlink");
1244 std::os::unix::fs::symlink(root.join("missing"), root.join("link"))
1245 .expect("broken source symlink should be created");
1246 let config = Config {
1247 options: Default::default(),
1248 files: vec![file_operation(
1249 FileOperationKind::Copy,
1250 &root,
1251 &worktree,
1252 "link",
1253 "link",
1254 )],
1255 commands: Vec::new(),
1256 };
1257
1258 let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
1259
1260 assert!(
1261 error
1262 .to_string()
1263 .contains("failed to resolve source symlink")
1264 );
1265 }
1266
1267 #[test]
1268 fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
1269 let (root, worktree) = temp_workspace("command-cwd");
1270 let config = Config {
1271 options: Default::default(),
1272 files: Vec::new(),
1273 commands: vec![CommandOperation {
1274 name: None,
1275 command: CommandKind::Shell {
1276 run: "pwd".to_owned(),
1277 },
1278 cwd: None,
1279 cwd_path: None,
1280 env: BTreeMap::new(),
1281 allow_failure: false,
1282 declaration: span(),
1283 }],
1284 };
1285
1286 let plan = ActionPlan::from_manifest(
1287 Path::new(".treeboot.toml"),
1288 &config,
1289 &context(&root, &worktree),
1290 ActionPlanOptions::default(),
1291 )
1292 .expect("command should plan");
1293
1294 assert_eq!(
1295 plan.commands[0].cwd_path,
1296 std::fs::canonicalize(worktree).expect("worktree should canonicalize")
1297 );
1298 }
1299}