use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
fs,
path::{Path, PathBuf},
process::Command,
rc::Rc,
};
use serde::Deserialize;
use super::{
ArchError,
model::{Architecture, Declaration, Dependency, Package, Role, Scope, WorkCounts},
};
#[derive(Deserialize)]
struct Metadata {
workspace_root: PathBuf,
workspace_members: Vec<String>,
packages: Vec<CargoPackage>,
}
#[derive(Deserialize)]
struct CargoPackage {
id: String,
name: String,
manifest_path: PathBuf,
targets: Vec<Target>,
dependencies: Vec<CargoDependency>,
}
#[derive(Deserialize)]
struct Target {
kind: Vec<String>,
src_path: PathBuf,
}
#[derive(Deserialize)]
struct CargoDependency {
name: String,
rename: Option<String>,
path: Option<PathBuf>,
kind: Option<String>,
target: Option<String>,
optional: bool,
uses_default_features: bool,
features: Vec<String>,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Project {
root: Option<PathBuf>,
workspaces: Vec<PathBuf>,
}
fn document(
path: &Path,
cache: &mut BTreeMap<PathBuf, Rc<toml::Value>>,
) -> Result<Rc<toml::Value>, ArchError> {
if let Some(value) = cache.get(path) {
return Ok(value.clone());
}
let text = fs::read_to_string(path).map_err(|error| ArchError::Read(path.to_owned(), error))?;
let value: toml::Value =
toml::from_str(&text).map_err(|error| ArchError::Manifest(path.to_owned(), error))?;
let value = Rc::new(value);
cache.insert(path.to_owned(), Rc::clone(&value));
Ok(value)
}
fn config<'document>(value: &'document toml::Value, table: &str) -> Option<&'document toml::Value> {
value.get(table)?.get("metadata")?.get("pipecircuit")
}
pub(super) fn infer(root: &Path, directory: &Path) -> Option<Declaration> {
let mut parts = directory.strip_prefix(root).ok()?.components();
let first = parts.next()?.as_os_str();
let (role, app, scope) = if first == "core" {
(Role::Core, None, None)
} else if first == "apps" {
let app = parts.next()?.as_os_str().to_str()?.to_owned();
let part = parts.next().map(|part| part.as_os_str());
if app == "shared" {
if part? != "pipes" {
return None;
}
(Role::Pipes, None, Some(Scope::Shared))
} else {
let role = match part.and_then(|part| part.to_str()) {
Some("pipes") => Role::Pipes,
Some("switchboard") => Role::Switchboard,
Some("wiring") => Role::Wiring,
None => Role::Application,
Some(_) => return None,
};
(role, Some(app), None)
}
} else {
return None;
};
Some(Declaration {
role,
app,
scope,
forbid_dependencies: Vec::new(),
})
}
pub(super) fn find_entry(directory: &Path) -> Result<PathBuf, ArchError> {
let directory = fs::canonicalize(directory)
.map_err(|error| ArchError::Read(directory.to_owned(), error))?;
let mut cache = BTreeMap::new();
for parent in directory.ancestors() {
let direct = parent.join("Cargo.toml");
if direct.is_file() {
return Ok(direct);
}
let mut candidates = Vec::new();
for anchor in ["apps", "core"] {
let manifest = parent.join(anchor).join("Cargo.toml");
if !manifest.is_file() {
continue;
}
let value = document(&manifest, &mut cache)?;
let Some(value) = config(&value, "workspace") else {
continue;
};
let project: Project = value
.clone()
.try_into()
.map_err(|error| ArchError::Manifest(manifest.clone(), error))?;
if let Some(root) = &project.root {
let root = parent.join(anchor).join(root);
let root = fs::canonicalize(&root).map_err(|error| ArchError::Read(root, error))?;
if root == parent {
candidates.push((manifest, project));
}
}
}
if let Some((entry, project)) = candidates.first() {
let base = entry.parent().ok_or_else(|| {
ArchError::Configuration(entry.clone(), "Manifest needs a parent directory.".into())
})?;
let mut included = BTreeSet::from([entry.clone()]);
for workspace in &project.workspaces {
let path = base.join(workspace);
let path = fs::canonicalize(&path).map_err(|error| ArchError::Read(path, error))?;
included.insert(path);
}
if candidates
.iter()
.any(|(manifest, _)| !included.contains(manifest))
{
return Err(ArchError::Configuration(parent.to_owned(),
"apps/ and core/ declare separate workspace sets. Link them with workspace.metadata.pipecircuit.workspaces, or select one explicitly with --manifest-path.".into()));
}
return Ok(entry.clone());
}
}
Err(ArchError::Configuration(directory,
"No Cargo workspace or declared Pipecircuit project found. Run inside a Cargo workspace, use --manifest-path <Cargo.toml>, or declare workspace.metadata.pipecircuit.root in apps/Cargo.toml or core/Cargo.toml.".into()))
}
pub(super) fn load(manifest: &Path) -> Result<Architecture, ArchError> {
let mut pending = VecDeque::from([manifest.to_owned()]);
let mut visited = BTreeSet::new();
let mut cache = BTreeMap::new();
let mut resolved_paths = BTreeMap::new();
let mut architecture = Architecture {
schema: "pipecircuit.arch/1",
root: PathBuf::new(),
packages: BTreeMap::new(),
findings: Vec::new(),
limitations: vec![
"Manifest architecture analysis; behavior, coverage, proofs, and performance are not checked.",
"no_std declarations are checked; target compilation and external dependency feature compatibility require separate build evidence.",
"Dependency arrows are not message routes. Runtime placement is not inferred from crate roles.",
"Explicit third-party packages remain in the graph; their internals and portability are not audited as first-party core.",
],
work: WorkCounts::default(),
};
while let Some(manifest) = pending.pop_front() {
let manifest = canonical(&manifest, &mut resolved_paths)?;
if visited.contains(&manifest) {
continue;
}
let directory = manifest.parent().ok_or_else(|| {
ArchError::Configuration(
manifest.clone(),
"Manifest has no parent directory.".to_owned(),
)
})?;
let output = Command::new("cargo")
.current_dir(directory)
.args([
"metadata",
"--no-deps",
"--offline",
"--locked",
"--format-version",
"1",
"--manifest-path",
])
.arg(&manifest)
.output()
.map_err(ArchError::CargoStart)?;
architecture.work.metadata_invocations = architecture
.work
.metadata_invocations
.checked_add(1)
.ok_or(ArchError::Capacity)?;
if !output.status.success() {
return Err(ArchError::Cargo(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
let metadata: Metadata = serde_json::from_slice(&output.stdout).map_err(ArchError::Json)?;
let workspace_manifest = canonical(
&metadata.workspace_root.join("Cargo.toml"),
&mut resolved_paths,
)?;
if visited.contains(&workspace_manifest) {
visited.insert(manifest);
continue;
}
visited.insert(workspace_manifest.clone());
visited.insert(manifest);
let workspace = document(&workspace_manifest, &mut cache)?;
let project: Project = config(&workspace, "workspace")
.cloned()
.map(toml::Value::try_into)
.transpose()
.map_err(|error| ArchError::Manifest(workspace_manifest.clone(), error))?
.unwrap_or_default();
let declared_root = project
.root
.as_ref()
.map(|root| metadata.workspace_root.join(root));
if architecture.root.as_os_str().is_empty() {
let root = declared_root.unwrap_or_else(|| metadata.workspace_root.clone());
architecture.root = canonical(&root, &mut resolved_paths)?;
} else if let Some(root) = declared_root {
let root = canonical(&root, &mut resolved_paths)?;
if root != architecture.root {
return Err(ArchError::Configuration(
workspace_manifest.clone(),
"Listed workspaces must agree on one project root.".to_owned(),
));
}
}
for path in project.workspaces {
pending.push_back(metadata.workspace_root.join(path));
}
let lints = Rc::new(
workspace
.get("workspace")
.and_then(|workspace| workspace.get("lints"))
.cloned()
.unwrap_or_else(|| toml::Value::Table(Default::default())),
);
let members: BTreeSet<_> = metadata.workspace_members.into_iter().collect();
for package in metadata
.packages
.into_iter()
.filter(|package| members.contains(&package.id))
{
let manifest = canonical(&package.manifest_path, &mut resolved_paths)?;
visited.insert(manifest.clone());
if architecture.packages.contains_key(&manifest) {
continue;
}
let doc = document(&manifest, &mut cache)?;
let inferred = manifest
.parent()
.and_then(|parent| infer(&architecture.root, parent));
let declared: Option<Declaration> = config(&doc, "package")
.cloned()
.map(toml::Value::try_into)
.transpose()
.map_err(|error| ArchError::Manifest(manifest.clone(), error))?;
let provenance = if declared.is_some() {
"declared"
} else if inferred.is_some() {
"canonical-path"
} else {
"unclassified"
};
if let Some(declaration) = &declared {
let conflict = inferred.as_ref().is_some_and(|inferred| {
inferred.role != declaration.role
|| inferred.app != declaration.app
|| inferred.scope != declaration.scope
});
if !declaration.valid() || conflict {
architecture.report(&manifest, "arch.role.conflict", package.name.clone(), "The declared role is invalid or contradicts its canonical location.".to_owned(), "Use the role and application established by the canonical path, or move the crate to a custom location.");
}
}
let declaration = inferred
.map(|mut inferred| {
if let Some(declared) = &declared {
inferred
.forbid_dependencies
.clone_from(&declared.forbid_dependencies);
}
inferred
})
.or(declared);
let proc_macro = package
.targets
.iter()
.any(|target| target.kind.iter().any(|kind| kind == "proc-macro"));
let library = package
.targets
.into_iter()
.find(|target| {
target
.kind
.iter()
.any(|kind| kind == "lib" || kind == "rlib" || kind == "cdylib")
})
.map(|target| target.src_path);
let dependencies = package
.dependencies
.into_iter()
.map(|dependency| {
let path = dependency
.path
.map(|path| {
let manifest =
canonical(&path.join("Cargo.toml"), &mut resolved_paths)?;
manifest.parent().map(Path::to_owned).ok_or_else(|| {
ArchError::Configuration(
manifest.clone(),
"Dependency manifest has no parent.".to_owned(),
)
})
})
.transpose()?;
Ok(Dependency {
name: dependency.name,
alias: dependency.rename,
path,
kind: dependency.kind.unwrap_or_else(|| "normal".to_owned()),
target: dependency.target,
optional: dependency.optional,
default_features: dependency.uses_default_features,
features: dependency.features,
})
})
.collect::<Result<Vec<_>, ArchError>>()?;
architecture.packages.insert(
manifest.clone(),
Package {
name: package.name,
manifest,
declaration,
provenance,
dependencies,
proc_macro,
document: doc,
library,
workspace_lints: lints.clone(),
workspace_manifest: workspace_manifest.clone(),
},
);
}
}
if architecture.packages.is_empty() {
return Err(ArchError::Configuration(
manifest.to_owned(),
"No workspace packages were available to inspect.".to_owned(),
));
}
architecture.work.manifests_read = cache.len();
Ok(architecture)
}
fn canonical(path: &Path, cache: &mut BTreeMap<PathBuf, PathBuf>) -> Result<PathBuf, ArchError> {
if let Some(resolved) = cache.get(path) {
return Ok(resolved.clone());
}
let resolved = path
.canonicalize()
.map_err(|error| ArchError::Read(path.to_owned(), error))?;
cache.insert(path.to_owned(), resolved.clone());
cache
.entry(resolved.clone())
.or_insert_with(|| resolved.clone());
Ok(resolved)
}