use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use seshat_core::ir::{Language, ProjectFile};
use seshat_core::{DependencyDomain, classify_domain};
use crate::error::ScanError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestType {
CargoToml,
PackageJson,
PyprojectToml,
}
impl ManifestType {
pub fn from_filename(name: &str) -> Option<Self> {
match name {
"Cargo.toml" => Some(Self::CargoToml),
"package.json" => Some(Self::PackageJson),
"pyproject.toml" => Some(Self::PyprojectToml),
_ => None,
}
}
pub fn all_filenames() -> &'static [&'static str] {
&["Cargo.toml", "package.json", "pyproject.toml"]
}
}
#[derive(Debug, Clone)]
pub struct DeclaredDependency {
pub name: String,
pub version: String,
pub is_dev: bool,
pub category: DependencyDomain,
}
#[derive(Debug, Clone)]
pub struct DependencyUsageStats {
pub dependency: DeclaredDependency,
pub files_using: usize,
pub is_dead: bool,
}
#[derive(Debug, Clone)]
pub struct ManifestAnalysis {
pub manifest_path: PathBuf,
pub manifest_type: ManifestType,
pub dependencies: Vec<DependencyUsageStats>,
pub internal_names: Vec<String>,
}
pub fn parse_manifest(
path: &Path,
content: &str,
manifest_type: ManifestType,
) -> Result<Vec<DeclaredDependency>, ScanError> {
match manifest_type {
ManifestType::CargoToml => parse_cargo_toml(path, content),
ManifestType::PackageJson => parse_package_json(path, content),
ManifestType::PyprojectToml => parse_pyproject_toml(path, content),
}
}
pub fn analyze_manifests(
manifests: &[(PathBuf, String, ManifestType)],
parsed_files: &[ProjectFile],
) -> Result<Vec<ManifestAnalysis>, ScanError> {
let mut results = Vec::with_capacity(manifests.len());
for (path, content, manifest_type) in manifests {
let declared = parse_manifest(path, content, *manifest_type)?;
let stats = cross_reference(&declared, parsed_files, *manifest_type);
let internal_names = match manifest_type {
ManifestType::CargoToml => extract_crate_names(path, content),
ManifestType::PyprojectToml => extract_package_names(path, content),
ManifestType::PackageJson => extract_js_package_names(path, content),
};
results.push(ManifestAnalysis {
manifest_path: path.clone(),
manifest_type: *manifest_type,
dependencies: stats,
internal_names,
});
}
Ok(results)
}
#[derive(Deserialize)]
struct CargoManifest {
#[serde(default)]
dependencies: HashMap<String, toml::Value>,
#[serde(default, rename = "dev-dependencies")]
dev_dependencies: HashMap<String, toml::Value>,
}
fn parse_cargo_toml(path: &Path, content: &str) -> Result<Vec<DeclaredDependency>, ScanError> {
let manifest: CargoManifest =
toml::from_str(content).map_err(|e| ScanError::ManifestError {
path: path.to_path_buf(),
reason: format!("invalid TOML: {e}"),
})?;
let mut deps = Vec::new();
for (name, value) in &manifest.dependencies {
let version = extract_cargo_version(value);
deps.push(DeclaredDependency {
name: name.clone(),
version,
is_dev: false,
category: categorize_dependency(name, ManifestType::CargoToml),
});
}
for (name, value) in &manifest.dev_dependencies {
let version = extract_cargo_version(value);
deps.push(DeclaredDependency {
name: name.clone(),
version,
is_dev: true,
category: categorize_dependency(name, ManifestType::CargoToml),
});
}
Ok(deps)
}
fn extract_cargo_version(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_owned(),
_ => "*".to_owned(),
}
}
fn extract_crate_names(path: &Path, content: &str) -> Vec<String> {
#[derive(Deserialize)]
struct PackageInfo {
#[serde(default)]
name: Option<String>,
}
#[derive(Deserialize)]
struct WorkspaceInfo {
#[serde(default)]
members: Vec<String>,
}
#[derive(Deserialize)]
struct PartialCargoToml {
package: Option<PackageInfo>,
workspace: Option<WorkspaceInfo>,
}
let manifest: PartialCargoToml = match toml::from_str(content) {
Ok(m) => m,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to parse Cargo.toml for crate name extraction");
return Vec::new();
}
};
let mut names = Vec::new();
if let Some(ref pkg) = manifest.package {
if let Some(ref name) = pkg.name {
names.push(name.replace('-', "_"));
}
}
if let Some(ws) = &manifest.workspace {
let manifest_dir = path.parent().unwrap_or(Path::new("."));
for member in &ws.members {
let dirs: Vec<PathBuf> = if is_glob_pattern(member) {
expand_glob_member(manifest_dir, member)
} else {
vec![manifest_dir.join(member)]
};
for dir in dirs {
let Some(crate_name) = read_inner_crate_name(&dir.join("Cargo.toml")) else {
continue;
};
if !crate_name.is_empty() {
names.push(crate_name.replace('-', "_"));
}
}
}
}
names
}
fn is_glob_pattern(s: &str) -> bool {
s.contains('*') || s.contains('?') || s.contains('[')
}
fn expand_glob_member(manifest_dir: &Path, pattern: &str) -> Vec<PathBuf> {
if Path::new(pattern).is_absolute() {
tracing::warn!(
pattern = %pattern,
"Absolute path in [workspace.members] glob; skipping",
);
return Vec::new();
}
let joined = manifest_dir.join(pattern);
let Some(pattern_str) = joined.to_str() else {
tracing::warn!(
pattern = %pattern,
manifest_dir = %manifest_dir.display(),
"Non-UTF8 path while expanding workspace-member glob; skipping",
);
return Vec::new();
};
#[cfg(windows)]
let pattern_owned = pattern_str.replace('\\', "/");
#[cfg(windows)]
let pattern_str: &str = pattern_owned.as_str();
let paths = match glob::glob(pattern_str) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
pattern = %pattern_str,
error = %e,
"Invalid glob pattern in [workspace.members]; skipping",
);
return Vec::new();
}
};
let mut out = Vec::new();
for entry in paths {
match entry {
Ok(path) if path.is_dir() => out.push(path),
Ok(_) => {}
Err(e) => tracing::warn!(
pattern = %pattern_str,
error = %e,
"I/O error while expanding workspace-member glob entry; skipping",
),
}
}
out
}
fn is_safe_workspace_pattern(pattern: &str) -> bool {
let p = Path::new(pattern);
if p.is_absolute() {
return false;
}
p.components()
.all(|c| !matches!(c, std::path::Component::ParentDir))
}
fn strip_utf8_bom(s: &str) -> &str {
s.strip_prefix('\u{FEFF}').unwrap_or(s)
}
fn read_inner_crate_name(path: &Path) -> Option<String> {
#[derive(Deserialize)]
struct InnerPackage {
name: Option<String>,
}
#[derive(Deserialize)]
struct InnerCargo {
package: Option<InnerPackage>,
}
let content = std::fs::read_to_string(path).ok()?;
let manifest: InnerCargo = toml::from_str(&content).ok()?;
manifest.package?.name
}
fn extract_package_names(path: &Path, content: &str) -> Vec<String> {
#[derive(Deserialize)]
struct Pep621Project {
name: String,
}
#[derive(Deserialize)]
struct PoetrySection {
name: Option<String>,
}
#[derive(Deserialize)]
struct ToolSection {
poetry: Option<PoetrySection>,
}
#[derive(Deserialize)]
struct PyprojectNames {
project: Option<Pep621Project>,
tool: Option<ToolSection>,
}
let manifest: PyprojectNames = match toml::from_str(content) {
Ok(m) => m,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to parse pyproject.toml for package name extraction");
return Vec::new();
}
};
let mut names = Vec::new();
if let Some(project) = manifest.project {
names.push(project.name.replace('-', "_"));
return names;
}
if let Some(tool) = manifest.tool {
if let Some(poetry) = tool.poetry {
if let Some(name) = poetry.name {
names.push(name.replace('-', "_"));
}
}
}
names
}
#[derive(Deserialize)]
struct PackageJson {
#[serde(default)]
dependencies: HashMap<String, String>,
#[serde(default, rename = "devDependencies")]
dev_dependencies: HashMap<String, String>,
}
fn parse_package_json(path: &Path, content: &str) -> Result<Vec<DeclaredDependency>, ScanError> {
let manifest: PackageJson =
serde_json::from_str(content).map_err(|e| ScanError::ManifestError {
path: path.to_path_buf(),
reason: format!("invalid JSON: {e}"),
})?;
let mut deps = Vec::new();
for (name, version) in &manifest.dependencies {
deps.push(DeclaredDependency {
name: name.clone(),
version: version.clone(),
is_dev: false,
category: categorize_dependency(name, ManifestType::PackageJson),
});
}
for (name, version) in &manifest.dev_dependencies {
deps.push(DeclaredDependency {
name: name.clone(),
version: version.clone(),
is_dev: true,
category: categorize_dependency(name, ManifestType::PackageJson),
});
}
Ok(deps)
}
fn extract_js_package_names(path: &Path, content: &str) -> Vec<String> {
let manifest_dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => {
tracing::warn!(
path = %path.display(),
"package.json path has no usable parent directory; skipping workspace extraction"
);
return Vec::new();
}
};
let content = strip_utf8_bom(content);
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to parse package.json for workspace package name extraction");
return Vec::new();
}
};
let mut names = Vec::new();
if let Some(name) = value.get("name").and_then(|v| v.as_str()) {
if !name.trim().is_empty() {
names.push(name.to_owned());
}
}
let patterns: Vec<String> = match value.get("workspaces") {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
Some(serde_json::Value::Object(obj)) => obj
.get("packages")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
_ => Vec::new(),
};
for pattern in &patterns {
for dir in expand_js_workspace_pattern(manifest_dir, pattern) {
if let Some(name) = read_inner_package_name(&dir.join("package.json")) {
if !name.trim().is_empty() {
names.push(name);
}
}
}
}
names.sort();
names.dedup();
names
}
fn expand_js_workspace_pattern(manifest_dir: &Path, pattern: &str) -> Vec<PathBuf> {
if pattern.trim().is_empty() {
return Vec::new();
}
if !is_safe_workspace_pattern(pattern) {
tracing::warn!(
pattern = %pattern,
"rejecting unsafe workspace pattern (absolute path or `..` segment)"
);
return Vec::new();
}
if !is_glob_pattern(pattern) {
let p = manifest_dir.join(pattern);
return if p.is_dir() { vec![p] } else { Vec::new() };
}
let abs_pattern = manifest_dir.join(pattern);
let abs_str = match abs_pattern.to_str() {
Some(s) => s,
None => {
tracing::warn!(pattern = %pattern, "non-UTF8 workspace pattern; skipping");
return Vec::new();
}
};
#[cfg(windows)]
let abs_owned = abs_str.replace('\\', "/");
#[cfg(windows)]
let abs_str: &str = abs_owned.as_str();
match glob::glob(abs_str) {
Ok(iter) => {
let mut matches: Vec<PathBuf> =
iter.filter_map(Result::ok).filter(|p| p.is_dir()).collect();
matches.sort();
matches
}
Err(e) => {
tracing::warn!(pattern = %pattern, error = %e, "invalid workspace glob pattern");
Vec::new()
}
}
}
fn read_inner_package_name(path: &Path) -> Option<String> {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to read workspace package.json"
);
return None;
}
};
let content = strip_utf8_bom(&content);
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to parse workspace package.json"
);
return None;
}
};
value.get("name").and_then(|v| v.as_str()).map(String::from)
}
#[allow(dead_code)]
fn parse_pnpm_workspace_yaml(path: &Path) -> Vec<String> {
let manifest_dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => {
tracing::warn!(
path = %path.display(),
"pnpm-workspace.yaml path has no usable parent directory; skipping"
);
return Vec::new();
}
};
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to read pnpm-workspace.yaml");
return Vec::new();
}
};
let content = strip_utf8_bom(&content);
let value: serde_yml::Value = match serde_yml::from_str(content) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to parse pnpm-workspace.yaml");
return Vec::new();
}
};
let patterns: Vec<String> = value
.get("packages")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut names = Vec::new();
for pattern in &patterns {
for dir in expand_js_workspace_pattern(manifest_dir, pattern) {
if let Some(name) = read_inner_package_name(&dir.join("package.json")) {
if !name.trim().is_empty() {
names.push(name);
}
}
}
}
names.sort();
names.dedup();
names
}
#[derive(Deserialize)]
struct PyprojectToml {
#[serde(default)]
project: Option<PyprojectProject>,
}
#[derive(Deserialize)]
struct PyprojectProject {
#[serde(default)]
dependencies: Vec<String>,
#[serde(default, rename = "optional-dependencies")]
optional_dependencies: HashMap<String, Vec<String>>,
}
fn parse_pyproject_toml(path: &Path, content: &str) -> Result<Vec<DeclaredDependency>, ScanError> {
let manifest: PyprojectToml =
toml::from_str(content).map_err(|e| ScanError::ManifestError {
path: path.to_path_buf(),
reason: format!("invalid TOML: {e}"),
})?;
let project = match manifest.project {
Some(p) => p,
None => return Ok(Vec::new()),
};
let mut deps = Vec::new();
for spec in &project.dependencies {
let (name, version) = parse_pep508_name_version(spec);
let category = categorize_dependency(&name, ManifestType::PyprojectToml);
deps.push(DeclaredDependency {
name,
version,
is_dev: false,
category,
});
}
let dev_group_names = ["dev", "test", "testing"];
for (group, group_deps) in &project.optional_dependencies {
let is_dev = dev_group_names.contains(&group.to_lowercase().as_str());
for spec in group_deps {
let (name, version) = parse_pep508_name_version(spec);
deps.push(DeclaredDependency {
name: name.clone(),
version,
is_dev,
category: categorize_dependency(&name, ManifestType::PyprojectToml),
});
}
}
Ok(deps)
}
fn parse_pep508_name_version(spec: &str) -> (String, String) {
let name_end = spec
.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.')
.unwrap_or(spec.len());
let name = spec[..name_end].trim().to_lowercase().replace('-', "_");
let version = spec[name_end..].trim().to_owned();
let version = if version.is_empty() {
"*".to_owned()
} else {
version
};
(name, version)
}
fn cross_reference(
declared: &[DeclaredDependency],
parsed_files: &[ProjectFile],
manifest_type: ManifestType,
) -> Vec<DependencyUsageStats> {
declared
.iter()
.map(|dep| {
let files_using = count_files_importing(&dep.name, parsed_files, manifest_type);
DependencyUsageStats {
dependency: dep.clone(),
files_using,
is_dead: files_using == 0,
}
})
.collect()
}
fn count_files_importing(
dep_name: &str,
parsed_files: &[ProjectFile],
manifest_type: ManifestType,
) -> usize {
let normalised = dep_name.replace('-', "_");
parsed_files
.iter()
.filter(|pf| {
pf.imports.iter().any(|imp| {
let module = &imp.module;
match manifest_type {
ManifestType::CargoToml => {
let mod_normalised = module.replace('-', "_");
mod_normalised == normalised
|| mod_normalised.starts_with(&format!("{normalised}::"))
}
ManifestType::PackageJson => {
module == dep_name || module.starts_with(&format!("{dep_name}/"))
}
ManifestType::PyprojectToml => {
let mod_normalised = module.replace('-', "_").to_lowercase();
mod_normalised == normalised
|| mod_normalised.starts_with(&format!("{normalised}."))
}
}
})
})
.count()
}
fn manifest_type_to_language(mt: ManifestType) -> Language {
match mt {
ManifestType::CargoToml => Language::Rust,
ManifestType::PackageJson => Language::TypeScript,
ManifestType::PyprojectToml => Language::Python,
}
}
pub fn categorize_dependency(name: &str, manifest_type: ManifestType) -> DependencyDomain {
classify_domain(name, manifest_type_to_language(manifest_type))
.unwrap_or(DependencyDomain::Unknown)
}
#[cfg(test)]
mod tests {
use super::*;
use seshat_core::DependencyDomain;
use seshat_core::ir::{Import, Language, LanguageIR, RustIR};
use tempfile::tempdir;
fn make_pf_with_imports(imports: Vec<Import>, language: Language) -> ProjectFile {
ProjectFile {
path: PathBuf::from("test.rs"),
language,
content_hash: String::new(),
imports,
exports: Vec::new(),
functions: Vec::new(),
types: Vec::new(),
dependencies_used: Vec::new(),
language_ir: match language {
Language::Rust => LanguageIR::Rust(RustIR::default()),
Language::TypeScript => {
LanguageIR::TypeScript(seshat_core::ir::TypeScriptIR::default())
}
Language::JavaScript => {
LanguageIR::JavaScript(seshat_core::ir::JavaScriptIR::default())
}
Language::Python => LanguageIR::Python(seshat_core::ir::PythonIR::default()),
},
file_doc: None,
}
}
fn make_import(module: &str) -> Import {
Import {
module: module.to_owned(),
names: Vec::new(),
is_type_only: false,
line: 1,
}
}
#[test]
fn manifest_type_from_filename() {
assert_eq!(
ManifestType::from_filename("Cargo.toml"),
Some(ManifestType::CargoToml)
);
assert_eq!(
ManifestType::from_filename("package.json"),
Some(ManifestType::PackageJson)
);
assert_eq!(
ManifestType::from_filename("pyproject.toml"),
Some(ManifestType::PyprojectToml)
);
assert_eq!(ManifestType::from_filename("Makefile"), None);
}
#[test]
fn cargo_toml_simple_version() {
let content = r#"
[dependencies]
serde = "1.0"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tempfile = "3"
"#;
let deps = parse_cargo_toml(Path::new("Cargo.toml"), content).unwrap();
assert_eq!(deps.len(), 3);
let serde_dep = deps.iter().find(|d| d.name == "serde").unwrap();
assert_eq!(serde_dep.version, "1.0");
assert!(!serde_dep.is_dev);
let tokio_dep = deps.iter().find(|d| d.name == "tokio").unwrap();
assert_eq!(tokio_dep.version, "1");
assert!(!tokio_dep.is_dev);
let tempfile_dep = deps.iter().find(|d| d.name == "tempfile").unwrap();
assert_eq!(tempfile_dep.version, "3");
assert!(tempfile_dep.is_dev);
}
#[test]
fn cargo_toml_path_dependency() {
let content = r#"
[dependencies]
my-crate = { path = "../my-crate" }
"#;
let deps = parse_cargo_toml(Path::new("Cargo.toml"), content).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "my-crate");
assert_eq!(deps[0].version, "*"); }
#[test]
fn cargo_toml_workspace_dependency() {
let content = r#"
[dependencies]
serde.workspace = true
"#;
let deps = parse_cargo_toml(Path::new("Cargo.toml"), content).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "serde");
assert_eq!(deps[0].version, "*");
}
#[test]
fn cargo_toml_empty() {
let content = "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n";
let deps = parse_cargo_toml(Path::new("Cargo.toml"), content).unwrap();
assert!(deps.is_empty());
}
#[test]
fn cargo_toml_invalid() {
let content = "this is not valid toml {{{}";
let result = parse_cargo_toml(Path::new("Cargo.toml"), content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ScanError::ManifestError { .. }));
}
#[test]
fn package_json_basic() {
let content = r#"{
"dependencies": {
"react": "^18.2.0",
"axios": "^1.6.0"
},
"devDependencies": {
"jest": "^29.0.0"
}
}"#;
let deps = parse_package_json(Path::new("package.json"), content).unwrap();
assert_eq!(deps.len(), 3);
let react = deps.iter().find(|d| d.name == "react").unwrap();
assert_eq!(react.version, "^18.2.0");
assert!(!react.is_dev);
let jest = deps.iter().find(|d| d.name == "jest").unwrap();
assert!(jest.is_dev);
}
#[test]
fn package_json_no_deps() {
let content = r#"{ "name": "my-pkg", "version": "1.0.0" }"#;
let deps = parse_package_json(Path::new("package.json"), content).unwrap();
assert!(deps.is_empty());
}
#[test]
fn package_json_invalid() {
let content = "not json {}}";
let result = parse_package_json(Path::new("package.json"), content);
assert!(result.is_err());
}
#[test]
fn pyproject_toml_basic() {
let content = r#"
[project]
dependencies = [
"requests>=2.28",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "black"]
docs = ["sphinx"]
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert_eq!(deps.len(), 5);
let requests = deps.iter().find(|d| d.name == "requests").unwrap();
assert_eq!(requests.version, ">=2.28");
assert!(!requests.is_dev);
let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
assert!(pytest.is_dev);
let sphinx = deps.iter().find(|d| d.name == "sphinx").unwrap();
assert!(!sphinx.is_dev); }
#[test]
fn pyproject_toml_no_project_table() {
let content = r#"
[tool.poetry]
name = "my-pkg"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(deps.is_empty());
}
#[test]
fn pyproject_toml_test_group_is_dev() {
let content = r#"
[project]
dependencies = []
[project.optional-dependencies]
test = ["pytest"]
testing = ["hypothesis"]
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(deps.iter().all(|d| d.is_dev));
}
#[test]
fn pyproject_toml_invalid() {
let content = "not valid [[[ toml";
let result = parse_pyproject_toml(Path::new("pyproject.toml"), content);
assert!(result.is_err());
}
#[test]
fn pep508_simple_name() {
let (name, version) = parse_pep508_name_version("requests");
assert_eq!(name, "requests");
assert_eq!(version, "*");
}
#[test]
fn pep508_with_version() {
let (name, version) = parse_pep508_name_version("requests>=2.28");
assert_eq!(name, "requests");
assert_eq!(version, ">=2.28");
}
#[test]
fn pep508_with_extras() {
let (name, version) = parse_pep508_name_version("uvicorn[standard]>=0.20");
assert_eq!(name, "uvicorn");
assert_eq!(version, "[standard]>=0.20");
}
#[test]
fn pep508_normalises_hyphens() {
let (name, _) = parse_pep508_name_version("my-cool-package>=1.0");
assert_eq!(name, "my_cool_package");
}
#[test]
fn cross_reference_finds_usage() {
let declared = vec![DeclaredDependency {
name: "serde".to_owned(),
version: "1".to_owned(),
is_dev: false,
category: DependencyDomain::Serialization,
}];
let files = vec![
make_pf_with_imports(vec![make_import("serde::Serialize")], Language::Rust),
make_pf_with_imports(vec![make_import("serde")], Language::Rust),
make_pf_with_imports(vec![make_import("tokio::spawn")], Language::Rust),
];
let stats = cross_reference(&declared, &files, ManifestType::CargoToml);
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].files_using, 2);
assert!(!stats[0].is_dead);
}
#[test]
fn cross_reference_dead_dependency() {
let declared = vec![DeclaredDependency {
name: "never-used".to_owned(),
version: "1".to_owned(),
is_dev: false,
category: DependencyDomain::Unknown,
}];
let files = vec![make_pf_with_imports(
vec![make_import("serde")],
Language::Rust,
)];
let stats = cross_reference(&declared, &files, ManifestType::CargoToml);
assert_eq!(stats[0].files_using, 0);
assert!(stats[0].is_dead);
}
#[test]
fn cross_reference_cargo_normalises_hyphens() {
let declared = vec![DeclaredDependency {
name: "serde-json".to_owned(),
version: "1".to_owned(),
is_dev: false,
category: DependencyDomain::Serialization,
}];
let files = vec![make_pf_with_imports(
vec![make_import("serde_json::Value")],
Language::Rust,
)];
let stats = cross_reference(&declared, &files, ManifestType::CargoToml);
assert_eq!(stats[0].files_using, 1);
assert!(!stats[0].is_dead);
}
#[test]
fn cross_reference_npm_scoped_package() {
let declared = vec![DeclaredDependency {
name: "@testing-library/react".to_owned(),
version: "^14".to_owned(),
is_dev: true,
category: DependencyDomain::Testing,
}];
let files = vec![make_pf_with_imports(
vec![make_import("@testing-library/react")],
Language::TypeScript,
)];
let stats = cross_reference(&declared, &files, ManifestType::PackageJson);
assert_eq!(stats[0].files_using, 1);
assert!(!stats[0].is_dead);
}
#[test]
fn cross_reference_npm_subpath() {
let declared = vec![DeclaredDependency {
name: "react-dom".to_owned(),
version: "^18".to_owned(),
is_dev: false,
category: DependencyDomain::Unknown,
}];
let files = vec![make_pf_with_imports(
vec![make_import("react-dom/client")],
Language::TypeScript,
)];
let stats = cross_reference(&declared, &files, ManifestType::PackageJson);
assert_eq!(stats[0].files_using, 1);
assert!(!stats[0].is_dead);
}
#[test]
fn cross_reference_python_normalises() {
let declared = vec![DeclaredDependency {
name: "my_package".to_owned(),
version: ">=1.0".to_owned(),
is_dev: false,
category: DependencyDomain::Unknown,
}];
let files = vec![make_pf_with_imports(
vec![make_import("my_package.utils")],
Language::Python,
)];
let stats = cross_reference(&declared, &files, ManifestType::PyprojectToml);
assert_eq!(stats[0].files_using, 1);
assert!(!stats[0].is_dead);
}
#[test]
fn categorize_known_rust_deps() {
assert_eq!(
categorize_dependency("serde", ManifestType::CargoToml),
DependencyDomain::Serialization
);
assert_eq!(
categorize_dependency("tokio", ManifestType::CargoToml),
DependencyDomain::AsyncRuntime
);
assert_eq!(
categorize_dependency("axum", ManifestType::CargoToml),
DependencyDomain::WebFramework
);
assert_eq!(
categorize_dependency("tracing", ManifestType::CargoToml),
DependencyDomain::Logging
);
assert_eq!(
categorize_dependency("rusqlite", ManifestType::CargoToml),
DependencyDomain::Database
);
assert_eq!(
categorize_dependency("tempfile", ManifestType::CargoToml),
DependencyDomain::Testing
);
}
#[test]
fn categorize_known_js_deps() {
assert_eq!(
categorize_dependency("react", ManifestType::PackageJson),
DependencyDomain::WebFramework
);
assert_eq!(
categorize_dependency("jest", ManifestType::PackageJson),
DependencyDomain::Testing
);
assert_eq!(
categorize_dependency("axios", ManifestType::PackageJson),
DependencyDomain::Http
);
}
#[test]
fn categorize_known_python_deps() {
assert_eq!(
categorize_dependency("django", ManifestType::PyprojectToml),
DependencyDomain::WebFramework
);
assert_eq!(
categorize_dependency("pytest", ManifestType::PyprojectToml),
DependencyDomain::Testing
);
assert_eq!(
categorize_dependency("requests", ManifestType::PyprojectToml),
DependencyDomain::Http
);
}
#[test]
fn categorize_unknown_dep() {
assert_eq!(
categorize_dependency("my-custom-lib", ManifestType::CargoToml),
DependencyDomain::Unknown
);
}
#[test]
fn extract_crate_names_single_package() {
let content = r#"
[package]
name = "my-app"
version = "0.1.0"
"#;
let names = extract_crate_names(Path::new("Cargo.toml"), content);
assert_eq!(names, vec!["my_app"]);
}
#[test]
fn extract_crate_names_workspace_members() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/core")).unwrap();
std::fs::create_dir_all(root.join("crates/api")).unwrap();
std::fs::write(
root.join("crates/core/Cargo.toml"),
"[package]\nname = \"core\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
root.join("crates/api/Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["crates/core", "crates/api"]
"#;
let names = extract_crate_names(&manifest_path, content);
assert_eq!(names, vec!["core", "api"]);
}
#[test]
fn extract_crate_names_workspace_and_root_package() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/seshat-core")).unwrap();
std::fs::create_dir_all(root.join("crates/seshat-graph")).unwrap();
std::fs::write(
root.join("crates/seshat-core/Cargo.toml"),
"[package]\nname = \"seshat-core\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
root.join("crates/seshat-graph/Cargo.toml"),
"[package]\nname = \"seshat-graph\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[package]
name = "seshat-root"
version = "0.1.0"
[workspace]
members = ["crates/seshat-core", "crates/seshat-graph"]
"#;
let names = extract_crate_names(&manifest_path, content);
assert!(names.contains(&"seshat_root".to_owned()));
assert!(names.contains(&"seshat_core".to_owned()));
assert!(names.contains(&"seshat_graph".to_owned()));
assert_eq!(names.len(), 3);
}
#[test]
fn extract_crate_names_literal_member_without_cargo_toml_is_skipped() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("legacy")).unwrap();
std::fs::write(
root.join("legacy/Cargo.toml"),
"[package]\nname = \"legacy\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("ghost")).unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["legacy", "ghost"]
"#;
let names = extract_crate_names(&manifest_path, content);
assert_eq!(
names,
vec!["legacy".to_owned()],
"literal member without Cargo.toml must be silently skipped",
);
}
#[test]
fn extract_crate_names_hyphen_normalisation() {
let content = r#"
[package]
name = "my-crate"
version = "0.1.0"
"#;
let names = extract_crate_names(Path::new("Cargo.toml"), content);
assert_eq!(names, vec!["my_crate"]);
}
#[test]
fn extract_crate_names_workspace_members_with_glob_expanded() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::create_dir_all(root.join("crates/bar")).unwrap();
std::fs::write(
root.join("crates/foo/Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
root.join("crates/bar/Cargo.toml"),
"[package]\nname = \"bar\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["crates/*"]
"#;
let mut names = extract_crate_names(&manifest_path, content);
names.sort();
assert_eq!(
names,
vec!["bar".to_owned(), "foo".to_owned()],
"glob `crates/*` should expand to every inner crate",
);
}
#[test]
fn extract_crate_names_workspace_glob_skips_dir_without_cargo_toml() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::create_dir_all(root.join("crates/empty")).unwrap();
std::fs::write(
root.join("crates/foo/Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["crates/*"]
"#;
let names = extract_crate_names(&manifest_path, content);
assert_eq!(
names,
vec!["foo".to_owned()],
"glob-expanded dir without Cargo.toml must be silently skipped",
);
}
#[test]
fn extract_crate_names_workspace_mixed_literal_and_glob_members() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("legacy-crate")).unwrap();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::write(
root.join("legacy-crate/Cargo.toml"),
"[package]\nname = \"legacy-crate\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
root.join("crates/foo/Cargo.toml"),
"[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["legacy-crate", "crates/*"]
"#;
let names = extract_crate_names(&manifest_path, content);
assert!(
names.contains(&"foo".to_owned()),
"glob branch must resolve `foo` — got {names:?}",
);
assert!(
names.contains(&"legacy_crate".to_owned()),
"literal branch must resolve `legacy_crate` — got {names:?}",
);
assert_eq!(
names.len(),
2,
"mixed members must not produce stray names — got {names:?}",
);
}
#[test]
fn extract_crate_names_workspace_invalid_glob_alongside_literal_member() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("legacy-crate")).unwrap();
std::fs::write(
root.join("legacy-crate/Cargo.toml"),
"[package]\nname = \"legacy-crate\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let manifest_path = root.join("Cargo.toml");
let content = r#"
[workspace]
members = ["legacy-crate", "crates/["]
"#;
let names = extract_crate_names(&manifest_path, content);
assert_eq!(
names,
vec!["legacy_crate".to_owned()],
"invalid glob must be silently dropped, literal members still resolve",
);
}
#[test]
fn extract_crate_names_workspace_package_name_optional() {
let content = r#"
[package]
version = "0.1.0"
edition = "2021"
"#;
let names = extract_crate_names(Path::new("Cargo.toml"), content);
assert!(
names.is_empty(),
"missing [package].name must produce empty names"
);
}
#[test]
fn extract_crate_names_empty_workspace_members() {
let content = r#"
[workspace]
members = []
"#;
let names = extract_crate_names(Path::new("Cargo.toml"), content);
assert!(names.is_empty());
}
#[test]
fn extract_crate_names_invalid_toml_returns_empty() {
let content = "not valid toml {{{ oops";
let names = extract_crate_names(Path::new("Cargo.toml"), content);
assert!(
names.is_empty(),
"should return empty list on parse error, not crash"
);
}
#[test]
fn expand_glob_member_happy_path_resolves_subdirs() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/foo")).unwrap();
std::fs::create_dir_all(root.join("crates/bar")).unwrap();
let mut paths = expand_glob_member(root, "crates/*");
paths.sort();
assert_eq!(
paths,
vec![root.join("crates/bar"), root.join("crates/foo")],
);
}
#[test]
fn expand_glob_member_invalid_pattern_returns_empty() {
let tmp = tempdir().expect("tempdir");
assert!(
glob::Pattern::new("crates/[").is_err(),
"test premise: `crates/[` must be an invalid glob",
);
let paths = expand_glob_member(tmp.path(), "crates/[");
assert!(
paths.is_empty(),
"invalid glob pattern must yield empty Vec, not panic",
);
}
#[test]
fn expand_glob_member_no_matches_returns_empty() {
let tmp = tempdir().expect("tempdir");
let paths = expand_glob_member(tmp.path(), "nonexistent/*");
assert!(
paths.is_empty(),
"valid glob with no matches must yield empty Vec",
);
}
#[test]
fn expand_glob_member_absolute_pattern_is_rejected() {
let tmp = tempdir().expect("tempdir");
let paths = expand_glob_member(tmp.path(), "/etc/*");
assert!(
paths.is_empty(),
"absolute glob pattern must be rejected, got {paths:?}",
);
}
#[test]
fn expand_glob_member_filters_non_directories() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("crates/realdir")).unwrap();
std::fs::write(root.join("crates/notadir.txt"), "hello").unwrap();
let paths = expand_glob_member(root, "crates/*");
assert_eq!(paths, vec![root.join("crates/realdir")]);
}
#[test]
fn extract_package_names_pep621() {
let content = r#"
[project]
name = "my-package"
version = "1.0.0"
"#;
let names = extract_package_names(Path::new("pyproject.toml"), content);
assert_eq!(names, vec!["my_package"]);
}
#[test]
fn extract_package_names_poetry_fallback() {
let content = r#"
[tool.poetry]
name = "my-package"
version = "1.0.0"
"#;
let names = extract_package_names(Path::new("pyproject.toml"), content);
assert_eq!(names, vec!["my_package"]);
}
#[test]
fn extract_package_names_pep621_takes_precedence_over_poetry() {
let content = r#"
[project]
name = "pep621-name"
[tool.poetry]
name = "poetry-name"
"#;
let names = extract_package_names(Path::new("pyproject.toml"), content);
assert_eq!(names, vec!["pep621_name"]);
}
#[test]
fn extract_package_names_invalid_toml_returns_empty() {
let content = "not valid toml {{{ oops";
let names = extract_package_names(Path::new("pyproject.toml"), content);
assert!(
names.is_empty(),
"should return empty list on parse error, not crash"
);
}
fn write_js_workspace_fixture(root: &Path, files: &[(&str, &str)]) {
for (rel, content) in files {
let file_path = root.join(rel);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).expect("create fixture dir");
}
std::fs::write(&file_path, content).expect("write fixture file");
}
}
#[test]
fn extract_js_names_workspaces_array_with_glob() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "private": true, "workspaces": ["packages/*"] }"#,
),
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("packages/web/package.json", r#"{ "name": "my-web" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names.len(), 2, "got: {names:?}");
assert!(names.contains(&"@myorg/shared".to_owned()));
assert!(names.contains(&"my-web".to_owned()));
}
#[test]
fn extract_js_names_root_name_only_no_workspaces() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": "my-app", "version": "1.0.0" }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["my-app"]);
}
#[test]
fn extract_js_names_no_workspaces_field_no_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "version": "1.0.0", "dependencies": {} }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn extract_js_names_invalid_json_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = "{ not valid json ::: ";
let names = extract_js_package_names(&root.join("package.json"), content);
assert!(names.is_empty());
}
#[test]
fn extract_js_names_empty_workspaces_array() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": "my-app", "workspaces": [] }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["my-app"]);
}
#[test]
fn extract_js_names_workspaces_yarn_classic_object_form() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{
"private": true,
"workspaces": {
"packages": ["packages/*"],
"nohoist": ["**/react-native"]
}
}"#,
),
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("packages/web/package.json", r#"{ "name": "@myorg/web" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert!(
names.contains(&"@myorg/shared".to_owned()),
"got: {names:?}"
);
assert!(names.contains(&"@myorg/web".to_owned()), "got: {names:?}");
}
#[test]
fn extract_js_names_workspaces_multiple_patterns() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "workspaces": ["packages/*", "apps/*"] }"#,
),
(
"packages/lib-a/package.json",
r#"{ "name": "@myorg/lib-a" }"#,
),
("apps/web/package.json", r#"{ "name": "web-app" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert!(names.contains(&"@myorg/lib-a".to_owned()), "got: {names:?}");
assert!(names.contains(&"web-app".to_owned()), "got: {names:?}");
}
#[test]
fn extract_js_names_root_name_and_workspaces_both_included() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "name": "monorepo-root", "workspaces": ["packages/*"] }"#,
),
("packages/lib/package.json", r#"{ "name": "@myorg/lib" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert!(
names.contains(&"monorepo-root".to_owned()),
"got: {names:?}"
);
assert!(names.contains(&"@myorg/lib".to_owned()), "got: {names:?}");
}
#[test]
fn extract_js_names_preserves_scope_and_hyphens_verbatim() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
("package.json", r#"{ "workspaces": ["packages/*"] }"#),
("packages/a/package.json", r#"{ "name": "@my-org/my-pkg" }"#),
(
"packages/b/package.json",
r#"{ "name": "plain-hyphen-name" }"#,
),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert!(
names.contains(&"@my-org/my-pkg".to_owned()),
"scope/hyphen preserved: {names:?}"
);
assert!(
names.contains(&"plain-hyphen-name".to_owned()),
"hyphens preserved verbatim: {names:?}"
);
assert!(!names.contains(&"my_org/my_pkg".to_owned()));
assert!(!names.contains(&"plain_hyphen_name".to_owned()));
}
#[test]
fn extract_js_names_literal_workspace_path_no_glob() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "workspaces": ["packages/shared", "packages/web"] }"#,
),
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("packages/web/package.json", r#"{ "name": "@myorg/web" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert!(names.contains(&"@myorg/shared".to_owned()));
assert!(names.contains(&"@myorg/web".to_owned()));
}
#[test]
fn extract_js_names_workspace_without_package_json_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
("package.json", r#"{ "workspaces": ["packages/*"] }"#),
(
"packages/has-pkg/package.json",
r#"{ "name": "@myorg/has-pkg" }"#,
),
],
);
std::fs::create_dir_all(root.join("packages").join("empty")).unwrap();
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names, vec!["@myorg/has-pkg"], "got: {names:?}");
}
#[test]
fn extract_js_names_workspace_package_json_without_name_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
("package.json", r#"{ "workspaces": ["packages/*"] }"#),
(
"packages/named/package.json",
r#"{ "name": "@myorg/named" }"#,
),
(
"packages/nameless/package.json",
r#"{ "version": "1.0.0" }"#,
),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names, vec!["@myorg/named"], "got: {names:?}");
}
#[test]
fn extract_js_names_handles_js_monorepo_fixture() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("js_monorepo");
let manifest_path = fixture.join("package.json");
let content = std::fs::read_to_string(&manifest_path).expect("fixture exists");
let names = extract_js_package_names(&manifest_path, &content);
assert!(
names.contains(&"@myorg/shared".to_owned()),
"got: {names:?}"
);
assert!(names.contains(&"@myorg/web".to_owned()), "got: {names:?}");
}
#[test]
fn extract_js_names_rejects_absolute_workspace_pattern() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("etc")).unwrap();
std::fs::write(
root.join("etc").join("package.json"),
r#"{ "name": "should-not-leak" }"#,
)
.unwrap();
let absolute = root.join("etc");
let absolute_str = absolute.to_str().unwrap();
let content = format!(r#"{{ "workspaces": ["{absolute_str}"] }}"#);
let names = extract_js_package_names(&root.join("package.json"), &content);
assert!(
!names.contains(&"should-not-leak".to_owned()),
"absolute pattern was honoured: {names:?}"
);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn extract_js_names_rejects_parent_dir_escape() {
let dir = tempfile::tempdir().unwrap();
let outer = dir.path();
std::fs::create_dir_all(outer.join("sibling")).unwrap();
std::fs::write(
outer.join("sibling").join("package.json"),
r#"{ "name": "outside-the-project" }"#,
)
.unwrap();
let project = outer.join("project");
std::fs::create_dir_all(&project).unwrap();
let manifest_path = project.join("package.json");
let content = r#"{ "workspaces": ["../sibling"] }"#;
let names = extract_js_package_names(&manifest_path, content);
assert!(
!names.contains(&"outside-the-project".to_owned()),
"parent-dir escape honoured: {names:?}"
);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn extract_js_names_skips_empty_pattern_no_duplicate_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": "my-app", "workspaces": [""] }"#;
std::fs::write(root.join("package.json"), content).unwrap();
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["my-app"]);
}
#[test]
fn extract_js_names_deduplicates_overlapping_patterns() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "workspaces": ["packages/*", "packages/shared"] }"#,
),
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names, vec!["@myorg/shared"]);
}
#[test]
fn extract_js_names_returned_list_is_sorted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "name": "z-root", "workspaces": ["apps/*", "packages/*"] }"#,
),
("apps/web/package.json", r#"{ "name": "web-app" }"#),
("packages/lib/package.json", r#"{ "name": "@a/lib" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "names should be returned sorted: {names:?}");
}
#[test]
fn extract_js_names_strips_utf8_bom() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = "\u{FEFF}{ \"name\": \"bom-prefixed-app\" }";
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["bom-prefixed-app"]);
}
#[test]
fn extract_js_names_tolerates_null_workspaces() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": "my-app", "workspaces": null }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["my-app"]);
}
#[test]
fn extract_js_names_tolerates_string_workspaces() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": "my-app", "workspaces": "packages/*" }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert_eq!(names, vec!["my-app"]);
}
#[test]
fn extract_js_names_skips_non_string_workspace_elements() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "workspaces": [123, "packages/*", null] }"#,
),
("packages/lib/package.json", r#"{ "name": "@myorg/lib" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names, vec!["@myorg/lib"]);
}
#[test]
fn extract_js_names_rejects_whitespace_only_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let content = r#"{ "name": " ", "version": "1.0.0" }"#;
let names = extract_js_package_names(&root.join("package.json"), content);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn extract_js_names_rejects_non_string_root_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"package.json",
r#"{ "name": 123, "workspaces": ["packages/*"] }"#,
),
("packages/lib/package.json", r#"{ "name": "@myorg/lib" }"#),
],
);
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap();
let names = extract_js_package_names(&root.join("package.json"), &manifest);
assert_eq!(names, vec!["@myorg/lib"]);
}
#[test]
fn extract_js_names_no_parent_path_returns_empty() {
let content = r#"{ "name": "my-app", "workspaces": ["packages/*"] }"#;
let names = extract_js_package_names(Path::new("package.json"), content);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn parse_pnpm_yaml_typical_glob_layout() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("packages/web/package.json", r#"{ "name": "@myorg/web" }"#),
],
);
let yaml = r#"
packages:
- "packages/*"
"#;
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, yaml).unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names.len(), 2, "got: {names:?}");
assert!(names.contains(&"@myorg/shared".to_owned()));
assert!(names.contains(&"@myorg/web".to_owned()));
}
#[test]
fn parse_pnpm_yaml_multiple_patterns() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("apps/web/package.json", r#"{ "name": "@myorg/web" }"#),
],
);
let yaml = r#"
packages:
- "packages/*"
- "apps/*"
"#;
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, yaml).unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(
names.contains(&"@myorg/shared".to_owned()),
"got: {names:?}"
);
assert!(names.contains(&"@myorg/web".to_owned()), "got: {names:?}");
}
#[test]
fn parse_pnpm_yaml_literal_path_no_glob() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
)],
);
let yaml = r#"
packages:
- "packages/shared"
"#;
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, yaml).unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names, vec!["@myorg/shared"]);
}
#[test]
fn parse_pnpm_yaml_preserves_scope_and_hyphens_verbatim() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"packages/foo-bar/package.json",
r#"{ "name": "@my-org/foo-bar" }"#,
),
("packages/baz/package.json", r#"{ "name": "plain-name" }"#),
],
);
let yaml = r#"
packages:
- "packages/*"
"#;
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, yaml).unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(
names.contains(&"@my-org/foo-bar".to_owned()),
"got: {names:?}"
);
assert!(names.contains(&"plain-name".to_owned()), "got: {names:?}");
}
#[test]
fn parse_pnpm_yaml_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("pnpm-workspace.yaml");
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(names.is_empty());
}
#[test]
fn parse_pnpm_yaml_invalid_yaml_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, ":\n - not: [valid yaml: at all").unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(names.is_empty());
}
#[test]
fn parse_pnpm_yaml_empty_packages_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, "packages: []\n").unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(names.is_empty());
}
#[test]
fn parse_pnpm_yaml_member_without_name_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[
(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
),
("packages/anon/package.json", r#"{ "version": "1.0.0" }"#),
],
);
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, "packages:\n - \"packages/*\"\n").unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names, vec!["@myorg/shared"]);
}
#[test]
fn parse_pnpm_yaml_strips_utf8_bom() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
)],
);
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, "\u{FEFF}packages:\n - \"packages/*\"\n").unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names, vec!["@myorg/shared"]);
}
#[test]
fn parse_pnpm_yaml_tolerates_missing_packages_key() {
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("pnpm-workspace.yaml");
std::fs::write(&yaml_path, "shared-workspace-lockfile: true\n").unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert!(names.is_empty(), "got: {names:?}");
}
#[test]
fn parse_pnpm_yaml_skips_non_string_package_elements() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[("packages/lib/package.json", r#"{ "name": "@myorg/lib" }"#)],
);
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(
&yaml_path,
"packages:\n - 123\n - \"packages/*\"\n - null\n",
)
.unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names, vec!["@myorg/lib"]);
}
#[test]
fn parse_pnpm_yaml_deduplicates_overlapping_patterns() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_js_workspace_fixture(
root,
&[(
"packages/shared/package.json",
r#"{ "name": "@myorg/shared" }"#,
)],
);
let yaml_path = root.join("pnpm-workspace.yaml");
std::fs::write(
&yaml_path,
"packages:\n - \"packages/*\"\n - \"packages/shared\"\n",
)
.unwrap();
let names = parse_pnpm_workspace_yaml(&yaml_path);
assert_eq!(names, vec!["@myorg/shared"]);
}
#[test]
fn analyze_manifests_populates_internal_names_from_cargo() {
let content = r#"
[package]
name = "my-crate"
version = "0.1.0"
"#;
let manifests = vec![(
PathBuf::from("Cargo.toml"),
content.to_owned(),
ManifestType::CargoToml,
)];
let results = analyze_manifests(&manifests, &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].internal_names, vec!["my_crate"]);
}
#[test]
fn analyze_manifests_populates_internal_names_from_pyproject() {
let content = r#"
[project]
name = "my-package"
"#;
let manifests = vec![(
PathBuf::from("pyproject.toml"),
content.to_owned(),
ManifestType::PyprojectToml,
)];
let results = analyze_manifests(&manifests, &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].internal_names, vec!["my_package"]);
}
#[test]
fn analyze_manifests_package_json_has_populated_internal_names() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let shared_dir = root.join("packages").join("shared");
let web_dir = root.join("packages").join("web");
std::fs::create_dir_all(&shared_dir).unwrap();
std::fs::create_dir_all(&web_dir).unwrap();
std::fs::write(
shared_dir.join("package.json"),
r#"{ "name": "@myorg/shared", "version": "1.0.0" }"#,
)
.unwrap();
std::fs::write(
web_dir.join("package.json"),
r#"{ "name": "my-web", "version": "1.0.0" }"#,
)
.unwrap();
let root_content = r#"{ "private": true, "workspaces": ["packages/*"] }"#;
let root_path = root.join("package.json");
std::fs::write(&root_path, root_content).unwrap();
let manifests = vec![(
root_path,
root_content.to_owned(),
ManifestType::PackageJson,
)];
let results = analyze_manifests(&manifests, &[]).unwrap();
assert_eq!(results.len(), 1);
let names = &results[0].internal_names;
assert!(
names.contains(&"@myorg/shared".to_owned()),
"expected @myorg/shared in {names:?}"
);
assert!(
names.contains(&"my-web".to_owned()),
"expected my-web in {names:?}"
);
}
#[test]
fn analyze_manifests_end_to_end() {
let cargo_content = r#"
[dependencies]
serde = "1"
tokio = "1"
[dev-dependencies]
tempfile = "3"
"#;
let files = vec![
make_pf_with_imports(
vec![make_import("serde::Serialize"), make_import("tokio::spawn")],
Language::Rust,
),
make_pf_with_imports(vec![make_import("serde")], Language::Rust),
];
let manifests = vec![(
PathBuf::from("Cargo.toml"),
cargo_content.to_owned(),
ManifestType::CargoToml,
)];
let results = analyze_manifests(&manifests, &files).unwrap();
assert_eq!(results.len(), 1);
let analysis = &results[0];
assert_eq!(analysis.manifest_type, ManifestType::CargoToml);
assert_eq!(analysis.dependencies.len(), 3);
let serde_stats = analysis
.dependencies
.iter()
.find(|s| s.dependency.name == "serde")
.unwrap();
assert_eq!(serde_stats.files_using, 2);
assert!(!serde_stats.is_dead);
let tokio_stats = analysis
.dependencies
.iter()
.find(|s| s.dependency.name == "tokio")
.unwrap();
assert_eq!(tokio_stats.files_using, 1);
assert!(!tokio_stats.is_dead);
let tempfile_stats = analysis
.dependencies
.iter()
.find(|s| s.dependency.name == "tempfile")
.unwrap();
assert_eq!(tempfile_stats.files_using, 0);
assert!(tempfile_stats.is_dead); }
#[test]
fn analyze_manifests_multiple_manifest_types() {
let cargo_content = "[dependencies]\nserde = \"1\"\n";
let package_json = r#"{"dependencies": {"react": "^18"}}"#;
let files = vec![
make_pf_with_imports(vec![make_import("serde")], Language::Rust),
make_pf_with_imports(vec![make_import("react")], Language::TypeScript),
];
let manifests = vec![
(
PathBuf::from("Cargo.toml"),
cargo_content.to_owned(),
ManifestType::CargoToml,
),
(
PathBuf::from("package.json"),
package_json.to_owned(),
ManifestType::PackageJson,
),
];
let results = analyze_manifests(&manifests, &files).unwrap();
assert_eq!(results.len(), 2);
let cargo_analysis = results
.iter()
.find(|r| r.manifest_type == ManifestType::CargoToml)
.unwrap();
assert!(!cargo_analysis.dependencies[0].is_dead);
let npm_analysis = results
.iter()
.find(|r| r.manifest_type == ManifestType::PackageJson)
.unwrap();
assert!(!npm_analysis.dependencies[0].is_dead);
}
}