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