use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use seshat_core::ir::{Language, ProjectFile};
use seshat_core::{DependencyDomain, PathAlias, 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 path_aliases: Vec<PathAlias>,
}
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 mut path_aliases = Vec::new();
let internal_names = match manifest_type {
ManifestType::CargoToml => extract_crate_names(path, content),
ManifestType::PyprojectToml => extract_package_names(path, content),
ManifestType::PackageJson => {
let mut names = extract_js_package_names(path, content);
if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
let pnpm_yaml = dir.join("pnpm-workspace.yaml");
if pnpm_yaml.is_file() {
names.extend(parse_pnpm_workspace_yaml(&pnpm_yaml));
names.sort();
names.dedup();
}
let tsconfig = dir.join("tsconfig.json");
if tsconfig.is_file() {
if let Ok(content) = std::fs::read_to_string(&tsconfig) {
path_aliases = parse_tsconfig(&tsconfig, &content);
}
}
}
names
}
};
results.push(ManifestAnalysis {
manifest_path: path.clone(),
manifest_type: *manifest_type,
dependencies: stats,
internal_names,
path_aliases,
});
}
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)
}
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_norway::Value = match serde_norway::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
}
const MAX_TSCONFIG_EXTENDS_DEPTH: usize = 8;
pub fn parse_tsconfig(path: &Path, content: &str) -> Vec<PathAlias> {
let mut merged: HashMap<String, Vec<String>> = HashMap::new();
let mut visited: HashSet<PathBuf> = HashSet::new();
collect_tsconfig_aliases(path, content, 0, &mut visited, &mut merged);
let mut aliases: Vec<PathAlias> = merged
.into_iter()
.map(|(pattern, targets)| PathAlias { pattern, targets })
.collect();
aliases.sort_by(|a, b| a.pattern.cmp(&b.pattern));
aliases
}
fn collect_tsconfig_aliases(
path: &Path,
content: &str,
depth: usize,
visited: &mut HashSet<PathBuf>,
merged: &mut HashMap<String, Vec<String>>,
) {
if depth > MAX_TSCONFIG_EXTENDS_DEPTH {
tracing::warn!(path = %path.display(), "tsconfig `extends` chain too deep; stopping");
return;
}
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canonical) {
return;
}
let content = strip_utf8_bom(content);
let stripped = strip_jsonc(content);
let value: serde_json::Value = match serde_json::from_str(&stripped) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to parse tsconfig.json");
return;
}
};
let dir = path.parent().unwrap_or_else(|| Path::new(""));
for base in tsconfig_extends_targets(&value) {
if !(base.starts_with("./") || base.starts_with("../") || base.starts_with('.')) {
continue;
}
let base_path = resolve_tsconfig_extends(dir, &base);
if let Some(base_path) = base_path {
if let Ok(base_content) = std::fs::read_to_string(&base_path) {
collect_tsconfig_aliases(&base_path, &base_content, depth + 1, visited, merged);
}
}
}
let compiler_options = value.get("compilerOptions");
let base_url = compiler_options
.and_then(|c| c.get("baseUrl"))
.and_then(|v| v.as_str())
.unwrap_or(".");
let Some(paths) = compiler_options
.and_then(|c| c.get("paths"))
.and_then(|v| v.as_object())
else {
return;
};
for (pattern, targets) in paths {
let joined: Vec<String> = targets
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|t| join_base_url(base_url, t))
.collect()
})
.unwrap_or_default();
if !joined.is_empty() {
merged.insert(pattern.clone(), joined);
}
}
}
fn tsconfig_extends_targets(value: &serde_json::Value) -> Vec<String> {
match value.get("extends") {
Some(serde_json::Value::String(s)) => vec![s.clone()],
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
}
fn resolve_tsconfig_extends(dir: &Path, base: &str) -> Option<PathBuf> {
let candidate = dir.join(base);
if candidate.is_file() {
return Some(candidate);
}
let with_ext = dir.join(format!("{base}.json"));
with_ext.is_file().then_some(with_ext)
}
fn join_base_url(base_url: &str, target: &str) -> String {
let base = base_url
.trim()
.replace('\\', "/")
.trim_start_matches("./")
.trim_end_matches('/')
.to_owned();
let target = target.trim().replace('\\', "/");
let target = target.trim_start_matches("./");
let joined = if base.is_empty() || base == "." {
target.to_owned()
} else {
format!("{base}/{target}")
};
joined.trim_start_matches("./").to_owned()
}
fn strip_jsonc(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut out: Vec<char> = Vec::with_capacity(n);
let mut i = 0;
let mut in_string = false;
while i < n {
let c = chars[i];
if in_string {
out.push(c);
if c == '\\' && i + 1 < n {
out.push(chars[i + 1]);
i += 2;
continue;
}
if c == '"' {
in_string = false;
}
i += 1;
continue;
}
match c {
'"' => {
in_string = true;
out.push(c);
i += 1;
}
'/' if i + 1 < n && chars[i + 1] == '/' => {
i += 2;
while i < n && chars[i] != '\n' {
i += 1;
}
}
'/' if i + 1 < n && chars[i + 1] == '*' => {
i += 2;
while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2; }
_ => {
out.push(c);
i += 1;
}
}
}
drop_trailing_commas(&out)
}
fn drop_trailing_commas(chars: &[char]) -> String {
let n = chars.len();
let mut out = String::with_capacity(n);
let mut in_string = false;
let mut i = 0;
while i < n {
let c = chars[i];
if in_string {
out.push(c);
if c == '\\' && i + 1 < n {
out.push(chars[i + 1]);
i += 2;
continue;
}
if c == '"' {
in_string = false;
}
i += 1;
continue;
}
if c == '"' {
in_string = true;
out.push(c);
i += 1;
continue;
}
if c == ',' {
let mut j = i + 1;
while j < n && chars[j].is_whitespace() {
j += 1;
}
if j < n && (chars[j] == '}' || chars[j] == ']') {
i += 1;
continue;
}
}
out.push(c);
i += 1;
}
out
}
const PY_DEV_GROUP_NAMES: [&str; 3] = ["dev", "test", "testing"];
#[derive(Deserialize)]
struct PyprojectToml {
#[serde(default)]
project: Option<PyprojectProject>,
#[serde(default)]
tool: Option<toml::Value>,
}
#[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 mut deps = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
if let Some(project) = &manifest.project {
for spec in &project.dependencies {
let (name, version) = parse_pep508_name_version(spec);
seen.insert(name.clone());
deps.push(DeclaredDependency {
category: categorize_dependency(&name, ManifestType::PyprojectToml),
name,
version,
is_dev: false,
});
}
for (group, group_deps) in &project.optional_dependencies {
let is_dev = PY_DEV_GROUP_NAMES.contains(&group.to_lowercase().as_str());
for spec in group_deps {
let (name, version) = parse_pep508_name_version(spec);
seen.insert(name.clone());
deps.push(DeclaredDependency {
category: categorize_dependency(&name, ManifestType::PyprojectToml),
name,
version,
is_dev,
});
}
}
}
if let Some(tool) = &manifest.tool {
collect_tool_deps(tool, &mut seen, &mut deps);
}
Ok(deps)
}
fn collect_tool_deps(
tool: &toml::Value,
seen: &mut HashSet<String>,
deps: &mut Vec<DeclaredDependency>,
) {
if let Some(poetry) = tool.get("poetry").and_then(toml::Value::as_table) {
if let Some(t) = poetry.get("dependencies").and_then(toml::Value::as_table) {
collect_poetry_table(t, false, seen, deps);
}
if let Some(t) = poetry
.get("dev-dependencies")
.and_then(toml::Value::as_table)
{
collect_poetry_table(t, true, seen, deps);
}
if let Some(groups) = poetry.get("group").and_then(toml::Value::as_table) {
for (group_name, group) in groups {
let is_dev = PY_DEV_GROUP_NAMES.contains(&group_name.to_lowercase().as_str());
if let Some(t) = group.get("dependencies").and_then(toml::Value::as_table) {
collect_poetry_table(t, is_dev, seen, deps);
}
}
}
}
if let Some(groups) = tool
.get("pdm")
.and_then(toml::Value::as_table)
.and_then(|pdm| pdm.get("dev-dependencies"))
.and_then(toml::Value::as_table)
{
for specs in groups.values() {
let Some(specs) = specs.as_array() else {
continue;
};
for spec in specs.iter().filter_map(toml::Value::as_str) {
let (name, version) = parse_pep508_name_version(spec);
if name.is_empty() || !seen.insert(name.clone()) {
continue;
}
deps.push(DeclaredDependency {
category: categorize_dependency(&name, ManifestType::PyprojectToml),
name,
version,
is_dev: true,
});
}
}
}
}
fn collect_poetry_table(
table: &toml::Table,
is_dev: bool,
seen: &mut HashSet<String>,
deps: &mut Vec<DeclaredDependency>,
) {
for (raw_name, value) in table {
if raw_name.trim().eq_ignore_ascii_case("python") {
continue;
}
let name = raw_name.trim().to_lowercase().replace('-', "_");
if name.is_empty() || !seen.insert(name.clone()) {
continue;
}
let version = poetry_dep_version(value);
deps.push(DeclaredDependency {
category: categorize_dependency(&name, ManifestType::PyprojectToml),
name,
version,
is_dev,
});
}
}
fn poetry_dep_version(value: &toml::Value) -> String {
let raw = match value {
toml::Value::String(s) => s.trim(),
toml::Value::Table(t) => t
.get("version")
.and_then(toml::Value::as_str)
.unwrap_or("*"),
_ => "*",
};
if raw.trim().is_empty() {
"*".to_owned()
} else {
raw.trim().to_owned()
}
}
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 pyproject_poetry_dependencies_parsed() {
let content = r#"
[tool.poetry]
name = "my-pkg"
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
httpx = { version = "^0.24", optional = true }
[tool.poetry.group.dev.dependencies]
pytest = "^7.0"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(
!deps.iter().any(|d| d.name == "python"),
"python interpreter constraint must not be a dependency: {deps:?}"
);
let requests = deps.iter().find(|d| d.name == "requests").unwrap();
assert_eq!(requests.version, "^2.28");
assert!(!requests.is_dev);
let httpx = deps.iter().find(|d| d.name == "httpx").unwrap();
assert_eq!(httpx.version, "^0.24");
let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
assert!(pytest.is_dev);
}
#[test]
fn pyproject_poetry_legacy_dev_dependencies_are_dev() {
let content = r#"
[tool.poetry.dependencies]
requests = "^2.28"
[tool.poetry.dev-dependencies]
black = "^22.0"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(deps.iter().find(|d| d.name == "black").unwrap().is_dev);
assert!(!deps.iter().find(|d| d.name == "requests").unwrap().is_dev);
}
#[test]
fn pyproject_poetry_normalises_hyphens() {
let content = r#"
[tool.poetry.dependencies]
my-cool-pkg = "^1.0"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(
deps.iter().any(|d| d.name == "my_cool_pkg"),
"got: {deps:?}"
);
}
#[test]
fn pyproject_poetry_table_without_version_defaults_star() {
let content = r#"
[tool.poetry.dependencies]
mylib = { git = "https://example.com/mylib.git" }
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert_eq!(
deps.iter().find(|d| d.name == "mylib").unwrap().version,
"*"
);
}
#[test]
fn pyproject_pdm_dev_dependencies_parsed() {
let content = r#"
[project]
name = "my-pkg"
dependencies = ["requests>=2.28"]
[tool.pdm.dev-dependencies]
test = ["pytest>=7.0", "pytest-cov"]
lint = ["ruff"]
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
assert!(pytest.is_dev);
assert_eq!(pytest.version, ">=7.0");
assert!(deps.iter().find(|d| d.name == "ruff").unwrap().is_dev);
assert!(!deps.iter().find(|d| d.name == "requests").unwrap().is_dev);
}
#[test]
fn pyproject_poetry_does_not_double_count_pep621() {
let content = r#"
[project]
name = "my-pkg"
dependencies = ["requests>=2.28"]
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
let count = deps.iter().filter(|d| d.name == "requests").count();
assert_eq!(count, 1, "requests counted once, got: {deps:?}");
}
#[test]
fn pyproject_poetry_dedup_normalises_before_comparing() {
let content = r#"
[project]
name = "root"
dependencies = ["My-Pkg>=1.0"]
[tool.poetry.dependencies]
python = "^3.10"
My-Pkg = "^1.0"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
let count = deps.iter().filter(|d| d.name == "my_pkg").count();
assert_eq!(count, 1, "normalised name counted once, got: {deps:?}");
}
#[test]
fn pyproject_malformed_tool_table_does_not_abort_parse() {
let content = r#"
[project]
name = "root"
dependencies = ["requests>=2.28"]
[tool.pdm.dev-dependencies]
test = "pytest"
[tool.poetry]
dependencies = "not-a-table"
"#;
let deps = parse_pyproject_toml(Path::new("pyproject.toml"), content).unwrap();
assert!(
deps.iter().any(|d| d.name == "requests"),
"PEP 621 dep survives malformed tool tables, got: {deps:?}"
);
assert!(
!deps.iter().any(|d| d.name == "pytest"),
"malformed PDM group must be skipped, got: {deps:?}"
);
}
#[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_package_json_merges_pnpm_workspace_members() {
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": "@myorg/web", "version": "1.0.0" }"#,
)
.unwrap();
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - \"packages/*\"\n",
)
.unwrap();
let root_content = r#"{ "name": "root", "private": true }"#;
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();
let names = &results[0].internal_names;
assert!(
names.contains(&"@myorg/shared".to_owned()),
"expected @myorg/shared in {names:?}"
);
assert!(
names.contains(&"@myorg/web".to_owned()),
"expected @myorg/web in {names:?}"
);
}
#[test]
fn analyze_manifests_merges_pnpm_with_package_json_workspaces() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let npm_pkg = root.join("packages").join("npm-lib");
let pnpm_pkg = root.join("apps").join("pnpm-app");
std::fs::create_dir_all(&npm_pkg).unwrap();
std::fs::create_dir_all(&pnpm_pkg).unwrap();
std::fs::write(
npm_pkg.join("package.json"),
r#"{ "name": "@myorg/npm-lib" }"#,
)
.unwrap();
std::fs::write(
pnpm_pkg.join("package.json"),
r#"{ "name": "@myorg/pnpm-app" }"#,
)
.unwrap();
std::fs::write(
root.join("pnpm-workspace.yaml"),
"packages:\n - \"apps/*\"\n",
)
.unwrap();
let root_content = r#"{ "name": "root", "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();
let names = &results[0].internal_names;
assert!(
names.contains(&"@myorg/npm-lib".to_owned()),
"npm workspace member present, got: {names:?}"
);
assert!(
names.contains(&"@myorg/pnpm-app".to_owned()),
"pnpm workspace member merged, got: {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);
}
fn parse_tsconfig_str(dir: &Path, content: &str) -> Vec<PathAlias> {
let path = dir.join("tsconfig.json");
std::fs::write(&path, content).unwrap();
parse_tsconfig(&path, content)
}
fn find_alias<'a>(aliases: &'a [PathAlias], pattern: &str) -> &'a PathAlias {
aliases
.iter()
.find(|a| a.pattern == pattern)
.unwrap_or_else(|| panic!("alias {pattern:?} not found in {aliases:?}"))
}
#[test]
fn tsconfig_wildcard_and_exact_aliases() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["src/*"],
"@config": ["src/config/index.ts"]
}
}
}"#,
);
assert_eq!(aliases.len(), 2);
assert_eq!(find_alias(&aliases, "@app/*").targets, vec!["src/*"]);
assert_eq!(
find_alias(&aliases, "@config").targets,
vec!["src/config/index.ts"]
);
}
#[test]
fn tsconfig_base_url_joined_into_targets() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{ "compilerOptions": { "baseUrl": "./src", "paths": { "@app/*": ["*"] } } }"#,
);
assert_eq!(find_alias(&aliases, "@app/*").targets, vec!["src/*"]);
}
#[test]
fn tsconfig_multiple_targets_preserved_in_order() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{ "compilerOptions": { "paths": { "@app/*": ["src/*", "generated/*"] } } }"#,
);
assert_eq!(
find_alias(&aliases, "@app/*").targets,
vec!["src/*", "generated/*"]
);
}
#[test]
fn tsconfig_tolerates_comments_and_trailing_commas() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{
// leading line comment
"compilerOptions": {
/* block comment */
"paths": {
"@app/*": ["src/*"], // trailing comment with // inside
},
},
}"#,
);
assert_eq!(find_alias(&aliases, "@app/*").targets, vec!["src/*"]);
}
#[test]
fn tsconfig_comment_markers_inside_strings_are_preserved() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{ "compilerOptions": { "paths": { "@url/*": ["vendor//*"] } } }"#,
);
assert_eq!(find_alias(&aliases, "@url/*").targets, vec!["vendor//*"]);
}
#[test]
fn tsconfig_extends_relative_base_merges_child_wins() {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("tsconfig.base.json"),
r#"{ "compilerOptions": { "paths": { "@app/*": ["base/*"], "@lib/*": ["packages/lib/*"] } } }"#,
)
.unwrap();
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{
"extends": "./tsconfig.base.json",
"compilerOptions": { "paths": { "@app/*": ["src/*"] } }
}"#,
);
assert_eq!(find_alias(&aliases, "@app/*").targets, vec!["src/*"]);
assert_eq!(
find_alias(&aliases, "@lib/*").targets,
vec!["packages/lib/*"]
);
}
#[test]
fn tsconfig_extends_bare_specifier_is_skipped() {
let tmp = tempdir().expect("tempdir");
let aliases = parse_tsconfig_str(
tmp.path(),
r#"{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": { "paths": { "@app/*": ["src/*"] } }
}"#,
);
assert_eq!(find_alias(&aliases, "@app/*").targets, vec!["src/*"]);
}
#[test]
fn tsconfig_missing_paths_yields_empty() {
let tmp = tempdir().expect("tempdir");
assert!(parse_tsconfig_str(tmp.path(), r#"{ "compilerOptions": {} }"#).is_empty());
assert!(parse_tsconfig_str(tmp.path(), r#"{}"#).is_empty());
}
#[test]
fn tsconfig_malformed_json_degrades_to_empty() {
let tmp = tempdir().expect("tempdir");
assert!(parse_tsconfig_str(tmp.path(), r#"{ "compilerOptions": "#).is_empty());
}
#[test]
fn analyze_manifests_attaches_sibling_tsconfig_aliases() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("tsconfig.json"),
r#"{ "compilerOptions": { "paths": { "@app/*": ["src/*"] } } }"#,
)
.unwrap();
let pkg = root.join("package.json");
let pkg_content = r#"{ "name": "demo", "dependencies": { "react": "^18" } }"#;
let manifests = vec![(
pkg.clone(),
pkg_content.to_owned(),
ManifestType::PackageJson,
)];
let results = analyze_manifests(&manifests, &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path_aliases.len(), 1);
assert_eq!(results[0].path_aliases[0].pattern, "@app/*");
}
}