use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default, rename = "expect")]
pub expectations: Vec<Expectation>,
#[serde(default)]
pub source: BTreeMap<String, SourceDef>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Expectation {
pub command: String,
#[serde(default)]
pub prefer: Vec<String>,
#[serde(default)]
pub avoid: Vec<String>,
#[serde(default)]
pub os: Option<Vec<String>>,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct SourceDef {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub windows: Option<String>,
#[serde(default)]
pub macos: Option<String>,
#[serde(default)]
pub linux: Option<String>,
#[serde(default)]
pub termux: Option<String>,
#[serde(default)]
pub unix: Option<String>,
}
impl SourceDef {
pub fn path_for(&self, os: crate::os_detect::Os) -> Option<&str> {
use crate::os_detect::Os;
let direct = match os {
Os::Windows => self.windows.as_deref(),
Os::Macos => self.macos.as_deref(),
Os::Linux => self.linux.as_deref(),
Os::Termux => self.termux.as_deref(),
};
let fallback = match os {
Os::Macos | Os::Linux | Os::Termux => self.unix.as_deref(),
Os::Windows => None,
};
direct.or(fallback)
}
pub fn merge(&self, override_with: &SourceDef) -> SourceDef {
SourceDef {
description: override_with
.description
.clone()
.or_else(|| self.description.clone()),
windows: override_with
.windows
.clone()
.or_else(|| self.windows.clone()),
macos: override_with.macos.clone().or_else(|| self.macos.clone()),
linux: override_with.linux.clone().or_else(|| self.linux.clone()),
termux: override_with.termux.clone().or_else(|| self.termux.clone()),
unix: override_with.unix.clone().or_else(|| self.unix.clone()),
}
}
}
impl Config {
pub fn parse_toml(toml_text: &str) -> Result<Self, ConfigError> {
toml::from_str(toml_text).map_err(|e| ConfigError::Parse(e.to_string()))
}
pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
let text = fs::read_to_string(path)
.map_err(|e| ConfigError::Read(path.display().to_string(), e.to_string()))?;
Self::parse_toml(&text)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read {0}: {1}")]
Read(String, String),
#[error("failed to parse pathlint.toml: {0}")]
Parse(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::os_detect::Os;
#[test]
fn parses_minimal_expect_block() {
let cfg: Config = Config::parse_toml(
r#"
[[expect]]
command = "runex"
prefer = ["cargo"]
avoid = ["winget"]
"#,
)
.unwrap();
assert_eq!(cfg.expectations.len(), 1);
let e = &cfg.expectations[0];
assert_eq!(e.command, "runex");
assert_eq!(e.prefer, vec!["cargo"]);
assert_eq!(e.avoid, vec!["winget"]);
assert!(e.os.is_none());
assert!(!e.optional);
}
#[test]
fn parses_source_with_unix_fallback() {
let cfg: Config = Config::parse_toml(
r#"
[source.cargo]
windows = "C:/Users/x/.cargo/bin"
unix = "/home/x/.cargo/bin"
"#,
)
.unwrap();
let cargo = cfg.source.get("cargo").unwrap();
assert_eq!(cargo.path_for(Os::Windows), Some("C:/Users/x/.cargo/bin"));
assert_eq!(cargo.path_for(Os::Linux), Some("/home/x/.cargo/bin"));
assert_eq!(cargo.path_for(Os::Macos), Some("/home/x/.cargo/bin"));
}
#[test]
fn merge_prefers_override_fields() {
let base = SourceDef {
windows: Some("C:/old".into()),
unix: Some("/usr/old".into()),
..Default::default()
};
let user = SourceDef {
windows: Some("D:/new".into()),
..Default::default()
};
let merged = base.merge(&user);
assert_eq!(merged.windows.as_deref(), Some("D:/new"));
assert_eq!(merged.unix.as_deref(), Some("/usr/old"));
}
#[test]
fn unknown_fields_are_rejected() {
let err = Config::parse_toml(
r#"
[[expect]]
command = "x"
unknown_field = true
"#,
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unknown_field"), "got: {msg}");
}
}