use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{
MAX_LINEAGE_DEPTH, ParseFailure, ScanCoverage, ScanReport, dedupe_lineage_edges,
detect_lineage_failures, scan_workspace, synthesize_inherited_presentations,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CrateOrigin {
PathOrWorkspace,
Registry,
Git,
Other(String),
}
impl CrateOrigin {
fn from_source(source: Option<&str>) -> Self {
match source {
None => Self::PathOrWorkspace,
Some(s) if s.starts_with("registry+") => Self::Registry,
Some(s) if s.starts_with("git+") => Self::Git,
Some(s) => Self::Other(s.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepCrateRoot {
pub package_name: String,
pub version: String,
pub crate_root: PathBuf,
pub origin: CrateOrigin,
}
pub fn enumerate_dep_crate_roots(
workspace_root: &Path,
include_path_workspace: bool,
) -> std::io::Result<Vec<DepCrateRoot>> {
use std::process::Command;
let manifest_path = workspace_root.join("Cargo.toml");
let output = Command::new("cargo")
.arg("metadata")
.arg("--format-version")
.arg("1")
.arg("--manifest-path")
.arg(&manifest_path)
.output()
.map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"failed to invoke `cargo metadata` at `{}`: {e} \
(is cargo on PATH?)",
manifest_path.display()
),
)
})?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"`cargo metadata` exited with status {} for manifest `{}`: {}",
output.status,
manifest_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
std::io::Error::other(format!("failed to parse `cargo metadata` JSON output: {e}"))
})?;
let workspace_members: std::collections::HashSet<String> = metadata
.get("workspace_members")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let packages = metadata
.get("packages")
.and_then(|v| v.as_array())
.ok_or_else(|| {
std::io::Error::other(
"`cargo metadata` output missing `packages` array — unexpected schema",
)
})?;
let mut roots: Vec<DepCrateRoot> = Vec::new();
for pkg in packages {
let id = pkg.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if workspace_members.contains(id) {
continue;
}
let package_name = pkg
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let version = pkg
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let source = pkg.get("source").and_then(|v| v.as_str());
let manifest_str = pkg.get("manifest_path").and_then(|v| v.as_str());
let Some(manifest_str) = manifest_str else {
continue;
};
let manifest = PathBuf::from(manifest_str);
let Some(crate_root) = manifest.parent().map(Path::to_path_buf) else {
continue;
};
let origin = CrateOrigin::from_source(source);
if matches!(origin, CrateOrigin::PathOrWorkspace) && !include_path_workspace {
continue;
}
roots.push(DepCrateRoot {
package_name,
version,
crate_root,
origin,
});
}
Ok(roots)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceMemberRoot {
pub package_name: String,
pub version: String,
pub crate_root: PathBuf,
}
impl WorkspaceMemberRoot {
#[must_use]
pub fn canonical_path(&self) -> String {
format!("{}@{}", self.package_name, self.version)
}
}
pub fn enumerate_workspace_member_roots(
workspace_root: &Path,
) -> std::io::Result<Vec<WorkspaceMemberRoot>> {
use std::process::Command;
let manifest_path = workspace_root.join("Cargo.toml");
let output = Command::new("cargo")
.arg("metadata")
.arg("--no-deps")
.arg("--format-version")
.arg("1")
.arg("--manifest-path")
.arg(&manifest_path)
.output()
.map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"failed to invoke `cargo metadata` at `{}`: {e} (is cargo on PATH?)",
manifest_path.display()
),
)
})?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"`cargo metadata --no-deps` exited with status {} for manifest `{}`: {}",
output.status,
manifest_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
std::io::Error::other(format!("failed to parse `cargo metadata` JSON output: {e}"))
})?;
let member_ids: std::collections::HashSet<String> = metadata
.get("workspace_members")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let packages = metadata
.get("packages")
.and_then(|v| v.as_array())
.ok_or_else(|| {
std::io::Error::other(
"`cargo metadata` output missing `packages` array — unexpected schema",
)
})?;
let mut roots: Vec<WorkspaceMemberRoot> = Vec::new();
for pkg in packages {
let id = pkg.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if !member_ids.is_empty() && !member_ids.contains(id) {
continue;
}
let package_name = pkg
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let version = pkg
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let manifest_str = pkg.get("manifest_path").and_then(|v| v.as_str());
let Some(manifest_str) = manifest_str else {
continue;
};
let Some(crate_root) = PathBuf::from(manifest_str).parent().map(Path::to_path_buf) else {
continue;
};
roots.push(WorkspaceMemberRoot {
package_name,
version,
crate_root,
});
}
roots.sort_by(|a, b| a.package_name.cmp(&b.package_name));
Ok(roots)
}
impl ScanReport {
pub(crate) fn merge(&mut self, mut other: Self) {
self.antigens.append(&mut other.antigens);
self.presentations.append(&mut other.presentations);
self.immunities.append(&mut other.immunities);
self.tolerances.append(&mut other.tolerances);
self.lineage_edges.append(&mut other.lineage_edges);
self.deferred_defenses.append(&mut other.deferred_defenses);
self.convergent_evidences
.append(&mut other.convergent_evidences);
self.recurrent_declarations
.append(&mut other.recurrent_declarations);
self.mucosal_declarations
.append(&mut other.mucosal_declarations);
self.prescriptive_declarations
.append(&mut other.prescriptive_declarations);
self.defenses.append(&mut other.defenses);
self.generates_declarations
.append(&mut other.generates_declarations);
self.files_scanned += other.files_scanned;
self.parse_failures.append(&mut other.parse_failures);
}
}
pub fn resolve_cross_member_lineage_parents(report: &mut ScanReport) {
use std::collections::HashMap;
let mut decl_members: HashMap<String, std::collections::BTreeSet<String>> = HashMap::new();
for a in &report.antigens {
if let Some(cp) = a.canonical_path.as_deref() {
decl_members
.entry(a.type_name.clone())
.or_default()
.insert(cp.to_string());
}
}
let mut ambiguity_failures: Vec<ParseFailure> = Vec::new();
for e in &mut report.lineage_edges {
let Some(members) = decl_members.get(&e.parent) else {
continue;
};
match members.len() {
0 => {},
1 => {
let target = members.iter().next().expect("len==1");
if e.parent_canonical_path.as_deref() != Some(target.as_str()) {
e.parent_canonical_path = Some(target.clone());
}
},
_ => {
ambiguity_failures.push(ParseFailure {
file: e.file.clone(),
error: format!(
"#[descended_from({parent})] on `{child}` is ambiguous across the \
workspace: `{parent}` is declared in {n} members ({members}); \
cross-member lineage parent left unresolved (qualify the parent path \
to disambiguate)",
parent = e.parent,
child = e.child,
n = members.len(),
members = members.iter().cloned().collect::<Vec<_>>().join(", "),
),
});
},
}
}
report.parse_failures.extend(ambiguity_failures);
}
pub fn resolve_cross_member_addresses(report: &mut ScanReport) {
use std::collections::{BTreeMap, BTreeSet};
let mut decl_members: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for a in &report.antigens {
if let Some(cp) = a.canonical_path.as_deref() {
decl_members
.entry(a.type_name.as_str())
.or_default()
.insert(cp);
}
}
let mut collisions: BTreeMap<&str, String> = BTreeMap::new();
macro_rules! restamp_family {
($field:ident) => {
for rec in &mut report.$field {
let Some(members) = decl_members.get(rec.antigen_type.as_str()) else {
continue; };
let mut it = members.iter();
match (it.next(), it.next()) {
(Some(&target), None) => {
if rec.canonical_path.as_deref() != Some(target) {
rec.canonical_path = Some(target.to_owned());
}
}
(Some(_), Some(_)) => {
let name = rec.antigen_type.as_str();
collisions.entry(name).or_insert_with(|| {
format!(
"cross-crate addresses() for `{name}` is ambiguous across the \
workspace: `{name}` is declared in {n} members ({members}); the \
reference is left keyed to its own member and reads as \
out-of-frame (qualify the antigen path to disambiguate)",
n = members.len(),
members = members.iter().copied().collect::<Vec<_>>().join(", "),
)
});
}
(None, _) => {}
}
}
};
}
restamp_family!(presentations);
restamp_family!(defenses);
restamp_family!(immunities);
restamp_family!(tolerances);
for error in collisions.into_values() {
report.parse_failures.push(ParseFailure {
file: PathBuf::from("<workspace>"),
error,
});
}
}
pub fn scan_workspace_multi_crate(workspace_root: &Path) -> std::io::Result<ScanReport> {
let members = enumerate_workspace_member_roots(workspace_root)?;
let mut enumerated_members: Vec<String> = members
.iter()
.map(WorkspaceMemberRoot::canonical_path)
.collect();
let mut scanned_members: Vec<String> = Vec::with_capacity(members.len());
let mut merged = ScanReport::default();
for member in &members {
let mut member_report = match scan_workspace(&member.crate_root, None) {
Ok(r) => r,
Err(e) => {
merged.parse_failures.push(ParseFailure {
file: member.crate_root.clone(),
error: format!(
"member `{}` could not be scanned ({e}); its sites are UNSEEN \
(ignorance frontier), not defended",
member.canonical_path()
),
});
continue;
},
};
member_report.stamp_canonical_path(&member.canonical_path());
merged.merge(member_report);
scanned_members.push(member.canonical_path());
}
enumerated_members.sort();
scanned_members.sort();
merged.scan_coverage = Some(ScanCoverage {
enumerated_members,
scanned_members,
});
resolve_cross_member_lineage_parents(&mut merged);
resolve_cross_member_addresses(&mut merged);
let (deduped_edges, dedup_failures) = dedupe_lineage_edges(&merged.lineage_edges);
merged.lineage_edges = deduped_edges;
merged.parse_failures.extend(dedup_failures);
let lineage_failures = detect_lineage_failures(&merged.lineage_edges, MAX_LINEAGE_DEPTH);
merged.parse_failures.extend(lineage_failures);
synthesize_inherited_presentations(&mut merged);
Ok(merged)
}