#![forbid(unsafe_code)]
pub mod model;
pub use model::Manifest;
use std::fs;
use std::path::Path;
use vanta_core::{Area, VtaError, VtaResult};
pub fn parse_str(src: &str, origin: &str) -> VtaResult<Manifest> {
toml::from_str::<Manifest>(src).map_err(|e| cfg_error(src, origin, &e))
}
pub fn load_file(path: &Path) -> VtaResult<Manifest> {
let src = fs::read_to_string(path).map_err(|e| {
VtaError::new(
Area::Cfg,
1,
format!("cannot read manifest {}: {e}", path.display()),
)
})?;
parse_str(&src, &path.display().to_string())
}
pub fn merge(global: &Manifest, project: &Manifest) -> Manifest {
let mut out = global.clone();
out.tools.extend(project.tools.clone());
out.env.extend(project.env.clone());
out.tasks.extend(project.tasks.clone());
out.registries.extend(project.registries.clone());
out.settings = merge_settings(&global.settings, &project.settings);
if project.workspace.is_some() {
out.workspace = project.workspace.clone();
}
if project.version.is_some() {
out.version = project.version;
}
out
}
fn merge_settings(g: &model::Settings, p: &model::Settings) -> model::Settings {
macro_rules! pick {
($field:ident) => {
p.$field.clone().or_else(|| g.$field.clone())
};
}
model::Settings {
auto_install: pick!(auto_install),
verify: pick!(verify),
jobs: pick!(jobs),
link_strategy: pick!(link_strategy),
shims: pick!(shims),
offline: pick!(offline),
mirror: pick!(mirror),
targets: pick!(targets),
retain_generations: pick!(retain_generations),
gc_keep_days: pick!(gc_keep_days),
color: pick!(color),
telemetry: pick!(telemetry),
read_foreign_versions: pick!(read_foreign_versions),
}
}
fn cfg_error(src: &str, origin: &str, e: &toml::de::Error) -> VtaError {
let (line, col) = e.span().map(|s| line_col(src, s.start)).unwrap_or((0, 0));
VtaError::new(Area::Cfg, 1, format!("{e} ({origin}:{line}:{col})"))
}
fn line_col(src: &str, byte: usize) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for (i, ch) in src.char_indices() {
if i >= byte {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
#[cfg(test)]
mod tests {
use super::model::ToolSpec;
use super::*;
#[test]
fn parses_minimal() {
let m = parse_str("[tools]\nnode = \"24\"\npython = \"3.13\"\n", "<test>").unwrap();
assert_eq!(m.tools["node"].version(), "24");
assert_eq!(m.tools["python"].version(), "3.13");
}
#[test]
fn parses_detailed_tool() {
let m = parse_str(
"[tools]\nripgrep = { version = \"14\", os = [\"macos\", \"linux\"] }\n",
"<test>",
)
.unwrap();
match &m.tools["ripgrep"] {
ToolSpec::Detailed(d) => {
assert_eq!(d.version, "14");
assert_eq!(
d.os.as_deref(),
Some(&["macos".to_string(), "linux".to_string()][..])
);
}
_ => panic!("expected detailed"),
}
}
#[test]
fn unknown_top_level_key_is_error() {
let err = parse_str("[nonsense]\nx = 1\n", "<test>").unwrap_err();
assert_eq!(err.area, Area::Cfg);
}
#[test]
fn project_overrides_global() {
let g = parse_str("[tools]\nnode = \"24\"\nripgrep = \"14\"\n", "<g>").unwrap();
let p = parse_str("[tools]\nnode = \"20\"\n", "<p>").unwrap();
let merged = merge(&g, &p);
assert_eq!(merged.tools["node"].version(), "20"); assert_eq!(merged.tools["ripgrep"].version(), "14"); }
#[test]
fn settings_merge_is_field_wise() {
let g = parse_str("[settings]\njobs = 8\nverify = \"require\"\n", "<g>").unwrap();
let p = parse_str("[settings]\njobs = 12\n", "<p>").unwrap();
let merged = merge(&g, &p);
assert_eq!(merged.settings.jobs, Some(12)); assert_eq!(merged.settings.verify.as_deref(), Some("require")); }
}
#[cfg(test)]
mod fuzz {
use super::*;
proptest::proptest! {
#[test]
fn manifest_parse_never_panics(s in ".*") { let _ = parse_str(&s, "<fuzz>"); }
}
}