pipecircuit-cli 0.0.14

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

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

mod browser;
mod check;
mod discover;
mod model;
mod render;
use render::render;
#[cfg(test)]
mod tests;

#[derive(Debug)]
pub(super) enum ArchError {
    Read(PathBuf, io::Error),
    Write(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::Write(path, error) => write!(output, "cannot write {}: {error}", path.display()),
            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,
    Html,
    Browser,
}

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,
                    "html" => Format::Html,
                    _ => {
                        return Err(CliError::new(
                            "format must be text, json, mermaid, graphviz, or html",
                        ));
                    }
                };
                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(match (action, machine.is_some()) {
        (Action::Check, _) | (Action::Visualize, true) => Format::Text,
        (Action::Visualize, false) => Format::Browser,
    });
    if matches!(action, Action::Check)
        && (machine.is_some()
            || matches!(
                format,
                Format::Mermaid | Format::Graphviz | Format::Html | Format::Browser
            ))
    {
        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 | Format::Html | Format::Browser => {
                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> {
    discover::find_entry(directory).map_err(|error| CliError::new(error.to_string()))
}

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)?;
    let output = match command.format {
        Format::Browser => browser::present(&output)?,
        Format::Text | Format::Json | Format::Mermaid | Format::Graphviz | Format::Html => output,
    };
    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()))
}

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