use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const ENV_CONFIG_DIR: &str = "PMPX_CONFIG_DIR";
pub const ENV_DATA_DIR: &str = "PMPX_DATA_DIR";
pub fn global_config_path() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(ENV_CONFIG_DIR) {
return Ok(PathBuf::from(dir).join("config.toml"));
}
let dirs = directories::ProjectDirs::from("", "", "pmpx")
.context("cannot get the config directory (neither HOME nor APPDATA set?)")?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn default_data_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(ENV_DATA_DIR) {
return Ok(PathBuf::from(dir));
}
let dirs = directories::UserDirs::new()
.context("cannot get the user directory (HOME / USERPROFILE both unset?)")?;
Ok(dirs.home_dir().join(".pmpx"))
}
pub fn expand_tilde(raw: &str) -> PathBuf {
let rest = raw
.strip_prefix("~/")
.or_else(|| raw.strip_prefix("~\\"))
.or_else(|| if raw == "~" { Some("") } else { None });
match rest {
Some(rest) => match directories::UserDirs::new() {
Some(dirs) => dirs.home_dir().join(rest),
None => PathBuf::from(raw),
},
None => PathBuf::from(raw),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalConfig {
pub plugin: GlobalPluginConfig,
pub discovery: DiscoveryConfig,
pub plugin_store: PluginStoreConfig,
#[serde(flatten)]
pub extra: toml::Table,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalPluginConfig {
pub family_priority: Vec<String>,
pub priority: Vec<String>,
}
impl Default for GlobalPluginConfig {
fn default() -> Self {
Self {
family_priority: vec![
"node".into(),
"rust".into(),
"python".into(),
"go".into(),
"jvm".into(),
"dotnet".into(),
"php".into(),
"ruby".into(),
],
priority: vec![
"pnpm".into(),
"npm".into(),
"yarn".into(),
"bun".into(),
"cargo".into(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscoveryConfig {
pub walk_up: bool,
pub max_depth: usize,
pub stop_at_git: bool,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
walk_up: true,
max_depth: 8,
stop_at_git: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PluginStoreConfig {
pub data_dir: Option<PathBuf>,
pub prefer_prebuilt: Option<bool>,
}
impl PluginStoreConfig {
pub fn effective_data_dir(&self) -> Result<PathBuf> {
match &self.data_dir {
Some(p) => Ok(expand_tilde(&p.to_string_lossy())),
None => default_data_dir(),
}
}
pub fn effective_prefer_prebuilt(&self) -> bool {
self.prefer_prebuilt.unwrap_or(true)
}
}
impl GlobalConfig {
pub fn load() -> Result<Self> {
Self::load_from(&global_config_path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("failed to parse the global config: {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e)
.with_context(|| format!("failed to read the global config: {}", path.display())),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub plugin: BTreeMap<String, String>,
pub scripts: BTreeMap<String, String>,
#[serde(flatten)]
pub extra: toml::Table,
}
impl ProjectConfig {
pub fn load_from(path: &Path) -> Result<Option<Self>> {
match std::fs::read_to_string(path) {
Ok(text) => {
let cfg: Self = toml::from_str(&text).with_context(|| {
format!("failed to parse the project config: {}", path.display())
})?;
Ok(Some(cfg))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e)
.with_context(|| format!("failed to read the project config: {}", path.display())),
}
}
pub fn overlay(&mut self, other: ProjectConfig) {
for (k, v) in other.extra {
self.extra.insert(k, v);
}
self.plugin.extend(other.plugin);
self.scripts.extend(other.scripts);
}
}
#[derive(Debug, Clone, Default)]
pub struct MergedProjectConfig {
pub plugin: BTreeMap<String, String>,
#[allow(dead_code)]
pub scripts: BTreeMap<String, String>,
pub sources: Vec<PathBuf>,
}
impl MergedProjectConfig {
pub fn from_paths_near_to_far(paths: &[PathBuf]) -> Result<Self> {
let mut merged = ProjectConfig::default();
let mut found = Vec::new();
for path in paths.iter().rev() {
if let Some(cfg) = ProjectConfig::load_from(path)? {
merged.overlay(cfg);
found.push(path.clone());
}
}
found.reverse();
Ok(Self {
plugin: merged.plugin,
scripts: merged.scripts,
sources: found,
})
}
pub fn pinned_plugin(&self, family: &str) -> Option<&str> {
self.plugin.get(family).map(String::as_str)
}
pub fn pinned_families(&self) -> Vec<&str> {
self.plugin.keys().map(String::as_str).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn missing_global_config_is_all_defaults() {
let tmp = tempfile::tempdir().unwrap();
let cfg = GlobalConfig::load_from(&tmp.path().join("nope.toml")).unwrap();
assert_eq!(cfg.plugin.family_priority[0], "node");
assert_eq!(cfg.plugin.priority[0], "pnpm");
assert!(cfg.discovery.walk_up);
assert_eq!(cfg.discovery.max_depth, 8);
assert!(cfg.discovery.stop_at_git);
assert!(cfg.plugin_store.effective_prefer_prebuilt());
}
#[test]
fn unknown_keys_in_the_global_config_are_kept() {
let tmp = tempfile::tempdir().unwrap();
let path = write(
tmp.path(),
"config.toml",
r#"
[plugin]
family_priority = ["rust", "node"]
# added by the user, pmpx does not know it
[my_own_thing]
keep = "me"
"#,
);
let cfg = GlobalConfig::load_from(&path).unwrap();
assert_eq!(cfg.plugin.family_priority, vec!["rust", "node"]);
assert_eq!(cfg.plugin.priority[0], "pnpm");
assert!(cfg.extra.contains_key("my_own_thing"));
}
#[test]
fn a_broken_global_config_is_an_error_not_a_silent_default() {
let tmp = tempfile::tempdir().unwrap();
let path = write(tmp.path(), "config.toml", "this is not toml = = =");
let err = GlobalConfig::load_from(&path).unwrap_err();
assert!(
err.to_string()
.contains("failed to parse the global config"),
"{err}"
);
}
#[test]
fn project_config_missing_is_none_not_default() {
let tmp = tempfile::tempdir().unwrap();
assert!(ProjectConfig::load_from(&tmp.path().join("nope.toml"))
.unwrap()
.is_none());
}
#[test]
fn near_config_wins_over_far_one() {
let tmp = tempfile::tempdir().unwrap();
let far = write(
tmp.path(),
"far.toml",
"[plugin]\nnode = \"npm\"\nrust = \"cargo\"\n",
);
let near = write(tmp.path(), "near.toml", "[plugin]\nnode = \"pnpm\"\n");
let merged =
MergedProjectConfig::from_paths_near_to_far(&[near.clone(), far.clone()]).unwrap();
assert_eq!(
merged.pinned_plugin("node"),
Some("pnpm"),
"the nearer one wins"
);
assert_eq!(
merged.pinned_plugin("rust"),
Some("cargo"),
"the farther one is not clobbered"
);
assert_eq!(merged.pinned_plugin("python"), None);
}
#[test]
fn overlaying_unknown_keys_does_not_clobber_known_ones() {
let mut base = ProjectConfig::default();
base.plugin.insert("node".into(), "npm".into());
let mut other = ProjectConfig::default();
other.plugin.insert("node".into(), "pnpm".into());
base.overlay(other);
assert_eq!(base.plugin.get("node").map(String::as_str), Some("pnpm"));
}
#[test]
fn scripts_merge_across_layers() {
let tmp = tempfile::tempdir().unwrap();
let far = write(
tmp.path(),
"far.toml",
"[scripts]\nfmt = \"run format\"\nlint = \"run lint\"\n",
);
let near = write(tmp.path(), "near.toml", "[scripts]\nfmt = \"run f\"\n");
let merged = MergedProjectConfig::from_paths_near_to_far(&[near, far]).unwrap();
assert_eq!(merged.scripts.get("fmt").map(String::as_str), Some("run f"));
assert_eq!(
merged.scripts.get("lint").map(String::as_str),
Some("run lint")
);
}
#[test]
fn sources_are_recorded_near_to_far() {
let tmp = tempfile::tempdir().unwrap();
let far = write(tmp.path(), "far.toml", "[plugin]\nrust = \"cargo\"\n");
let near = write(tmp.path(), "near.toml", "[plugin]\nnode = \"pnpm\"\n");
let merged =
MergedProjectConfig::from_paths_near_to_far(&[near.clone(), far.clone()]).unwrap();
assert_eq!(merged.sources, vec![near, far], "near to far");
}
#[test]
fn a_config_file_that_vanished_mid_walk_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let real = write(tmp.path(), "real.toml", "[plugin]\nrust = \"cargo\"\n");
let ghost = tmp.path().join("ghost.toml");
let merged = MergedProjectConfig::from_paths_near_to_far(&[ghost, real]).unwrap();
assert_eq!(merged.pinned_plugin("rust"), Some("cargo"));
assert_eq!(merged.sources.len(), 1);
}
#[test]
fn expand_tilde_expands_to_home() {
let home = directories::UserDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
assert_eq!(expand_tilde("~/x/y"), home.join("x").join("y"));
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
assert_eq!(
expand_tilde("relative/path"),
PathBuf::from("relative/path")
);
}
#[test]
fn pinned_families_lists_every_pin() {
let tmp = tempfile::tempdir().unwrap();
let a = write(
tmp.path(),
"a.toml",
"[plugin]\nnode = \"pnpm\"\nrust = \"cargo\"\n",
);
let merged = MergedProjectConfig::from_paths_near_to_far(&[a]).unwrap();
let mut fams = merged.pinned_families();
fams.sort_unstable();
assert_eq!(fams, vec!["node", "rust"]);
}
#[test]
fn env_overrides_take_effect() {
let tmp = tempfile::tempdir().unwrap();
let cfg_dir = tmp.path().join("cfg");
let data_dir = tmp.path().join("data");
let old_cfg = std::env::var_os(ENV_CONFIG_DIR);
let old_data = std::env::var_os(ENV_DATA_DIR);
unsafe {
std::env::set_var(ENV_CONFIG_DIR, &cfg_dir);
std::env::set_var(ENV_DATA_DIR, &data_dir);
}
let got_cfg = global_config_path().unwrap();
let got_data = default_data_dir().unwrap();
unsafe {
match old_cfg {
Some(v) => std::env::set_var(ENV_CONFIG_DIR, v),
None => std::env::remove_var(ENV_CONFIG_DIR),
}
match old_data {
Some(v) => std::env::set_var(ENV_DATA_DIR, v),
None => std::env::remove_var(ENV_DATA_DIR),
}
}
assert_eq!(got_cfg, cfg_dir.join("config.toml"));
assert_eq!(got_data, data_dir);
}
#[test]
fn without_overrides_the_platform_paths_are_used() {
if std::env::var_os(ENV_CONFIG_DIR).is_none() {
let p = global_config_path().unwrap();
assert!(p.ends_with("config.toml"), "{p:?}");
assert!(
p.to_string_lossy().contains("pmpx"),
"the path should contain pmpx: {p:?}"
);
}
if std::env::var_os(ENV_DATA_DIR).is_none() {
let p = default_data_dir().unwrap();
assert_eq!(p.file_name().unwrap().to_string_lossy(), ".pmpx");
}
}
}