Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions adapters/archidoc-rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ categories = ["development-tools", "command-line-utilities"]

[dependencies]
archidoc-types = { version = "0.5.0", path = "../../core/archidoc-types" }
serde_json = "1"
quote = "1"
syn = { version = "2", features = ["full", "parsing"] }
ignore = "0.4"
Expand Down
235 changes: 235 additions & 0 deletions adapters/archidoc-rust/src/cargo_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//! Crate-level dependency edges from `cargo metadata`.
//!
//! Complements [`crate::cargo_modules`] (module-level, needs the external
//! `cargo-modules` tool) with a workspace-native, zero-extra-tooling source:
//! `cargo metadata` ships with every Cargo install. Edges are crate→crate
//! (workspace members only), which lines up with `@c4 component` granularity.
//!
//! The output is the same [`ImportGraph`] the `cargo_modules` path produces,
//! so the existing [`crate::cargo_modules::validate_relationships`] diff and
//! [`RelationshipWarning`] types apply unchanged.

use std::collections::HashSet;
use std::path::Path;
use std::process::Command;

use archidoc_types::ir::ArchitectureIR;

use crate::cargo_modules::{ImportGraph, RelationshipWarning, WarningKind};

/// Default dependency names to ignore (build/tooling artifacts, not architecture).
pub const DEFAULT_IGNORE: &[&str] = &["workspace-hack"];

/// Build a crate-level [`ImportGraph`] from `cargo metadata`.
///
/// Nodes are all workspace member crate names. Edges are normal (non-dev,
/// non-build) dependencies between members. `ignore` names are dropped from
/// both nodes and edges.
///
/// Fails loud: a missing/oversized/garbled `cargo metadata` is returned as an
/// `Err`, never silently swallowed into an empty graph.
pub fn workspace_import_graph(
manifest_dir: &Path,
ignore: &HashSet<String>,
) -> Result<ImportGraph, String> {
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(manifest_dir)
.output()
.map_err(|e| format!("failed to run `cargo metadata` in {manifest_dir:?}: {e}"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("`cargo metadata` failed: {stderr}"));
}

let meta: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("`cargo metadata` produced invalid JSON: {e}"))?;

let packages = meta["packages"]
.as_array()
.ok_or("`cargo metadata` JSON has no `packages` array")?;

let members: HashSet<String> = packages
.iter()
.filter_map(|p| p["name"].as_str())
.map(str::to_string)
.filter(|n| !ignore.contains(n))
.collect();

let mut graph = ImportGraph::default();
for member in &members {
graph.nodes.insert(member.clone());
}

for pkg in packages {
let from = match pkg["name"].as_str() {
Some(n) if members.contains(n) => n.to_string(),
_ => continue,
};
let deps = match pkg["dependencies"].as_array() {
Some(d) => d,
None => continue,
};
for dep in deps {
// `kind` is null for normal deps, "dev"/"build" otherwise.
if !dep["kind"].is_null() {
continue;
}
let to = match dep["name"].as_str() {
Some(n) if members.contains(n) && n != from => n.to_string(),
_ => continue,
};
Comment on lines +79 to +82
let edge = (from.clone(), to);
if !graph.edges.contains(&edge) {
graph.edges.push(edge);
}
}
}

Ok(graph)
}

/// Diff the `@c4 uses` relationships declared in a compiled IR against the
/// real crate-dependency graph.
///
/// Returns one [`RelationshipWarning`] per drift:
/// - [`WarningKind::NoImport`] — declared `@c4 uses` with no real dependency
/// (a stale arrow that should be removed), and
/// - [`WarningKind::Undeclared`] — real dependency with no `@c4 uses`
/// (a missing arrow that should be added).
///
/// Only annotated crate-level components are compared (their `name` must be a
/// workspace member); sub-module dirs and un-annotated crates are skipped.
pub fn validate_ir_relationships(
ir: &ArchitectureIR,
graph: &ImportGraph,
ignore: &HashSet<String>,
) -> Vec<RelationshipWarning> {
let mut warnings = Vec::new();

for dir in ir.annotated_dirs() {
let crate_name = dir.name.clone();
if !graph.nodes.contains(&crate_name) || ignore.contains(&crate_name) {
continue;
}

let declared: HashSet<String> = dir
.relationships
.iter()
.map(|r| r.target.clone())
.filter(|t| !ignore.contains(t))
.collect();
let actual: HashSet<String> = graph
.get_dependencies(&crate_name)
.into_iter()
.filter(|t| !ignore.contains(t))
.collect();
Comment on lines +117 to +127

for target in &declared {
if !actual.contains(target) {
warnings.push(RelationshipWarning {
module: crate_name.clone(),
target: target.clone(),
kind: WarningKind::NoImport,
});
}
}
for target in &actual {
if !declared.contains(target) {
warnings.push(RelationshipWarning {
module: crate_name.clone(),
target: target.clone(),
kind: WarningKind::Undeclared,
});
}
}
}

warnings.sort_by(|a, b| {
(&a.module, &a.target, a.kind.clone() as u8)
.cmp(&(&b.module, &b.target, b.kind.clone() as u8))
});
warnings
}

