pipecircuit-cli 0.0.14

Command-line architectural tools for Pipecircuit programs
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(),
    }
}