use std::path::Path;
use crate::graph::node::{DependencySource, ExternalClassification, ExternalNode};
use crate::language::LangId;
#[derive(Debug, Clone, serde::Serialize)]
pub struct DependencyEntry {
pub name: String,
pub version: Option<String>,
pub language: LangId,
pub source: DependencySource,
}
pub fn classify_external(external: &ExternalNode, project_root: &Path) -> ExternalClassification {
match external.language {
LangId::Python => classify_python(external, project_root),
LangId::JavaScript | LangId::TypeScript | LangId::Tsx => {
classify_node_ecosystem(external, project_root)
}
LangId::Rust => classify_rust(external, project_root),
LangId::Go => classify_go(external, project_root),
LangId::Ruby => classify_ruby(external, project_root),
LangId::C | LangId::Cpp => classify_c_cpp_best_effort(external, project_root),
}
}
pub fn resolve_dependencies(
graph: &crate::graph::CodeGraph,
partition: &crate::deploy::pod::PodPartition,
project_root: &Path,
) -> std::collections::HashMap<usize, Vec<DependencyEntry>> {
let mut deps: std::collections::HashMap<usize, Vec<DependencyEntry>> =
std::collections::HashMap::new();
let mut file_to_pod: std::collections::HashMap<crate::model::FileId, usize> =
std::collections::HashMap::new();
for pod in &partition.pods {
for &fid in &pod.files {
file_to_pod.insert(fid, pod.id);
}
}
for edge_idx in graph.graph.edge_indices() {
let weight = &graph.graph[edge_idx];
if weight.kind != crate::graph::EdgeKind::Import {
continue;
}
let Some((src, dst)) = graph.graph.edge_endpoints(edge_idx) else {
continue;
};
let src_fid = match &graph.graph[src] {
crate::graph::NodeData::File(f) => f.id,
crate::graph::NodeData::Symbol(s) => s.file_id,
_ => continue,
};
let Some(&pod_id) = file_to_pod.get(&src_fid) else {
continue;
};
let ext = match &graph.graph[dst] {
crate::graph::NodeData::External(e) => e,
_ => continue,
};
let classification = classify_external(ext, project_root);
let entry = match &classification {
ExternalClassification::Classified {
package_name,
version,
language,
source,
} => DependencyEntry {
name: package_name.clone(),
version: version.clone(),
language: *language,
source: *source,
},
ExternalClassification::Unresolved { .. } => continue,
};
let pod_deps = deps.entry(pod_id).or_default();
if !pod_deps.iter().any(|d| d.name == entry.name) {
pod_deps.push(entry);
}
}
deps
}
fn classify_python(external: &ExternalNode, root: &Path) -> ExternalClassification {
let lockfiles = [
root.join("uv.lock"),
root.join("poetry.lock"),
root.join("Pipfile.lock"),
];
for lf in &lockfiles {
if lf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_lockfile(lf, &external.raw_path),
language: LangId::Python,
source: DependencySource::Lockfile,
};
}
}
let manifests = [root.join("pyproject.toml"), root.join("requirements.txt")];
for mf in &manifests {
if mf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: LangId::Python,
source: DependencySource::Manifest,
};
}
}
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let subdir = entry.path();
if !subdir.is_dir() {
continue;
}
for mf in &manifests {
let p = subdir.join(mf.file_name().unwrap_or_default());
if p.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: LangId::Python,
source: DependencySource::Manifest,
};
}
}
}
}
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no Python lockfile or manifest found".into(),
}
}
fn classify_node_ecosystem(external: &ExternalNode, root: &Path) -> ExternalClassification {
let lockfiles = [
root.join("package-lock.json"),
root.join("yarn.lock"),
root.join("pnpm-lock.yaml"),
];
for lf in &lockfiles {
if lf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_lockfile(lf, &external.raw_path),
language: external.language,
source: DependencySource::Lockfile,
};
}
}
let mf = root.join("package.json");
if mf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_package_json(&mf, &external.raw_path),
language: external.language,
source: DependencySource::Manifest,
};
}
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let subdir = entry.path();
if !subdir.is_dir() {
continue;
}
let lock_path = subdir.join("package-lock.json");
if lock_path.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_lockfile(&lock_path, &external.raw_path),
language: external.language,
source: DependencySource::Lockfile,
};
}
let pkg_path = subdir.join("package.json");
if pkg_path.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_package_json(&pkg_path, &external.raw_path),
language: external.language,
source: DependencySource::Manifest,
};
}
}
}
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no Node.js lockfile or package.json found".into(),
}
}
fn classify_rust(external: &ExternalNode, root: &Path) -> ExternalClassification {
let lf = root.join("Cargo.lock");
if lf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_cargo_lock(&lf, &external.raw_path),
language: LangId::Rust,
source: DependencySource::Lockfile,
};
}
let mf = root.join("Cargo.toml");
if mf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: LangId::Rust,
source: DependencySource::Manifest,
};
}
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no Cargo.lock or Cargo.toml found".into(),
}
}
fn classify_go(external: &ExternalNode, root: &Path) -> ExternalClassification {
let lf = root.join("go.sum");
if lf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_go_sum(&lf, &external.raw_path),
language: LangId::Go,
source: DependencySource::Lockfile,
};
}
let mf = root.join("go.mod");
if mf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: LangId::Go,
source: DependencySource::Manifest,
};
}
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no go.sum or go.mod found".into(),
}
}
fn classify_ruby(external: &ExternalNode, root: &Path) -> ExternalClassification {
let lf = root.join("Gemfile.lock");
if lf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: parse_version_from_gemfile_lock(&lf, &external.raw_path),
language: LangId::Ruby,
source: DependencySource::Lockfile,
};
}
let mf = root.join("Gemfile");
if mf.exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: LangId::Ruby,
source: DependencySource::Manifest,
};
}
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no Gemfile.lock or Gemfile found".into(),
}
}
fn classify_c_cpp_best_effort(external: &ExternalNode, root: &Path) -> ExternalClassification {
if root.join("conanfile.txt").exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: external.language,
source: DependencySource::Manifest,
};
}
if root.join("vcpkg.json").exists() {
return ExternalClassification::Classified {
package_name: external.raw_path.clone(),
version: None,
language: external.language,
source: DependencySource::Manifest,
};
}
tracing::trace!(path = %external.raw_path, "C/C++ external dependency unresolved");
ExternalClassification::Unresolved {
raw_path: external.raw_path.clone(),
reason: "no C/C++ manifest convention found (conanfile.txt, vcpkg.json)".into(),
}
}
fn parse_version_from_lockfile(path: &Path, package: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
if line.contains(package) {
for candidate in content.lines().skip_while(|l| !l.contains(package)).take(5) {
if let Some(v) = extract_semver(candidate) {
return Some(v);
}
}
}
}
None
}
fn parse_version_from_package_json(path: &Path, package: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
for section in ["dependencies", "devDependencies", "peerDependencies"] {
if let Some(version) = json.get(section).and_then(|d| d.get(package))
&& let Some(s) = version.as_str()
{
return Some(s.to_string());
}
}
None
}
fn parse_version_from_cargo_lock(path: &Path, package: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_package_section = false;
for line in content.lines() {
if line.trim_start().starts_with("[[package]]") {
in_package_section = false;
}
if let Some(rest) = line.strip_prefix("name = ") {
let name = rest.trim().trim_matches('"');
if name == package {
in_package_section = true;
}
}
if in_package_section && let Some(rest) = line.strip_prefix("version = ") {
return Some(rest.trim().trim_matches('"').to_string());
}
}
None
}
fn parse_version_from_go_sum(path: &Path, package: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
if line.starts_with(package) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
return Some(parts[1].to_string());
}
}
}
None
}
fn parse_version_from_gemfile_lock(path: &Path, name: &str) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let line = line.trim_start();
if let Some(rest) = line
.strip_prefix(&format!("{name} ("))
.and_then(|rest| rest.strip_suffix(')'))
{
return Some(rest.trim().to_string());
}
}
None
}
fn extract_semver(line: &str) -> Option<String> {
let mut chars = line.chars().peekable();
let mut start = None;
let mut i = 0usize;
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
let mut version = String::new();
let mut dot_count = 0;
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
version.push(c);
chars.next();
} else if c == '.' {
version.push(c);
dot_count += 1;
chars.next();
} else {
break;
}
}
if dot_count >= 2 && !version.is_empty() {
return Some(version);
}
start = Some(i);
} else {
chars.next();
}
i += 1;
}
let _ = start;
None
}
#[cfg(test)]
mod tests {
use super::*;
fn external_node(raw_path: &str) -> ExternalNode {
ExternalNode {
raw_path: raw_path.to_string(),
language: LangId::Ruby,
classification: None,
}
}
fn test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("meta_ast_ruby_dep_{name}"));
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn parse_version_from_gemfile_lock_extracts_version() {
let dir = test_dir("parse");
let lf = dir.join("Gemfile.lock");
std::fs::write(
&lf,
"GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.8.4)\n actioncable (= 7.0.8.4)\n",
)
.unwrap();
assert_eq!(
parse_version_from_gemfile_lock(&lf, "rails"),
Some("7.0.8.4".to_string())
);
assert_eq!(parse_version_from_gemfile_lock(&lf, "missing"), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn classify_ruby_uses_gemfile_lock() {
let dir = test_dir("classify_lock");
std::fs::write(
dir.join("Gemfile.lock"),
"GEM\n specs:\n rails (7.0.8.4)\n",
)
.unwrap();
let classification = classify_ruby(&external_node("rails"), &dir);
match classification {
ExternalClassification::Classified {
package_name,
version,
language,
source,
} => {
assert_eq!(package_name, "rails");
assert_eq!(version.as_deref(), Some("7.0.8.4"));
assert_eq!(language, LangId::Ruby);
assert_eq!(source, DependencySource::Lockfile);
}
other => panic!("expected Classified, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn classify_ruby_unresolved_without_gemfile() {
let missing = std::env::temp_dir().join("meta_ast_ruby_dep_missing_dir");
let classification = classify_ruby(&external_node("rails"), &missing);
match classification {
ExternalClassification::Unresolved { raw_path, reason } => {
assert_eq!(raw_path, "rails");
assert_eq!(reason, "no Gemfile.lock or Gemfile found");
}
other => panic!("expected Unresolved, got {other:?}"),
}
}
}