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