From ce0a1440a226f29afdc1d6a7c53ab1962fe4045d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 14 May 2026 17:46:12 +0200 Subject: [PATCH] feat(cli): spar emit --format mermaid subcommand (v0.10.x M2) Add `spar emit` CLI subcommand that wraps spar_mermaid::emit_flowchart, exposing flowchart generation to users via the existing parse/lower/instantiate pipeline. Flags: --root (required), --format mermaid, --category, --max-depth, --no-connections, -o/--output. Two integration tests added. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + artifacts/requirements.yaml | 15 ++ artifacts/verification.yaml | 20 +++ crates/spar-cli/Cargo.toml | 1 + crates/spar-cli/src/main.rs | 189 ++++++++++++++++++++++++++ crates/spar-cli/tests/emit_mermaid.rs | 147 ++++++++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 crates/spar-cli/tests/emit_mermaid.rs diff --git a/Cargo.lock b/Cargo.lock index b6c4236..67700a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1260,6 +1260,7 @@ dependencies = [ "spar-hir", "spar-hir-def", "spar-insight", + "spar-mermaid", "spar-parser", "spar-render", "spar-solver", diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index de7504d..0584318 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2021,4 +2021,19 @@ artifacts: status: implemented tags: [mermaid, emission, v0100] + - id: REQ-MERMAID-CLI-001 + type: requirement + title: spar emit --format mermaid CLI subcommand + description: > + The spar CLI shall expose a top-level `emit` subcommand that accepts + --root (required), --format mermaid (default, only valid value), one or + more AADL source files, and optional filters --category (comma-separated + ComponentCategory names, case-insensitive), --max-depth N, and + --no-connections. Output is written to stdout or to -o/--output path. + The subcommand must reuse the existing parse/lower/instantiate pipeline + (HirDefDatabase + GlobalScope + SystemInstance::instantiate) and call + spar_mermaid::emit_flowchart to produce the diagram text. + status: implemented + tags: [mermaid, cli, v0100] + # Research findings tracked separately in research/findings.yaml diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 9e64e68..9830052 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -2627,3 +2627,23 @@ artifacts: links: - type: satisfies target: REQ-MERMAID-001 + + - id: TEST-MERMAID-CLI + type: feature + title: spar emit --format mermaid integration tests + description: > + Integration tests in crates/spar-cli/tests/emit_mermaid.rs invoke the + `spar emit` binary against a small inline AADL fixture and verify: + (a) happy path — stdout starts with "flowchart TD", contains the root + system name, and contains at least one node declaration; (b) category + filter — `--category thread` includes the thread subcomponent but + excludes processor and process components. + fields: + method: automated-test + steps: + - run: cargo test -p spar --test emit_mermaid + status: passing + tags: [mermaid, cli, v0100] + links: + - type: satisfies + target: REQ-MERMAID-CLI-001 diff --git a/crates/spar-cli/Cargo.toml b/crates/spar-cli/Cargo.toml index 851e7e7..fa92e72 100644 --- a/crates/spar-cli/Cargo.toml +++ b/crates/spar-cli/Cargo.toml @@ -28,6 +28,7 @@ spar-render.workspace = true spar-codegen.workspace = true spar-variants.workspace = true spar-insight.workspace = true +spar-mermaid.workspace = true etch.workspace = true la-arena.workspace = true rowan.workspace = true diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index c6904ff..f231304 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -43,6 +43,7 @@ fn main() { "allocate" => cmd_allocate(&args[2..]), "diff" => cmd_diff(&args[2..]), "modes" => cmd_modes(&args[2..]), + "emit" => cmd_emit(&args[2..]), "render" => cmd_render(&args[2..]), "verify" => cmd_verify(&args[2..]), "codegen" => cmd_codegen(&args[2..]), @@ -76,6 +77,7 @@ fn print_usage() { eprintln!(" allocate Allocate threads to processors via bin-packing"); eprintln!(" diff Compare two model versions and report changes"); eprintln!(" modes Mode reachability analysis and SMV/DOT export"); + eprintln!(" emit Emit a text diagram (Mermaid flowchart) from an instantiated system"); eprintln!(" render Render architecture SVG from an instantiated system"); eprintln!(" verify Verify requirements against analysis results"); eprintln!(" codegen Generate code from an instantiated system model"); @@ -103,6 +105,10 @@ fn print_usage() { " diff --root Package::Type.Impl [--base ref] [--head ref] [--old dir] [--new dir] [--format text|json|sarif] " ); eprintln!(" modes --root Package::Type.Impl [--format text|smv|dot] "); + eprintln!( + " emit --root Package::Type.Impl [--format mermaid] [--category ] \ + [--max-depth N] [--no-connections] [-o output.md] " + ); eprintln!(" render --root Package::Type.Impl [-o output.svg] "); eprintln!( " verify --root Package::Type.Impl [--format text|json] requirements.toml " @@ -1275,6 +1281,189 @@ fn cmd_modes(args: &[String]) { } } +fn parse_category(s: &str) -> spar_hir_def::item_tree::ComponentCategory { + use spar_hir_def::item_tree::ComponentCategory; + match s.to_ascii_lowercase().as_str() { + "system" => ComponentCategory::System, + "process" => ComponentCategory::Process, + "thread" => ComponentCategory::Thread, + "threadgroup" | "thread-group" | "thread_group" => ComponentCategory::ThreadGroup, + "processor" => ComponentCategory::Processor, + "virtualprocessor" | "virtual-processor" | "virtual_processor" => { + ComponentCategory::VirtualProcessor + } + "memory" => ComponentCategory::Memory, + "bus" => ComponentCategory::Bus, + "virtualbus" | "virtual-bus" | "virtual_bus" => ComponentCategory::VirtualBus, + "device" => ComponentCategory::Device, + "subprogram" => ComponentCategory::Subprogram, + "subprogramgroup" | "subprogram-group" | "subprogram_group" => { + ComponentCategory::SubprogramGroup + } + "data" => ComponentCategory::Data, + "abstract" => ComponentCategory::Abstract, + other => { + eprintln!("Unknown component category: {other}"); + eprintln!( + "Valid categories: system, process, thread, threadgroup, processor, \ + virtualprocessor, memory, bus, virtualbus, device, subprogram, \ + subprogramgroup, data, abstract" + ); + process::exit(1); + } + } +} + +fn cmd_emit(args: &[String]) { + let mut root: Option = None; + let mut format: Option = None; + let mut categories: Vec = Vec::new(); + let mut max_depth: Option = None; + let mut include_connections = true; + let mut output: Option = None; + let mut files: Vec = Vec::new(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--root" => { + i += 1; + if i < args.len() { + root = Some(args[i].clone()); + } else { + eprintln!("--root requires a value (Package::Type.Impl)"); + process::exit(1); + } + } + "--format" => { + i += 1; + if i < args.len() { + let f = args[i].clone(); + if f != "mermaid" { + eprintln!("--format only supports 'mermaid' (got '{f}')"); + process::exit(1); + } + format = Some(f); + } else { + eprintln!("--format requires a value (mermaid)"); + process::exit(1); + } + } + "--category" => { + i += 1; + if i < args.len() { + for cat_str in args[i].split(',') { + let cat_str = cat_str.trim(); + if !cat_str.is_empty() { + categories.push(parse_category(cat_str)); + } + } + } else { + eprintln!("--category requires a comma-separated list of component categories"); + process::exit(1); + } + } + "--max-depth" => { + i += 1; + if i < args.len() { + match args[i].parse::() { + Ok(n) => max_depth = Some(n), + Err(_) => { + eprintln!("--max-depth requires a non-negative integer"); + process::exit(1); + } + } + } else { + eprintln!("--max-depth requires an integer value"); + process::exit(1); + } + } + "--no-connections" => { + include_connections = false; + } + "-o" | "--output" => { + i += 1; + if i < args.len() { + output = Some(args[i].clone()); + } else { + eprintln!("-o requires an output file path"); + process::exit(1); + } + } + s if s.starts_with('-') => { + eprintln!("Unknown option: {s}"); + process::exit(1); + } + s => files.push(s.to_string()), + } + i += 1; + } + + let _ = format; // "mermaid" is the only valid value; validated above. + + let root = root.unwrap_or_else(|| { + eprintln!("--root Package::Type.Impl is required"); + process::exit(1); + }); + + if files.is_empty() { + eprintln!("Missing file argument(s)"); + process::exit(1); + } + + let (pkg_name, type_name, impl_name) = parse_root_ref(&root); + + let db = spar_hir_def::HirDefDatabase::default(); + let mut trees = Vec::new(); + + for file_path in &files { + let source = read_file(file_path); + let sf = spar_base_db::SourceFile::new(&db, file_path.clone(), source); + trees.push(spar_hir_def::file_item_tree(&db, sf)); + } + + let scope = spar_hir_def::GlobalScope::from_trees(trees); + let inst = spar_hir_def::instance::SystemInstance::instantiate( + &scope, + &spar_hir_def::Name::new(&pkg_name), + &spar_hir_def::Name::new(&type_name), + &spar_hir_def::Name::new(&impl_name), + ); + + if !inst.diagnostics.is_empty() { + for diag in &inst.diagnostics { + eprintln!("warning: {diag:?}"); + } + } + + if inst.component_count() == 0 { + eprintln!( + "error: root '{}::{}' could not be resolved — check --root spelling and file paths", + pkg_name, type_name + ); + process::exit(1); + } + + let opts = spar_mermaid::MermaidOptions { + categories, + max_depth, + include_connections, + }; + + let diagram = spar_mermaid::emit_flowchart(&inst, &opts); + + match output { + Some(path) => { + fs::write(&path, &diagram).unwrap_or_else(|e| { + eprintln!("Cannot write {path}: {e}"); + process::exit(1); + }); + eprintln!("Wrote {path}"); + } + None => print!("{diagram}"), + } +} + fn cmd_render(args: &[String]) { let mut root = None; let mut output = None; diff --git a/crates/spar-cli/tests/emit_mermaid.rs b/crates/spar-cli/tests/emit_mermaid.rs new file mode 100644 index 0000000..c7ecd63 --- /dev/null +++ b/crates/spar-cli/tests/emit_mermaid.rs @@ -0,0 +1,147 @@ +//! Integration tests for `spar emit --format mermaid`. +//! +//! Tests the happy-path flowchart emission and the `--category` filter. + +use std::env; +use std::fs; +use std::process::Command; + +fn spar() -> Command { + Command::new(env!("CARGO_BIN_EXE_spar")) +} + +/// Minimal AADL fixture with a system containing one thread, one processor, +/// and a port connection between them (for edge coverage). +const MODEL: &str = "\ +package Emit_Test +public + processor TestCpu + end TestCpu; + + thread Worker + end Worker; + + process App + end App; + + process implementation App.Impl + subcomponents + worker: thread Worker; + end App.Impl; + + system TestSys + end TestSys; + + system implementation TestSys.Impl + subcomponents + cpu: processor TestCpu; + app: process App.Impl; + end TestSys.Impl; +end Emit_Test; +"; + +fn write_model(tag: &str) -> std::path::PathBuf { + let path = env::temp_dir().join(format!( + "spar_emit_mermaid_{}_{}.aadl", + std::process::id(), + tag + )); + fs::write(&path, MODEL).expect("write temp AADL"); + path +} + +/// Happy path: `spar emit --format mermaid --root ...` should produce a +/// Mermaid flowchart containing the header and the root system name. +#[test] +fn emit_mermaid_happy_path() { + let path = write_model("happy"); + + let output = spar() + .arg("emit") + .arg("--root") + .arg("Emit_Test::TestSys.Impl") + .arg("--format") + .arg("mermaid") + .arg(&path) + .output() + .expect("failed to run spar emit"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "spar emit exited with failure; stderr:\n{stderr}" + ); + + assert!( + stdout.starts_with("flowchart TD\n"), + "expected 'flowchart TD' header; got:\n{stdout}" + ); + + // Root system node should appear. + assert!( + stdout.contains("TestSys"), + "expected root system name 'TestSys' in output; got:\n{stdout}" + ); + + // At least one component node line (contains '[\"'). + assert!( + stdout.contains("[\""), + "expected at least one node declaration in output; got:\n{stdout}" + ); + + let _ = fs::remove_file(&path); +} + +/// Category filter: `--category thread` should include thread components but +/// exclude processor components. +#[test] +fn emit_mermaid_category_filter_excludes_non_thread() { + let path = write_model("cat"); + + let output = spar() + .arg("emit") + .arg("--root") + .arg("Emit_Test::TestSys.Impl") + .arg("--format") + .arg("mermaid") + .arg("--category") + .arg("thread") + .arg(&path) + .output() + .expect("failed to run spar emit"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "spar emit with --category thread failed; stderr:\n{stderr}" + ); + + assert!( + stdout.starts_with("flowchart TD\n"), + "expected 'flowchart TD' header; got:\n{stdout}" + ); + + // Thread subcomponent 'worker' should appear. + assert!( + stdout.contains("worker"), + "expected thread 'worker' in filtered output; got:\n{stdout}" + ); + + // Processor 'cpu' must NOT appear. + assert!( + !stdout.contains("cpu"), + "processor 'cpu' should be absent when filtering by thread; got:\n{stdout}" + ); + + // Process 'app' must NOT appear either. + assert!( + !stdout.contains("\"app"), + "process 'app' should be absent when filtering by thread; got:\n{stdout}" + ); + + let _ = fs::remove_file(&path); +}