Skip to main content

treeboot_core/
config.rs

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