Skip to main content

treeboot_core/
config.rs

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