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)
}
}