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