pipecircuit-cli 0.0.11

Command-line architectural tools for Pipecircuit programs
use std::{
    env,
    error::Error,
    ffi::OsString,
    fmt::{self, Write as _},
    io,
    path::{Path, PathBuf},
    time::Instant,
};

use super::{CliError, Command, DiagramCommand, utf8};

mod check;
mod discover;
mod model;
#[cfg(test)]
mod tests;

#[derive(Debug)]
pub(super) enum ArchError {
    Read(PathBuf, io::Error),
    Manifest(PathBuf, toml::de::Error),
    Rust(PathBuf, syn::Error),
    CargoStart(io::Error),
    Cargo(String),
    Json(serde_json::Error),
    Format(fmt::Error),
    Capacity,
    Configuration(PathBuf, String),
}
impl fmt::Display for ArchError {
    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Read(path, error) => write!(output, "cannot read {}: {error}", path.display()),
            Self::Manifest(path, error) => {
                write!(output, "invalid manifest {}: {error}", path.display())
            }
            Self::Rust(path, error) => write!(output, "cannot parse {}: {error}", path.display()),
            Self::CargoStart(error) => write!(output, "cannot start cargo metadata: {error}"),
            Self::Cargo(error) => write!(output, "cargo metadata failed: {error}"),
            Self::Json(error) => write!(output, "invalid metadata/output JSON: {error}"),
            Self::Format(error) => write!(output, "cannot render architecture: {error}"),
            Self::Configuration(path, reason) => write!(
                output,
                "invalid architecture configuration {}: {reason}",
                path.display()
            ),
            Self::Capacity => output.write_str("architecture work counter exhausted"),
        }
    }
}
impl Error for ArchError {}

pub(super) struct ArchCommand {
    manifest: PathBuf,
    action: Action,
    format: Format,
    timings: bool,
}
#[derive(Clone, Copy)]
enum Action {
    Check,
    Visualize,
}
#[derive(Clone, Copy)]
enum Format {
    Text,
    Json,
    Mermaid,
    Graphviz,
}

pub(super) fn parse(mut arguments: impl Iterator<Item = OsString>) -> Result<Command, CliError> {
    let Some(action) = arguments.next() else {
        return Ok(Command::Help);
    };
    let action = match utf8(action, "architecture action")?.as_str() {
        "check" => Action::Check,
        "visualize" => Action::Visualize,
        "--help" | "-h" => return Ok(Command::Help),
        value => {
            return Err(CliError::new(format!(
                "unknown architecture action `{value}`; expected check or visualize"
            )));
        }
    };
    let mut manifest = None;
    let mut format = None;
    let mut machine = None;
    let mut timings = false;
    while let Some(argument) = arguments.next() {
        let option = utf8(argument, "architecture option")?;
        match option.as_str() {
            "--manifest-path" => {
                let value = arguments
                    .next()
                    .ok_or_else(|| CliError::new("missing manifest path"))?;
                if manifest.replace(PathBuf::from(value)).is_some() {
                    return Err(CliError::new("duplicate --manifest-path"));
                }
            }
            "--format" => {
                let value = utf8(
                    arguments
                        .next()
                        .ok_or_else(|| CliError::new("missing output format"))?,
                    "format",
                )?;
                let selected = match value.as_str() {
                    "text" => Format::Text,
                    "json" => Format::Json,
                    "mermaid" => Format::Mermaid,
                    "graphviz" => Format::Graphviz,
                    _ => {
                        return Err(CliError::new(
                            "format must be text, json, mermaid, or graphviz",
                        ));
                    }
                };
                if format.replace(selected).is_some() {
                    return Err(CliError::new("duplicate --format"));
                }
            }
            "--machine" => {
                let value = utf8(
                    arguments
                        .next()
                        .ok_or_else(|| CliError::new("missing machine name"))?,
                    "machine",
                )?;
                if machine.replace(value).is_some() {
                    return Err(CliError::new("duplicate --machine"));
                }
            }
            "--timings" => {
                if timings {
                    return Err(CliError::new("duplicate --timings"));
                }
                timings = true;
            }
            "--help" | "-h" => return Ok(Command::Help),
            _ => {
                return Err(CliError::new(format!(
                    "unknown architecture option `{option}`"
                )));
            }
        }
    }
    let manifest = match manifest {
        Some(path) => path,
        None => {
            find_manifest(&env::current_dir().map_err(|error| CliError::new(error.to_string()))?)?
        }
    };
    let format = format.unwrap_or(Format::Text);
    if matches!(action, Action::Check)
        && (machine.is_some() || matches!(format, Format::Mermaid | Format::Graphviz))
    {
        return Err(CliError::new(
            "arch check accepts text or json, without --machine",
        ));
    }
    if let Some(state_machine) = machine {
        if timings {
            return Err(CliError::new(
                "--timings applies to project architecture analysis",
            ));
        }
        let target = match format {
            Format::Text => pipecircuit_visualization::DiagramTarget::TextTree,
            Format::Mermaid => pipecircuit_visualization::DiagramTarget::MermaidFlowchart,
            Format::Graphviz => pipecircuit_visualization::DiagramTarget::GraphvizDot,
            Format::Json => {
                return Err(CliError::new(
                    "machine diagrams support text, mermaid, or graphviz",
                ));
            }
        };
        return Ok(Command::Diagram(DiagramCommand {
            crate_root: manifest
                .parent()
                .ok_or_else(|| CliError::new("manifest needs a parent directory"))?
                .to_owned(),
            state_machine,
            target,
        }));
    }
    Ok(Command::Arch(ArchCommand {
        manifest,
        action,
        format,
        timings,
    }))
}