#[cfg(test)]
mod tests {
use super::*;
use archidoc_types::ir::{C4Level, DirNode, Relationship};

fn component(name: &str, uses: &[&str]) -> DirNode {
let mut d = DirNode::empty(name, name);
d.c4_level = Some(C4Level::Component);
d.relationships = uses
.iter()
.map(|t| Relationship {
target: t.to_string(),
label: String::new(),
protocol: "Rust".to_string(),
})
.collect();
d
}

fn ir_with(components: Vec<DirNode>) -> ArchitectureIR {
let mut ir = ArchitectureIR::new("crates".to_string());
ir.root.dirs = components;
ir
}

fn graph(nodes: &[&str], edges: &[(&str, &str)]) -> ImportGraph {
let mut g = ImportGraph::default();
for n in nodes {
g.nodes.insert(n.to_string());
}
g.edges = edges
.iter()
.map(|(f, t)| (f.to_string(), t.to_string()))
.collect();
g
}

#[test]
fn clean_when_declared_matches_real() {
let ir = ir_with(vec![component("core", &["api"]), component("api", &[])]);
let g = graph(&["core", "api"], &[("core", "api")]);
let w = validate_ir_relationships(&ir, &g, &HashSet::new());
assert!(w.is_empty(), "expected no drift, got {w:?}");
}

#[test]
fn flags_missing_and_stale() {
// core really depends on api (undeclared) and declares a bogus turso edge (stale).
let ir = ir_with(vec![
component("core", &["turso"]),
component("api", &[]),
component("turso", &[]),
]);
let g = graph(&["core", "api", "turso"], &[("core", "api")]);
let w = validate_ir_relationships(&ir, &g, &HashSet::new());

let missing: Vec<_> = w
.iter()
.filter(|x| matches!(x.kind, WarningKind::Undeclared))
.map(|x| (x.module.as_str(), x.target.as_str()))
.collect();
let stale: Vec<_> = w
.iter()
.filter(|x| matches!(x.kind, WarningKind::NoImport))
.map(|x| (x.module.as_str(), x.target.as_str()))
.collect();

assert_eq!(missing, vec![("core", "api")]);
assert_eq!(stale, vec![("core", "turso")]);
}

#[test]
fn ignore_list_suppresses_both_directions() {
let ir = ir_with(vec![component("core", &["workspace-hack"])]);
let g = graph(&["core"], &[("core", "workspace-hack")]);
let ignore: HashSet<String> = ["workspace-hack".to_string()].into_iter().collect();
let w = validate_ir_relationships(&ir, &g, &ignore);
assert!(w.is_empty(), "ignored dep must not drift, got {w:?}");
}
}
2 changes: 2 additions & 0 deletions adapters/archidoc-rust/src/cargo_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ digraph {
protocol: "Rust".to_string(),
}],
files: vec![],
code_elements: vec![],
}];

let graph = ImportGraph::default(); // Empty graph
Expand All @@ -357,6 +358,7 @@ digraph {
parent_container: None,
relationships: vec![],
files: vec![],
code_elements: vec![],
}];

let mut graph = ImportGraph::default();
Expand Down
1 change: 1 addition & 0 deletions adapters/archidoc-rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//! | `promote.rs` | -- | Auto-promote planned to verified | planned |
//! | `cargo_modules.rs` | -- | cargo-modules integration (optional) | planned |

