use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
use yaml_rust2::YamlLoader;
use crate::tool::files;
use crate::tool::program;
use crate::types::PackageManager;
pub(crate) const PACKAGE_JSON_FILENAME: &str = "package.json";
pub(crate) const MANIFEST_FILENAMES: &[&str] =
&[PACKAGE_JSON_FILENAME, "package.json5", "package.yaml"];
pub(crate) const DEFAULT_CLEAN_DIRS: &[&str] = &["node_modules", ".cache", "dist"];
pub(crate) const FRAMEWORK_CLEAN_DIRS: &[&str] = &[".next", ".parcel-cache", ".svelte-kit"];
pub(crate) fn has_package_json(dir: &Path) -> bool {
find_manifest(dir).is_some()
}
pub(crate) fn find_manifest(dir: &Path) -> Option<PathBuf> {
files::find_first(dir, MANIFEST_FILENAMES).filter(|path| path.is_file())
}
pub(crate) fn find_manifest_upwards(dir: &Path) -> Option<PathBuf> {
files::find_first_upwards(dir, MANIFEST_FILENAMES).filter(|path| path.is_file())
}
pub(crate) fn within_workspace_upwards(dir: &Path) -> bool {
files::find_in_ancestors(dir, |ancestor| {
if ancestor.join("pnpm-workspace.yaml").is_file() || ancestor.join("lerna.json").is_file() {
return Some(());
}
let has_workspaces = std::fs::read_to_string(ancestor.join(PACKAGE_JSON_FILENAME))
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.is_some_and(|json| json.get("workspaces").is_some());
has_workspaces.then_some(())
})
.is_some()
}
pub(crate) fn detect_pm_from_field(dir: &Path) -> Option<PackageManager> {
detect_pm(parse_package_json(dir))
}
pub(crate) fn detect_pm_field_with_diagnostics(
dir: &Path,
) -> (Option<PackageManager>, Option<String>) {
let Some(parsed) = parse_package_json(dir) else {
return (None, None);
};
let Some(raw) = parsed
.package_manager
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return (None, None);
};
match parse_package_manager_spec(Some(raw)) {
Some((pm, _)) => (Some(pm), None),
None => (None, Some(raw.to_string())),
}
}
fn detect_pm(package_json: Option<PackageJson>) -> Option<PackageManager> {
parse_package_manager_spec(
package_json
.and_then(|package_json| package_json.package_manager)
.as_deref(),
)
.map(|(pm, _)| pm)
}
fn parse_package_manager_spec(spec: Option<&str>) -> Option<(PackageManager, Option<String>)> {
let raw = spec?.trim();
let (name, version) = match raw.split_once('@') {
Some((_, "")) => return None,
Some((n, v)) => (n, Some(v.to_string())),
None => (raw, None),
};
let pm = match name {
"npm" => PackageManager::Npm,
"pnpm" => PackageManager::Pnpm,
"yarn" => PackageManager::Yarn,
"bun" => PackageManager::Bun,
"deno" => PackageManager::Deno,
_ => return None,
};
Some((pm, version))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OnFail {
Ignore,
Warn,
Error,
}
impl OnFail {
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Ignore => "ignore",
Self::Warn => "warn",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ManifestSource {
PackageManager,
DevEngines,
}
#[derive(Debug, Clone)]
pub(crate) struct ManifestPmDecl {
pub pm: PackageManager,
pub source: ManifestSource,
pub version: Option<String>,
pub on_fail: OnFail,
}
pub(crate) fn detect_pm_from_manifest(dir: &Path) -> Option<ManifestPmDecl> {
let parsed = parse_package_json(dir)?;
let pm_spec = parsed
.package_manager
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(spec) = pm_spec {
return parse_package_manager_spec(Some(spec)).map(|(pm, version)| ManifestPmDecl {
pm,
source: ManifestSource::PackageManager,
version,
on_fail: OnFail::Ignore,
});
}
let dev_engines = parsed.dev_engines?;
let pm_field = dev_engines.package_manager?;
let entries: Vec<DevEngineDep> = match pm_field {
DevEnginesPmField::One(dep) => vec![dep],
DevEnginesPmField::Many(deps) => deps,
};
if entries.is_empty() {
return None;
}
let resolvable: Vec<(PackageManager, DevEngineDep)> = entries
.into_iter()
.filter_map(|entry| script_dispatching_pm(&entry.name).map(|pm| (pm, entry)))
.collect();
let total = resolvable.len();
if total == 0 {
return None;
}
let mut last_decl: Option<ManifestPmDecl> = None;
for (idx, (pm, entry)) in resolvable.into_iter().enumerate() {
let on_fail = entry.on_fail.map_or_else(
|| default_on_fail_for_array_position(idx, total),
OnFail::from_proposal,
);
last_decl = Some(ManifestPmDecl {
pm,
source: ManifestSource::DevEngines,
version: entry.version,
on_fail,
});
}
last_decl
}
fn script_dispatching_pm(label: &str) -> Option<PackageManager> {
let pm = PackageManager::from_label(label)?;
matches!(
pm,
PackageManager::Npm
| PackageManager::Pnpm
| PackageManager::Yarn
| PackageManager::Bun
| PackageManager::Deno
)
.then_some(pm)
}
const fn default_on_fail_for_array_position(idx: usize, total: usize) -> OnFail {
if idx + 1 == total {
OnFail::Error
} else {
OnFail::Ignore
}
}
impl OnFail {
const fn from_proposal(raw: ProposalOnFail) -> Self {
match raw {
ProposalOnFail::Ignore => Self::Ignore,
ProposalOnFail::Warn | ProposalOnFail::Download => Self::Warn,
ProposalOnFail::Error => Self::Error,
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum VersionCheck {
Satisfied,
Mismatch {
declared: String,
actual: String,
},
Unverifiable {
#[allow(
dead_code,
reason = "consumed by --explain / runner doctor traces in Phase 6+"
)]
reason: String,
},
}
pub(crate) fn check_version_constraint(pm: PackageManager, declared: &str) -> VersionCheck {
let req = match semver::VersionReq::parse(declared) {
Ok(req) => req,
Err(err) => {
return VersionCheck::Unverifiable {
reason: format!("invalid semver range {declared:?}: {err}"),
};
}
};
let Some(raw_version) = installed_version(pm) else {
return VersionCheck::Unverifiable {
reason: format!(
"`{} --version` did not produce a parseable version",
pm.label()
),
};
};
let actual = match semver::Version::parse(&normalize_version(&raw_version)) {
Ok(v) => v,
Err(err) => {
return VersionCheck::Unverifiable {
reason: format!("could not parse `{raw_version}` as semver: {err}"),
};
}
};
if req.matches(&actual) {
VersionCheck::Satisfied
} else {
VersionCheck::Mismatch {
declared: declared.to_string(),
actual: actual.to_string(),
}
}
}
fn installed_version(pm: PackageManager) -> Option<String> {
let out = program::command(pm.label())
.arg("--version")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let trimmed = raw.trim();
let first_line = trimmed.lines().next().unwrap_or(trimmed).trim();
parse_version_token(first_line)
}
fn parse_version_token(line: &str) -> Option<String> {
line.split_whitespace().find_map(|token| {
let cleaned = token.strip_prefix('v').unwrap_or(token);
semver::Version::parse(&normalize_version(cleaned))
.ok()
.map(|_| cleaned.to_string())
})
}
fn normalize_version(raw: &str) -> String {
let segments: Vec<&str> = raw.split('.').collect();
match segments.len() {
1 => format!("{}.0.0", segments[0]),
2 => format!("{}.{}.0", segments[0], segments[1]),
_ => raw.to_string(),
}
}
#[derive(Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum ProposalOnFail {
Ignore,
Warn,
Error,
Download,
}
pub(crate) fn extract_scripts(dir: &Path) -> anyhow::Result<Vec<(String, String)>> {
let Some((path, content)) = read_manifest(dir)? else {
return Ok(vec![]);
};
let package_json = parse_manifest(&path, &content)
.with_context(|| format!("{} is not valid {}", path.display(), manifest_format(&path)))?;
Ok(package_json
.scripts
.map_or_else(Vec::new, |scripts| scripts.into_iter().collect()))
}
pub(crate) fn extract_scripts_upwards(dir: &Path) -> anyhow::Result<Vec<(String, String)>> {
let Some((path, content)) = read_manifest_upwards(dir)? else {
return Ok(vec![]);
};
let package_json = parse_manifest(&path, &content)
.with_context(|| format!("{} is not valid {}", path.display(), manifest_format(&path)))?;
Ok(package_json
.scripts
.map_or_else(Vec::new, |scripts| scripts.into_iter().collect()))
}
#[derive(Deserialize)]
struct PackageJson {
#[serde(rename = "packageManager")]
package_manager: Option<String>,
#[serde(rename = "devEngines", default)]
dev_engines: Option<DevEngines>,
scripts: Option<HashMap<String, String>>,
}
#[derive(Deserialize)]
struct DevEngines {
#[serde(rename = "packageManager", default)]
package_manager: Option<DevEnginesPmField>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum DevEnginesPmField {
Many(Vec<DevEngineDep>),
One(DevEngineDep),
}
#[derive(Deserialize)]
struct DevEngineDep {
name: String,
#[serde(default)]
version: Option<String>,
#[serde(rename = "onFail", default)]
on_fail: Option<ProposalOnFail>,
}
fn parse_package_json(dir: &Path) -> Option<PackageJson> {
let (path, content) = read_manifest(dir).ok()??;
parse_manifest(&path, &content)
}
fn read_manifest(dir: &Path) -> anyhow::Result<Option<(PathBuf, String)>> {
let Some(path) = find_manifest(dir) else {
return Ok(None);
};
read_manifest_file(&path)
}
fn read_manifest_upwards(dir: &Path) -> anyhow::Result<Option<(PathBuf, String)>> {
let Some(path) = find_manifest_upwards(dir) else {
return Ok(None);
};
read_manifest_file(&path)
}
fn read_manifest_file(path: &Path) -> anyhow::Result<Option<(PathBuf, String)>> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))
.map(|content| Some((path.to_path_buf(), content)))
}
fn parse_manifest(path: &Path, content: &str) -> Option<PackageJson> {
if path
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("package.json5"))
{
json5::from_str(content).ok()
} else if path
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("package.yaml"))
{
parse_package_yaml(content)
} else {
serde_json::from_str(content).ok()
}
}
fn parse_package_yaml(content: &str) -> Option<PackageJson> {
let docs = YamlLoader::load_from_str(content).ok()?;
let doc = docs.first()?;
let root = doc.as_hash()?;
let package_manager = root
.iter()
.find_map(|(key, value)| (key.as_str() == Some("packageManager")).then_some(value))
.and_then(yaml_rust2::Yaml::as_str)
.map(ToOwned::to_owned);
let scripts = root
.iter()
.find_map(|(key, value)| (key.as_str() == Some("scripts")).then_some(value))
.and_then(yaml_rust2::Yaml::as_hash)
.map(|table| {
table
.iter()
.filter_map(|(name, body)| {
let name = name.as_str()?.to_owned();
let body = body.as_str().unwrap_or_default().to_owned();
Some((name, body))
})
.collect::<HashMap<_, _>>()
})
.filter(|table| !table.is_empty());
Some(PackageJson {
package_manager,
dev_engines: None,
scripts,
})
}
fn manifest_format(path: &Path) -> &'static str {
if path
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("package.json5"))
{
"JSON5"
} else if path
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("package.yaml"))
{
"YAML"
} else {
"JSON"
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
detect_pm_from_field, extract_scripts, extract_scripts_upwards, find_manifest_upwards,
};
use crate::tool::test_support::TempDir;
use crate::types::PackageManager;
#[test]
fn detect_pm_from_field_supports_package_json5() {
let dir = TempDir::new("node-package-json5-pm");
fs::write(
dir.path().join("package.json5"),
"{ packageManager: 'pnpm@9.0.0' }",
)
.expect("package.json5 should be written");
assert_eq!(detect_pm_from_field(dir.path()), Some(PackageManager::Pnpm));
}
#[test]
fn extract_scripts_supports_package_json5() {
let dir = TempDir::new("node-package-json5-scripts");
fs::write(
dir.path().join("package.json5"),
"{ scripts: { build: 'vite build', test: 'vitest' } }",
)
.expect("package.json5 should be written");
let mut scripts =
extract_scripts(dir.path()).expect("scripts should parse from package.json5");
scripts.sort_unstable();
assert_eq!(
scripts,
[
("build".to_owned(), "vite build".to_owned()),
("test".to_owned(), "vitest".to_owned()),
]
);
}
#[test]
fn detect_pm_from_field_supports_package_yaml() {
let dir = TempDir::new("node-package-yaml-pm");
fs::write(
dir.path().join("package.yaml"),
"packageManager: yarn@4.3.0\n",
)
.expect("package.yaml should be written");
assert_eq!(detect_pm_from_field(dir.path()), Some(PackageManager::Yarn));
}
#[test]
fn detect_pm_from_field_supports_deno_package_manager() {
let dir = TempDir::new("node-package-json-deno-pm");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "deno@2.7.12" }"#,
)
.expect("package.json should be written");
assert_eq!(detect_pm_from_field(dir.path()), Some(PackageManager::Deno));
}
#[test]
fn extract_scripts_supports_package_yaml() {
let dir = TempDir::new("node-package-yaml-scripts");
fs::write(
dir.path().join("package.yaml"),
"scripts:\n build: vite build\n test: vitest\n",
)
.expect("package.yaml should be written");
let mut scripts =
extract_scripts(dir.path()).expect("scripts should parse from package.yaml");
scripts.sort_unstable();
assert_eq!(
scripts,
[
("build".to_owned(), "vite build".to_owned()),
("test".to_owned(), "vitest".to_owned()),
]
);
}
#[test]
fn extract_scripts_supports_inline_yaml_script_map() {
let dir = TempDir::new("node-package-yaml-inline-scripts");
fs::write(
dir.path().join("package.yaml"),
"scripts: { build: vite build, test: vitest }\n",
)
.expect("package.yaml should be written");
let mut scripts =
extract_scripts(dir.path()).expect("scripts should parse from inline YAML map");
scripts.sort_unstable();
assert_eq!(
scripts,
[
("build".to_owned(), "vite build".to_owned()),
("test".to_owned(), "vitest".to_owned()),
]
);
}
#[test]
fn find_manifest_upwards_prefers_nearest_manifest() {
let dir = TempDir::new("node-manifest-upwards");
let nested = dir.path().join("apps").join("site").join("src");
fs::create_dir_all(&nested).expect("nested dir should be created");
fs::write(
dir.path().join("package.json"),
r#"{ "scripts": { "root": "1" } }"#,
)
.expect("root package.json should be written");
fs::write(
dir.path().join("apps").join("site").join("package.json"),
r#"{ "scripts": { "member": "1" } }"#,
)
.expect("member package.json should be written");
let path = find_manifest_upwards(&nested).expect("nearest manifest should resolve");
assert!(path.ends_with("apps/site/package.json"));
}
#[test]
fn detect_pm_from_manifest_prefers_package_manager_field() {
use super::{ManifestSource, OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-package-manager");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "yarn@4.3.0",
"devEngines": { "packageManager": { "name": "pnpm", "version": "9", "onFail": "error" } } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.pm, PackageManager::Yarn);
assert_eq!(decl.source, ManifestSource::PackageManager);
assert_eq!(decl.version.as_deref(), Some("4.3.0"));
assert_eq!(decl.on_fail, OnFail::Ignore);
}
#[test]
fn detect_pm_from_manifest_uses_dev_engines_when_package_manager_absent() {
use super::{ManifestSource, OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-dev-engines");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "pnpm", "version": "9.0.0", "onFail": "error" } } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.pm, PackageManager::Pnpm);
assert_eq!(decl.source, ManifestSource::DevEngines);
assert_eq!(decl.version.as_deref(), Some("9.0.0"));
assert_eq!(decl.on_fail, OnFail::Error);
}
#[test]
fn detect_pm_from_manifest_blocks_dev_engines_when_package_manager_unparseable() {
use super::detect_pm_from_manifest;
let dir = TempDir::new("node-manifest-decl-unparseable-pm-field");
fs::write(
dir.path().join("package.json"),
r#"{
"packageManager": "pnpmm@9",
"devEngines": { "packageManager": { "name": "yarn" } }
}"#,
)
.expect("package.json should be written");
assert!(
detect_pm_from_manifest(dir.path()).is_none(),
"unparseable packageManager must NOT silently elevate devEngines",
);
}
#[test]
fn detect_pm_from_manifest_treats_empty_package_manager_as_unset() {
use super::{ManifestSource, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-empty-pm-field");
fs::write(
dir.path().join("package.json"),
r#"{
"packageManager": " ",
"devEngines": { "packageManager": { "name": "yarn" } }
}"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("devEngines should still resolve");
assert_eq!(decl.pm, PackageManager::Yarn);
assert_eq!(decl.source, ManifestSource::DevEngines);
}
#[test]
fn parse_package_manager_spec_rejects_trailing_at_sign() {
use super::parse_package_manager_spec;
assert!(parse_package_manager_spec(Some("pnpm@")).is_none());
assert!(parse_package_manager_spec(Some("npm@")).is_none());
assert!(parse_package_manager_spec(Some(" pnpm@ ".trim())).is_none());
}
#[test]
fn parse_package_manager_spec_accepts_bare_name() {
use super::parse_package_manager_spec;
let (pm, version) =
parse_package_manager_spec(Some("pnpm")).expect("bare name still parses");
assert_eq!(pm, PackageManager::Pnpm);
assert!(version.is_none());
}
#[test]
fn detect_pm_from_manifest_surfaces_trailing_at_as_unparseable() {
use super::detect_pm_from_manifest;
let dir = TempDir::new("node-manifest-decl-trailing-at");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "pnpm@" }"#,
)
.expect("package.json should be written");
assert!(detect_pm_from_manifest(dir.path()).is_none());
}
#[test]
fn detect_pm_from_manifest_default_on_fail_for_single_object_is_error() {
use super::{OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-default-on-fail");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "bun" } } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.pm, PackageManager::Bun);
assert_eq!(decl.on_fail, OnFail::Error);
}
#[test]
fn detect_pm_from_manifest_uses_last_array_entry_with_error_default() {
use super::{OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-array");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": [
{ "name": "yarn", "version": "1" },
{ "name": "pnpm", "version": "9" }
] } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.pm, PackageManager::Pnpm);
assert_eq!(decl.on_fail, OnFail::Error);
}
#[test]
fn detect_pm_from_manifest_honors_explicit_on_fail_warn() {
use super::{OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-warn");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "yarn", "onFail": "warn" } } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.on_fail, OnFail::Warn);
}
#[test]
fn detect_pm_from_manifest_treats_download_as_warn() {
use super::{OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-download");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "yarn", "onFail": "download" } } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.on_fail, OnFail::Warn);
}
#[test]
fn detect_pm_from_manifest_returns_none_for_unknown_name() {
use super::detect_pm_from_manifest;
let dir = TempDir::new("node-manifest-decl-unknown");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "zoot" } } }"#,
)
.expect("package.json should be written");
assert!(detect_pm_from_manifest(dir.path()).is_none());
}
#[test]
fn detect_pm_from_manifest_rejects_non_script_dispatching_pm() {
use super::detect_pm_from_manifest;
let dir = TempDir::new("node-manifest-decl-cargo");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "cargo" } } }"#,
)
.expect("package.json should be written");
assert!(detect_pm_from_manifest(dir.path()).is_none());
}
#[test]
fn detect_pm_from_manifest_array_trailing_unresolvable_does_not_downgrade_on_fail() {
use super::{OnFail, detect_pm_from_manifest};
let dir = TempDir::new("node-manifest-decl-array-trailing-unresolvable");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": [
{ "name": "pnpm", "version": "9" },
{ "name": "zoot-unknown" }
] } }"#,
)
.expect("package.json should be written");
let decl = detect_pm_from_manifest(dir.path()).expect("decl should be present");
assert_eq!(decl.pm, PackageManager::Pnpm);
assert_eq!(decl.on_fail, OnFail::Error);
}
#[test]
fn check_version_constraint_satisfied_for_matching_range() {
use super::{VersionCheck, check_version_constraint};
let res = check_version_constraint(PackageManager::Cargo, ">=1.0.0");
match res {
VersionCheck::Satisfied => {}
VersionCheck::Mismatch { declared, actual } => {
panic!("expected satisfaction, got mismatch: {declared} vs {actual}");
}
VersionCheck::Unverifiable { reason } => {
eprintln!("skipping: {reason}");
}
}
}
#[test]
fn parse_version_token_handles_bare_semver() {
use super::parse_version_token;
assert_eq!(parse_version_token("10.9.2"), Some("10.9.2".to_string()));
assert_eq!(parse_version_token("9.0.0"), Some("9.0.0".to_string()));
assert_eq!(parse_version_token("1.22.22"), Some("1.22.22".to_string()));
}
#[test]
fn parse_version_token_strips_v_prefix() {
use super::parse_version_token;
assert_eq!(parse_version_token("v20.11.0"), Some("20.11.0".to_string()));
}
#[test]
fn parse_version_token_finds_version_in_deno_verbose_output() {
use super::parse_version_token;
let line = "deno 2.7.12 (stable, release, x86_64-unknown-linux-gnu)";
assert_eq!(parse_version_token(line), Some("2.7.12".to_string()));
}
#[test]
fn parse_version_token_handles_deno_short_flag_output() {
use super::parse_version_token;
assert_eq!(
parse_version_token("deno 2.7.12"),
Some("2.7.12".to_string())
);
}
#[test]
fn parse_version_token_handles_bare_major_minor() {
use super::parse_version_token;
assert_eq!(parse_version_token("9"), Some("9".to_string()));
assert_eq!(parse_version_token("9.5"), Some("9.5".to_string()));
}
#[test]
fn parse_version_token_returns_none_for_garbage() {
use super::parse_version_token;
assert_eq!(parse_version_token(""), None);
assert_eq!(parse_version_token("not a version"), None);
assert_eq!(parse_version_token("---"), None);
}
#[test]
fn check_version_constraint_returns_unverifiable_for_invalid_range() {
use super::{VersionCheck, check_version_constraint};
let res = check_version_constraint(PackageManager::Cargo, "not-a-range");
assert!(matches!(res, VersionCheck::Unverifiable { .. }));
}
#[test]
fn extract_scripts_upwards_reads_nearest_manifest() {
let dir = TempDir::new("node-scripts-upwards");
let nested = dir.path().join("apps").join("site").join("src");
fs::create_dir_all(&nested).expect("nested dir should be created");
fs::write(
dir.path().join("package.json"),
r#"{ "scripts": { "root": "1" } }"#,
)
.expect("root package.json should be written");
fs::write(
dir.path().join("apps").join("site").join("package.json"),
r#"{ "scripts": { "member": "1" } }"#,
)
.expect("member package.json should be written");
let tasks = extract_scripts_upwards(&nested).expect("nearest scripts should parse");
assert_eq!(tasks, [("member".to_owned(), "1".to_owned())]);
}
}