Skip to main content

treeboot_core/
config.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use serde::de::{self, MapAccess, Visitor, value::MapAccessDeserializer};
6use serde::{Deserialize, Serialize};
7use toml::Spanned;
8
9use crate::context;
10use crate::discovery;
11use crate::path_filter::{IncludePatternIssue, invalid_include_pattern};
12use crate::paths::{self, UnsupportedPath};
13use crate::{EnvironmentInput, Error, Result, Worktree, WorktreeOptions};
14
15/// Options for inspecting a treeboot config.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct ConfigOptions {
18    /// Directory from which config discovery starts.
19    pub cwd: Option<PathBuf>,
20    /// Overrides the root checkout used for resolved source paths.
21    pub root: Option<PathBuf>,
22    /// Explicit environment input used for compatibility discovery.
23    pub environment: EnvironmentInput,
24    /// Uses one specific config file instead of discovery.
25    pub config: Option<PathBuf>,
26}
27
28/// Loaded treeboot config selected for a worktree.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct LoadedConfig {
31    /// Runtime context used while resolving config paths.
32    pub context: Worktree,
33    /// Config file path.
34    pub path: PathBuf,
35    /// Parsed and normalized config.
36    pub config: Config,
37}
38
39/// Result summary for a `treeboot config` invocation.
40pub type ConfigReport = LoadedConfig;
41
42/// Parsed and normalized treeboot config.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct Config {
45    /// Runtime options declared by the config.
46    #[serde(flatten)]
47    pub options: ConfigRuntimeOptions,
48    /// Ordered file operations.
49    pub files: Vec<FileOperation>,
50    /// Ordered command operations.
51    pub commands: Vec<CommandOperation>,
52}
53
54impl Config {
55    /// Loads and parses a treeboot config from disk.
56    ///
57    /// Relative paths inside the config are normalized against the supplied
58    /// worktree context.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the config cannot be read or TOML parsing and
63    /// normalization fails.
64    pub fn load(path: &Path, context: &Worktree) -> Result<Self> {
65        let content = std::fs::read_to_string(path).map_err(|source| Error::ConfigIo {
66            path: path.to_path_buf(),
67            source,
68        })?;
69
70        Self::parse(path, &content, context)
71    }
72
73    /// Parses a treeboot config string.
74    ///
75    /// The path is used for diagnostics. Relative paths inside the config are
76    /// normalized against the supplied worktree context.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if TOML parsing or normalization fails.
81    pub fn parse(path: &Path, content: &str, context: &Worktree) -> Result<Self> {
82        parse_config(path, content, context)
83    }
84
85    /// Discovers the selected treeboot config path for a worktree.
86    ///
87    /// When `requested_config` is provided, it is resolved relative to the
88    /// worktree path and must exist. When omitted, standard treeboot config
89    /// paths are searched in precedence order.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error when a requested config path does not exist.
94    pub fn discover_path(
95        context: &Worktree,
96        requested_config: Option<&Path>,
97    ) -> Result<Option<PathBuf>> {
98        discovery::discover_config(&context.worktree_path, requested_config)
99    }
100
101    /// Discovers, loads, and parses the selected treeboot config.
102    ///
103    /// Returns `Ok(None)` when no config was requested and no standard config
104    /// path exists.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if a requested config path does not exist, the selected
109    /// config cannot be read, or TOML parsing and normalization fails.
110    pub fn load_discovered(
111        context: &Worktree,
112        requested_config: Option<&Path>,
113    ) -> Result<Option<LoadedConfig>> {
114        let Some(path) = Self::discover_path(context, requested_config)? else {
115            return Ok(None);
116        };
117        let config = Self::load(&path, context)?;
118
119        Ok(Some(LoadedConfig {
120            context: context.clone(),
121            path,
122            config,
123        }))
124    }
125}
126
127/// A normalized file operation.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129pub struct FileOperation {
130    /// File operation kind.
131    pub operation: FileOperationKind,
132    /// Declared source path.
133    pub source: PathBuf,
134    /// Declared target path.
135    pub target: PathBuf,
136    /// Source path resolved from the root checkout.
137    pub source_path: PathBuf,
138    /// Target path resolved from the current worktree.
139    pub target_path: PathBuf,
140    /// Whether a missing source should fail validation.
141    pub required: bool,
142    /// Sync comparison mode.
143    pub compare: Option<SyncCompare>,
144    /// Whether sync should delete target-only files.
145    pub delete: Option<bool>,
146    /// How copy and sync should treat source symlinks.
147    pub symlinks: Option<SymlinkMode>,
148    /// Source-relative path patterns that narrow copy and sync directory
149    /// traversal to matching source paths. Empty means no include filtering.
150    pub include: Vec<String>,
151    /// Source-relative path patterns ignored by copy and sync.
152    pub ignore: Vec<String>,
153    /// Metadata fields ignored by copy and sync.
154    pub ignore_metadata: Vec<MetadataField>,
155    /// Source location for the operation declaration.
156    pub declaration: SourceSpan,
157}
158
159/// File operation kind.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum FileOperationKind {
163    /// Copy source content to the target.
164    Copy,
165    /// Create a target symlink to the source.
166    Symlink,
167    /// Reconcile target content with source content.
168    Sync,
169}
170
171impl FileOperationKind {
172    /// Returns the stable lowercase operation name.
173    #[must_use]
174    pub const fn as_str(self) -> &'static str {
175        match self {
176            Self::Copy => "copy",
177            Self::Symlink => "symlink",
178            Self::Sync => "sync",
179        }
180    }
181}
182
183impl fmt::Display for FileOperationKind {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        formatter.write_str(self.as_str())
186    }
187}
188
189/// Sync comparison mode.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum SyncCompare {
193    /// Compare size and modified time.
194    Metadata,
195    /// Compare file contents.
196    Checksum,
197}
198
199/// Copy or sync symlink handling.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum SymlinkMode {
203    /// Recreate safe source symlinks as symlinks.
204    Preserve,
205}
206
207/// Metadata field ignored by copy and sync operations.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum MetadataField {
211    /// Ignore file and directory permissions.
212    Permissions,
213    /// Ignore file and directory owner.
214    Owner,
215    /// Ignore file and directory group.
216    Group,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub(crate) enum RawMetadataField {
222    Permissions,
223    Owner,
224    Group,
225    Ownership,
226}
227
228impl RawMetadataField {
229    const fn expanded(self) -> &'static [MetadataField] {
230        match self {
231            Self::Permissions => &[MetadataField::Permissions],
232            Self::Owner => &[MetadataField::Owner],
233            Self::Group => &[MetadataField::Group],
234            Self::Ownership => &[MetadataField::Owner, MetadataField::Group],
235        }
236    }
237}
238
239/// A normalized command operation.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
241pub struct CommandOperation {
242    /// Optional display name.
243    pub name: Option<String>,
244    /// Command invocation.
245    pub command: CommandKind,
246    /// Declared working directory.
247    pub cwd: Option<PathBuf>,
248    /// Working directory resolved from the current worktree.
249    pub cwd_path: Option<PathBuf>,
250    /// Extra environment variables for this command.
251    pub env: BTreeMap<String, String>,
252    /// Whether a non-zero exit status should be non-fatal.
253    pub allow_failure: bool,
254    /// Source location for the command declaration.
255    pub declaration: SourceSpan,
256}
257
258/// Command invocation kind.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260#[serde(tag = "kind", rename_all = "snake_case")]
261pub enum CommandKind {
262    /// Shell command invocation.
263    Shell {
264        /// Shell command string.
265        run: String,
266    },
267    /// Direct program invocation.
268    Direct {
269        /// Program executable.
270        program: String,
271        /// Program arguments.
272        args: Vec<String>,
273    },
274}
275
276/// Runtime options declared by a config file.
277#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
278pub struct ConfigRuntimeOptions {
279    /// Enables strict declarative validation and conflict handling.
280    pub strict: bool,
281    /// Default path ignore patterns prepended to copy and sync operations.
282    pub default_ignore: Vec<String>,
283    /// Allows file operation sources outside the root checkout.
284    pub dangerously_allow_sources_outside_root: bool,
285    /// Allows file operation targets outside the current worktree.
286    pub dangerously_allow_targets_outside_worktree: bool,
287}
288
289/// Byte and line location for a declaration in a config file.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
291pub struct SourceSpan {
292    /// Starting byte offset.
293    pub start: usize,
294    /// Ending byte offset.
295    pub end: usize,
296    /// One-based starting line.
297    pub line: usize,
298    /// One-based starting column.
299    pub column: usize,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub(crate) struct FileOperationSettingsInput {
304    pub(crate) compare: Option<SyncCompare>,
305    pub(crate) delete: Option<bool>,
306    pub(crate) symlinks: Option<SymlinkMode>,
307    pub(crate) include: Vec<String>,
308    pub(crate) ignore: Vec<String>,
309    pub(crate) ignore_metadata: Vec<RawMetadataField>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub(crate) struct FileOperationSettings {
314    pub(crate) compare: Option<SyncCompare>,
315    pub(crate) delete: Option<bool>,
316    pub(crate) symlinks: Option<SymlinkMode>,
317    pub(crate) include: Vec<String>,
318    pub(crate) ignore: Vec<String>,
319    pub(crate) ignore_metadata: Vec<MetadataField>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub(crate) enum InvalidFileOperationSettings {
324    Field(InvalidFileOperationField),
325    IncludeWithDelete,
326    IncludePattern {
327        pattern: String,
328        issue: IncludePatternIssue,
329    },
330}
331
332impl InvalidFileOperationSettings {
333    pub(crate) fn config_message(&self) -> String {
334        match self {
335            Self::Field(field) => format!(
336                "`{}` is only valid for {} file operations",
337                field.name(),
338                field.allowed_operations()
339            ),
340            Self::IncludeWithDelete => include_with_delete_message(),
341            Self::IncludePattern { pattern, issue } => issue.message(pattern),
342        }
343    }
344
345    pub(crate) fn manual_message(&self) -> String {
346        match self {
347            Self::Field(field) => format!(
348                "`{}` is only valid for {}",
349                field.name(),
350                field.allowed_operations()
351            ),
352            Self::IncludeWithDelete => include_with_delete_message(),
353            Self::IncludePattern { pattern, issue } => issue.message(pattern),
354        }
355    }
356}
357
358pub(crate) fn include_with_delete_message() -> String {
359    "`include` cannot be combined with `delete = true`; \
360     drop `delete` or narrow the operation source instead"
361        .to_owned()
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub(crate) enum InvalidFileOperationField {
366    Compare,
367    Delete,
368    Symlinks,
369    Include,
370    Ignore,
371    IgnoreMetadata,
372}
373
374impl InvalidFileOperationField {
375    pub(crate) const fn name(self) -> &'static str {
376        match self {
377            Self::Compare => "compare",
378            Self::Delete => "delete",
379            Self::Symlinks => "symlinks",
380            Self::Include => "include",
381            Self::Ignore => "ignore",
382            Self::IgnoreMetadata => "ignore_metadata",
383        }
384    }
385
386    pub(crate) const fn allowed_operations(self) -> &'static str {
387        match self {
388            Self::Compare | Self::Delete => "sync",
389            Self::Symlinks | Self::Include | Self::Ignore | Self::IgnoreMetadata => "copy and sync",
390        }
391    }
392}
393
394pub(crate) fn normalize_file_operation_settings(
395    operation: FileOperationKind,
396    input: FileOperationSettingsInput,
397) -> std::result::Result<FileOperationSettings, InvalidFileOperationSettings> {
398    let compare = match operation {
399        FileOperationKind::Sync => Some(input.compare.unwrap_or(SyncCompare::Metadata)),
400        FileOperationKind::Copy | FileOperationKind::Symlink => {
401            if input.compare.is_some() {
402                return Err(InvalidFileOperationSettings::Field(
403                    InvalidFileOperationField::Compare,
404                ));
405            }
406            None
407        }
408    };
409    let delete = match operation {
410        FileOperationKind::Sync => Some(input.delete.unwrap_or(false)),
411        FileOperationKind::Copy | FileOperationKind::Symlink => {
412            if input.delete.is_some() {
413                return Err(InvalidFileOperationSettings::Field(
414                    InvalidFileOperationField::Delete,
415                ));
416            }
417            None
418        }
419    };
420    let symlinks = match operation {
421        FileOperationKind::Copy | FileOperationKind::Sync => {
422            Some(input.symlinks.unwrap_or(SymlinkMode::Preserve))
423        }
424        FileOperationKind::Symlink => {
425            if input.symlinks.is_some() {
426                return Err(InvalidFileOperationSettings::Field(
427                    InvalidFileOperationField::Symlinks,
428                ));
429            }
430            None
431        }
432    };
433    let include = match operation {
434        FileOperationKind::Copy | FileOperationKind::Sync => {
435            for pattern in &input.include {
436                if let Some(issue) = invalid_include_pattern(pattern) {
437                    return Err(InvalidFileOperationSettings::IncludePattern {
438                        pattern: pattern.clone(),
439                        issue,
440                    });
441                }
442            }
443            input.include
444        }
445        FileOperationKind::Symlink => {
446            if !input.include.is_empty() {
447                return Err(InvalidFileOperationSettings::Field(
448                    InvalidFileOperationField::Include,
449                ));
450            }
451            Vec::new()
452        }
453    };
454    if operation == FileOperationKind::Sync && delete == Some(true) && !include.is_empty() {
455        return Err(InvalidFileOperationSettings::IncludeWithDelete);
456    }
457    let ignore = match operation {
458        FileOperationKind::Copy | FileOperationKind::Sync => input.ignore,
459        FileOperationKind::Symlink => {
460            if !input.ignore.is_empty() {
461                return Err(InvalidFileOperationSettings::Field(
462                    InvalidFileOperationField::Ignore,
463                ));
464            }
465            Vec::new()
466        }
467    };
468    let ignore_metadata = match operation {
469        FileOperationKind::Copy | FileOperationKind::Sync => {
470            normalize_ignored_metadata(input.ignore_metadata)
471        }
472        FileOperationKind::Symlink => {
473            if !input.ignore_metadata.is_empty() {
474                return Err(InvalidFileOperationSettings::Field(
475                    InvalidFileOperationField::IgnoreMetadata,
476                ));
477            }
478            Vec::new()
479        }
480    };
481
482    Ok(FileOperationSettings {
483        compare,
484        delete,
485        symlinks,
486        include,
487        ignore,
488        ignore_metadata,
489    })
490}
491
492pub(crate) fn effective_ignore_patterns(
493    operation: FileOperationKind,
494    default_ignore: &[String],
495    ignore: Vec<String>,
496) -> Vec<String> {
497    match operation {
498        FileOperationKind::Copy | FileOperationKind::Sync => {
499            let mut effective = Vec::with_capacity(default_ignore.len() + ignore.len());
500            effective.extend(default_ignore.iter().cloned());
501            effective.extend(ignore);
502            effective
503        }
504        FileOperationKind::Symlink => ignore,
505    }
506}
507
508pub(crate) fn normalize_ignored_metadata(fields: Vec<RawMetadataField>) -> Vec<MetadataField> {
509    let mut normalized = Vec::new();
510    for field in fields {
511        for expanded in field.expanded() {
512            if !normalized.contains(expanded) {
513                normalized.push(*expanded);
514            }
515        }
516    }
517    normalized
518}
519
520/// Parses, normalizes, and returns the selected config file.
521///
522/// # Errors
523///
524/// Returns an error if context discovery fails, no config exists, the requested
525/// config path does not exist, the config cannot be read, or TOML parsing and
526/// normalization fails.
527pub fn inspect_config(options: ConfigOptions) -> Result<ConfigReport> {
528    let worktree_options = WorktreeOptions {
529        cwd: options.cwd,
530        root: options.root,
531        environment: options.environment,
532    };
533    let context = context::resolve(&worktree_options)?;
534    Config::load_discovered(&context, options.config.as_deref())?
535        .ok_or(Error::NoConfigDetectedStrict)
536}
537
538fn parse_config(path: &Path, content: &str, context: &Worktree) -> Result<Config> {
539    let raw: RawConfig = toml::from_str(content).map_err(|source| {
540        let message = parse_error_message(content, &source);
541        Error::ConfigParse {
542            path: path.to_path_buf(),
543            message,
544        }
545    })?;
546
547    let default_ignore = raw.default_ignore;
548    let mut files = Vec::new();
549    normalize_file_group(
550        path,
551        content,
552        context,
553        &mut files,
554        FileOperationKind::Copy,
555        raw.copy,
556        &default_ignore,
557    )?;
558    normalize_file_group(
559        path,
560        content,
561        context,
562        &mut files,
563        FileOperationKind::Symlink,
564        raw.symlink,
565        &default_ignore,
566    )?;
567    normalize_file_group(
568        path,
569        content,
570        context,
571        &mut files,
572        FileOperationKind::Sync,
573        raw.sync,
574        &default_ignore,
575    )?;
576    normalize_mixed_files(
577        path,
578        content,
579        context,
580        &mut files,
581        raw.files,
582        &default_ignore,
583    )?;
584    normalize_file_tables(
585        path,
586        content,
587        context,
588        &mut files,
589        raw.file,
590        &default_ignore,
591    )?;
592
593    let mut commands = Vec::new();
594    normalize_command_entries(path, content, context, &mut commands, raw.commands)?;
595    normalize_command_tables(path, content, context, &mut commands, raw.command)?;
596
597    Ok(Config {
598        options: ConfigRuntimeOptions {
599            strict: raw.strict,
600            default_ignore,
601            dangerously_allow_sources_outside_root: raw.dangerously_allow_sources_outside_root,
602            dangerously_allow_targets_outside_worktree: raw
603                .dangerously_allow_targets_outside_worktree,
604        },
605        files,
606        commands,
607    })
608}
609
610fn normalize_file_group(
611    path: &Path,
612    content: &str,
613    context: &Worktree,
614    files: &mut Vec<FileOperation>,
615    operation: FileOperationKind,
616    entries: Vec<Spanned<RawFileEntry>>,
617    default_ignore: &[String],
618) -> Result<()> {
619    for entry in entries {
620        let span = entry_span(content, &entry);
621        let entry = entry.into_inner();
622        let object = match entry {
623            RawFileEntry::Path(source) => RawFileObject {
624                operation: None,
625                source: Some(source),
626                target: None,
627                required: false,
628                compare: None,
629                delete: None,
630                symlinks: None,
631                include: Vec::new(),
632                ignore: Vec::new(),
633                ignore_metadata: Vec::new(),
634            },
635            RawFileEntry::Object(object) => object,
636        };
637
638        if object.operation.is_some() {
639            return invalid_config(
640                path,
641                content,
642                span,
643                "`operation` is only valid in `files` and `[[file]]` entries",
644            );
645        }
646
647        files.push(normalize_file_object(
648            path,
649            content,
650            context,
651            operation,
652            object,
653            span,
654            default_ignore,
655        )?);
656    }
657
658    Ok(())
659}
660
661fn normalize_mixed_files(
662    path: &Path,
663    content: &str,
664    context: &Worktree,
665    files: &mut Vec<FileOperation>,
666    entries: Vec<Spanned<RawFileObject>>,
667    default_ignore: &[String],
668) -> Result<()> {
669    for entry in entries {
670        let span = entry_span(content, &entry);
671        let object = entry.into_inner();
672        let operation = required_operation(path, content, span, object.operation)?;
673        files.push(normalize_file_object(
674            path,
675            content,
676            context,
677            operation,
678            object,
679            span,
680            default_ignore,
681        )?);
682    }
683
684    Ok(())
685}
686
687fn normalize_file_tables(
688    path: &Path,
689    content: &str,
690    context: &Worktree,
691    files: &mut Vec<FileOperation>,
692    entries: Vec<Spanned<RawFileObject>>,
693    default_ignore: &[String],
694) -> Result<()> {
695    normalize_mixed_files(path, content, context, files, entries, default_ignore)
696}
697
698fn normalize_file_object(
699    path: &Path,
700    content: &str,
701    context: &Worktree,
702    operation: FileOperationKind,
703    object: RawFileObject,
704    span: SourceSpan,
705    default_ignore: &[String],
706) -> Result<FileOperation> {
707    let source = object.source.ok_or_else(|| {
708        invalid_config_error(
709            path,
710            content,
711            span,
712            "file operation is missing required `source`",
713        )
714    })?;
715    let target = object.target.unwrap_or_else(|| source.clone());
716    let settings = normalize_file_operation_settings(
717        operation,
718        FileOperationSettingsInput {
719            compare: object.compare,
720            delete: object.delete,
721            symlinks: object.symlinks,
722            include: object.include,
723            ignore: object.ignore,
724            ignore_metadata: object.ignore_metadata,
725        },
726    )
727    .map_err(|invalid| invalid_config_error(path, content, span, invalid.config_message()))?;
728
729    Ok(FileOperation {
730        operation,
731        source_path: resolve_path(path, content, span, &context.root_path, Path::new(&source))?,
732        target_path: resolve_target_path(
733            path,
734            content,
735            span,
736            &context.worktree_path,
737            Path::new(&target),
738        )?,
739        source: PathBuf::from(source),
740        target: PathBuf::from(target),
741        required: object.required,
742        compare: settings.compare,
743        delete: settings.delete,
744        symlinks: settings.symlinks,
745        include: settings.include,
746        ignore: effective_ignore_patterns(operation, default_ignore, settings.ignore),
747        ignore_metadata: settings.ignore_metadata,
748        declaration: span,
749    })
750}
751
752fn required_operation(
753    path: &Path,
754    content: &str,
755    span: SourceSpan,
756    operation: Option<FileOperationKind>,
757) -> Result<FileOperationKind> {
758    operation.ok_or_else(|| {
759        invalid_config_error(
760            path,
761            content,
762            span,
763            "file operation is missing required `operation`",
764        )
765    })
766}
767
768fn normalize_command_entries(
769    path: &Path,
770    content: &str,
771    context: &Worktree,
772    commands: &mut Vec<CommandOperation>,
773    entries: Vec<Spanned<RawCommandEntry>>,
774) -> Result<()> {
775    for entry in entries {
776        let span = entry_span(content, &entry);
777        let object = match entry.into_inner() {
778            RawCommandEntry::Run(run) => RawCommandObject {
779                name: None,
780                run: Some(run),
781                program: None,
782                args: None,
783                cwd: None,
784                env: BTreeMap::new(),
785                allow_failure: false,
786            },
787            RawCommandEntry::Object(object) => object,
788        };
789
790        commands.push(normalize_command_object(
791            path, content, context, object, span,
792        )?);
793    }
794
795    Ok(())
796}
797
798fn normalize_command_tables(
799    path: &Path,
800    content: &str,
801    context: &Worktree,
802    commands: &mut Vec<CommandOperation>,
803    entries: Vec<Spanned<RawCommandObject>>,
804) -> Result<()> {
805    for entry in entries {
806        let span = entry_span(content, &entry);
807        commands.push(normalize_command_object(
808            path,
809            content,
810            context,
811            entry.into_inner(),
812            span,
813        )?);
814    }
815
816    Ok(())
817}
818
819fn normalize_command_object(
820    path: &Path,
821    content: &str,
822    context: &Worktree,
823    object: RawCommandObject,
824    span: SourceSpan,
825) -> Result<CommandOperation> {
826    let command = match (object.run, object.program) {
827        (Some(_), Some(_)) => {
828            return invalid_config(
829                path,
830                content,
831                span,
832                "`run` and `program` are mutually exclusive",
833            );
834        }
835        (Some(_), None) if object.args.is_some() => {
836            return invalid_config(path, content, span, "`args` requires `program`");
837        }
838        (Some(run), None) => CommandKind::Shell { run },
839        (None, Some(program)) => CommandKind::Direct {
840            program,
841            args: object.args.unwrap_or_default(),
842        },
843        (None, None) => {
844            return invalid_config(
845                path,
846                content,
847                span,
848                "command is missing required `run` or `program`",
849            );
850        }
851    };
852    let cwd_path = object
853        .cwd
854        .as_ref()
855        .map(|cwd| resolve_path(path, content, span, &context.worktree_path, Path::new(cwd)))
856        .transpose()?;
857
858    Ok(CommandOperation {
859        name: object.name,
860        command,
861        cwd: object.cwd.map(PathBuf::from),
862        cwd_path,
863        env: object.env,
864        allow_failure: object.allow_failure,
865        declaration: span,
866    })
867}
868
869fn resolve_path(
870    config_path: &Path,
871    content: &str,
872    span: SourceSpan,
873    base: &Path,
874    path: &Path,
875) -> Result<PathBuf> {
876    let resolved = paths::resolve_path(base, path).map_err(|source| {
877        invalid_config_error(
878            config_path,
879            content,
880            span,
881            unsupported_path_message(path, source),
882        )
883    })?;
884
885    paths::normalize_maybe_existing(&resolved).map_err(|source| {
886        invalid_config_error(
887            config_path,
888            content,
889            span,
890            normalize_path_message(path, source),
891        )
892    })
893}
894
895fn resolve_target_path(
896    config_path: &Path,
897    content: &str,
898    span: SourceSpan,
899    base: &Path,
900    path: &Path,
901) -> Result<PathBuf> {
902    let resolved = paths::resolve_path(base, path).map_err(|source| {
903        invalid_config_error(
904            config_path,
905            content,
906            span,
907            unsupported_path_message(path, source),
908        )
909    })?;
910
911    let Some(name) = resolved.file_name() else {
912        return paths::normalize_maybe_existing(&resolved).map_err(|source| {
913            invalid_config_error(
914                config_path,
915                content,
916                span,
917                normalize_path_message(path, source),
918            )
919        });
920    };
921    let parent = resolved.parent().unwrap_or_else(|| Path::new("."));
922    let mut normalized = paths::normalize_maybe_existing(parent).map_err(|source| {
923        invalid_config_error(
924            config_path,
925            content,
926            span,
927            normalize_path_message(path, source),
928        )
929    })?;
930    normalized.push(name);
931
932    Ok(paths::normalize_lexical(&normalized))
933}
934
935fn unsupported_path_message(path: &Path, source: UnsupportedPath) -> String {
936    format!("unsupported path `{}`: {}", path.display(), source.reason())
937}
938
939fn normalize_path_message(path: &Path, source: std::io::Error) -> String {
940    format!("failed to normalize path `{}`: {}", path.display(), source)
941}
942
943fn parse_error_message(content: &str, error: &toml::de::Error) -> String {
944    match error.span() {
945        Some(span) => format!("{} {}", error.message(), location_suffix(content, &span)),
946        None => error.message().to_owned(),
947    }
948}
949
950fn invalid_config<T>(
951    path: &Path,
952    content: &str,
953    span: SourceSpan,
954    message: impl Into<String>,
955) -> Result<T> {
956    Err(invalid_config_error(path, content, span, message))
957}
958
959fn invalid_config_error(
960    path: &Path,
961    content: &str,
962    span: SourceSpan,
963    message: impl Into<String>,
964) -> Error {
965    Error::ConfigInvalid {
966        path: path.to_path_buf(),
967        message: format!(
968            "{} {}",
969            message.into(),
970            location_suffix(content, &(span.start..span.end))
971        ),
972    }
973}
974
975fn entry_span<T>(content: &str, entry: &Spanned<T>) -> SourceSpan {
976    SourceSpan::from_range(content, entry.span())
977}
978
979fn location_suffix(content: &str, range: &std::ops::Range<usize>) -> String {
980    let span = SourceSpan::from_range(content, range.clone());
981    format!("at line {}, column {}", span.line, span.column)
982}
983
984impl SourceSpan {
985    fn from_range(content: &str, range: std::ops::Range<usize>) -> Self {
986        let (line, column) = line_column(content, range.start);
987
988        Self {
989            start: range.start,
990            end: range.end,
991            line,
992            column,
993        }
994    }
995}
996
997fn line_column(content: &str, offset: usize) -> (usize, usize) {
998    let mut line = 1;
999    let mut column = 1;
1000
1001    for character in content[..offset.min(content.len())].chars() {
1002        if character == '\n' {
1003            line += 1;
1004            column = 1;
1005        } else {
1006            column += 1;
1007        }
1008    }
1009
1010    (line, column)
1011}
1012
1013#[derive(Debug, Default, Deserialize)]
1014#[serde(default, deny_unknown_fields)]
1015struct RawConfig {
1016    strict: bool,
1017    default_ignore: Vec<String>,
1018    dangerously_allow_sources_outside_root: bool,
1019    dangerously_allow_targets_outside_worktree: bool,
1020    copy: Vec<Spanned<RawFileEntry>>,
1021    symlink: Vec<Spanned<RawFileEntry>>,
1022    sync: Vec<Spanned<RawFileEntry>>,
1023    files: Vec<Spanned<RawFileObject>>,
1024    file: Vec<Spanned<RawFileObject>>,
1025    commands: Vec<Spanned<RawCommandEntry>>,
1026    command: Vec<Spanned<RawCommandObject>>,
1027}
1028
1029#[derive(Debug)]
1030enum RawFileEntry {
1031    Path(String),
1032    Object(RawFileObject),
1033}
1034
1035impl<'de> Deserialize<'de> for RawFileEntry {
1036    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1037    where
1038        D: serde::Deserializer<'de>,
1039    {
1040        struct RawFileEntryVisitor;
1041
1042        impl<'de> Visitor<'de> for RawFileEntryVisitor {
1043            type Value = RawFileEntry;
1044
1045            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1046                formatter.write_str("a path string or file operation object")
1047            }
1048
1049            fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
1050            where
1051                E: de::Error,
1052            {
1053                Ok(RawFileEntry::Path(value.to_owned()))
1054            }
1055
1056            fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
1057            where
1058                E: de::Error,
1059            {
1060                Ok(RawFileEntry::Path(value))
1061            }
1062
1063            fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
1064            where
1065                M: MapAccess<'de>,
1066            {
1067                RawFileObject::deserialize(MapAccessDeserializer::new(map))
1068                    .map(RawFileEntry::Object)
1069            }
1070        }
1071
1072        deserializer.deserialize_any(RawFileEntryVisitor)
1073    }
1074}
1075
1076#[derive(Debug, Default, Deserialize)]
1077#[serde(default, deny_unknown_fields)]
1078struct RawFileObject {
1079    operation: Option<FileOperationKind>,
1080    source: Option<String>,
1081    target: Option<String>,
1082    required: bool,
1083    compare: Option<SyncCompare>,
1084    delete: Option<bool>,
1085    symlinks: Option<SymlinkMode>,
1086    include: Vec<String>,
1087    ignore: Vec<String>,
1088    ignore_metadata: Vec<RawMetadataField>,
1089}
1090
1091#[derive(Debug)]
1092enum RawCommandEntry {
1093    Run(String),
1094    Object(RawCommandObject),
1095}
1096
1097impl<'de> Deserialize<'de> for RawCommandEntry {
1098    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1099    where
1100        D: serde::Deserializer<'de>,
1101    {
1102        struct RawCommandEntryVisitor;
1103
1104        impl<'de> Visitor<'de> for RawCommandEntryVisitor {
1105            type Value = RawCommandEntry;
1106
1107            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1108                formatter.write_str("a shell command string or command object")
1109            }
1110
1111            fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
1112            where
1113                E: de::Error,
1114            {
1115                Ok(RawCommandEntry::Run(value.to_owned()))
1116            }
1117
1118            fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
1119            where
1120                E: de::Error,
1121            {
1122                Ok(RawCommandEntry::Run(value))
1123            }
1124
1125            fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
1126            where
1127                M: MapAccess<'de>,
1128            {
1129                RawCommandObject::deserialize(MapAccessDeserializer::new(map))
1130                    .map(RawCommandEntry::Object)
1131            }
1132        }
1133
1134        deserializer.deserialize_any(RawCommandEntryVisitor)
1135    }
1136}
1137
1138#[derive(Debug, Default, Deserialize)]
1139#[serde(default, deny_unknown_fields)]
1140struct RawCommandObject {
1141    name: Option<String>,
1142    run: Option<String>,
1143    program: Option<String>,
1144    args: Option<Vec<String>>,
1145    cwd: Option<String>,
1146    env: BTreeMap<String, String>,
1147    allow_failure: bool,
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use std::ffi::OsString;
1153
1154    use crate::test_support::symlink_dir;
1155
1156    use super::*;
1157
1158    fn context() -> Worktree {
1159        Worktree {
1160            root_path: PathBuf::from("/repo"),
1161            worktree_path: PathBuf::from("/repo-worktree"),
1162            default_branch: "main".to_owned(),
1163            environment: BTreeMap::from([(
1164                "TREEBOOT_ROOT_PATH".to_owned(),
1165                OsString::from("/repo"),
1166            )]),
1167        }
1168    }
1169
1170    fn parse(content: &str) -> Config {
1171        parse_config(Path::new(".treeboot.toml"), content, &context()).expect("config should parse")
1172    }
1173
1174    fn parse_error(content: &str) -> String {
1175        parse_config(Path::new(".treeboot.toml"), content, &context())
1176            .expect_err("config should fail")
1177            .to_string()
1178    }
1179
1180    fn assert_parse_error_contains(content: &str, expected: &str) {
1181        let error = parse_error(content);
1182
1183        assert!(
1184            error.contains(expected),
1185            "expected error to contain {expected:?}, got {error:?}"
1186        );
1187    }
1188
1189    fn toml_basic_string_path(path: &Path) -> String {
1190        path.display()
1191            .to_string()
1192            .replace('\\', "\\\\")
1193            .replace('"', "\\\"")
1194    }
1195
1196    #[test]
1197    fn parse_config_should_normalize_file_operations_in_spec_order() {
1198        let config = parse(
1199            r#"
1200sync = ["sync-dir"]
1201copy = [".env"]
1202symlink = [{ source = "shared/bin", target = "bin" }]
1203files = [{ operation = "copy", source = ".npmrc" }]
1204
1205[[file]]
1206operation = "sync"
1207source = "editor"
1208target = ".editor"
1209"#,
1210        );
1211
1212        let operations = config
1213            .files
1214            .iter()
1215            .map(|operation| (operation.operation, operation.source.as_path()))
1216            .collect::<Vec<_>>();
1217
1218        assert_eq!(
1219            operations,
1220            vec![
1221                (FileOperationKind::Copy, Path::new(".env")),
1222                (FileOperationKind::Symlink, Path::new("shared/bin")),
1223                (FileOperationKind::Sync, Path::new("sync-dir")),
1224                (FileOperationKind::Copy, Path::new(".npmrc")),
1225                (FileOperationKind::Sync, Path::new("editor")),
1226            ]
1227        );
1228    }
1229
1230    #[test]
1231    fn parse_config_should_apply_file_defaults() {
1232        let config = parse(
1233            r#"
1234copy = [{ source = ".env.local" }]
1235sync = ["shared/config"]
1236"#,
1237        );
1238
1239        let copy = &config.files[0];
1240        let sync = &config.files[1];
1241
1242        assert_eq!(copy.target, PathBuf::from(".env.local"));
1243        assert!(!copy.required);
1244        assert_eq!(copy.symlinks, Some(SymlinkMode::Preserve));
1245        assert!(copy.ignore.is_empty());
1246        assert!(copy.ignore_metadata.is_empty());
1247        assert_eq!(sync.compare, Some(SyncCompare::Metadata));
1248        assert_eq!(sync.delete, Some(false));
1249        assert!(sync.ignore.is_empty());
1250        assert!(sync.ignore_metadata.is_empty());
1251    }
1252
1253    #[test]
1254    fn parse_config_should_preserve_explicit_sync_options() {
1255        let config = parse(
1256            r#"
1257sync = [{
1258  source = "shared/config",
1259  compare = "checksum",
1260  delete = true,
1261  symlinks = "preserve",
1262}]
1263"#,
1264        );
1265
1266        let sync = &config.files[0];
1267
1268        assert_eq!(sync.compare, Some(SyncCompare::Checksum));
1269        assert_eq!(sync.delete, Some(true));
1270        assert_eq!(sync.symlinks, Some(SymlinkMode::Preserve));
1271    }
1272
1273    #[test]
1274    fn parse_config_should_normalize_ignored_metadata() {
1275        let config = parse(
1276            r#"
1277copy = [{ source = ".env", ignore_metadata = ["ownership", "permissions", "owner"] }]
1278sync = [{ source = "shared", ignore_metadata = ["group"] }]
1279"#,
1280        );
1281
1282        assert_eq!(
1283            config.files[0].ignore_metadata,
1284            vec![
1285                MetadataField::Owner,
1286                MetadataField::Group,
1287                MetadataField::Permissions,
1288            ]
1289        );
1290        assert_eq!(config.files[1].ignore_metadata, vec![MetadataField::Group]);
1291    }
1292
1293    #[test]
1294    fn parse_config_should_preserve_explicit_include_patterns() {
1295        let config = parse(
1296            r#"
1297copy = [{ source = "shared", include = ["docs/**", "*.toml"] }]
1298sync = [{ source = "editor", include = ["settings/**"] }]
1299"#,
1300        );
1301
1302        assert_eq!(config.files[0].include, vec!["docs/**", "*.toml"]);
1303        assert_eq!(config.files[1].include, vec!["settings/**"]);
1304    }
1305
1306    #[test]
1307    fn parse_config_should_default_include_to_empty() {
1308        let config = parse("copy = [\"shared\"]\n");
1309
1310        assert!(config.files[0].include.is_empty());
1311    }
1312
1313    #[test]
1314    fn parse_config_should_reject_include_on_symlink_file_operations() {
1315        assert_parse_error_contains(
1316            "symlink = [{ source = \"shared\", include = [\"docs/**\"] }]\n",
1317            "`include` is only valid for copy and sync file operations",
1318        );
1319        assert_parse_error_contains(
1320            r#"
1321[[file]]
1322operation = "symlink"
1323source = "shared"
1324include = ["docs/**"]
1325"#,
1326            "`include` is only valid for copy and sync file operations",
1327        );
1328    }
1329
1330    #[test]
1331    fn parse_config_should_reject_include_with_sync_delete() {
1332        assert_parse_error_contains(
1333            "sync = [{ source = \"shared\", include = [\"docs/**\"], delete = true }]\n",
1334            "`include` cannot be combined with `delete = true`",
1335        );
1336        assert_parse_error_contains(
1337            r#"
1338files = [{ operation = "sync", source = "shared", include = ["docs/**"], delete = true }]
1339"#,
1340            "`include` cannot be combined with `delete = true`",
1341        );
1342    }
1343
1344    #[test]
1345    fn parse_config_should_allow_empty_include_with_sync_delete() {
1346        let config = parse("sync = [{ source = \"shared\", include = [], delete = true }]\n");
1347
1348        assert!(config.files[0].include.is_empty());
1349        assert_eq!(config.files[0].delete, Some(true));
1350    }
1351
1352    #[test]
1353    fn parse_config_should_reject_inert_include_patterns() {
1354        assert_parse_error_contains(
1355            "copy = [{ source = \"shared\", include = [\"!docs\"] }]\n",
1356            "uses `!` negation",
1357        );
1358        assert_parse_error_contains(
1359            "copy = [{ source = \"shared\", include = [\"\"] }]\n",
1360            "include patterns cannot be blank",
1361        );
1362        assert_parse_error_contains(
1363            "copy = [{ source = \"shared\", include = [\"# docs\"] }]\n",
1364            "is a gitignore comment",
1365        );
1366    }
1367
1368    #[test]
1369    fn parse_config_should_accept_escaped_include_prefixes() {
1370        let config =
1371            parse(r#"copy = [{ source = "shared", include = ['\!literal', '\#literal'] }]"#);
1372
1373        assert_eq!(config.files[0].include, vec![r"\!literal", r"\#literal"]);
1374    }
1375
1376    #[test]
1377    fn parse_config_should_keep_comment_and_blank_patterns_valid_in_ignore() {
1378        let config =
1379            parse("copy = [{ source = \"shared\", ignore = [\"# comment\", \"\", \"!keep\"] }]\n");
1380
1381        assert_eq!(config.files[0].ignore, vec!["# comment", "", "!keep"]);
1382    }
1383
1384    #[test]
1385    fn parse_config_should_preserve_explicit_ignore_patterns() {
1386        let config = parse(
1387            r#"
1388copy = [{ source = ".env", ignore = ["**/vendor/**", "!**/vendor/keep/**"] }]
1389sync = [{ source = "shared", ignore = ["cache/", "!cache/keep"] }]
1390"#,
1391        );
1392
1393        assert_eq!(
1394            config.files[0].ignore,
1395            vec!["**/vendor/**", "!**/vendor/keep/**"]
1396        );
1397        assert_eq!(config.files[1].ignore, vec!["cache/", "!cache/keep"]);
1398    }
1399
1400    #[test]
1401    fn parse_config_should_prepend_default_ignore_to_copy_and_sync() {
1402        let config = parse(
1403            r#"
1404default_ignore = [".DS_Store", "Thumbs.db"]
1405copy = [{ source = ".env", ignore = ["!.DS_Store"] }]
1406sync = [{ source = "shared", ignore = ["cache/"] }]
1407"#,
1408        );
1409
1410        assert_eq!(
1411            config.options.default_ignore,
1412            vec![".DS_Store", "Thumbs.db"]
1413        );
1414        assert_eq!(
1415            config.files[0].ignore,
1416            vec![".DS_Store", "Thumbs.db", "!.DS_Store"]
1417        );
1418        assert_eq!(
1419            config.files[1].ignore,
1420            vec![".DS_Store", "Thumbs.db", "cache/"]
1421        );
1422    }
1423
1424    #[test]
1425    fn parse_config_should_prepend_default_ignore_to_mixed_file_entries() {
1426        let config = parse(
1427            r#"
1428default_ignore = [".DS_Store"]
1429files = [
1430  { operation = "copy", source = ".env", ignore = ["!.DS_Store"] },
1431  { operation = "symlink", source = "bin" },
1432]
1433
1434[[file]]
1435operation = "sync"
1436source = "shared"
1437ignore = ["cache/"]
1438"#,
1439        );
1440
1441        assert_eq!(config.files[0].ignore, vec![".DS_Store", "!.DS_Store"]);
1442        assert!(config.files[1].ignore.is_empty());
1443        assert_eq!(config.files[2].ignore, vec![".DS_Store", "cache/"]);
1444    }
1445
1446    #[test]
1447    fn parse_config_should_not_apply_default_ignore_to_symlink() {
1448        let config = parse(
1449            r#"
1450default_ignore = [".DS_Store"]
1451symlink = ["shared/bin"]
1452"#,
1453        );
1454
1455        assert!(config.files[0].ignore.is_empty());
1456    }
1457
1458    #[test]
1459    fn parse_config_should_reject_ignore_on_symlink_file_operations() {
1460        assert_parse_error_contains(
1461            r#"
1462symlink = [{ source = "link", ignore = ["**/tmp/**"] }]
1463"#,
1464            "`ignore` is only valid for copy and sync",
1465        );
1466    }
1467
1468    #[test]
1469    fn parse_config_should_reject_ignored_metadata_on_symlink_file_operations() {
1470        assert_parse_error_contains(
1471            r#"
1472symlink = [{ source = "link", ignore_metadata = ["ownership"] }]
1473"#,
1474            "`ignore_metadata` is only valid for copy and sync",
1475        );
1476    }
1477
1478    #[test]
1479    fn parse_config_should_apply_runtime_options() {
1480        let config = parse(
1481            r#"
1482strict = true
1483default_ignore = [".DS_Store"]
1484dangerously_allow_sources_outside_root = true
1485dangerously_allow_targets_outside_worktree = true
1486"#,
1487        );
1488
1489        assert!(config.options.strict);
1490        assert_eq!(config.options.default_ignore, vec![".DS_Store"]);
1491        assert!(config.options.dangerously_allow_sources_outside_root);
1492        assert!(config.options.dangerously_allow_targets_outside_worktree);
1493    }
1494
1495    #[test]
1496    fn parse_config_should_reject_nested_validation_options() {
1497        assert_parse_error_contains(
1498            r#"
1499[validation]
1500dangerously_allow_sources_outside_root = true
1501"#,
1502            "unknown field",
1503        );
1504    }
1505
1506    #[test]
1507    fn parse_config_should_resolve_absolute_paths_without_rebasing() {
1508        let temp = std::env::temp_dir().join("treeboot-config-absolute-paths");
1509        let source = temp.join("shared").join("..").join(".env");
1510        let target = temp.join("worktree").join("..").join("worktree/.env");
1511        let cwd = temp.join("worktree").join("..").join("worktree/app");
1512        let config = parse(&format!(
1513            r#"
1514copy = [{{ source = "{}", target = "{}" }}]
1515commands = [{{ program = "make", cwd = "{}" }}]
1516"#,
1517            toml_basic_string_path(&source),
1518            toml_basic_string_path(&target),
1519            toml_basic_string_path(&cwd),
1520        ));
1521
1522        assert_eq!(
1523            config.files[0].source_path,
1524            paths::normalize_maybe_existing(&temp.join(".env")).expect("source should normalize")
1525        );
1526        assert_eq!(
1527            config.files[0].target_path,
1528            paths::normalize_maybe_existing(&temp.join("worktree/.env"))
1529                .expect("target should normalize")
1530        );
1531        assert_eq!(
1532            config.commands[0].cwd_path,
1533            Some(
1534                paths::normalize_maybe_existing(&temp.join("worktree/app"))
1535                    .expect("cwd should normalize")
1536            )
1537        );
1538    }
1539
1540    #[test]
1541    fn parse_config_should_normalize_relative_paths_through_existing_aliases() {
1542        let temp = tempfile::TempDir::new().expect("tempdir should be created");
1543        let actual = temp.path().join("actual");
1544        let root = actual.join("root");
1545        let worktree = actual.join("worktree");
1546        let alias = temp.path().join("alias");
1547        let alias_root = alias.join("root");
1548        let alias_worktree = alias.join("worktree");
1549        std::fs::create_dir_all(root.join("shared")).expect("root source dir should be created");
1550        std::fs::create_dir_all(worktree.join("app")).expect("worktree app dir should be created");
1551        std::fs::create_dir_all(&alias).expect("alias dir should be created");
1552        symlink_dir(&root, &alias_root).expect("root alias should be created");
1553        symlink_dir(&worktree, &alias_worktree).expect("worktree alias should be created");
1554
1555        let context = Worktree {
1556            root_path: alias_root,
1557            worktree_path: alias_worktree,
1558            default_branch: "main".to_owned(),
1559            environment: BTreeMap::new(),
1560        };
1561        let config = parse_config(
1562            Path::new(".treeboot.toml"),
1563            r#"
1564copy = [{ source = "shared/.env", target = ".env" }]
1565commands = [{ program = "make", cwd = "app" }]
1566"#,
1567            &context,
1568        )
1569        .expect("config should parse");
1570
1571        assert_eq!(
1572            config.files[0].source_path,
1573            paths::normalize_maybe_existing(&root.join("shared/.env"))
1574                .expect("source should normalize through alias")
1575        );
1576        assert_eq!(
1577            config.files[0].target_path,
1578            paths::normalize_maybe_existing(&worktree.join(".env"))
1579                .expect("target should normalize through alias")
1580        );
1581        assert_eq!(
1582            config.commands[0].cwd_path,
1583            Some(
1584                paths::normalize_maybe_existing(&worktree.join("app"))
1585                    .expect("cwd should normalize through alias")
1586            )
1587        );
1588    }
1589
1590    #[cfg(windows)]
1591    #[test]
1592    fn parse_config_should_reject_drive_relative_windows_paths() {
1593        assert_parse_error_contains(
1594            r#"copy = [{ source = 'C:shared/.env' }]"#,
1595            "drive-relative paths are not supported",
1596        );
1597    }
1598
1599    #[cfg(windows)]
1600    #[test]
1601    fn parse_config_should_reject_root_relative_windows_paths() {
1602        assert_parse_error_contains(
1603            r#"commands = [{ program = "git", cwd = '\app' }]"#,
1604            "root-relative paths without a drive or share are not supported",
1605        );
1606    }
1607
1608    #[test]
1609    fn parse_config_should_normalize_command_forms() {
1610        let config = parse(
1611            r#"
1612commands = [
1613  "mise install",
1614  { run = "bundle install" },
1615]
1616
1617[[command]]
1618program = "npm"
1619args = ["install"]
1620cwd = "web"
1621allow_failure = true
1622"#,
1623        );
1624
1625        assert_eq!(config.commands.len(), 3);
1626        assert_eq!(
1627            config.commands[0].command,
1628            CommandKind::Shell {
1629                run: "mise install".to_owned()
1630            }
1631        );
1632        assert_eq!(
1633            config.commands[2].command,
1634            CommandKind::Direct {
1635                program: "npm".to_owned(),
1636                args: vec!["install".to_owned()]
1637            }
1638        );
1639        assert_eq!(
1640            config.commands[2].cwd_path,
1641            Some(
1642                paths::normalize_maybe_existing(&context().worktree_path.join("web"))
1643                    .expect("expected cwd should normalize")
1644            )
1645        );
1646    }
1647
1648    #[test]
1649    fn parse_config_should_normalize_command_metadata_and_defaults() {
1650        let config = parse(
1651            r#"
1652commands = [{
1653  name = "Install",
1654  program = "npm",
1655  env = { NODE_ENV = "development" },
1656}]
1657"#,
1658        );
1659
1660        let command = &config.commands[0];
1661
1662        assert_eq!(command.name.as_deref(), Some("Install"));
1663        assert_eq!(command.env["NODE_ENV"], "development");
1664        assert!(!command.allow_failure);
1665    }
1666
1667    #[test]
1668    fn parse_config_should_reject_async_command_field() {
1669        assert_parse_error_contains(
1670            r#"commands = [{ run = "npm install", async = true }]"#,
1671            "unknown field",
1672        );
1673        assert_parse_error_contains(
1674            r#"commands = [{ run = "npm install", async = false }]"#,
1675            "unknown field",
1676        );
1677    }
1678
1679    #[test]
1680    fn parse_config_should_allow_program_without_args() {
1681        let config = parse(r#"commands = [{ program = "mise" }]"#);
1682
1683        assert_eq!(
1684            config.commands[0].command,
1685            CommandKind::Direct {
1686                program: "mise".to_owned(),
1687                args: Vec::new()
1688            }
1689        );
1690    }
1691
1692    #[test]
1693    fn parse_config_should_reject_mutually_exclusive_command_fields() {
1694        assert_parse_error_contains(
1695            r#"commands = [{ run = "npm install", program = "npm" }]"#,
1696            "mutually exclusive",
1697        );
1698    }
1699
1700    #[test]
1701    fn parse_config_should_reject_args_without_program() {
1702        assert_parse_error_contains(
1703            r#"commands = [{ run = "npm install", args = [] }]"#,
1704            "`args` requires `program`",
1705        );
1706    }
1707
1708    #[test]
1709    fn parse_config_should_reject_missing_command_invocation() {
1710        assert_parse_error_contains(
1711            r#"commands = [{ name = "Install" }]"#,
1712            "missing required `run` or `program`",
1713        );
1714    }
1715
1716    #[test]
1717    fn parse_config_should_reject_unknown_fields() {
1718        assert_parse_error_contains(
1719            r#"copy = [{ source = ".env", unknown = true }]"#,
1720            "unknown field",
1721        );
1722    }
1723
1724    #[test]
1725    fn parse_config_should_reject_missing_file_operation() {
1726        assert_parse_error_contains(
1727            r#"files = [{ source = ".env" }]"#,
1728            "missing required `operation`",
1729        );
1730    }
1731
1732    #[test]
1733    fn parse_config_should_reject_missing_file_source() {
1734        assert_parse_error_contains(
1735            r#"copy = [{ target = ".env" }]"#,
1736            "missing required `source`",
1737        );
1738    }
1739
1740    #[test]
1741    fn parse_config_should_reject_operation_in_specific_file_groups() {
1742        assert_parse_error_contains(
1743            r#"copy = [{ operation = "copy", source = ".env" }]"#,
1744            "`operation` is only valid in `files` and `[[file]]` entries",
1745        );
1746    }
1747
1748    #[test]
1749    fn parse_config_should_reject_compare_on_copy_file_operations() {
1750        assert_parse_error_contains(
1751            r#"copy = [{ source = ".env", compare = "checksum" }]"#,
1752            "`compare` is only valid for sync file operations",
1753        );
1754    }
1755
1756    #[test]
1757    fn parse_config_should_reject_delete_on_symlink_file_operations() {
1758        assert_parse_error_contains(
1759            r#"symlink = [{ source = ".env", delete = true }]"#,
1760            "`delete` is only valid for sync file operations",
1761        );
1762    }
1763
1764    #[test]
1765    fn parse_config_should_reject_legacy_delete_extra_field() {
1766        assert_parse_error_contains(
1767            r#"sync = [{ source = "shared", delete_extra = true }]"#,
1768            "unknown field `delete_extra`",
1769        );
1770    }
1771
1772    #[test]
1773    fn parse_config_should_reject_symlinks_on_symlink_file_operations() {
1774        assert_parse_error_contains(
1775            r#"symlink = [{ source = ".env", symlinks = "preserve" }]"#,
1776            "`symlinks` is only valid for copy and sync file operations",
1777        );
1778    }
1779
1780    #[test]
1781    fn parse_config_should_report_invalid_toml_location() {
1782        assert_parse_error_contains("commands = [\n", "line 1, column");
1783    }
1784}