use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow};
use serde::{Deserialize, Serialize};
use crate::types::{DetectionWarning, Ecosystem, PackageManager};
pub(crate) const CONFIG_FILENAME: &str = "runner.toml";
pub(crate) const INIT_TEMPLATE: &str = r#"# runner.toml — project task-runner configuration.
# Docs: https://runner.kjanat.dev
#
# Every key below shows its built-in default and is commented out. Uncomment
# and edit the ones you want to pin. Precedence, highest first:
# CLI flags > RUNNER_* env vars > this file > manifest declarations.
# Force the package manager per ecosystem, overriding lockfile detection.
[pm]
# node = "pnpm" # npm | pnpm | yarn | bun | deno
# python = "uv" # uv | poetry | pipenv
# Restrict and rank task runners for ambiguous task names. Candidates not in
# the list are rejected; earlier entries win.
[task_runner]
# prefer = ["just", "turbo"] # turbo, nx, make, just, task, mise, bacon
# Restrict which detected package managers `runner install` runs. Empty/absent
# installs every detected PM. Overridden by RUNNER_INSTALL_PMS (comma-separated).
[install]
# pms = ["bun"] # only install with these; each must be detected
# Resolver policy knobs.
[resolution]
# fallback = "probe" # probe (PATH probe) | npm (legacy) | error
# on_mismatch = "warn" # warn | error (exit 2) | ignore (manifest vs lockfile)
# Failure policy for `-s`/`-p` task chains and `install <tasks>`.
# keep_going and kill_on_fail are mutually exclusive — setting both is an error.
[chain]
# keep_going = false # run every task despite failures (same as -k)
# kill_on_fail = false # parallel: kill siblings on first failure (same as -K)
# GitHub Actions output grouping. Both keys take effect only under Actions.
[github]
# group_output = true # wrap each task's output in a collapsible ::group::
# group_parallel = true # buffer parallel tasks, print each as one block
# Parallel (`-p`) output presentation outside GitHub Actions.
[parallel]
# grouped = false # buffer + print each task as one block on completion
"#;
#[derive(Debug, Clone)]
pub(crate) struct LoadedConfig {
pub path: PathBuf,
pub config: RunnerConfig,
pub warnings: Vec<DetectionWarning>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(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,
#[serde(default)]
pub chain: ChainSection,
#[serde(default)]
pub github: GitHubSection,
#[serde(default)]
pub parallel: ParallelSection,
#[serde(default)]
pub install: InstallSection,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct InstallSection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pms: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
#[cfg_attr(
feature = "schema",
schemars(extend("not" = {
"required": ["keep_going", "kill_on_fail"],
"properties": {
"keep_going": { "const": true },
"kill_on_fail": { "const": true }
}
}))
)]
pub(crate) struct ChainSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_going: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kill_on_fail: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct GitHubSection {
#[serde(default = "default_group_output")]
pub group_output: bool,
#[serde(default = "default_github_group_parallel")]
pub group_parallel: bool,
}
impl Default for GitHubSection {
fn default() -> Self {
Self {
group_output: default_group_output(),
group_parallel: default_github_group_parallel(),
}
}
}
const fn default_group_output() -> bool {
true
}
const fn default_github_group_parallel() -> bool {
true
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct ParallelSection {
#[serde(default)]
pub grouped: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct PmSection {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(extend("enum" = ["npm", "pnpm", "yarn", "bun", "deno", null]))
)]
pub node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(extend("enum" = ["uv", "poetry", "pipenv", null]))
)]
pub python: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct TaskRunnerSection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prefer: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct ResolutionSection {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(extend("enum" = ["probe", "npm", "error", null]))
)]
pub fallback: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(extend("enum" = ["warn", "error", "ignore", null]))
)]
pub on_mismatch: Option<String>,
}
const KNOWN_SCHEMA: &[(&str, &[&str])] = &[
("pm", &["node", "python"]),
("task_runner", &["prefer"]),
("install", &["pms"]),
("resolution", &["fallback", "on_mismatch"]),
("chain", &["keep_going", "kill_on_fail"]),
("github", &["group_output", "group_parallel"]),
("parallel", &["grouped"]),
];
fn collect_unknown_keys(value: &toml::Value) -> Vec<DetectionWarning> {
let Some(table) = value.as_table() else {
return Vec::new();
};
let mut warnings = Vec::new();
for (section, body) in table {
let Some((_, known_fields)) = KNOWN_SCHEMA.iter().find(|(name, _)| name == section) else {
warnings.push(DetectionWarning::UnknownConfigKey {
path: section.clone(),
});
continue;
};
if let Some(body) = body.as_table() {
for field in body.keys() {
if !known_fields.contains(&field.as_str()) {
warnings.push(DetectionWarning::UnknownConfigKey {
path: format!("{section}.{field}"),
});
}
}
}
}
warnings
}
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 value: toml::Value =
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
let warnings = collect_unknown_keys(&value);
let config: RunnerConfig = value
.try_into()
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(LoadedConfig {
path,
config,
warnings,
}))
}
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, INIT_TEMPLATE, KNOWN_SCHEMA, LoadedConfig, load, parse_node_pm,
parse_python_pm,
};
use crate::tool::test_support::TempDir;
use crate::types::{DetectionWarning, PackageManager};
fn unknown_paths(loaded: &LoadedConfig) -> Vec<String> {
loaded
.warnings
.iter()
.filter_map(|w| match w {
DetectionWarning::UnknownConfigKey { path } => Some(path.clone()),
_ => None,
})
.collect()
}
#[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_warns_on_unknown_section_without_failing() {
let dir = TempDir::new("config-unknown-key");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[pm]\nnode = \"bun\"\n[zoot]\nfoo = 1\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("unknown section must be tolerated, not fatal")
.expect("config should be present");
assert_eq!(unknown_paths(&loaded), vec!["zoot".to_string()]);
assert_eq!(loaded.config.pm.node.as_deref(), Some("bun"));
}
#[test]
fn load_warns_on_unknown_field_within_known_section() {
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 loaded = load(dir.path())
.expect("unknown field must be tolerated, not fatal")
.expect("config should be present");
assert_eq!(unknown_paths(&loaded), vec!["pm.rust".to_string()]);
}
#[test]
fn load_still_rejects_wrong_type_on_known_field() {
let dir = TempDir::new("config-wrong-type");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[github]\ngroup_output = \"yes\"\n",
)
.expect("config should be written");
let err = load(dir.path()).expect_err("wrong type on a known field must stay fatal");
assert!(format!("{err:#}").contains("failed to parse"));
}
#[test]
fn known_schema_matches_init_template_sections_and_fields() {
use std::collections::{BTreeMap, BTreeSet};
let mut template: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut section: Option<String> = None;
for line in INIT_TEMPLATE.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('[') {
section = Some(rest.trim_end_matches(']').to_string());
template
.entry(section.clone().expect("just set"))
.or_default();
continue;
}
let body = trimmed.strip_prefix('#').map_or(trimmed, str::trim);
let Some((lhs, _)) = body.split_once('=') else {
continue;
};
let key = lhs.trim();
if !key.is_empty()
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& let Some(sec) = §ion
{
template
.get_mut(sec)
.expect("section recorded above")
.insert(key.to_string());
}
}
let known: BTreeMap<String, BTreeSet<String>> = KNOWN_SCHEMA
.iter()
.map(|(name, fields)| {
(
(*name).to_string(),
fields.iter().map(|f| (*f).to_string()).collect(),
)
})
.collect();
assert_eq!(
template, known,
"INIT_TEMPLATE sections/fields must match KNOWN_SCHEMA exactly — keep the section \
structs, the scaffold template, and KNOWN_SCHEMA in sync when adding a knob"
);
}
#[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"));
}
#[test]
fn load_parses_install_section() {
let dir = TempDir::new("config-install");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[install]\npms = [\"bun\", \"cargo\"]\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert_eq!(loaded.config.install.pms, vec!["bun", "cargo"]);
}
#[test]
fn load_warns_on_unknown_install_key() {
let dir = TempDir::new("config-unknown-install-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[install]\nfoo = true\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("unknown [install] key tolerated")
.expect("config present");
assert_eq!(unknown_paths(&loaded), vec!["install.foo".to_string()]);
}
#[test]
fn load_parses_chain_section() {
let dir = TempDir::new("config-chain");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[chain]\nkeep_going = true\nkill_on_fail = false\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert_eq!(loaded.config.chain.keep_going, Some(true));
assert_eq!(loaded.config.chain.kill_on_fail, Some(false));
}
#[test]
fn load_warns_on_unknown_chain_key() {
let dir = TempDir::new("config-unknown-chain-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[chain]\nfast = true\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("unknown [chain] key tolerated")
.expect("config present");
assert_eq!(unknown_paths(&loaded), vec!["chain.fast".to_string()]);
}
#[test]
fn load_parses_github_section() {
let dir = TempDir::new("config-github");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[github]\ngroup_output = false\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(!loaded.config.github.group_output);
}
#[test]
fn github_group_output_defaults_true_when_key_omitted() {
let dir = TempDir::new("config-github-default");
fs::write(dir.path().join(CONFIG_FILENAME), "[github]\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(loaded.config.github.group_output);
}
#[test]
fn github_group_output_defaults_true_when_section_absent() {
let dir = TempDir::new("config-github-absent");
fs::write(dir.path().join(CONFIG_FILENAME), "[pm]\nnode = \"npm\"\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(loaded.config.github.group_output);
}
#[test]
fn load_warns_on_unknown_github_key() {
let dir = TempDir::new("config-unknown-github-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[github]\nfoo = true\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("unknown [github] key tolerated")
.expect("config present");
assert_eq!(unknown_paths(&loaded), vec!["github.foo".to_string()]);
}
#[test]
fn load_parses_parallel_grouped() {
let dir = TempDir::new("config-parallel-grouped");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[parallel]\ngrouped = true\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(loaded.config.parallel.grouped);
}
#[test]
fn parallel_grouped_defaults_false_when_section_absent() {
let dir = TempDir::new("config-parallel-default");
fs::write(dir.path().join(CONFIG_FILENAME), "[pm]\nnode = \"npm\"\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(!loaded.config.parallel.grouped);
}
#[test]
fn load_warns_on_unknown_parallel_key() {
let dir = TempDir::new("config-unknown-parallel-key");
fs::write(dir.path().join(CONFIG_FILENAME), "[parallel]\nfoo = true\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("unknown [parallel] key tolerated")
.expect("config present");
assert_eq!(unknown_paths(&loaded), vec!["parallel.foo".to_string()]);
}
#[test]
fn load_parses_github_group_parallel() {
let dir = TempDir::new("config-github-group-parallel");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[github]\ngroup_parallel = false\n",
)
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(!loaded.config.github.group_parallel);
assert!(loaded.config.github.group_output);
}
#[test]
fn github_group_parallel_defaults_true() {
let dir = TempDir::new("config-github-group-parallel-default");
fs::write(dir.path().join(CONFIG_FILENAME), "[github]\n")
.expect("config should be written");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(loaded.config.github.group_parallel);
}
}