use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::Deserialize;
use crate::config::{Relation, SourceDef};
const EMBEDDED: &str = include_str!(concat!(env!("OUT_DIR"), "/embedded_catalog.toml"));
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub(crate) struct EmbeddedCatalogFile {
#[serde(default)]
pub catalog_version: Option<u32>,
#[serde(default, rename = "source")]
pub sources: BTreeMap<String, SourceDef>,
#[serde(default, rename = "relation")]
pub relations: Vec<Relation>,
}
#[doc(hidden)]
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PluginFileShape {
#[serde(default, rename = "source")]
pub sources: BTreeMap<String, SourceDef>,
#[serde(default, rename = "relation")]
pub relations: Vec<Relation>,
}
fn embedded() -> &'static EmbeddedCatalogFile {
static CACHE: OnceLock<EmbeddedCatalogFile> = OnceLock::new();
CACHE.get_or_init(|| toml::from_str(EMBEDDED).expect("embedded_catalog.toml must parse"))
}
pub fn builtin() -> BTreeMap<String, SourceDef> {
embedded().sources.clone()
}
pub fn embedded_version() -> u32 {
embedded().catalog_version.unwrap_or(0)
}
pub fn version_check(require_catalog: Option<u32>, embedded: u32) -> Result<(), String> {
let Some(required) = require_catalog else {
return Ok(());
};
if embedded >= required {
return Ok(());
}
Err(format!(
"rules require catalog_version >= {required}, but this binary embeds version {embedded}. \
Upgrade pathlint or lower require_catalog."
))
}
pub fn merge_with_user(user: &BTreeMap<String, SourceDef>) -> BTreeMap<String, SourceDef> {
let mut out = builtin();
for (name, user_def) in user {
let merged = match out.get(name) {
Some(existing) => existing.merge(user_def),
None => user_def.clone(),
};
out.insert(name.clone(), merged);
}
out
}
pub struct RelationIndex<'a> {
relations: &'a [Relation],
}
pub struct ProvenanceRef<'a> {
pub host: &'a str,
pub guest_pattern: &'a str,
pub guest_provider: &'a str,
pub installer_token: Option<&'a str>,
}
impl<'a> RelationIndex<'a> {
pub fn from_slice(relations: &'a [Relation]) -> Self {
Self { relations }
}
pub fn iter_aliases(&self) -> impl Iterator<Item = (&'a str, &'a [String])> {
self.relations.iter().filter_map(|r| match r {
Relation::AliasOf { parent, children } => Some((parent.as_str(), children.as_slice())),
_ => None,
})
}
pub fn iter_conflicts(&self) -> impl Iterator<Item = (&'a [String], &'a str)> {
self.relations.iter().filter_map(|r| match r {
Relation::ConflictsWhenBothInPath {
sources,
diagnostic,
} => Some((sources.as_slice(), diagnostic.as_str())),
_ => None,
})
}
pub fn iter_provenances(&self) -> impl Iterator<Item = ProvenanceRef<'a>> {
self.relations.iter().filter_map(|r| match r {
Relation::ServedByVia {
host,
guest_pattern,
guest_provider,
installer_token,
} => Some(ProvenanceRef {
host: host.as_str(),
guest_pattern: guest_pattern.as_str(),
guest_provider: guest_provider.as_str(),
installer_token: installer_token.as_deref(),
}),
_ => None,
})
}
pub fn iter_depends_on(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
self.relations.iter().filter_map(|r| match r {
Relation::DependsOn { source, target } => Some((source.as_str(), target.as_str())),
_ => None,
})
}
pub fn iter_prefer_orders(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
self.relations.iter().filter_map(|r| match r {
Relation::PreferOrderOver { earlier, later } => {
Some((earlier.as_str(), later.as_str()))
}
_ => None,
})
}
}
pub fn builtin_relations() -> Vec<Relation> {
embedded().relations.clone()
}
pub fn merge_with_user_relations(user: &[Relation]) -> Vec<Relation> {
let mut out = builtin_relations();
out.extend(user.iter().cloned());
out
}
pub fn check_acyclic(relations: &[Relation]) -> Result<(), String> {
use std::collections::BTreeSet;
let index = RelationIndex::from_slice(relations);
let mut edges: Vec<(String, String)> = Vec::new();
for prov in index.iter_provenances() {
edges.push((prov.host.to_string(), prov.guest_provider.to_string()));
}
for (source, target) in index.iter_depends_on() {
edges.push((source.to_string(), target.to_string()));
}
for (earlier, later) in index.iter_prefer_orders() {
edges.push((earlier.to_string(), later.to_string()));
}
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (from, to) in &edges {
adj.entry(from.clone()).or_default().push(to.clone());
}
let mut color: BTreeMap<String, u8> = BTreeMap::new();
fn visit(
node: &str,
adj: &BTreeMap<String, Vec<String>>,
color: &mut BTreeMap<String, u8>,
stack: &mut Vec<String>,
) -> Result<(), String> {
let entry = color.entry(node.to_string()).or_insert(0);
match *entry {
1 => {
let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
let mut cycle = stack[cycle_start..].join(" -> ");
cycle.push_str(&format!(" -> {node}"));
return Err(format!("relation cycle: {cycle}"));
}
2 => return Ok(()), _ => {}
}
*entry = 1;
stack.push(node.to_string());
let neighbours: Vec<String> = adj.get(node).cloned().unwrap_or_default();
for next in neighbours {
visit(&next, adj, color, stack)?;
}
stack.pop();
color.insert(node.to_string(), 2);
Ok(())
}
let nodes: BTreeSet<String> = edges
.iter()
.flat_map(|(a, b)| [a.clone(), b.clone()])
.collect();
for node in &nodes {
let mut stack = Vec::new();
visit(node, &adj, &mut color, &mut stack)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::os_detect::Os;
#[test]
fn embedded_catalog_parses() {
let cat = builtin();
assert!(cat.contains_key("cargo"));
assert!(cat.contains_key("winget"));
assert!(cat.contains_key("brew_arm"));
assert!(cat.contains_key("pkg"));
}
#[test]
fn cargo_has_per_os_paths() {
let cat = builtin();
let cargo = &cat["cargo"];
assert!(cargo.path_for(Os::Windows).is_some());
assert!(cargo.path_for(Os::Linux).is_some());
assert!(cargo.path_for(Os::Macos).is_some());
assert!(cargo.path_for(Os::Termux).is_some()); }
#[test]
fn user_override_replaces_only_specified_field() {
let mut user = BTreeMap::new();
user.insert(
"mise".to_string(),
SourceDef {
windows: Some("D:/tools/mise".into()),
..Default::default()
},
);
let merged = merge_with_user(&user);
let mise = &merged["mise"];
assert_eq!(mise.path_for(Os::Windows), Some("D:/tools/mise"));
assert!(mise.path_for(Os::Linux).is_some());
}
#[test]
fn user_can_add_new_source() {
let mut user = BTreeMap::new();
user.insert(
"my_dotfiles_bin".to_string(),
SourceDef {
unix: Some("$HOME/dotfiles/bin".into()),
..Default::default()
},
);
let merged = merge_with_user(&user);
assert!(merged.contains_key("my_dotfiles_bin"));
}
#[test]
fn linux_only_source_is_none_on_windows() {
let cat = builtin();
let apt = &cat["apt"];
assert!(apt.path_for(Os::Linux).is_some());
assert!(apt.path_for(Os::Windows).is_none());
assert!(apt.path_for(Os::Macos).is_none());
assert!(apt.path_for(Os::Termux).is_none());
}
#[test]
fn user_override_can_replace_all_known_fields() {
let mut user = BTreeMap::new();
user.insert(
"cargo".to_string(),
SourceDef {
description: Some("user-overridden".into()),
windows: Some("X:/cargo".into()),
unix: Some("/x/cargo".into()),
..Default::default()
},
);
let merged = merge_with_user(&user);
let cargo = &merged["cargo"];
assert_eq!(cargo.description.as_deref(), Some("user-overridden"));
assert_eq!(cargo.path_for(Os::Windows), Some("X:/cargo"));
assert_eq!(cargo.path_for(Os::Linux), Some("/x/cargo"));
}
#[test]
fn embedded_version_is_at_least_one() {
assert!(embedded_version() >= 1);
}
#[test]
fn version_check_passes_when_no_requirement_set() {
assert!(version_check(None, 0).is_ok());
assert!(version_check(None, 9999).is_ok());
}
#[test]
fn version_check_passes_when_embedded_meets_required() {
assert!(version_check(Some(2), 2).is_ok());
assert!(version_check(Some(2), 3).is_ok());
}
#[test]
fn version_check_fails_when_embedded_below_required() {
let err = version_check(Some(7), 3).unwrap_err();
assert!(err.contains("7"), "error must name required: {err}");
assert!(err.contains("3"), "error must name embedded: {err}");
assert!(
err.contains("Upgrade") || err.contains("require_catalog"),
"error must hint at fix: {err}"
);
}
#[test]
fn mise_layered_sources_are_present() {
let cat = builtin();
assert!(cat.contains_key("mise"));
assert!(cat.contains_key("mise_shims"));
assert!(cat.contains_key("mise_installs"));
}
#[test]
fn builtin_relations_includes_mise_alias_and_conflict() {
let rels = builtin_relations();
assert!(
rels.iter().any(|r| matches!(
r,
Relation::AliasOf { parent, .. } if parent == "mise"
)),
"alias_of mise missing: {rels:?}"
);
assert!(
rels.iter().any(|r| matches!(
r,
Relation::ConflictsWhenBothInPath { diagnostic, .. }
if diagnostic == "mise_activate_both"
)),
"mise_activate_both conflict missing: {rels:?}"
);
}
#[test]
fn merge_user_relations_appends_after_builtin() {
let user = vec![Relation::DependsOn {
source: "paru".into(),
target: "pacman".into(),
}];
let merged = merge_with_user_relations(&user);
let last = merged.last().unwrap();
assert!(matches!(
last,
Relation::DependsOn { source, target }
if source == "paru" && target == "pacman"
));
}
#[test]
fn check_acyclic_accepts_simple_chain() {
let rels = vec![
Relation::DependsOn {
source: "a".into(),
target: "b".into(),
},
Relation::DependsOn {
source: "b".into(),
target: "c".into(),
},
];
assert!(check_acyclic(&rels).is_ok());
}
#[test]
fn check_acyclic_rejects_two_node_cycle() {
let rels = vec![
Relation::DependsOn {
source: "a".into(),
target: "b".into(),
},
Relation::DependsOn {
source: "b".into(),
target: "a".into(),
},
];
let err = check_acyclic(&rels).unwrap_err();
assert!(err.contains("cycle"), "{err}");
}
#[test]
fn check_acyclic_rejects_three_node_cycle_through_served_by_via() {
let rels = vec![
Relation::ServedByVia {
host: "a".into(),
guest_pattern: "*".into(),
guest_provider: "b".into(),
installer_token: None,
},
Relation::ServedByVia {
host: "b".into(),
guest_pattern: "*".into(),
guest_provider: "c".into(),
installer_token: None,
},
Relation::ServedByVia {
host: "c".into(),
guest_pattern: "*".into(),
guest_provider: "a".into(),
installer_token: None,
},
];
assert!(check_acyclic(&rels).is_err());
}
#[test]
fn check_acyclic_rejects_cycle_through_prefer_order_over() {
let rels = vec![
Relation::PreferOrderOver {
earlier: "a".into(),
later: "b".into(),
},
Relation::PreferOrderOver {
earlier: "b".into(),
later: "a".into(),
},
];
assert!(check_acyclic(&rels).is_err());
}
#[test]
fn check_acyclic_ignores_alias_of_and_conflicts() {
let rels = vec![
Relation::AliasOf {
parent: "a".into(),
children: vec!["a".into(), "b".into()],
},
Relation::ConflictsWhenBothInPath {
sources: vec!["a".into(), "b".into()],
diagnostic: "x".into(),
},
];
assert!(check_acyclic(&rels).is_ok());
}
#[test]
fn builtin_relations_form_a_dag() {
check_acyclic(&builtin_relations()).expect("built-in relations must be acyclic");
}
#[test]
fn mise_shims_path_is_a_subdirectory_of_mise() {
let cat = builtin();
let mise = cat["mise"].path_for(Os::Linux).unwrap();
let shims = cat["mise_shims"].path_for(Os::Linux).unwrap();
let installs = cat["mise_installs"].path_for(Os::Linux).unwrap();
assert!(shims.starts_with(mise), "{shims} not under {mise}");
assert!(installs.starts_with(mise), "{installs} not under {mise}");
}
}