use std::fmt::Write as _;
use std::path::PathBuf;
use super::{Action, ArchError, Format, model};
pub(super) fn render(
architecture: &model::Architecture,
action: Action,
format: Format,
) -> Result<String, ArchError> {
if matches!(format, Format::Json) {
return serde_json::to_string_pretty(architecture)
.map(|text| text + "\n")
.map_err(ArchError::Json);
}
let mut output = String::new();
match format {
Format::Text => {
let title = architecture
.root
.file_name()
.unwrap_or(architecture.root.as_os_str())
.to_string_lossy();
if matches!(action, Action::Visualize) {
writeln!(
output,
"{title} — source map\n{}\n",
architecture.root.display()
)
.map_err(ArchError::Format)?;
let mut groups: std::collections::BTreeMap<String, Vec<&model::Package>> =
std::collections::BTreeMap::new();
for package in architecture.packages.values() {
groups
.entry(group_label(package))
.or_default()
.push(package);
}
for (group, packages) in groups {
writeln!(output, "{group}").map_err(ArchError::Format)?;
for package in packages {
let role = package
.declaration
.as_ref()
.map(|declaration| declaration.role.to_string())
.unwrap_or_else(|| "unclassified".into());
let path = package
.manifest
.strip_prefix(&architecture.root)
.unwrap_or(&package.manifest);
writeln!(
output,
" {role:11} {}\n {}",
package.name,
path.display()
)
.map_err(ArchError::Format)?;
}
output.push('\n');
}
output.push_str("Groups show source ownership, not execution placement.\nDependency links are not message routes.\nRun `pipecircuit arch visualize` to explore the interactive map.\n");
}
for finding in &architecture.findings {
writeln!(
output,
"\nerror[{}]: {}\n {}\n {}\n {}",
finding.code,
finding.subject,
finding.manifest.display(),
finding.explanation,
finding.guidance
)
.map_err(ArchError::Format)?;
}
writeln!(
output,
"{} {title} — {} packages checked, {} architectural violations",
if architecture.findings.is_empty() {
"PASS"
} else {
"FAIL"
},
architecture.packages.len(),
architecture.findings.len()
)
.map_err(ArchError::Format)?;
output
.push_str("Architecture rules only. Explore with `pipecircuit arch visualize`.\n");
if matches!(action, Action::Check) && architecture.findings.is_empty() {
output.push_str("Next: `pipecircuit review -- <repository-evidence-command>`; include behavior, portability, coverage, mutations, and required proofs.\n");
}
}
Format::Mermaid | Format::Graphviz => {
let mermaid = matches!(format, Format::Mermaid);
output.push_str(if mermaid {
"flowchart LR\n"
} else {
"digraph architecture {\n"
});
let ids: BTreeIds<'_> = architecture
.packages
.values()
.enumerate()
.map(|(id, package)| (&package.manifest, id))
.collect();
for package in architecture.packages.values() {
let Some(id) = ids.get(&package.manifest) else {
continue;
};
let label = format!("{} ({})", package.name, role_label(package));
let label = serde_json::to_string(&label).map_err(ArchError::Json)?;
if mermaid {
writeln!(output, " n{id}[{label}]").map_err(ArchError::Format)?;
} else {
writeln!(output, " n{id} [label={label}];").map_err(ArchError::Format)?;
}
for dependency in package
.dependencies
.iter()
.filter(|dependency| dependency.kind != "dev")
{
if let Some(path) = &dependency.path {
let target = path.join("Cargo.toml");
if let Some(target) = ids.get(&target) {
writeln!(
output,
" n{id} {} n{target}{}",
if mermaid { "-->" } else { "->" },
if mermaid { "" } else { ";" }
)
.map_err(ArchError::Format)?;
}
}
}
}
output.push_str(if mermaid { "%% Dependency graph only; semantic routes and physical placement are not inferred.\n" } else { "// Dependency graph only; semantic routes and physical placement are not inferred.\n}\n" });
}
Format::Html | Format::Browser => return super::browser::document(architecture),
Format::Json => {}
}
Ok(output)
}
type BTreeIds<'a> = std::collections::BTreeMap<&'a PathBuf, usize>;
fn role_label(package: &model::Package) -> String {
match &package.declaration {
None => "unclassified".to_owned(),
Some(declaration) => match (&declaration.app, declaration.scope) {
(Some(app), None) => format!("{} for {app}", declaration.role),
(None, Some(model::Scope::Shared)) => format!("shared {}", declaration.role),
(None, None) => declaration.role.to_string(),
(Some(app), Some(model::Scope::Shared)) => {
format!("invalid shared {} for {app}", declaration.role)
}
},
}
}
fn group_label(package: &model::Package) -> String {
match &package.declaration {
Some(declaration) => match (&declaration.app, declaration.scope, declaration.role) {
(Some(app), None, _) => format!("App: {app}"),
(None, None, model::Role::Core) => "Shared core".into(),
(None, None, model::Role::Tooling) => "Development and build tooling".into(),
(None, None, model::Role::ThirdParty) => {
"Third-party dependencies (not audited as core)".into()
}
(None, Some(model::Scope::Shared), _) => "Shared contracts and wiring".into(),
(Some(_), Some(model::Scope::Shared), _) | (None, None, _) => {
"Invalid ownership".into()
}
},
None => "Unclassified".into(),
}
}