use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow};
use serde::Deserialize;
use crate::types::{Ecosystem, PackageManager};
pub(crate) const CONFIG_FILENAME: &str = "runner.toml";
#[derive(Debug, Clone)]
pub(crate) struct LoadedConfig {
pub path: PathBuf,
pub config: RunnerConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RunnerConfig {
#[serde(default)]
pub pm: PmSection,
#[serde(default, rename = "task_runner")]
pub task_runner: TaskRunnerSection,
#[serde(default)]
pub resolution: ResolutionSection,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct PmSection {
#[cfg_attr(
feature = "schema-gen",
schemars(extend("enum" = ["npm", "pnpm", "yarn", "bun", "deno", null]))
)]
pub node: Option<String>,
#[cfg_attr(
feature = "schema-gen",
schemars(extend("enum" = ["uv", "poetry", "pipenv", null]))
)]
pub python: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct TaskRunnerSection {
#[serde(default)]
pub prefer: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct ResolutionSection {
#[cfg_attr(
feature = "schema-gen",
schemars(extend("enum" = ["probe", "npm", "error", null]))
)]
pub fallback: Option<String>,
#[cfg_attr(
feature = "schema-gen",
schemars(extend("enum" = ["warn", "error", "ignore", null]))
)]
pub on_mismatch: Option<String>,
}
pub(crate) fn load(dir: &Path) -> Result<Option<LoadedConfig>> {
let path = dir.join(CONFIG_FILENAME);
let content = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(e).with_context(|| format!("failed to read {}", path.display()));
}
};
let config: RunnerConfig =
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(LoadedConfig { path, config }))
}
pub(crate) fn parse_node_pm(raw: &str) -> Result<PackageManager> {
let pm = PackageManager::from_label(raw)
.ok_or_else(|| anyhow!("[pm].node: unknown package manager {raw:?}"))?;
let eco = pm.ecosystem();
if !matches!(eco, Ecosystem::Node | Ecosystem::Deno) {
return Err(anyhow!(
"[pm].node: {} cannot dispatch package.json scripts (it belongs to ecosystem {:?})",
pm.label(),
eco,
));
}
Ok(pm)
}
pub(crate) fn parse_python_pm(raw: &str) -> Result<PackageManager> {
let pm = PackageManager::from_label(raw)
.ok_or_else(|| anyhow!("[pm].python: unknown package manager {raw:?}"))?;
if pm.ecosystem() != Ecosystem::Python {
return Err(anyhow!(
"[pm].python: {} is not a Python package manager",
pm.label(),
));
}
Ok(pm)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{CONFIG_FILENAME, load, parse_node_pm, parse_python_pm};
use crate::tool::test_support::TempDir;
use crate::types::PackageManager;
#[test]
fn load_returns_none_when_file_absent() {
let dir = TempDir::new("config-absent");
let result = load(dir.path()).expect("absent file should be Ok(None)");
assert!(result.is_none());
}
#[test]
fn load_parses_pm_section() {
let dir = TempDir::new("config-pm");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[pm]\nnode = \"pnpm\"\npython = \"uv\"\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(loaded.path.ends_with(CONFIG_FILENAME));
assert_eq!(loaded.config.pm.node.as_deref(), Some("pnpm"));
assert_eq!(loaded.config.pm.python.as_deref(), Some("uv"));
}
#[test]
fn load_rejects_unknown_top_level_key() {
let dir = TempDir::new("config-unknown-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[zoot]\nfoo = 1\n")
.expect("config should be written");
let err = load(dir.path()).expect_err("unknown key should error");
let msg = format!("{err:#}");
assert!(msg.contains("failed to parse"));
assert!(msg.contains("zoot") || msg.contains("unknown"));
}
#[test]
fn load_rejects_unknown_pm_key() {
let dir = TempDir::new("config-unknown-pm-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[pm]\nrust = \"cargo\"\n")
.expect("config should be written");
let err = load(dir.path()).expect_err("unknown [pm] key should error");
let msg = format!("{err:#}");
assert!(msg.contains("failed to parse"));
}
#[test]
fn parse_node_pm_accepts_node_and_deno() {
assert_eq!(parse_node_pm("pnpm").unwrap(), PackageManager::Pnpm);
assert_eq!(parse_node_pm("bun").unwrap(), PackageManager::Bun);
assert_eq!(parse_node_pm("deno").unwrap(), PackageManager::Deno);
}
#[test]
fn parse_node_pm_rejects_cross_ecosystem() {
let err = parse_node_pm("cargo").expect_err("cargo should not be a Node PM");
assert!(format!("{err}").contains("cannot dispatch package.json scripts"));
}
#[test]
fn parse_python_pm_accepts_uv_poetry_pipenv() {
assert_eq!(parse_python_pm("uv").unwrap(), PackageManager::Uv);
assert_eq!(parse_python_pm("poetry").unwrap(), PackageManager::Poetry);
assert_eq!(parse_python_pm("pipenv").unwrap(), PackageManager::Pipenv);
}
#[test]
fn parse_python_pm_rejects_node_pm() {
let err = parse_python_pm("pnpm").expect_err("pnpm should not be Python");
assert!(format!("{err}").contains("not a Python package manager"));
}
}