pub mod cargo_metadata;
pub mod cargo_modules;
Comment on lines 14 to 18
pub mod fitness;
pub mod parser;
Expand Down
90 changes: 87 additions & 3 deletions adapters/archidoc-rust/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::fs;
use std::path::Path;

use archidoc_types::{
C4Level, FileEntry, HealthStatus, PatternStatus, Relationship,
C4Level, CodeElement, FileEntry, HealthStatus, PatternStatus, Relationship,
};

/// Extract `//!` doc comments from a Rust source file.
Expand Down Expand Up @@ -40,9 +40,11 @@ pub fn archidoc_from_file(path: &Path) -> Option<String> {

/// Extract the C4 level marker from doc content.
///
/// Uses `@c4 container` / `@c4 component` syntax.
/// Uses `@c4 system` / `@c4 container` / `@c4 component` syntax.
pub fn extract_c4_level(content: &str) -> C4Level {
if content.contains("@c4 container") {
if content.contains("@c4 system") {
C4Level::System
} else if content.contains("@c4 container") {
C4Level::Container
} else if content.contains("@c4 component") {
C4Level::Component
Expand Down Expand Up @@ -370,6 +372,58 @@ fn parse_pattern_field(field: &str) -> (String, PatternStatus) {
}
}

/// Join the `#[doc = "..."]` attributes of an item into one string.
fn doc_of(attrs: &[syn::Attribute]) -> String {
let mut out = String::new();
for attr in attrs {
if !attr.path().is_ident("doc") {
continue;
}
if let syn::Meta::NameValue(nv) = &attr.meta {
if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }) = &nv.value {
out.push_str(s.value().trim());
out.push('\n');
}
}
}
out
}

/// Extract `@c4 code` elements (struct/enum/trait/fn) from a Rust source file.
///
/// Only items whose doc comment contains a `@c4 code` marker are collected;
/// everything else is ignored, keeping the code diagram a curated set of
/// architecturally load-bearing types rather than a dump of every symbol.
/// `@c4 uses` lines in the item doc become the element's relationships.
pub fn extract_code_elements(source: &str) -> Vec<CodeElement> {
let file = match syn::parse_file(source) {
Ok(f) => f,
Err(_) => return Vec::new(),
};

let mut elements = Vec::new();
for item in &file.items {
let (name, kind, attrs): (String, &str, &[syn::Attribute]) = match item {
syn::Item::Struct(i) => (i.ident.to_string(), "struct", &i.attrs),
syn::Item::Enum(i) => (i.ident.to_string(), "enum", &i.attrs),
syn::Item::Trait(i) => (i.ident.to_string(), "trait", &i.attrs),
syn::Item::Fn(i) => (i.sig.ident.to_string(), "fn", &i.attrs),
_ => continue,
};
let doc = doc_of(attrs);
if !doc.contains("@c4 code") {
continue;
}
elements.push(CodeElement {
name,
kind: kind.to_string(),
description: extract_description(&doc),
relationships: extract_relationships(&doc),
});
}
elements
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -380,6 +434,36 @@ mod tests {
assert_eq!(extract_pattern(content), "Facade");
}

#[test]
fn c4_level_recognizes_all_levels() {
assert_eq!(extract_c4_level("//! @c4 system"), C4Level::System);
assert_eq!(extract_c4_level("//! @c4 container"), C4Level::Container);
assert_eq!(extract_c4_level("//! @c4 component"), C4Level::Component);
assert_eq!(extract_c4_level("//! no annotation"), C4Level::Unknown);
}

#[test]
fn code_elements_are_curated_and_typed() {
let src = r#"
/// @c4 code
/// The storage seam.
/// @c4 uses Row "yields" "Rust"
pub trait Adapter {}

/// @c4 code
pub struct Row {}

/// Not architectural — no marker.
pub struct Helper {}
"#;
let els = extract_code_elements(src);
let names: Vec<_> = els.iter().map(|e| (e.name.as_str(), e.kind.as_str())).collect();
assert_eq!(names, vec![("Adapter", "trait"), ("Row", "struct")]);
assert_eq!(els[0].relationships.len(), 1);
assert_eq!(els[0].relationships[0].target, "Row");
assert_eq!(els[0].description, "The storage seam.");
}

#[test]
fn explicit_pattern_multi_word() {
let content = "@c4 component\n\nSome description.\n\nPattern: Value Object";
Expand Down
Loading