diff --git a/Cargo.lock b/Cargo.lock index 6f4585e..a96a610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "archidoc-types", "ignore", "quote", + "serde_json", "syn", "tempfile", ] diff --git a/adapters/archidoc-rust/Cargo.toml b/adapters/archidoc-rust/Cargo.toml index 63c7cd3..cae834b 100644 --- a/adapters/archidoc-rust/Cargo.toml +++ b/adapters/archidoc-rust/Cargo.toml @@ -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" diff --git a/adapters/archidoc-rust/src/cargo_metadata.rs b/adapters/archidoc-rust/src/cargo_metadata.rs new file mode 100644 index 0000000..9d214e3 --- /dev/null +++ b/adapters/archidoc-rust/src/cargo_metadata.rs @@ -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, +) -> Result { + 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 = 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, + }; + 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, +) -> Vec { + 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 = dir + .relationships + .iter() + .map(|r| r.target.clone()) + .filter(|t| !ignore.contains(t)) + .collect(); + let actual: HashSet = graph + .get_dependencies(&crate_name) + .into_iter() + .filter(|t| !ignore.contains(t)) + .collect(); + + 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) -> 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 = ["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:?}"); + } +} diff --git a/adapters/archidoc-rust/src/cargo_modules.rs b/adapters/archidoc-rust/src/cargo_modules.rs index d8b3b07..4406b60 100644 --- a/adapters/archidoc-rust/src/cargo_modules.rs +++ b/adapters/archidoc-rust/src/cargo_modules.rs @@ -331,6 +331,7 @@ digraph { protocol: "Rust".to_string(), }], files: vec![], + code_elements: vec![], }]; let graph = ImportGraph::default(); // Empty graph @@ -357,6 +358,7 @@ digraph { parent_container: None, relationships: vec![], files: vec![], + code_elements: vec![], }]; let mut graph = ImportGraph::default(); diff --git a/adapters/archidoc-rust/src/lib.rs b/adapters/archidoc-rust/src/lib.rs index f76fc08..c941630 100644 --- a/adapters/archidoc-rust/src/lib.rs +++ b/adapters/archidoc-rust/src/lib.rs @@ -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; pub mod fitness; pub mod parser; diff --git a/adapters/archidoc-rust/src/parser.rs b/adapters/archidoc-rust/src/parser.rs index 5a6d6cb..6b61894 100644 --- a/adapters/archidoc-rust/src/parser.rs +++ b/adapters/archidoc-rust/src/parser.rs @@ -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. @@ -40,9 +40,11 @@ pub fn archidoc_from_file(path: &Path) -> Option { /// 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 @@ -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 { + 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::*; @@ -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"; diff --git a/adapters/archidoc-rust/src/walker.rs b/adapters/archidoc-rust/src/walker.rs index 9cd0421..9a9f02c 100644 --- a/adapters/archidoc-rust/src/walker.rs +++ b/adapters/archidoc-rust/src/walker.rs @@ -72,6 +72,19 @@ pub fn extract_all_docs(root: &Path) -> Vec { let relationships = parser::extract_relationships(&content); let files = parser::extract_file_table(&content); + // `@c4 code` elements live on item docs across the module's source + // files. For an entry file, scan its directory's siblings so the + // crate/module component owns every code element it declares. + let code_elements = if is_standard_entry { + let dir = path.parent().unwrap_or(path); + read_rs_sources(dir) + .iter() + .flat_map(|(_, src)| parser::extract_code_elements(src)) + .collect() + } else { + parser::extract_code_elements(&fs::read_to_string(path).unwrap_or_default()) + }; + docs_map.insert(module_path.clone(), (ModuleDoc { module_path, content, @@ -83,6 +96,7 @@ pub fn extract_all_docs(root: &Path) -> Vec { parent_container, relationships, files, + code_elements, }, is_priority)); } diff --git a/core/archidoc-cli/src/main.rs b/core/archidoc-cli/src/main.rs index 5b1b093..f52dbbb 100644 --- a/core/archidoc-cli/src/main.rs +++ b/core/archidoc-cli/src/main.rs @@ -260,6 +260,34 @@ enum IrCommand { log: bool, }, + /// Check declared `@c4 uses` relationships against real crate dependencies + /// + /// Reads the actual crate→crate dependency graph from `cargo metadata` + /// (no extra tooling) and diffs it against the `@c4 uses` arrows declared + /// in a compiled IR. Reports two kinds of drift: + /// missing — a real dependency with no `@c4 uses` (add the arrow) + /// stale — an `@c4 uses` with no real dependency (remove the arrow) + /// + /// Examples: + /// archidoc ir check-deps _context/current.json --manifest-dir crates + /// archidoc ir check-deps _context/current.json --manifest-dir . --strict + CheckDeps { + /// Path to the compiled IR JSON (the declared `@c4 uses` source) + ir: PathBuf, + + /// Directory containing the Cargo workspace/crate to read deps from + #[arg(long, default_value = ".")] + manifest_dir: PathBuf, + + /// Dependency names to ignore (repeatable); `workspace-hack` is always ignored + #[arg(long)] + ignore: Vec, + + /// Exit 1 if any drift is found (CI gate) + #[arg(long)] + strict: bool, + }, + /// List directory children from compiled IR (no rescan) /// /// Reads from _context/current.json by default. @@ -507,6 +535,9 @@ fn run_ir(args: IrArgs) { IrCommand::Validate { architecture, current, strict, log } => { run_ir_validate(architecture, current, strict, log); } + IrCommand::CheckDeps { ir, manifest_dir, ignore, strict } => { + run_ir_check_deps(ir, manifest_dir, ignore, strict); + } IrCommand::Ls { path, depth, ir_path } => { let ir = load_ir_default(ir_path); match archidoc_engine::ir_query::ls(&ir, &path, depth) { @@ -728,8 +759,10 @@ fn render_plantuml(cfg: &RenderConfig) { std::process::exit(1); }); + archidoc_engine::plantuml::generate_context(&diagrams_dir, cfg.ir); archidoc_engine::plantuml::generate_container(&diagrams_dir, cfg.ir); archidoc_engine::plantuml::generate_component(&diagrams_dir, cfg.ir); + archidoc_engine::plantuml::generate_code(&diagrams_dir, cfg.ir); println!("wrote PlantUML files to {}", diagrams_dir.display()); } @@ -770,6 +803,75 @@ fn run_ir_validate(architecture: PathBuf, current: PathBuf, strict: bool, log: b } } +fn run_ir_check_deps(ir: PathBuf, manifest_dir: PathBuf, ignore: Vec, strict: bool) { + use archidoc_rust::cargo_metadata::{ + validate_ir_relationships, workspace_import_graph, DEFAULT_IGNORE, + }; + use archidoc_rust::cargo_modules::WarningKind; + use std::collections::HashSet; + + let base = cwd(); + let ir_path = resolve_path(&base, &ir); + let manifest_dir = resolve_path(&base, &manifest_dir); + let ir = load_ir(&ir_path); + + let ignore: HashSet = DEFAULT_IGNORE + .iter() + .map(|s| s.to_string()) + .chain(ignore) + .collect(); + + let graph = match workspace_import_graph(&manifest_dir, &ignore) { + Ok(g) => g, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(2); + } + }; + + let warnings = validate_ir_relationships(&ir, &graph, &ignore); + + let missing: Vec<_> = warnings + .iter() + .filter(|w| matches!(w.kind, WarningKind::Undeclared)) + .collect(); + let stale: Vec<_> = warnings + .iter() + .filter(|w| matches!(w.kind, WarningKind::NoImport)) + .collect(); + + if warnings.is_empty() { + println!("Relationship check passed — every `@c4 uses` matches a real crate dependency."); + return; + } + + if !missing.is_empty() { + println!( + "Missing `@c4 uses` ({} — real dependency, no declared arrow):", + missing.len() + ); + for w in &missing { + println!( + " {} → {}\n add to {}: //! @c4 uses {} \"\" \"Rust\"", + w.module, w.target, w.module, w.target + ); + } + } + if !stale.is_empty() { + println!( + "\nStale `@c4 uses` ({} — declared arrow, no real dependency):", + stale.len() + ); + for w in &stale { + println!(" {} → {} (remove or fix the `@c4 uses` line)", w.module, w.target); + } + } + + if strict { + std::process::exit(1); + } +} + fn print_git_context(actual_path: &Path) { use std::process::Command; diff --git a/core/archidoc-engine/src/ir_builder.rs b/core/archidoc-engine/src/ir_builder.rs index ff3c7e8..869672b 100644 --- a/core/archidoc-engine/src/ir_builder.rs +++ b/core/archidoc-engine/src/ir_builder.rs @@ -152,6 +152,30 @@ fn overlay_module(tree: &mut DirNode, target_path: &str, module: &ModuleDoc, sca }) .collect(); + // Code-level elements (@c4 code) + node.code_elements = module + .code_elements + .iter() + .map(|c| archidoc_types::ir::CodeElement { + name: c.name.clone(), + kind: c.kind.clone(), + description: if c.description.is_empty() || c.description == "*No description*" { + None + } else { + Some(c.description.clone()) + }, + relationships: c + .relationships + .iter() + .map(|r| Relationship { + target: r.target.clone(), + label: r.label.clone(), + protocol: r.protocol.clone(), + }) + .collect(), + }) + .collect(); + // Overlay file table entries onto existing FileNodes for file_entry in &module.files { let file_node = node diff --git a/core/archidoc-engine/src/merge.rs b/core/archidoc-engine/src/merge.rs index c9e73bb..6631196 100644 --- a/core/archidoc-engine/src/merge.rs +++ b/core/archidoc-engine/src/merge.rs @@ -65,6 +65,11 @@ fn merge_dir(base: DirNode, incoming: DirNode) -> Result { } else { incoming.relationships }, + code_elements: if incoming.code_elements.is_empty() { + base.code_elements + } else { + incoming.code_elements + }, dirs: Vec::new(), // filled below files: Vec::new(), // filled below }; diff --git a/core/archidoc-engine/src/plantuml.rs b/core/archidoc-engine/src/plantuml.rs index aa10b33..05a0b56 100644 --- a/core/archidoc-engine/src/plantuml.rs +++ b/core/archidoc-engine/src/plantuml.rs @@ -28,6 +28,19 @@ pub fn generate_container(output_dir: &Path, ir: &ArchitectureIR) { )); } + // `@c4 system` nodes are external systems the containers talk to — render + // them outside the boundary so cross-level `Rel(...)` arrows resolve. + let mut system_defs = String::new(); + for dir in systems_of(ir) { + let id = to_puml_id(&dir.path); + let name = to_title_case(&dir.name); + let desc = dir.description.as_deref().unwrap_or(""); + system_defs.push_str(&format!( + "System_Ext({}, \"{}\", \"{}\")\n", + id, name, desc + )); + } + let mut rel_defs = String::new(); for dir in &containers { let from_id = to_puml_id(&dir.path); @@ -49,15 +62,75 @@ title Container Diagram System_Boundary(sys, "System") {{ {}}} +{} {} @enduml "#, - container_defs, rel_defs + container_defs, system_defs, rel_defs ); fs::write(&filepath, content).expect("Failed to write c4-container.puml"); } +/// Annotated `@c4 system` nodes. +fn systems_of(ir: &ArchitectureIR) -> Vec<&DirNode> { + ir.annotated_dirs() + .into_iter() + .filter(|d| d.c4_level == Some(C4Level::System)) + .collect() +} + +/// Generate the PlantUML C4 system-context diagram from `@c4 system` nodes. +/// +/// Renders one `System(...)` per `@c4 system` annotation plus every relationship +/// declared on those nodes. Emits nothing if no system-level node exists, so the +/// diagram only appears once a project declares its context. +pub fn generate_context(output_dir: &Path, ir: &ArchitectureIR) { + let systems = systems_of(ir); + if systems.is_empty() { + return; + } + + let mut system_defs = String::new(); + for dir in &systems { + let id = to_puml_id(&dir.path); + let name = to_title_case(&dir.name); + let desc = dir.description.as_deref().unwrap_or(""); + system_defs.push_str(&format!( + "System({}, \"{}\", \"{}\")\n", + id, name, desc + )); + } + + let mut rel_defs = String::new(); + for dir in &systems { + let from_id = to_puml_id(&dir.path); + for rel in &dir.relationships { + let to_id = to_puml_id(&rel.target); + rel_defs.push_str(&format!( + "Rel({}, {}, \"{}\", \"{}\")\n", + from_id, to_id, rel.label, rel.protocol + )); + } + } + + let content = format!( + r#"@startuml c4-context +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml + +title System Context Diagram + +{} +{} +@enduml +"#, + system_defs, rel_defs + ); + + fs::write(output_dir.join("c4-context.puml"), content) + .expect("Failed to write c4-context.puml"); +} + /// Generate PlantUML C4 component diagram. pub fn generate_component(output_dir: &Path, ir: &ArchitectureIR) { let filepath = output_dir.join("c4-component.puml"); @@ -126,6 +199,80 @@ title Component Diagram (GoF Patterns) fs::write(&filepath, content).expect("Failed to write c4-component.puml"); } +/// Generate the PlantUML C4 code diagram from `@c4 code` elements. +/// +/// Each annotated component that declares code elements becomes a +/// `Container_Boundary`, with one `Component(...)` per element (kind shown as +/// the technology tag) and any `@c4 uses` relationships as `Rel(...)` arrows. +/// Emits nothing when no code element exists. +pub fn generate_code(output_dir: &Path, ir: &ArchitectureIR) { + let owners: Vec<&DirNode> = ir + .annotated_dirs() + .into_iter() + .filter(|d| !d.code_elements.is_empty()) + .collect(); + if owners.is_empty() { + return; + } + + // Map a code element's bare name to its qualified puml id so that + // intra-component `@c4 uses StorageEntity` arrows land on the defined node + // instead of auto-creating a bare one. + let mut by_name: BTreeMap<&str, String> = BTreeMap::new(); + for owner in &owners { + for el in &owner.code_elements { + by_name.insert( + el.name.as_str(), + to_puml_id(&format!("{}__{}", owner.path, el.name)), + ); + } + } + + let mut boundary_defs = String::new(); + let mut rel_defs = String::new(); + for owner in &owners { + let boundary_id = to_puml_id(&owner.path); + boundary_defs.push_str(&format!( + "Container_Boundary({}_code, \"{}\") {{\n", + boundary_id, owner.name + )); + for el in &owner.code_elements { + let id = to_puml_id(&format!("{}__{}", owner.path, el.name)); + let desc = el.description.as_deref().unwrap_or(""); + boundary_defs.push_str(&format!( + " Component({}, \"{}\", \"{}\", \"{}\")\n", + id, el.name, el.kind, desc + )); + for rel in &el.relationships { + let to_id = by_name + .get(rel.target.as_str()) + .cloned() + .unwrap_or_else(|| to_puml_id(&rel.target)); + rel_defs.push_str(&format!( + "Rel({}, {}, \"{}\", \"{}\")\n", + id, to_id, rel.label, rel.protocol + )); + } + } + boundary_defs.push_str("}\n\n"); + } + + let content = format!( + r#"@startuml c4-code +!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml + +title Code Diagram (@c4 code elements) + +{}{} +@enduml +"#, + boundary_defs, rel_defs + ); + + fs::write(output_dir.join("c4-code.puml"), content) + .expect("Failed to write c4-code.puml"); +} + fn to_puml_id(s: &str) -> String { s.replace('.', "_").replace('/', "_").replace('-', "_") } diff --git a/core/archidoc-engine/src/tree.rs b/core/archidoc-engine/src/tree.rs index 0739794..2eab93a 100644 --- a/core/archidoc-engine/src/tree.rs +++ b/core/archidoc-engine/src/tree.rs @@ -283,6 +283,7 @@ pub fn build_dir_tree( source_file: None, parent: None, relationships: Vec::new(), + code_elements: Vec::new(), dirs, files, } diff --git a/core/archidoc-types/src/ir.rs b/core/archidoc-types/src/ir.rs index 5f0774e..c80d5a6 100644 --- a/core/archidoc-types/src/ir.rs +++ b/core/archidoc-types/src/ir.rs @@ -9,6 +9,7 @@ use crate::annotation::{HealthStatus, PatternStatus}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum C4Level { + System, Container, Component, Unknown, @@ -17,6 +18,7 @@ pub enum C4Level { impl fmt::Display for C4Level { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::System => write!(f, "system"), Self::Container => write!(f, "container"), Self::Component => write!(f, "component"), Self::Unknown => write!(f, "unknown"), @@ -82,6 +84,10 @@ pub struct DirNode { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub relationships: Vec, + // -- Code-level elements (@c4 code) -- + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub code_elements: Vec, + // -- Children -- #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dirs: Vec, @@ -89,6 +95,18 @@ pub struct DirNode { pub files: Vec, } +/// A code-level element (`@c4 code`) attached to its component. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeElement { + pub name: String, + /// `struct` | `enum` | `trait` | `fn` + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relationships: Vec, +} + /// A file node in the architecture tree. /// /// Files listed in a `@c4` file table carry typed attributes. @@ -210,6 +228,7 @@ impl DirNode { source_file: None, parent: None, relationships: Vec::new(), + code_elements: Vec::new(), dirs: Vec::new(), files: Vec::new(), } diff --git a/core/archidoc-types/src/lib.rs b/core/archidoc-types/src/lib.rs index 45e29f7..987c147 100644 --- a/core/archidoc-types/src/lib.rs +++ b/core/archidoc-types/src/lib.rs @@ -18,7 +18,7 @@ pub mod scaffold_ir; pub use annotation::{HealthStatus, PatternStatus}; pub use ir::{ArchitectureIR, C4Level, DirNode, FileNode}; pub use scaffold_ir::{ScaffoldIR, ScaffoldNode, ScaffoldPostHook, ScaffoldTemplate, ScaffoldVariable}; -pub use module_doc::{FileEntry, ModuleDoc, Relationship}; +pub use module_doc::{CodeElement, FileEntry, ModuleDoc, Relationship}; pub use report::{ AnnotationStatus, CoverageReport, DirCoverage, DriftReport, DriftedFile, ElementHealth, GhostEntry, HealthReport, OrphanEntry, ValidationReport, diff --git a/core/archidoc-types/src/module_doc.rs b/core/archidoc-types/src/module_doc.rs index 6965456..1407526 100644 --- a/core/archidoc-types/src/module_doc.rs +++ b/core/archidoc-types/src/module_doc.rs @@ -24,6 +24,18 @@ pub struct FileEntry { pub extra: HashMap, } +/// A code-level element (`@c4 code`) — a curated struct/enum/trait/fn that is +/// architecturally load-bearing enough to appear in a diagram. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodeElement { + pub name: String, + /// `struct` | `enum` | `trait` | `fn` + pub kind: String, + pub description: String, + #[serde(default)] + pub relationships: Vec, +} + /// A parsed module documentation unit. /// /// This is the core data structure — the JSON IR contract between @@ -40,4 +52,6 @@ pub struct ModuleDoc { pub parent_container: Option, pub relationships: Vec, pub files: Vec, + #[serde(default)] + pub code_elements: Vec, }