use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize, Clone, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(title = "pathlint.toml")]
pub struct Config {
#[serde(default)]
pub catalog_version: Option<u32>,
#[serde(default)]
pub require_catalog: Option<u32>,
#[serde(default, rename = "expect")]
pub expectations: Vec<Expectation>,
#[serde(default)]
pub source: BTreeMap<String, SourceDef>,
#[serde(default, rename = "relation")]
pub relations: Vec<Relation>,
}
#[derive(Debug, Deserialize, serde::Serialize, Clone, PartialEq, Eq, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Relation {
AliasOf {
parent: String,
children: Vec<String>,
},
ConflictsWhenBothInPath {
sources: Vec<String>,
diagnostic: String,
},
ServedByVia {
host: String,
guest_pattern: String,
guest_provider: String,
#[serde(default)]
installer_token: Option<String>,
},
DependsOn { source: String, target: String },
PreferOrderOver { earlier: String, later: String },
}
#[derive(Debug, Default, Deserialize, Clone, schemars::JsonSchema)]
#[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,
#[serde(default)]
pub kind: Option<Kind>,
#[serde(default)]
pub severity: Severity,
}
#[derive(
Debug, Deserialize, serde::Serialize, Clone, Copy, PartialEq, Eq, Default, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
#[default]
Error,
Warn,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Executable,
}
#[derive(Debug, Default, Deserialize, Clone, schemars::JsonSchema)]
#[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>,
#[serde(default)]
pub uninstall_command: 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()),
uninstall_command: override_with
.uninstall_command
.clone()
.or_else(|| self.uninstall_command.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 = load_rules_text(path)?;
Self::parse_toml(&text)
}
}
pub const RULES_MAX_BYTES: u64 = 16 * 1024 * 1024;
fn load_rules_text(path: &Path) -> Result<String, ConfigError> {
let display = path.display().to_string();
let lst = fs::symlink_metadata(path)
.map_err(|e| ConfigError::Read(display.clone(), e.to_string()))?;
if lst.file_type().is_symlink() {
let target_lst =
fs::symlink_metadata(fs::read_link(path).map_err(|e| {
ConfigError::Read(display.clone(), format!("read_link failed: {e}"))
})?)
.map_err(|e| {
ConfigError::Read(display.clone(), format!("symlink target stat failed: {e}"))
})?;
if target_lst.file_type().is_symlink() {
return Err(ConfigError::RulesNotRegularFile(display));
}
if !target_lst.is_file() {
return Err(ConfigError::RulesNotRegularFile(display));
}
if target_lst.len() > RULES_MAX_BYTES {
return Err(ConfigError::RulesTooLarge(display, target_lst.len()));
}
} else {
if !lst.is_file() {
return Err(ConfigError::RulesNotRegularFile(display));
}
if lst.len() > RULES_MAX_BYTES {
return Err(ConfigError::RulesTooLarge(display, lst.len()));
}
}
fs::read_to_string(path).map_err(|e| ConfigError::Read(display, e.to_string()))
}
#[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),
#[error(
"{0} is not a regular file (a single-hop symlink to a regular file is the only indirection allowed)"
)]
RulesNotRegularFile(String),
#[error("{0} is too large ({1} bytes); rules files are capped at 16 MiB")]
RulesTooLarge(String, u64),
}
#[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}");
}
#[test]
fn catalog_version_and_require_catalog_are_parsed() {
let cfg = Config::parse_toml(
r#"
catalog_version = 7
require_catalog = 5
"#,
)
.unwrap();
assert_eq!(cfg.catalog_version, Some(7));
assert_eq!(cfg.require_catalog, Some(5));
}
#[test]
fn require_catalog_is_optional() {
let cfg = Config::parse_toml("").unwrap();
assert_eq!(cfg.catalog_version, None);
assert_eq!(cfg.require_catalog, None);
}
#[test]
fn kind_executable_parses() {
let cfg = Config::parse_toml(
r#"
[[expect]]
command = "x"
kind = "executable"
"#,
)
.unwrap();
assert_eq!(cfg.expectations[0].kind, Some(Kind::Executable));
}
#[test]
fn kind_unknown_value_is_a_parse_error() {
let err = Config::parse_toml(
r#"
[[expect]]
command = "x"
kind = "binary"
"#,
)
.unwrap_err();
assert!(format!("{err}").contains("kind"), "{err}");
}
#[test]
fn kind_is_optional() {
let cfg = Config::parse_toml(
r#"
[[expect]]
command = "x"
"#,
)
.unwrap();
assert_eq!(cfg.expectations[0].kind, None);
}
#[test]
fn severity_defaults_to_error() {
let cfg = Config::parse_toml(
r#"
[[expect]]
command = "x"
"#,
)
.unwrap();
assert_eq!(cfg.expectations[0].severity, Severity::Error);
}
#[test]
fn severity_warn_parses() {
let cfg = Config::parse_toml(
r#"
[[expect]]
command = "x"
severity = "warn"
"#,
)
.unwrap();
assert_eq!(cfg.expectations[0].severity, Severity::Warn);
}
#[test]
fn severity_error_parses_explicitly() {
let cfg = Config::parse_toml(
r#"
[[expect]]
command = "x"
severity = "error"
"#,
)
.unwrap();
assert_eq!(cfg.expectations[0].severity, Severity::Error);
}
#[test]
fn severity_unknown_value_is_a_parse_error() {
let err = Config::parse_toml(
r#"
[[expect]]
command = "x"
severity = "info"
"#,
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("severity") || msg.contains("info"), "{msg}");
}
#[test]
fn relations_default_to_empty() {
let cfg = Config::parse_toml("").unwrap();
assert!(cfg.relations.is_empty());
}
#[test]
fn relation_alias_of_parses() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "alias_of"
parent = "mise"
children = ["mise_shims", "mise_installs"]
"#,
)
.unwrap();
assert_eq!(cfg.relations.len(), 1);
match &cfg.relations[0] {
Relation::AliasOf { parent, children } => {
assert_eq!(parent, "mise");
assert_eq!(
children,
&vec!["mise_shims".to_string(), "mise_installs".to_string()]
);
}
other => panic!("expected AliasOf, got {other:?}"),
}
}
#[test]
fn relation_conflicts_when_both_in_path_parses() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "conflicts_when_both_in_path"
sources = ["mise_shims", "mise_installs"]
diagnostic = "mise_activate_both"
"#,
)
.unwrap();
match &cfg.relations[0] {
Relation::ConflictsWhenBothInPath {
sources,
diagnostic,
} => {
assert_eq!(sources.len(), 2);
assert_eq!(diagnostic, "mise_activate_both");
}
other => panic!("expected ConflictsWhenBothInPath, got {other:?}"),
}
}
#[test]
fn relation_served_by_via_parses() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "served_by_via"
host = "mise_installs"
guest_pattern = "cargo-*"
guest_provider = "cargo"
"#,
)
.unwrap();
match &cfg.relations[0] {
Relation::ServedByVia {
host,
guest_pattern,
guest_provider,
installer_token,
} => {
assert_eq!(host, "mise_installs");
assert_eq!(guest_pattern, "cargo-*");
assert_eq!(guest_provider, "cargo");
assert!(installer_token.is_none());
}
other => panic!("expected ServedByVia, got {other:?}"),
}
}
#[test]
fn relation_served_by_via_parses_installer_token() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "served_by_via"
host = "mise_installs"
guest_pattern = "pipx-*"
guest_provider = "pip_user"
installer_token = "pipx"
"#,
)
.unwrap();
match &cfg.relations[0] {
Relation::ServedByVia {
installer_token, ..
} => {
assert_eq!(installer_token.as_deref(), Some("pipx"));
}
other => panic!("expected ServedByVia, got {other:?}"),
}
}
#[test]
fn relation_prefer_order_over_parses() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "prefer_order_over"
earlier = "cargo"
later = "system_linux"
"#,
)
.unwrap();
match &cfg.relations[0] {
Relation::PreferOrderOver { earlier, later } => {
assert_eq!(earlier, "cargo");
assert_eq!(later, "system_linux");
}
other => panic!("expected PreferOrderOver, got {other:?}"),
}
}
#[test]
fn relation_depends_on_parses() {
let cfg = Config::parse_toml(
r#"
[[relation]]
kind = "depends_on"
source = "paru"
target = "pacman"
"#,
)
.unwrap();
match &cfg.relations[0] {
Relation::DependsOn { source, target } => {
assert_eq!(source, "paru");
assert_eq!(target, "pacman");
}
other => panic!("expected DependsOn, got {other:?}"),
}
}
#[test]
fn relation_unknown_kind_is_a_parse_error() {
let err = Config::parse_toml(
r#"
[[relation]]
kind = "this_does_not_exist"
"#,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("kind") || msg.contains("this_does_not_exist"),
"{msg}"
);
}
#[test]
fn relation_missing_required_field_is_a_parse_error() {
let err = Config::parse_toml(
r#"
[[relation]]
kind = "alias_of"
parent = "mise"
"#,
)
.unwrap_err();
assert!(format!("{err}").contains("children"), "{err}");
}
}