fn find_manifest(directory: &Path) -> Result<PathBuf, CliError> {
    directory
        .ancestors()
        .map(|parent| parent.join("Cargo.toml"))
        .find(|path| path.is_file())
        .ok_or_else(|| CliError::new("no containing Cargo.toml; use --manifest-path"))
}

pub(super) fn execute(command: ArchCommand) -> Result<(String, bool), ArchError> {
    let started = Instant::now();
    let mut architecture = discover::load(&command.manifest)?;
    let discovered = Instant::now();
    check::validate(&mut architecture)?;
    let checked = Instant::now();
    let output = render(&architecture, command.action, command.format)?;
    if command.timings {
        eprintln!(
            "architecture timings: discovery={:?} analysis={:?} rendering={:?}; metadata={} manifests={} sources={} edges={}",
            discovered.duration_since(started),
            checked.duration_since(discovered),
            checked.elapsed(),
            architecture.work.metadata_invocations,
            architecture.work.manifests_read,
            architecture.work.sources_read,
            architecture.work.dependency_edges_checked
        );
    }
    Ok((output, architecture.findings.is_empty()))
}

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 => {
            writeln!(output, "architecture {}", architecture.root.display())
                .map_err(ArchError::Format)?;
            if matches!(action, Action::Visualize) {
                output.push_str("Project dependencies (not message routes)\n");
                for package in architecture.packages.values() {
                    writeln!(
                        output,
                        "  {}: {} [{}]",
                        package.name,
                        role_label(package),
                        package.provenance
                    )
                    .map_err(ArchError::Format)?;
                    let mut summarized = 0_usize;
                    for dependency in &package.dependencies {
                        let local = dependency.path.as_ref().is_some_and(|path| {
                            architecture.packages.contains_key(&path.join("Cargo.toml"))
                        });
                        if dependency.kind == "dev" || !local {
                            summarized = summarized.checked_add(1).ok_or(ArchError::Capacity)?;
                            continue;
                        }
                        write!(output, "    → {}", dependency.name).map_err(ArchError::Format)?;
                        if dependency.kind != "normal" {
                            write!(output, " [{}]", dependency.kind).map_err(ArchError::Format)?;
                        }
                        if dependency.optional {
                            output.push_str(" [optional]");
                        }
                        if let Some(target) = &dependency.target {
                            write!(output, " [{target}]").map_err(ArchError::Format)?;
                        }
                        output.push('\n');
                    }
                    if summarized != 0 {
                        writeln!(
                            output,
                            "    {summarized} external/dev declarations; details in --format json"
                        )
                        .map_err(ArchError::Format)?;
                    }
                }
                output.push_str("Semantic routes: not derived by project dependency analysis.\nPhysical placement: unknown; crate roles do not establish runtime placement.\nUse --machine <qualified-name> with its crate manifest for a derived machine diagram.\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,
                "\n{}: {} packages, {} architectural violations",
                if architecture.findings.is_empty() {
                    "PASS"
                } else {
                    "FAIL"
                },
                architecture.packages.len(),
                architecture.findings.len()
            )
            .map_err(ArchError::Format)?;
            for limitation in &architecture.limitations {
                writeln!(output, "  {limitation}").map_err(ArchError::Format)?;
            }
        }
        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::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)
            }
        },
    }
}

impl ArchCommand {
    pub(super) fn wants_json(&self) -> bool {
        matches!(self.format, Format::Json)
    }
}