use std::collections::BTreeMap;
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 CONFIG_DIRS: [&str; 2] = ["", ".config"];
pub(crate) const INIT_TEMPLATE: &str = include_str!("../schemas/runner.init.toml");
#[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)]
pub tasks: TasksSection,
#[serde(default, rename = "task_runner")]
pub task_runner: TaskRunnerSection,
#[serde(default)]
pub install: InstallSection,
#[serde(default)]
pub resolution: ResolutionSection,
#[serde(default)]
pub chain: ChainSection,
#[serde(default)]
pub github: GitHubSection,
#[serde(default)]
pub parallel: ParallelSection,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(extend("enum" = ["deny", "allow", null]))
)]
pub scripts: Option<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, extend("deprecated" = true))
)]
pub(crate) struct TaskRunnerSection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(feature = "schema", schemars(extend("deprecated" = true)))]
pub prefer: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[cfg_attr(
feature = "schema",
derive(schemars::JsonSchema),
schemars(deny_unknown_fields)
)]
pub(crate) struct TasksSection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prefer: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub overrides: BTreeMap<String, 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"]),
("tasks", &["prefer", "overrides"]),
("install", &["pms", "scripts"]),
("resolution", &["fallback", "on_mismatch"]),
("chain", &["keep_going", "kill_on_fail"]),
("github", &["group_output", "group_parallel"]),
("parallel", &["grouped"]),
];
pub(crate) 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 Some((path, content)) = read_first_candidate(dir)? else {
return Ok(None);
};
let value: toml::Value =
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
let mut warnings = collect_unknown_keys(&value);
let config: RunnerConfig = value
.try_into()
.with_context(|| format!("failed to parse {}", path.display()))?;
warnings.extend(deprecation_warnings(&config));
Ok(Some(LoadedConfig {
path,
config,
warnings,
}))
}
fn read_first_candidate(dir: &Path) -> Result<Option<(PathBuf, String)>> {
let dotted = format!(".{CONFIG_FILENAME}");
let filenames = [CONFIG_FILENAME, dotted.as_str()];
for subdir in CONFIG_DIRS {
let base = if subdir.is_empty() {
dir.to_path_buf()
} else {
dir.join(subdir)
};
for filename in filenames {
let path = base.join(filename);
match fs::read_to_string(&path) {
Ok(content) => return Ok(Some((path, content))),
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).with_context(|| format!("failed to read {}", path.display()));
}
}
}
}
Ok(None)
}
pub(crate) fn deprecation_warnings(config: &RunnerConfig) -> Vec<DetectionWarning> {
let mut out = Vec::new();
if !config.task_runner.prefer.is_empty() {
let prefer_set = !config.tasks.prefer.is_empty();
let overrides_set = !config.tasks.overrides.is_empty();
let replacement = if overrides_set && !prefer_set {
"tasks.overrides"
} else {
"tasks.prefer"
};
out.push(DetectionWarning::DeprecatedConfigKey {
path: "task_runner.prefer".to_string(),
replacement,
superseded: prefer_set || overrides_set,
});
}
out
}
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, RunnerConfig, 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_discovers_hidden_dotfile() {
let dir = TempDir::new("config-hidden");
fs::write(dir.path().join(".runner.toml"), "[pm]\nnode = \"npm\"\n")
.expect("seed hidden config");
let loaded = load(dir.path())
.expect("config should parse")
.expect(".runner.toml should be discovered");
assert!(loaded.path.ends_with(".runner.toml"));
}
#[test]
fn load_discovers_config_dir_variant() {
let dir = TempDir::new("config-dot-config-dir");
fs::create_dir_all(dir.path().join(".config")).expect("mk .config");
fs::write(
dir.path().join(".config/runner.toml"),
"[pm]\nnode = \"npm\"\n",
)
.expect("seed .config config");
let loaded = load(dir.path())
.expect("config should parse")
.expect(".config/runner.toml should be discovered");
assert!(loaded.path.ends_with("runner.toml"));
assert!(loaded.path.to_string_lossy().contains(".config"));
}
#[test]
fn load_prefers_canonical_over_fallbacks() {
let dir = TempDir::new("config-precedence");
fs::write(dir.path().join(CONFIG_FILENAME), "[pm]\nnode = \"npm\"\n")
.expect("seed canonical");
fs::write(dir.path().join(".runner.toml"), "[pm]\nnode = \"bun\"\n").expect("seed hidden");
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("npm"));
}
#[test]
fn load_prefers_root_over_config_dir() {
let dir = TempDir::new("config-dir-precedence");
fs::write(dir.path().join(CONFIG_FILENAME), "[pm]\nnode = \"npm\"\n")
.expect("seed root config");
fs::create_dir_all(dir.path().join(".config")).expect("mk .config");
fs::write(
dir.path().join(".config/runner.toml"),
"[pm]\nnode = \"bun\"\n",
)
.expect("seed .config config");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(!loaded.path.to_string_lossy().contains(".config"));
assert_eq!(loaded.config.pm.node.as_deref(), Some("npm"));
}
#[test]
fn legacy_task_runner_prefer_warns_deprecated() {
let dir = TempDir::new("config-deprecated-task-runner");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[task_runner]\nprefer = [\"turbo\"]\n",
)
.expect("seed config");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(
loaded.warnings.iter().any(|w| matches!(
w,
DetectionWarning::DeprecatedConfigKey {
superseded: false,
..
}
)),
"expected a non-superseded deprecation warning, got: {:?}",
loaded.warnings,
);
}
#[test]
fn tasks_section_marks_legacy_prefer_superseded() {
let dir = TempDir::new("config-deprecated-superseded");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[task_runner]\nprefer = [\"turbo\"]\n\n[tasks]\nprefer = [\"bun\"]\n",
)
.expect("seed config");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(
loaded.warnings.iter().any(|w| matches!(
w,
DetectionWarning::DeprecatedConfigKey {
superseded: true,
..
}
)),
"expected a superseded deprecation warning, got: {:?}",
loaded.warnings,
);
}
#[test]
fn tasks_overrides_alone_names_itself_as_the_replacement() {
let dir = TempDir::new("config-deprecated-overrides-only");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[task_runner]\nprefer = [\"turbo\"]\n\n[tasks.overrides]\nbuild = \"bun\"\n",
)
.expect("seed config");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
assert!(
loaded.warnings.iter().any(|w| matches!(
w,
DetectionWarning::DeprecatedConfigKey {
superseded: true,
replacement: "tasks.overrides",
..
}
)),
"expected the warning to name tasks.overrides, got: {:?}",
loaded.warnings,
);
}
#[test]
fn tasks_section_validates() {
let dir = TempDir::new("config-tasks-valid");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[tasks]\nprefer = [\"turbo\", \"bun\"]\n\n[tasks.overrides]\nbuild = \"turbo\"\n",
)
.expect("seed config");
let loaded = load(dir.path())
.expect("config should parse")
.expect("config should be present");
crate::resolver::validate_config(&loaded).expect("a well-formed [tasks] section validates");
}
#[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};
const DEPRECATED_SECTIONS: &[&str] = &["task_runner"];
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 mut known: BTreeMap<String, BTreeSet<String>> = KNOWN_SCHEMA
.iter()
.map(|(name, fields)| {
(
(*name).to_string(),
fields.iter().map(|f| (*f).to_string()).collect(),
)
})
.collect();
for section in DEPRECATED_SECTIONS {
known.remove(*section);
}
assert_eq!(
template, known,
"INIT_TEMPLATE sections/fields must match KNOWN_SCHEMA (minus DEPRECATED_SECTIONS) \
exactly — keep the section structs, the scaffold template, and KNOWN_SCHEMA in sync \
when adding a knob"
);
}
#[cfg(feature = "schema")]
#[test]
fn known_schema_matches_generated_runner_config_schema() {
use std::collections::{BTreeMap, BTreeSet};
let schema = serde_json::to_value(schemars::schema_for!(RunnerConfig))
.expect("RunnerConfig schema should serialize");
let top_properties = schema["properties"]
.as_object()
.expect("RunnerConfig schema must have top-level properties");
let defs = schema["$defs"]
.as_object()
.expect("RunnerConfig schema must have $defs");
let generated: BTreeMap<String, BTreeSet<String>> = top_properties
.iter()
.map(|(section, section_schema)| {
let def_name = section_schema["$ref"]
.as_str()
.and_then(|r| r.strip_prefix("#/$defs/"))
.unwrap_or_else(|| {
panic!(
"{section}: expected a $defs $ref in the generated schema, got \
{section_schema:?}"
)
});
let fields = defs[def_name]["properties"]
.as_object()
.unwrap_or_else(|| {
panic!("{def_name}: expected a properties object in the generated schema")
})
.keys()
.cloned()
.collect();
(section.clone(), fields)
})
.collect();
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!(
generated, known,
"KNOWN_SCHEMA must match RunnerConfig's real (schemars-derived) shape exactly — a \
struct field with no KNOWN_SCHEMA entry is silently treated as unknown by \
collect_unknown_keys even though the typed deserializer accepts it"
);
}
#[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_parses_install_scripts() {
let dir = TempDir::new("config-install-scripts");
fs::write(
dir.path().join(CONFIG_FILENAME),
"[install]\nscripts = \"deny\"\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.scripts.as_deref(), Some("deny"));
}
#[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);
}
}