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