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,
}
#[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,
},
}
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,
}
}
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",
),
}
}
}
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 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",
}
}
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),
_ => 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,
}
}
}
pub(crate) fn version_matches(expected: &str, current: &str) -> bool {
let expected = expected.trim();
let current = current.trim();
let expected_clean = expected
.trim_start_matches(">=")
.trim_start_matches("<=")
.trim_start_matches('>')
.trim_start_matches('<')
.trim_start_matches('~')
.trim_start_matches('^')
.trim_start_matches('v')
.trim();
if !expected_clean.contains('.') {
return current.starts_with(expected_clean)
&& current[expected_clean.len()..]
.chars()
.next()
.is_none_or(|c| c == '.');
}
current.starts_with(expected_clean)
&& current[expected_clean.len()..]
.chars()
.next()
.is_none_or(|c| c == '.')
}
#[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 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");
}
}