Skip to main content

vanta_config/
lib.rs

1//! `vanta-config` — load, validate, and merge Vanta manifests.
2//!
3//! Parses `vanta.toml`/`config.toml` into the typed [`model::Manifest`], reports
4//! span-accurate `VTA-CFG-*` diagnostics on failure, and implements the
5//! precedence merge (global < project). See `docs/05-configuration.md`.
6#![forbid(unsafe_code)]
7
8pub mod model;
9
10pub use model::Manifest;
11
12use std::fs;
13use std::path::Path;
14use vanta_core::{Area, VtaError, VtaResult};
15
16/// Parse a manifest from TOML source. `origin` is used in diagnostics (a path or
17/// `<string>`); errors carry the line and column of the offending token.
18pub fn parse_str(src: &str, origin: &str) -> VtaResult<Manifest> {
19    toml::from_str::<Manifest>(src).map_err(|e| cfg_error(src, origin, &e))
20}
21
22/// Load and parse a manifest file.
23pub fn load_file(path: &Path) -> VtaResult<Manifest> {
24    let src = fs::read_to_string(path).map_err(|e| {
25        VtaError::new(
26            Area::Cfg,
27            1,
28            format!("cannot read manifest {}: {e}", path.display()),
29        )
30    })?;
31    parse_str(&src, &path.display().to_string())
32}
33
34/// Merge a project manifest over a global one. The project
35/// layer wins on every per-key conflict.
36pub fn merge(global: &Manifest, project: &Manifest) -> Manifest {
37    let mut out = global.clone();
38    out.tools.extend(project.tools.clone());
39    out.env.extend(project.env.clone());
40    out.tasks.extend(project.tasks.clone());
41    out.registries.extend(project.registries.clone());
42    out.settings = merge_settings(&global.settings, &project.settings);
43    if project.workspace.is_some() {
44        out.workspace = project.workspace.clone();
45    }
46    if project.version.is_some() {
47        out.version = project.version;
48    }
49    out
50}
51
52fn merge_settings(g: &model::Settings, p: &model::Settings) -> model::Settings {
53    macro_rules! pick {
54        ($field:ident) => {
55            p.$field.clone().or_else(|| g.$field.clone())
56        };
57    }
58    model::Settings {
59        auto_install: pick!(auto_install),
60        verify: pick!(verify),
61        jobs: pick!(jobs),
62        link_strategy: pick!(link_strategy),
63        shims: pick!(shims),
64        offline: pick!(offline),
65        mirror: pick!(mirror),
66        targets: pick!(targets),
67        retain_generations: pick!(retain_generations),
68        gc_keep_days: pick!(gc_keep_days),
69        color: pick!(color),
70        telemetry: pick!(telemetry),
71        read_foreign_versions: pick!(read_foreign_versions),
72    }
73}
74
75fn cfg_error(src: &str, origin: &str, e: &toml::de::Error) -> VtaError {
76    let (line, col) = e.span().map(|s| line_col(src, s.start)).unwrap_or((0, 0));
77    VtaError::new(Area::Cfg, 1, format!("{e} ({origin}:{line}:{col})"))
78}
79
80/// Convert a byte offset into a 1-based line and column.
81fn line_col(src: &str, byte: usize) -> (usize, usize) {
82    let mut line = 1usize;
83    let mut col = 1usize;
84    for (i, ch) in src.char_indices() {
85        if i >= byte {
86            break;
87        }
88        if ch == '\n' {
89            line += 1;
90            col = 1;
91        } else {
92            col += 1;
93        }
94    }
95    (line, col)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::model::ToolSpec;
101    use super::*;
102
103    #[test]
104    fn parses_minimal() {
105        let m = parse_str("[tools]\nnode = \"24\"\npython = \"3.13\"\n", "<test>").unwrap();
106        assert_eq!(m.tools["node"].version(), "24");
107        assert_eq!(m.tools["python"].version(), "3.13");
108    }
109
110    #[test]
111    fn parses_detailed_tool() {
112        let m = parse_str(
113            "[tools]\nripgrep = { version = \"14\", os = [\"macos\", \"linux\"] }\n",
114            "<test>",
115        )
116        .unwrap();
117        match &m.tools["ripgrep"] {
118            ToolSpec::Detailed(d) => {
119                assert_eq!(d.version, "14");
120                assert_eq!(
121                    d.os.as_deref(),
122                    Some(&["macos".to_string(), "linux".to_string()][..])
123                );
124            }
125            _ => panic!("expected detailed"),
126        }
127    }
128
129    #[test]
130    fn unknown_top_level_key_is_error() {
131        let err = parse_str("[nonsense]\nx = 1\n", "<test>").unwrap_err();
132        assert_eq!(err.area, Area::Cfg);
133    }
134
135    #[test]
136    fn project_overrides_global() {
137        let g = parse_str("[tools]\nnode = \"24\"\nripgrep = \"14\"\n", "<g>").unwrap();
138        let p = parse_str("[tools]\nnode = \"20\"\n", "<p>").unwrap();
139        let merged = merge(&g, &p);
140        assert_eq!(merged.tools["node"].version(), "20"); // project wins
141        assert_eq!(merged.tools["ripgrep"].version(), "14"); // global retained
142    }
143
144    #[test]
145    fn settings_merge_is_field_wise() {
146        let g = parse_str("[settings]\njobs = 8\nverify = \"require\"\n", "<g>").unwrap();
147        let p = parse_str("[settings]\njobs = 12\n", "<p>").unwrap();
148        let merged = merge(&g, &p);
149        assert_eq!(merged.settings.jobs, Some(12)); // project overrides
150        assert_eq!(merged.settings.verify.as_deref(), Some("require")); // global retained
151    }
152}
153
154#[cfg(test)]
155mod fuzz {
156    use super::*;
157    proptest::proptest! {
158        #[test]
159        fn manifest_parse_never_panics(s in ".*") { let _ = parse_str(&s, "<fuzz>"); }
160    }
161}