mod error;
mod overrides;
mod policies;
mod probe;
mod resolve;
mod types;
pub(crate) use error::{DevEnginesFailReason, ResolveError};
pub(crate) use probe::probe_in as probe_path_for_doctor;
pub(crate) use types::{
DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides,
ResolvedPm, Resolver,
};
pub(super) fn join_labels<I: Iterator<Item = &'static str>>(labels: I) -> String {
labels.collect::<Vec<_>>().join(", ")
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use super::types::{
ExplainSource, OverrideSources, PmOverride, ResolutionStep, RunnerOverride, SourceValue,
};
use super::{FallbackPolicy, OverrideOrigin, ResolutionOverrides, ResolveError, Resolver};
use crate::config::{LoadedConfig, PmSection, RunnerConfig};
use crate::types::{Ecosystem, PackageManager, ProjectContext, TaskRunner};
fn context(package_managers: Vec<PackageManager>) -> ProjectContext {
ProjectContext {
root: PathBuf::from("."),
package_managers,
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
fn resolver<'ctx>(
ctx: &'ctx ProjectContext,
overrides: &'ctx ResolutionOverrides,
) -> Resolver<'ctx> {
Resolver::new(ctx, overrides)
}
fn with_pm_override(pm: PackageManager, origin: OverrideOrigin) -> ResolutionOverrides {
ResolutionOverrides {
pm: Some(PmOverride { pm, origin }),
..ResolutionOverrides::default()
}
}
fn with_config_pm(pm: PackageManager, eco: Ecosystem) -> ResolutionOverrides {
let mut map = HashMap::new();
map.insert(
eco,
PmOverride {
pm,
origin: OverrideOrigin::ConfigFile {
path: PathBuf::from("/test/runner.toml"),
},
},
);
ResolutionOverrides {
pm_by_ecosystem: map,
..ResolutionOverrides::default()
}
}
#[test]
fn resolves_detected_node_pm_via_lockfile() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = ResolutionOverrides::default();
let decision = resolver(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Pnpm);
assert_eq!(decision.via, ResolutionStep::Lockfile);
}
#[test]
fn falls_back_to_legacy_npm_when_fallback_policy_is_npm() {
let ctx = context(vec![]);
let overrides = ResolutionOverrides {
fallback: FallbackPolicy::Npm,
..ResolutionOverrides::default()
};
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("legacy npm fallback should succeed");
assert_eq!(decision.pm, PackageManager::Npm);
assert_eq!(decision.via, ResolutionStep::LegacyNpmFallback);
}
#[test]
fn fallback_error_policy_returns_helpful_error_when_no_signal() {
let ctx = context(vec![]);
let overrides = ResolutionOverrides {
fallback: FallbackPolicy::Error,
..ResolutionOverrides::default()
};
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("error policy should bail when nothing matches");
let msg = format!("{err}");
assert!(msg.contains("no node package manager detected"));
assert!(msg.contains("--pm"));
}
#[test]
fn fallback_error_policy_is_a_hard_no_signals_found() {
let ctx = context(vec![]);
let overrides = ResolutionOverrides {
fallback: FallbackPolicy::Error,
..ResolutionOverrides::default()
};
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("error policy should bail");
assert!(
matches!(err, ResolveError::NoSignalsFound { soft: false, .. }),
"error-policy failure must be hard, got: {err:?}"
);
}
#[test]
fn fallback_probe_with_empty_path_yields_soft_no_signals_found() {
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-soft-no-signals");
let mut ctx = context(vec![]);
ctx.root = dir.path().to_path_buf();
let overrides = ResolutionOverrides::default();
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("probe with no Node evidence must error");
assert!(
matches!(err, ResolveError::NoSignalsFound { soft: true, .. }),
"probe-policy miss must be the soft variant, got: {err:?}"
);
}
#[test]
fn fallback_probe_skipped_when_no_package_json_present() {
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-no-pkgjson");
let mut ctx = context(vec![PackageManager::Go]);
ctx.root = dir.path().to_path_buf();
let overrides = ResolutionOverrides::default();
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("non-Node project must not yield a Node PM from PATH");
assert!(
matches!(err, ResolveError::NoSignalsFound { soft: true, .. }),
"expected soft NoSignalsFound, got: {err:?}"
);
}
#[test]
fn fallback_probe_fires_when_package_json_exists() {
use std::fs;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-greenfield-node");
fs::write(dir.path().join("package.json"), "{}").expect("package.json should be written");
let mut ctx = context(vec![]);
ctx.root = dir.path().to_path_buf();
let overrides = ResolutionOverrides::default();
match Resolver::new(&ctx, &overrides).resolve_node_pm() {
Ok(decision) => assert!(
matches!(decision.via, ResolutionStep::PathProbe { .. }),
"expected PathProbe step, got {:?}",
decision.via,
),
Err(ResolveError::NoSignalsFound { soft: true, .. }) => {
}
Err(e) => panic!("unexpected resolver error: {e:?}"),
}
}
#[test]
fn prefers_node_pm_over_non_node_primary() {
let ctx = context(vec![PackageManager::Cargo, PackageManager::Bun]);
let overrides = ResolutionOverrides::default();
let decision = resolver(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Bun);
assert_eq!(decision.via, ResolutionStep::Lockfile);
}
#[test]
fn falls_back_to_primary_pm_when_no_node_pm_detected() {
let ctx = context(vec![PackageManager::Deno]);
let overrides = ResolutionOverrides::default();
let decision = resolver(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Deno);
assert_eq!(decision.via, ResolutionStep::Lockfile);
}
#[test]
fn cli_override_beats_detected_pm() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = with_pm_override(PackageManager::Yarn, OverrideOrigin::CliFlag);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Yarn);
assert_eq!(
decision.via,
ResolutionStep::Override(OverrideOrigin::CliFlag)
);
}
#[test]
fn env_override_beats_detected_pm() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = with_pm_override(PackageManager::Bun, OverrideOrigin::EnvVar);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Bun);
assert_eq!(
decision.via,
ResolutionStep::Override(OverrideOrigin::EnvVar)
);
}
#[test]
fn pm_override_for_deno_is_honored_by_node_resolver() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = with_pm_override(PackageManager::Deno, OverrideOrigin::CliFlag);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Deno);
}
#[test]
fn cross_ecosystem_pm_override_for_node_scripts_is_a_hard_error() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = with_pm_override(PackageManager::Cargo, OverrideOrigin::CliFlag);
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("cargo cannot dispatch package.json scripts");
assert!(
matches!(err, ResolveError::InvalidOverride { ref value, .. } if value == "cargo"),
"expected InvalidOverride for cargo, got: {err:?}",
);
}
#[test]
fn cli_pm_value_parses_to_overrides() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("yarn"),
env: None,
},
..OverrideSources::default()
})
.expect("--pm yarn should parse");
let pm = overrides.pm.expect("pm override should be present");
assert_eq!(pm.pm, PackageManager::Yarn);
assert_eq!(pm.origin, OverrideOrigin::CliFlag);
assert!(overrides.runner.is_none());
}
#[test]
fn env_pm_value_parses_when_cli_absent() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: None,
env: Some("bun"),
},
..OverrideSources::default()
})
.expect("RUNNER_PM=bun should parse");
let pm = overrides.pm.expect("pm override should be present");
assert_eq!(pm.pm, PackageManager::Bun);
assert_eq!(pm.origin, OverrideOrigin::EnvVar);
}
#[test]
fn cli_wins_over_env() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("yarn"),
env: Some("bun"),
},
..OverrideSources::default()
})
.expect("both sources should parse");
let pm = overrides.pm.expect("pm override should be present");
assert_eq!(pm.pm, PackageManager::Yarn);
assert_eq!(pm.origin, OverrideOrigin::CliFlag);
}
#[test]
fn empty_env_is_treated_as_unset() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: None,
env: Some(""),
},
..OverrideSources::default()
})
.expect("empty env should parse as no override");
assert!(overrides.pm.is_none());
}
#[test]
fn cli_runner_value_parses_to_overrides() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
runner: SourceValue {
cli: Some("just"),
env: None,
},
..OverrideSources::default()
})
.expect("--runner just should parse");
let runner: RunnerOverride = overrides.runner.expect("runner override should be present");
assert_eq!(runner.runner, TaskRunner::Just);
assert_eq!(runner.origin, OverrideOrigin::CliFlag);
}
#[test]
fn unknown_pm_label_errors_with_valid_value_list() {
let err = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("zoot"),
env: None,
},
..OverrideSources::default()
})
.expect_err("unknown PM should error");
let msg = format!("{err}");
assert!(msg.contains("unknown package manager"));
assert!(msg.contains("npm"));
assert!(msg.contains("pnpm"));
}
#[test]
fn unknown_runner_label_errors_with_valid_value_list() {
let err = ResolutionOverrides::from_sources(OverrideSources {
runner: SourceValue {
cli: Some("zoot"),
env: None,
},
..OverrideSources::default()
})
.expect_err("unknown runner should error");
let msg = format!("{err}");
assert!(msg.contains("unknown task runner"));
assert!(msg.contains("turbo"));
}
#[test]
fn pm_label_that_names_a_runner_suggests_runner_flag() {
let err = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("mise"),
env: None,
},
..OverrideSources::default()
})
.expect_err("`--pm mise` should error; mise is a task runner");
let msg = format!("{err}");
assert!(
msg.contains("task runner"),
"error should call out the category mismatch: {msg}"
);
assert!(
msg.contains("--runner mise"),
"error should suggest the correct flag: {msg}"
);
}
#[test]
fn runner_label_that_names_a_pm_suggests_pm_flag() {
let err = ResolutionOverrides::from_sources(OverrideSources {
runner: SourceValue {
cli: Some("pnpm"),
env: None,
},
..OverrideSources::default()
})
.expect_err("`--runner pnpm` should error; pnpm is a package manager");
let msg = format!("{err}");
assert!(
msg.contains("package manager"),
"error should call out the category mismatch: {msg}"
);
assert!(
msg.contains("--pm pnpm"),
"error should suggest the correct flag: {msg}"
);
}
#[test]
fn bundler_alias_bundle_is_accepted() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("bundle"),
env: None,
},
..OverrideSources::default()
})
.expect("`bundle` should alias to bundler");
assert_eq!(
overrides.pm.expect("pm should be present").pm,
PackageManager::Bundler,
);
}
#[test]
fn go_task_alias_is_accepted() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
runner: SourceValue {
cli: Some("go-task"),
env: None,
},
..OverrideSources::default()
})
.expect("`go-task` should alias to GoTask");
assert_eq!(
overrides.runner.expect("runner should be present").runner,
TaskRunner::GoTask,
);
}
fn loaded_config_with_node(node: &str) -> LoadedConfig {
LoadedConfig {
path: PathBuf::from("/test/runner.toml"),
config: RunnerConfig {
pm: PmSection {
node: Some(node.to_owned()),
python: None,
},
..RunnerConfig::default()
},
}
}
#[test]
fn config_pm_node_field_overrides_detection() {
let ctx = context(vec![PackageManager::Pnpm]);
let overrides = with_config_pm(PackageManager::Yarn, Ecosystem::Node);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Yarn);
match decision.via {
ResolutionStep::Override(OverrideOrigin::ConfigFile { .. }) => {}
other => panic!("expected Override(ConfigFile), got {other:?}"),
}
}
#[test]
fn cli_override_beats_config_override() {
let ctx = context(vec![PackageManager::Pnpm]);
let mut overrides = with_config_pm(PackageManager::Yarn, Ecosystem::Node);
overrides.pm = Some(PmOverride {
pm: PackageManager::Bun,
origin: OverrideOrigin::CliFlag,
});
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Bun);
match decision.via {
ResolutionStep::Override(OverrideOrigin::CliFlag) => {}
other => panic!("expected CliFlag origin, got {other:?}"),
}
}
#[test]
fn config_loaded_value_populates_pm_by_ecosystem() {
let loaded = loaded_config_with_node("bun");
let overrides = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect("config-only overrides should parse");
assert!(overrides.pm.is_none());
let entry = overrides
.pm_by_ecosystem
.get(&Ecosystem::Node)
.expect("Node ecosystem entry should be present");
assert_eq!(entry.pm, PackageManager::Bun);
match &entry.origin {
OverrideOrigin::ConfigFile { path } => {
assert!(path.ends_with("runner.toml"));
}
other => panic!("expected ConfigFile origin, got {other:?}"),
}
}
#[test]
fn config_python_pm_keyed_under_python_ecosystem() {
let loaded = LoadedConfig {
path: PathBuf::from("/test/runner.toml"),
config: RunnerConfig {
pm: PmSection {
node: None,
python: Some("uv".to_owned()),
},
..RunnerConfig::default()
},
};
let overrides = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect("python config should parse");
let entry = overrides
.pm_by_ecosystem
.get(&Ecosystem::Python)
.expect("python ecosystem entry should be present");
assert_eq!(entry.pm, PackageManager::Uv);
}
#[test]
fn config_cross_ecosystem_node_value_rejected_at_parse_time() {
let loaded = loaded_config_with_node("cargo");
let err = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect_err("cargo is not a node-script PM");
assert!(format!("{err}").contains("cannot dispatch package.json scripts"));
}
#[test]
fn manifest_package_manager_field_beats_lockfile_signal() {
use std::fs;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-manifest-wins");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "yarn@4.3.0" }"#,
)
.expect("package.json should be written");
fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9\n")
.expect("lockfile should be written");
let ctx = detect(dir.path());
assert!(ctx.package_managers.contains(&PackageManager::Pnpm));
let decision = Resolver::new(&ctx, &ResolutionOverrides::default())
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Yarn);
assert_eq!(decision.via, ResolutionStep::ManifestPackageManager);
assert_eq!(decision.warnings.len(), 1);
assert_eq!(decision.warnings[0].source(), "package.json");
let detail = decision.warnings[0].detail();
assert!(
detail.contains("declaration wins"),
"warning should mention declaration wins: {detail}",
);
}
#[test]
fn dev_engines_used_when_package_manager_absent() {
use std::fs;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-dev-engines-only");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "bun", "onFail": "warn" } } }"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
let decision = Resolver::new(&ctx, &ResolutionOverrides::default())
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Bun);
match decision.via {
ResolutionStep::ManifestDevEngines { .. } => {}
other => panic!("expected ManifestDevEngines, got {other:?}"),
}
}
#[test]
fn cli_override_still_beats_manifest_declaration() {
use std::fs;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-cli-beats-manifest");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "yarn@4" }"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
let overrides = with_pm_override(PackageManager::Bun, OverrideOrigin::CliFlag);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Bun);
assert_eq!(
decision.via,
ResolutionStep::Override(OverrideOrigin::CliFlag)
);
}
#[test]
fn matching_lockfile_and_manifest_produce_no_warning() {
use std::fs;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
let dir = TempDir::new("resolver-matching-signals");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "pnpm@9" }"#,
)
.expect("package.json should be written");
fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9\n")
.expect("lockfile should be written");
let ctx = detect(dir.path());
let decision = Resolver::new(&ctx, &ResolutionOverrides::default())
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Pnpm);
assert_eq!(decision.via, ResolutionStep::ManifestPackageManager);
assert!(decision.warnings.is_empty());
}
fn mismatch_dir(name: &str) -> crate::tool::test_support::TempDir {
use std::fs;
use crate::tool::test_support::TempDir;
let dir = TempDir::new(name);
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "yarn@4" }"#,
)
.expect("package.json should be written");
fs::write(dir.path().join("pnpm-lock.yaml"), "lockfileVersion: 9\n")
.expect("pnpm-lock.yaml should be written");
dir
}
#[test]
fn on_mismatch_warn_emits_warning_and_keeps_declaration() {
use super::MismatchPolicy;
use crate::detect::detect;
let dir = mismatch_dir("mismatch-warn");
let ctx = detect(dir.path());
let overrides = ResolutionOverrides {
on_mismatch: MismatchPolicy::Warn,
..ResolutionOverrides::default()
};
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("warn policy should not bail");
assert_eq!(decision.pm, PackageManager::Yarn);
assert_eq!(decision.warnings.len(), 1);
assert!(decision.warnings[0].detail().contains("declaration wins"));
}
#[test]
fn on_mismatch_ignore_silently_keeps_declaration() {
use super::MismatchPolicy;
use crate::detect::detect;
let dir = mismatch_dir("mismatch-ignore");
let ctx = detect(dir.path());
let overrides = ResolutionOverrides {
on_mismatch: MismatchPolicy::Ignore,
..ResolutionOverrides::default()
};
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("ignore policy should not bail");
assert_eq!(decision.pm, PackageManager::Yarn);
assert!(decision.warnings.is_empty());
}
#[test]
fn on_mismatch_error_bails_with_resolve_error() {
use super::MismatchPolicy;
use crate::detect::detect;
let dir = mismatch_dir("mismatch-error");
let ctx = detect(dir.path());
let overrides = ResolutionOverrides {
on_mismatch: MismatchPolicy::Error,
..ResolutionOverrides::default()
};
let err = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect_err("error policy should bail on mismatch");
assert!(
matches!(err, ResolveError::MismatchPolicyError { .. }),
"expected MismatchPolicyError, got: {err:?}"
);
}
#[test]
fn prefer_runners_parses_known_labels() {
use crate::config::{LoadedConfig, RunnerConfig, TaskRunnerSection};
let loaded = LoadedConfig {
path: PathBuf::from("/test/runner.toml"),
config: RunnerConfig {
task_runner: TaskRunnerSection {
prefer: vec!["just".to_string(), "turbo".to_string()],
},
..RunnerConfig::default()
},
};
let overrides = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect("prefer list of known runners should parse");
assert_eq!(
overrides.prefer_runners,
vec![TaskRunner::Just, TaskRunner::Turbo],
);
}
#[test]
fn prefer_runners_rejects_unknown_label() {
use crate::config::{LoadedConfig, RunnerConfig, TaskRunnerSection};
let loaded = LoadedConfig {
path: PathBuf::from("/test/runner.toml"),
config: RunnerConfig {
task_runner: TaskRunnerSection {
prefer: vec!["zoot".to_string()],
},
..RunnerConfig::default()
},
};
let err = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect_err("unknown runner label must error at parse time");
let msg = format!("{err}");
assert!(msg.contains("unknown runner"), "got: {msg}");
assert!(msg.contains("zoot"), "got: {msg}");
}
#[test]
fn on_mismatch_label_parses_three_values() {
use super::MismatchPolicy;
use super::policies::parse_mismatch_label;
assert_eq!(parse_mismatch_label("warn").unwrap(), MismatchPolicy::Warn);
assert_eq!(
parse_mismatch_label("error").unwrap(),
MismatchPolicy::Error
);
assert_eq!(
parse_mismatch_label("ignore").unwrap(),
MismatchPolicy::Ignore
);
assert!(parse_mismatch_label("nope").is_err());
}
#[test]
fn manifest_on_fail_error_bails_when_binary_missing() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck};
let decl = ManifestPmDecl {
pm: PackageManager::Yarn,
source: ManifestSource::DevEngines,
version: None,
on_fail: OnFail::Error,
};
let mut warnings = Vec::new();
let err = super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| false,
|_, _| VersionCheck::Unverifiable {
reason: String::new(),
},
)
.expect_err("Error + missing should bail");
let msg = format!("{err}");
assert!(msg.contains("yarn"), "error should name the PM: {msg}");
assert!(
msg.contains("not found on PATH"),
"error should explain: {msg}"
);
assert!(
msg.contains("onFail=error"),
"error should attribute: {msg}"
);
}
#[test]
fn manifest_on_fail_error_bails_when_version_mismatched() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck};
let decl = ManifestPmDecl {
pm: PackageManager::Pnpm,
source: ManifestSource::DevEngines,
version: Some(">=9.0.0".to_string()),
on_fail: OnFail::Error,
};
let mut warnings = Vec::new();
let err = super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| true,
|_, _| VersionCheck::Mismatch {
declared: ">=9.0.0".to_string(),
actual: "8.15.0".to_string(),
},
)
.expect_err("Error + version mismatch should bail");
let msg = format!("{err}");
assert!(msg.contains("pnpm"));
assert!(msg.contains(">=9.0.0"));
assert!(msg.contains("8.15.0"));
assert!(msg.contains("onFail=error"));
}
#[test]
fn manifest_on_fail_warn_emits_warning_when_binary_missing() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck};
let decl = ManifestPmDecl {
pm: PackageManager::Bun,
source: ManifestSource::DevEngines,
version: None,
on_fail: OnFail::Warn,
};
let mut warnings = Vec::new();
super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| false,
|_, _| VersionCheck::Unverifiable {
reason: String::new(),
},
)
.expect("Warn should not bail");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].source(), "package.json");
assert!(warnings[0].detail().contains("bun"));
}
#[test]
fn manifest_on_fail_warn_emits_warning_on_version_mismatch() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck};
let decl = ManifestPmDecl {
pm: PackageManager::Yarn,
source: ManifestSource::DevEngines,
version: Some("^4.0.0".to_string()),
on_fail: OnFail::Warn,
};
let mut warnings = Vec::new();
super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| true,
|_, _| VersionCheck::Mismatch {
declared: "^4.0.0".to_string(),
actual: "1.22.22".to_string(),
},
)
.expect("Warn should not bail");
assert_eq!(warnings.len(), 1);
let detail = warnings[0].detail();
assert!(detail.contains("yarn"));
assert!(detail.contains("^4.0.0"));
assert!(detail.contains("1.22.22"));
}
#[test]
fn manifest_on_fail_ignore_skips_all_checks() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail};
let decl = ManifestPmDecl {
pm: PackageManager::Npm,
source: ManifestSource::DevEngines,
version: Some(">=20".to_string()),
on_fail: OnFail::Ignore,
};
let mut warnings = Vec::new();
super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| panic!("presence check should not run when onFail=Ignore"),
|_, _| panic!("version check should not run when onFail=Ignore"),
)
.expect("Ignore should always succeed");
assert!(warnings.is_empty());
}
#[test]
fn manifest_on_fail_unverifiable_version_continues_without_warning() {
use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck};
let decl = ManifestPmDecl {
pm: PackageManager::Yarn,
source: ManifestSource::DevEngines,
version: Some("not-a-valid-range".to_string()),
on_fail: OnFail::Error,
};
let mut warnings = Vec::new();
super::resolve::apply_manifest_on_fail(
&decl,
&mut warnings,
|_| true,
|_, _| VersionCheck::Unverifiable {
reason: "unparseable range".to_string(),
},
)
.expect("Unverifiable should continue, not bail");
assert!(warnings.is_empty());
}
#[test]
fn from_sources_builder_is_ergonomic_for_partial_overrides() {
let overrides = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some("yarn"),
env: None,
},
explain: ExplainSource {
cli: true,
env: None,
},
..OverrideSources::default()
})
.expect("structured override should parse");
assert_eq!(
overrides.pm.expect("pm override should be present").pm,
PackageManager::Yarn
);
assert!(overrides.explain);
assert!(overrides.runner.is_none());
}
#[test]
fn parse_override_trims_whitespace_in_env_and_cli() {
let from_env = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: None,
env: Some(" pnpm "),
},
..OverrideSources::default()
})
.expect("padded env value should parse after trimming");
assert_eq!(
from_env.pm.expect("pm should be present").pm,
PackageManager::Pnpm
);
let from_cli = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: Some(" yarn\n"),
env: None,
},
..OverrideSources::default()
})
.expect("padded CLI value should parse after trimming");
assert_eq!(
from_cli.pm.expect("pm should be present").pm,
PackageManager::Yarn
);
let blank = ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue {
cli: None,
env: Some(" "),
},
..OverrideSources::default()
})
.expect("whitespace-only env should parse as no override");
assert!(blank.pm.is_none());
}
#[test]
fn is_env_truthy_is_case_insensitive_for_falsy_values() {
use super::policies::is_env_truthy;
assert!(!is_env_truthy("false"));
assert!(!is_env_truthy("FALSE"));
assert!(!is_env_truthy("False"));
assert!(!is_env_truthy("no"));
assert!(!is_env_truthy("NO"));
assert!(!is_env_truthy("off"));
assert!(!is_env_truthy("OFF"));
assert!(!is_env_truthy("Off"));
assert!(!is_env_truthy("0"));
assert!(!is_env_truthy(""));
assert!(!is_env_truthy(" false "));
assert!(!is_env_truthy("\nfalse\n"));
assert!(is_env_truthy("1"));
assert!(is_env_truthy("true"));
assert!(is_env_truthy("yes"));
assert!(is_env_truthy("on"));
assert!(is_env_truthy("anything"));
}
#[test]
fn describe_renders_human_friendly_step_label() {
use crate::tool::node::OnFail;
let cases: &[(super::ResolvedPm, &str)] = &[
(
super::ResolvedPm {
pm: PackageManager::Yarn,
via: ResolutionStep::Override(OverrideOrigin::CliFlag),
warnings: vec![],
},
"yarn via --pm (CLI override)",
),
(
super::ResolvedPm {
pm: PackageManager::Bun,
via: ResolutionStep::Override(OverrideOrigin::EnvVar),
warnings: vec![],
},
"bun via RUNNER_PM (environment)",
),
(
super::ResolvedPm {
pm: PackageManager::Pnpm,
via: ResolutionStep::Override(OverrideOrigin::ConfigFile {
path: PathBuf::from("/proj/runner.toml"),
}),
warnings: vec![],
},
"pnpm via runner.toml at /proj/runner.toml",
),
(
super::ResolvedPm {
pm: PackageManager::Pnpm,
via: ResolutionStep::ManifestPackageManager,
warnings: vec![],
},
"pnpm via package.json \"packageManager\"",
),
(
super::ResolvedPm {
pm: PackageManager::Bun,
via: ResolutionStep::ManifestDevEngines {
on_fail: OnFail::Error,
},
warnings: vec![],
},
"bun via package.json \"devEngines.packageManager\" (onFail=Error)",
),
(
super::ResolvedPm {
pm: PackageManager::Pnpm,
via: ResolutionStep::Lockfile,
warnings: vec![],
},
"pnpm via detected lockfile",
),
(
super::ResolvedPm {
pm: PackageManager::Npm,
via: ResolutionStep::PathProbe {
binary: PathBuf::from("/usr/bin/npm"),
},
warnings: vec![],
},
"npm via PATH probe at /usr/bin/npm",
),
(
super::ResolvedPm {
pm: PackageManager::Npm,
via: ResolutionStep::LegacyNpmFallback,
warnings: vec![],
},
"npm via --fallback=npm (legacy)",
),
];
for (decision, expected) in cases {
assert_eq!(&decision.describe(), expected);
}
}
#[test]
fn deno_config_value_lands_under_deno_ecosystem_and_resolves_for_node_scripts() {
let loaded = loaded_config_with_node("deno");
let overrides = ResolutionOverrides::from_sources(OverrideSources {
config: Some(&loaded),
..OverrideSources::default()
})
.expect("deno config should parse");
assert!(overrides.pm_by_ecosystem.contains_key(&Ecosystem::Deno));
let ctx = context(vec![PackageManager::Pnpm]);
let decision = Resolver::new(&ctx, &overrides)
.resolve_node_pm()
.expect("resolution should succeed");
assert_eq!(decision.pm, PackageManager::Deno);
}
fn test_loaded_config_with_chain(
keep_going: Option<bool>,
kill_on_fail: Option<bool>,
) -> LoadedConfig {
use crate::config::ChainSection;
LoadedConfig {
path: PathBuf::from("/test/runner.toml"),
config: RunnerConfig {
chain: ChainSection {
keep_going,
kill_on_fail,
},
..RunnerConfig::default()
},
}
}
#[test]
fn from_sources_resolves_cli_keep_going() {
use crate::chain::FailurePolicy;
let overrides = ResolutionOverrides::from_sources(OverrideSources {
keep_going: ExplainSource {
cli: true,
env: None,
},
..OverrideSources::default()
})
.expect("resolves");
assert_eq!(overrides.failure_policy, FailurePolicy::KeepGoing);
}
#[test]
fn from_sources_env_overrides_config_for_failure_policy() {
use crate::chain::FailurePolicy;
let loaded = test_loaded_config_with_chain(Some(false), None);
let overrides = ResolutionOverrides::from_sources(OverrideSources {
keep_going: ExplainSource {
cli: false,
env: Some("1"),
},
config: Some(&loaded),
..OverrideSources::default()
})
.expect("resolves");
assert_eq!(overrides.failure_policy, FailurePolicy::KeepGoing);
}
#[test]
fn from_sources_rejects_both_keep_going_and_kill_on_fail() {
let err = ResolutionOverrides::from_sources(OverrideSources {
keep_going: ExplainSource {
cli: true,
env: None,
},
kill_on_fail: ExplainSource {
cli: true,
env: None,
},
..OverrideSources::default()
})
.expect_err("conflict must error");
let downcast = err.downcast_ref::<ResolveError>();
assert!(
matches!(
downcast,
Some(ResolveError::ConflictingFailurePolicy { .. })
),
"expected ConflictingFailurePolicy, got: {err:#}",
);
}
#[test]
fn from_sources_env_false_overrides_config_true_for_failure_policy() {
use crate::chain::FailurePolicy;
let loaded = test_loaded_config_with_chain(Some(true), None);
let overrides = ResolutionOverrides::from_sources(OverrideSources {
keep_going: ExplainSource {
cli: false,
env: Some("0"),
},
config: Some(&loaded),
..OverrideSources::default()
})
.expect("resolves");
assert_eq!(overrides.failure_policy, FailurePolicy::FailFast);
}
#[test]
fn from_sources_env_false_neutralises_config_conflict() {
use crate::chain::FailurePolicy;
let loaded = test_loaded_config_with_chain(Some(true), Some(true));
let overrides = ResolutionOverrides::from_sources(OverrideSources {
kill_on_fail: ExplainSource {
cli: false,
env: Some("false"),
},
config: Some(&loaded),
..OverrideSources::default()
})
.expect("env=false on one side should neutralise the [chain] config conflict");
assert_eq!(overrides.failure_policy, FailurePolicy::KeepGoing);
}
#[test]
fn from_sources_rejects_both_env_vars_truthy() {
let err = ResolutionOverrides::from_sources(OverrideSources {
keep_going: ExplainSource {
cli: false,
env: Some("1"),
},
kill_on_fail: ExplainSource {
cli: false,
env: Some("1"),
},
..OverrideSources::default()
})
.expect_err("env-layer conflict must error");
let downcast = err.downcast_ref::<ResolveError>();
assert!(
matches!(
downcast,
Some(ResolveError::ConflictingFailurePolicy { source: "env vars" })
),
"expected env-layer ConflictingFailurePolicy, got: {err:#}",
);
}
}