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