use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Ecosystem {
Node,
Deno,
Python,
Rust,
Go,
Ruby,
Php,
}
impl Ecosystem {
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Node => "node",
Self::Deno => "deno",
Self::Python => "python",
Self::Rust => "rust",
Self::Go => "go",
Self::Ruby => "ruby",
Self::Php => "php",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum PackageManager {
Npm,
Yarn,
Pnpm,
Bun,
Cargo,
Deno,
Uv,
Poetry,
Pipenv,
Go,
Bundler,
Composer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum TaskRunner {
Turbo,
Nx,
Make,
Just,
GoTask,
Mise,
Bacon,
}
#[derive(Debug, Clone)]
pub(crate) struct Task {
pub name: String,
pub source: TaskSource,
pub run_target: Option<String>,
pub description: Option<String>,
pub alias_of: Option<String>,
pub passthrough_to: Option<TaskRunner>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum TaskSource {
PackageJson,
Makefile,
Justfile,
Taskfile,
TurboJson,
DenoJson,
CargoAliases,
GoPackage,
BaconToml,
MiseToml,
PyprojectScripts,
}
#[derive(Debug, Clone)]
pub(crate) struct NodeVersion {
pub expected: String,
pub source: &'static str,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub(crate) enum DetectionWarning {
PmMismatch {
declared: PackageManager,
field: &'static str,
lockfile: PackageManager,
},
DevEnginesBinaryMissing {
pm: PackageManager,
},
DevEnginesVersionMismatch {
pm: PackageManager,
declared: String,
actual: String,
},
PathProbeFallback {
picked: PackageManager,
ecosystem: Ecosystem,
others_available: Vec<PackageManager>,
},
LegacyNpmFallbackUsed {
ecosystem: Ecosystem,
},
TaskListUnreadable {
source: &'static str,
error: String,
},
UnparseablePackageManager {
raw: String,
},
InvalidEnvOverride {
var: &'static str,
raw: String,
message: String,
},
}
impl DetectionWarning {
pub(crate) const fn source(&self) -> &'static str {
match self {
Self::PmMismatch { .. }
| Self::DevEnginesBinaryMissing { .. }
| Self::DevEnginesVersionMismatch { .. }
| Self::UnparseablePackageManager { .. } => "package.json",
Self::PathProbeFallback { .. } | Self::LegacyNpmFallbackUsed { .. } => "resolver",
Self::TaskListUnreadable { source, .. } => source,
Self::InvalidEnvOverride { .. } => "env",
}
}
pub(crate) fn detail(&self) -> String {
match self {
Self::PmMismatch {
declared,
field,
lockfile,
} => format!(
"{field} declares {} but the lockfile reflects {} — declaration wins; regenerate \
the lockfile to silence this",
declared.label(),
lockfile.label(),
),
Self::DevEnginesBinaryMissing { pm } => format!(
"devEngines.packageManager declares {} but it was not found on PATH; dispatch \
will fail at spawn time",
pm.label(),
),
Self::DevEnginesVersionMismatch {
pm,
declared,
actual,
} => format!(
"devEngines.packageManager requires {} {declared} but the installed version is \
{actual}",
pm.label(),
),
Self::PathProbeFallback {
picked,
ecosystem,
others_available,
} => {
let eco = ecosystem.label();
if others_available.is_empty() {
format!(
"no {eco} signals matched — using {} from PATH",
picked.label(),
)
} else {
let others = others_available
.iter()
.map(|pm| pm.label())
.collect::<Vec<_>>()
.join(", ");
format!(
"no {eco} signals matched — using {} from PATH (also available: {others})",
picked.label(),
)
}
}
Self::LegacyNpmFallbackUsed { ecosystem } => format!(
"no {} signals matched; using npm via --fallback=npm",
ecosystem.label(),
),
Self::TaskListUnreadable { error, .. } => format!("failed to read tasks: {error}"),
Self::UnparseablePackageManager { raw } => format!(
"packageManager value {raw:?} doesn't name a script-dispatching package manager \
(expected one of npm|pnpm|yarn|bun|deno, optionally followed by @<version>); \
declaration ignored, falling back to lockfile / PATH probe",
),
Self::InvalidEnvOverride { var, message, .. } => {
format!("{var} is set but invalid and was ignored for this report: {message}")
}
}
}
}
impl std::fmt::Display for DetectionWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.source(), self.detail())
}
}
pub(crate) struct ProjectContext {
pub root: PathBuf,
pub package_managers: Vec<PackageManager>,
pub task_runners: Vec<TaskRunner>,
pub tasks: Vec<Task>,
pub node_version: Option<NodeVersion>,
pub current_node: Option<String>,
pub is_monorepo: bool,
pub warnings: Vec<DetectionWarning>,
}
impl ProjectContext {
pub(crate) fn primary_node_pm(&self) -> Option<PackageManager> {
self.package_managers
.iter()
.copied()
.find(|pm| pm.is_node())
}
pub(crate) fn primary_pm(&self) -> Option<PackageManager> {
self.package_managers.first().copied()
}
}
impl PackageManager {
pub(crate) const fn is_node(self) -> bool {
matches!(self, Self::Npm | Self::Yarn | Self::Pnpm | Self::Bun)
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Yarn => "yarn",
Self::Pnpm => "pnpm",
Self::Bun => "bun",
Self::Cargo => "cargo",
Self::Deno => "deno",
Self::Uv => "uv",
Self::Poetry => "poetry",
Self::Pipenv => "pipenv",
Self::Go => "go",
Self::Bundler => "bundler",
Self::Composer => "composer",
}
}
pub(crate) fn from_label(label: &str) -> Option<Self> {
match label.trim() {
"npm" => Some(Self::Npm),
"yarn" => Some(Self::Yarn),
"pnpm" => Some(Self::Pnpm),
"bun" => Some(Self::Bun),
"cargo" => Some(Self::Cargo),
"deno" => Some(Self::Deno),
"uv" => Some(Self::Uv),
"poetry" => Some(Self::Poetry),
"pipenv" => Some(Self::Pipenv),
"go" => Some(Self::Go),
"bundler" | "bundle" => Some(Self::Bundler),
"composer" => Some(Self::Composer),
_ => None,
}
}
pub(crate) const fn all() -> &'static [Self] {
&[
Self::Npm,
Self::Yarn,
Self::Pnpm,
Self::Bun,
Self::Cargo,
Self::Deno,
Self::Uv,
Self::Poetry,
Self::Pipenv,
Self::Go,
Self::Bundler,
Self::Composer,
]
}
pub(crate) const COUNT: usize = 12;
pub(crate) const fn index(self) -> usize {
match self {
Self::Npm => 0,
Self::Yarn => 1,
Self::Pnpm => 2,
Self::Bun => 3,
Self::Cargo => 4,
Self::Deno => 5,
Self::Uv => 6,
Self::Poetry => 7,
Self::Pipenv => 8,
Self::Go => 9,
Self::Bundler => 10,
Self::Composer => 11,
}
}
pub(crate) const fn ecosystem(self) -> Ecosystem {
match self {
Self::Npm | Self::Yarn | Self::Pnpm | Self::Bun => Ecosystem::Node,
Self::Deno => Ecosystem::Deno,
Self::Cargo => Ecosystem::Rust,
Self::Uv | Self::Poetry | Self::Pipenv => Ecosystem::Python,
Self::Go => Ecosystem::Go,
Self::Bundler => Ecosystem::Ruby,
Self::Composer => Ecosystem::Php,
}
}
pub(crate) const fn can_dispatch_node_scripts(self) -> bool {
self.is_node() || matches!(self, Self::Deno)
}
}
impl TaskRunner {
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Turbo => "turbo",
Self::Nx => "nx",
Self::Make => "make",
Self::Just => "just",
Self::GoTask => "task",
Self::Mise => "mise",
Self::Bacon => "bacon",
}
}
pub(crate) fn from_label(label: &str) -> Option<Self> {
match label.trim() {
"turbo" => Some(Self::Turbo),
"nx" => Some(Self::Nx),
"make" => Some(Self::Make),
"just" => Some(Self::Just),
"task" | "go-task" => Some(Self::GoTask),
"mise" => Some(Self::Mise),
"bacon" => Some(Self::Bacon),
_ => None,
}
}
pub(crate) const fn all() -> &'static [Self] {
&[
Self::Turbo,
Self::Nx,
Self::Make,
Self::Just,
Self::GoTask,
Self::Mise,
Self::Bacon,
]
}
pub(crate) const fn task_source(self) -> Option<TaskSource> {
match self {
Self::Turbo => Some(TaskSource::TurboJson),
Self::Make => Some(TaskSource::Makefile),
Self::Just => Some(TaskSource::Justfile),
Self::GoTask => Some(TaskSource::Taskfile),
Self::Bacon => Some(TaskSource::BaconToml),
Self::Mise => Some(TaskSource::MiseToml),
Self::Nx => None,
}
}
}
impl TaskSource {
pub(crate) const fn all() -> &'static [Self] {
&[
Self::PackageJson,
Self::Makefile,
Self::Justfile,
Self::Taskfile,
Self::TurboJson,
Self::DenoJson,
Self::CargoAliases,
Self::GoPackage,
Self::BaconToml,
Self::MiseToml,
Self::PyprojectScripts,
]
}
pub(crate) const fn label(self) -> &'static str {
match self {
Self::PackageJson => "package.json",
Self::Makefile => "make",
Self::Justfile => "just",
Self::Taskfile => "task",
Self::TurboJson => "turbo",
Self::DenoJson => "deno",
Self::CargoAliases => "cargo",
Self::GoPackage => "go",
Self::BaconToml => "bacon",
Self::MiseToml => "mise",
Self::PyprojectScripts => "pyproject.toml",
}
}
pub(crate) fn from_label(label: &str) -> Option<Self> {
match label {
"package.json" => Some(Self::PackageJson),
"make" | "Makefile" => Some(Self::Makefile),
"just" | "justfile" => Some(Self::Justfile),
"task" | "Taskfile" | "go-task" => Some(Self::Taskfile),
"turbo" | "turbo.json" | "turbo.jsonc" => Some(Self::TurboJson),
"deno" | "deno.json" | "deno.jsonc" => Some(Self::DenoJson),
"cargo" => Some(Self::CargoAliases),
"go" | "go.mod" => Some(Self::GoPackage),
"bacon" | "bacon.toml" => Some(Self::BaconToml),
"mise" | "mise.toml" | ".mise.toml" => Some(Self::MiseToml),
"pyproject" | "pyproject.toml" => Some(Self::PyprojectScripts),
_ => None,
}
}
pub(crate) const fn display_order(self) -> u8 {
match self {
Self::PackageJson => 0,
Self::Makefile => 1,
Self::Justfile => 2,
Self::Taskfile => 3,
Self::TurboJson => 4,
Self::DenoJson => 5,
Self::CargoAliases => 6,
Self::GoPackage => 7,
Self::BaconToml => 8,
Self::MiseToml => 9,
Self::PyprojectScripts => 10,
}
}
}
pub(crate) fn version_matches(expected: &str, current: &str) -> bool {
let expected = expected.trim();
let current = current.trim();
if bare_version(expected) {
return prefix_version_matches(expected, current);
}
range_matches(expected, current).unwrap_or_else(|| prefix_version_matches(expected, current))
}
fn prefix_version_matches(expected: &str, current: &str) -> bool {
let after_ops = expected
.trim()
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('=')
.trim_start_matches('~')
.trim_start_matches('^')
.trim_start();
let expected_clean = strip_v(after_ops).trim();
current.starts_with(expected_clean)
&& current[expected_clean.len()..]
.chars()
.next()
.is_none_or(|c| c == '.')
}
fn bare_version(s: &str) -> bool {
let stripped = strip_v(s);
!stripped.is_empty() && stripped.chars().all(|c| c.is_ascii_digit() || c == '.')
}
fn strip_v(s: &str) -> &str {
s.strip_prefix('v').unwrap_or(s)
}
fn range_matches(expected: &str, current: &str) -> Option<bool> {
let cur = parse_current_version(current)?;
let mut any_unparseable = false;
for group in expected.split("||") {
let group = group.trim();
if group.is_empty() {
any_unparseable = true;
continue;
}
let req = normalize_range_group(group)
.and_then(|normalized| semver::VersionReq::parse(&normalized).ok());
match req {
Some(req) if req.matches(&cur) => return Some(true),
Some(_) => {}
None => any_unparseable = true,
}
}
if any_unparseable { None } else { Some(false) }
}
fn normalize_range_group(group: &str) -> Option<String> {
let group = group.replace(',', " ");
let tokens: Vec<&str> = group.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
if let [low, "-", high] = tokens.as_slice() {
return Some(format!(">={}, <={}", strip_v(low), strip_v(high)));
}
if tokens.contains(&"-") {
return None;
}
let mut parts: Vec<String> = Vec::with_capacity(tokens.len());
let mut iter = tokens.iter();
while let Some(token) = iter.next() {
let (op, rest) = split_operator(token);
if op.is_empty() {
let rest = strip_v(rest);
if rest.starts_with(|c: char| c.is_ascii_digit()) {
parts.push(format!("={rest}"));
} else {
parts.push(rest.to_string());
}
} else if rest.is_empty() {
let version = iter.next()?;
parts.push(format!("{op}{}", strip_v(version)));
} else {
parts.push(format!("{op}{}", strip_v(rest)));
}
}
Some(parts.join(", "))
}
fn split_operator(token: &str) -> (&str, &str) {
for op in [">=", "<=", ">", "<", "=", "~", "^"] {
if let Some(rest) = token.strip_prefix(op) {
return (op, rest);
}
}
("", token)
}
fn parse_current_version(current: &str) -> Option<semver::Version> {
let padded = match current.split('.').count() {
1 => format!("{current}.0.0"),
2 => format!("{current}.0"),
_ => current.to_string(),
};
semver::Version::parse(&padded).ok()
}
#[cfg(test)]
mod tests {
use super::version_matches;
use super::{DetectionWarning, PackageManager};
#[test]
fn dotted_versions_match_segment_boundaries_only() {
assert!(version_matches("20.11", "20.11.0"));
assert!(!version_matches("20.11", "20.110.0"));
}
#[test]
fn gte_range_matches_higher_versions() {
assert!(version_matches(">=22.22.2", "22.22.3"));
assert!(version_matches(">=22.22.2", "25.9.0"));
assert!(!version_matches(">=22.22.2", "22.22.1"));
}
#[test]
fn operator_with_space_before_version() {
assert!(version_matches(">= 18", "20.0.0"));
assert!(!version_matches(">= 18", "17.9.0"));
}
#[test]
fn partial_comparator_bounds() {
assert!(version_matches(">=18", "18.0.0"));
assert!(!version_matches(">22", "22.5.0"));
assert!(version_matches(">22", "23.0.0"));
assert!(version_matches("<21", "20.99.0"));
assert!(!version_matches("<21", "21.0.0"));
assert!(version_matches("<=20", "20.99.0"));
}
#[test]
fn caret_ranges() {
assert!(version_matches("^20.11", "20.12.0"));
assert!(!version_matches("^20.11", "20.10.9"));
assert!(!version_matches("^20.11", "21.0.0"));
assert!(version_matches("^0.3", "0.3.9"));
assert!(!version_matches("^0.3", "0.4.0"));
}
#[test]
fn tilde_ranges() {
assert!(version_matches("~18.15", "18.15.7"));
assert!(!version_matches("~18.15", "18.16.0"));
assert!(version_matches("~18.15.0", "18.15.3"));
}
#[test]
fn space_separated_and_conjunction() {
assert!(version_matches(">=18 <21", "20.5.1"));
assert!(!version_matches(">=18 <21", "21.0.0"));
assert!(!version_matches(">=18 <21", "17.0.0"));
}
#[test]
fn or_unions() {
assert!(version_matches("18||20", "20.4.2"));
assert!(!version_matches("18||20", "19.0.0"));
assert!(version_matches(">=18 <19 || >=20", "18.5.0"));
assert!(!version_matches(">=18 <19 || >=20", "19.5.0"));
assert!(version_matches(">=18 <19 || >=20", "25.9.0"));
}
#[test]
fn hyphen_ranges() {
assert!(version_matches("18 - 20", "19.0.0"));
assert!(version_matches("18 - 20", "20.9.9"));
assert!(!version_matches("18 - 20", "21.0.0"));
assert!(!version_matches("18 - 20", "17.9.9"));
}
#[test]
fn wildcard_ranges() {
assert!(version_matches("20.x", "20.5.1"));
assert!(!version_matches("20.x", "21.0.0"));
assert!(version_matches("20.*", "20.0.0"));
assert!(version_matches("*", "99.0.0"));
}
#[test]
fn bare_versions_keep_prefix_semantics() {
assert!(!version_matches("20.11", "20.12.0"));
assert!(version_matches("20", "20.11.0"));
assert!(!version_matches("2", "20.11.0"));
assert!(version_matches("v20", "20.1.0"));
assert!(version_matches("20.11.0", "20.11.0"));
}
#[test]
fn exact_operator_partial_equality() {
assert!(version_matches("=20.11", "20.11.5"));
assert!(!version_matches("=20.11", "20.12.0"));
}
#[test]
fn operator_with_v_prefix() {
assert!(version_matches(">=v18", "18.0.0"));
}
#[test]
fn unparseable_expected_falls_back_to_prefix() {
assert!(!version_matches("lts/*", "22.0.0"));
assert!(!version_matches("lts/jod", "22.0.0"));
assert!(!version_matches("", "20.0.0"));
}
#[test]
fn unparseable_or_group_does_not_block_parsed_match() {
assert!(version_matches(">=18 || lts/*", "20.0.0"));
}
#[test]
fn unparseable_current_falls_back_to_prefix() {
assert!(!version_matches(">=18", "not-a-version"));
}
#[test]
fn prefix_fallback_strips_equals_and_spaced_v() {
assert!(version_matches("=20.11", "20.11.beta"));
assert!(!version_matches("=20.11", "20.12.beta"));
assert!(version_matches(">= v18", "18.unknown"));
}
#[test]
fn detection_warning_can_be_hashed() {
use std::collections::HashSet;
let a = DetectionWarning::DevEnginesBinaryMissing {
pm: PackageManager::Pnpm,
};
let b = DetectionWarning::DevEnginesBinaryMissing {
pm: PackageManager::Pnpm,
};
let c = DetectionWarning::DevEnginesBinaryMissing {
pm: PackageManager::Yarn,
};
let mut set = HashSet::new();
set.insert(a);
set.insert(b);
set.insert(c);
assert_eq!(set.len(), 2, "equal variants should dedup");
}
}