use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use super::{
Access, JobGraphDescriptor, JobKind, ModelGraphDescriptor, MountedRoute, NodeKind, Provenance,
RepositoryGraphDescriptor, RouteAuth, RouteGraphDescriptor, is_safe_method,
};
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
pub const ARCHITECTURE_GRAPH_MARKER: &str = "[autumn:graph] ";
pub const DUMP_ENV: &str = "AUTUMN_DUMP_GRAPH";
pub const LIMITS: &[&str] = &[
"Edges from a route or job are derived from that item's own tokens: a call into a helper \
function in another module is not followed (static derivation only — dynamic call-graph \
tracing is out of scope for this slice).",
"Symbol resolution is name-based: a type alias or a `use ... as ...` rename is not resolved, \
and a model whose name matches a common type is linked wherever that name appears.",
"Raw SQL is matched by identifier, and only inside string literals that contain a SQL \
keyword; SQL assembled at runtime from fragments is invisible.",
"A router mounted with `merge`/`nest` is opaque: its endpoints cannot be enumerated at all, \
so `completeness.opaque_mounted_routers` counts them rather than naming them. The \
`AUTUMN_DUMP_GRAPH` count covers the routers the *builder* declares; a router the \
configuration adds during startup (a blob store, the SEO endpoints, an inbound-mail \
webhook) is mounted after that dump exits, so the running binary's `/actuator/graph` can \
report a higher count than the committed document.",
"A model's declared relations (`#[votable]`, `#[commentable]`) are attributed to its \
repository as a whole, because that is where the generated methods live — so a route \
holding the repository is reported as reaching the edge table whether or not it calls \
them. The edge exists only when some `#[model]` maps that table; `#[commentable]`'s \
shared comments table in an app with no comment model is a relation with nothing to \
point at.",
"Only the item's own tokens are read, so a module-level `use crate::schema::posts::dsl::*` \
followed by a bare `posts.filter(…)` in the body names no candidate and produces no edge. \
Write the table module (`posts::table`) or the model in the handler to be linked.",
"A route's access is its declared HTTP method (safe methods read, everything else writes); a \
job's is set per edge: a write when the edge's symbol names a mutating SQL statement, or when \
the job calls `.update`/`.create`/`.delete`/`.insert` on a repository parameter, or when \
mutation evidence names no symbol (then every edge). Either way it is the declared intent, \
not an executed statement.",
"Node ids come from `module_path!()`. When a binary links two versions of one crate, each \
id they share ends in `@<version>`. A mounted route records only its module and handler, \
so the route node of each version shows the mounts of both versions. An auto-API route \
links to the repository of each version. Two copies of one version (from two sources) \
still share one node.",
];
fn is_fixed_delay(schedule: &str) -> bool {
schedule.split_whitespace().count() < 5
&& schedule
.chars()
.all(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
&& schedule.chars().any(|c| c.is_ascii_alphabetic())
&& schedule.chars().any(|c| c.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteFacts {
pub method: String,
pub path: String,
pub mounted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<RouteAuth>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub also_mounted_at: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelFacts {
pub table: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryFacts {
pub implementation: String,
pub model: String,
pub table: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobFacts {
pub kind: JobKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphNode {
pub id: String,
pub kind: NodeKind,
pub name: String,
pub module: String,
pub location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<RouteFacts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<ModelFacts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<RepositoryFacts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<JobFacts>,
}
impl GraphNode {
#[must_use]
pub fn crate_version(&self) -> Option<&str> {
if self.id.starts_with(AUTO_API_ID_PREFIX) {
return None;
}
self.id.split_once('@').map(|(_, version)| version)
}
#[must_use]
pub fn base_id(&self) -> &str {
self.crate_version().map_or(self.id.as_str(), |version| {
&self.id[..self.id.len() - version.len() - 1]
})
}
#[must_use]
pub fn label(&self) -> String {
let label = self.unversioned_label();
match self.crate_version() {
Some(version) => format!("{label} @{version}"),
None => label,
}
}
fn unversioned_label(&self) -> String {
match (&self.route, &self.model, &self.repository, &self.job) {
(Some(r), ..) => format!("{} {}", r.method, r.path),
(_, Some(m), ..) => format!("{} (table {})", self.name, m.table),
(_, _, Some(r), _) => format!("{} over {}", self.name, r.model),
(.., Some(j)) => j.schedule.as_ref().map_or_else(
|| self.name.clone(),
|s| {
if is_fixed_delay(s) {
format!("{} (every {s})", self.name)
} else {
format!("{} ({s})", self.name)
}
},
),
_ => self.name.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphEdge {
pub from: String,
pub to: String,
pub access: Access,
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub symbol: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Completeness {
pub declared_routes: usize,
pub mounted_routes: usize,
pub models: usize,
pub repositories: usize,
pub jobs: usize,
#[serde(default)]
pub generated_routes: usize,
#[serde(default)]
pub opaque_mounted_routers: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unmounted_routes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unmodelled_mounted_routes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declared_mounts: Vec<DeclaredMount>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclaredMount {
pub method: String,
pub path: String,
pub auth: RouteAuth,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchitectureGraph {
pub schema_version: u32,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
pub completeness: Completeness,
pub limits: Vec<String>,
}
impl ArchitectureGraph {
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|e| {
panic!("architecture graph is not serializable: {e}");
})
}
#[must_use]
pub fn node(&self, id: &str) -> Option<&GraphNode> {
self.nodes.iter().find(|n| n.id == id)
}
#[must_use]
pub fn summary(&self) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"Application architecture graph (schema v{})",
self.schema_version
);
let _ = writeln!(
out,
" {} nodes, {} edges",
self.nodes.len(),
self.edges.len()
);
let c = &self.completeness;
let _ = writeln!(
out,
" {} routes ({} mounted), {} models, {} repositories, {} jobs",
c.declared_routes, c.mounted_routes, c.models, c.repositories, c.jobs
);
if c.generated_routes > 0 {
let _ = writeln!(out, " {} repository auto-API route(s)", c.generated_routes);
}
if c.opaque_mounted_routers > 0 {
let _ = writeln!(
out,
" {} mounted router(s) this graph cannot enumerate (raw merge/nest)",
c.opaque_mounted_routers
);
}
for kind in [
NodeKind::Model,
NodeKind::Repository,
NodeKind::Route,
NodeKind::StaticRoute,
NodeKind::Job,
NodeKind::ScheduledTask,
NodeKind::OneOffTask,
] {
let nodes: Vec<&GraphNode> = self.nodes.iter().filter(|n| n.kind == kind).collect();
if nodes.is_empty() {
continue;
}
let _ = writeln!(out, "\n{kind}s ({}):", nodes.len());
for node in nodes {
let _ = writeln!(out, " {}", node.label());
for edge in self.edges.iter().filter(|e| e.from == node.id) {
let target = self
.node(&edge.to)
.map_or(edge.to.as_str(), |n| n.name.as_str());
let _ = writeln!(
out,
" {} {} ({})",
edge.access, target, edge.provenance
);
}
}
}
if !c.unmounted_routes.is_empty() {
let _ = writeln!(
out,
"\nDeclared but not mounted ({}):",
c.unmounted_routes.len()
);
for id in &c.unmounted_routes {
let _ = writeln!(out, " {id}");
}
}
if !c.declared_mounts.is_empty() {
let _ = writeln!(
out,
"\nDeclared plugin mounts ({}):",
c.declared_mounts.len()
);
for m in &c.declared_mounts {
let _ = writeln!(out, " {} {} (auth {})", m.method, m.path, m.auth.label());
}
}
if !c.unmodelled_mounted_routes.is_empty() {
let _ = writeln!(
out,
"\nMounted with no macro declaration ({}):",
c.unmodelled_mounted_routes.len()
);
for id in &c.unmodelled_mounted_routes {
let _ = writeln!(out, " {id}");
}
}
let _ = writeln!(out, "\nLimits of this derivation:");
for limit in &self.limits {
let _ = writeln!(out, " - {limit}");
}
out
}
}
fn route_id(module_path: &str, handler: &str) -> String {
format!("route:{module_path}::{handler}")
}
fn model_id(model_path: &str) -> String {
format!("model:{model_path}")
}
fn repository_id(module_path: &str, repository: &str) -> String {
format!("repository:{module_path}::{repository}")
}
const AUTO_API_ID_PREFIX: &str = "route:auto-api:";
fn job_id(module_path: &str, handler: &str) -> String {
format!("job:{module_path}::{handler}")
}
fn crate_root(module_path: &str) -> &str {
module_path.split("::").next().unwrap_or(module_path)
}
struct NodeIds {
shared: BTreeSet<String>,
}
impl NodeIds {
fn new(
routes: &[RouteGraphDescriptor],
models: &[ModelGraphDescriptor],
repositories: &[RepositoryGraphDescriptor],
jobs: &[JobGraphDescriptor],
) -> Self {
let bases = routes
.iter()
.map(|r| (route_id(r.module_path, r.handler), r.crate_version))
.chain(
models
.iter()
.map(|m| (model_id(m.model_path), m.crate_version)),
)
.chain(
repositories
.iter()
.map(|r| (repository_id(r.module_path, r.repository), r.crate_version)),
)
.chain(
jobs.iter()
.map(|j| (job_id(j.module_path, j.handler), j.crate_version)),
);
let mut versions: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();
for (base, version) in bases {
versions.entry(base).or_default().insert(version);
}
Self {
shared: versions
.into_iter()
.filter(|(_, versions)| versions.len() > 1)
.map(|(base, _)| base)
.collect(),
}
}
fn versioned(&self, base: String, version: &str) -> String {
if !version.is_empty() && self.shared.contains(&base) {
format!("{base}@{version}")
} else {
base
}
}
fn route(&self, route: &RouteGraphDescriptor) -> String {
self.versioned(
route_id(route.module_path, route.handler),
route.crate_version,
)
}
fn model(&self, model: &ModelGraphDescriptor) -> String {
self.versioned(model_id(model.model_path), model.crate_version)
}
fn repository(&self, repo: &RepositoryGraphDescriptor) -> String {
self.versioned(
repository_id(repo.module_path, repo.repository),
repo.crate_version,
)
}
fn job(&self, job: &JobGraphDescriptor) -> String {
self.versioned(job_id(job.module_path, job.handler), job.crate_version)
}
}
fn location(file: &str, line: u32) -> String {
let file = file.replace('\\', "/");
let normalized = shorten_dependency_path(&file);
format!("{normalized}:{line}")
}
fn shorten_dependency_path(file: &str) -> String {
if let Some((_, tail)) = file.split_once("/registry/src/")
&& let Some((_, crate_relative)) = tail.split_once('/')
{
return crate_relative.to_owned();
}
if let Some((_, tail)) = file.split_once("/git/checkouts/") {
let mut parts = tail.splitn(3, '/');
if let (Some(dir), Some(_rev), Some(rest)) = (parts.next(), parts.next(), parts.next()) {
let name = dir.rsplit_once('-').map_or(dir, |(name, _)| name);
return format!("{name}/{rest}");
}
}
file.to_owned()
}
fn method_access(method: &str) -> Access {
if is_safe_method(method) {
Access::Read
} else {
Access::Write
}
}
fn symbol_index(
ids: &NodeIds,
models: &[ModelGraphDescriptor],
repositories: &[RepositoryGraphDescriptor],
) -> BTreeMap<String, BTreeSet<String>> {
let mut index: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for model in models {
let id = ids.model(model);
index
.entry(model.model.to_owned())
.or_default()
.insert(id.clone());
index.entry(model.table.to_owned()).or_default().insert(id);
}
for repo in repositories {
let id = ids.repository(repo);
index
.entry(repo.repository.to_owned())
.or_default()
.insert(id.clone());
index
.entry(repo.implementation.to_owned())
.or_default()
.insert(id);
}
index
}
fn sql_table_index(
ids: &NodeIds,
models: &[ModelGraphDescriptor],
) -> BTreeMap<String, BTreeSet<String>> {
let mut index: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for model in models {
index
.entry(model.table.to_ascii_lowercase())
.or_default()
.insert(ids.model(model));
}
index
}
#[derive(Debug, Clone, Copy)]
struct Candidates<'a> {
signature: &'a [&'a str],
body: &'a [&'a str],
sql: &'a [&'a str],
}
fn resolve_edges(
from: &str,
candidates: Candidates<'_>,
access: impl Fn(&str) -> Access,
index: &BTreeMap<String, BTreeSet<String>>,
sql_tables: &BTreeMap<String, BTreeSet<String>>,
edges: &mut BTreeMap<(String, String), GraphEdge>,
) {
let sql_symbols = candidates.sql;
for (symbols, provenance) in [
(candidates.signature, Provenance::Signature),
(candidates.body, Provenance::Body),
] {
for symbol in symbols {
let folded = sql_symbols
.contains(symbol)
.then(|| sql_tables.get(&symbol.to_ascii_lowercase()))
.flatten();
let targets = index.get(*symbol).or(folded);
let Some(targets) = targets else {
continue;
};
for target in targets {
let key = (from.to_owned(), target.clone());
let mut candidate = GraphEdge {
from: from.to_owned(),
to: target.clone(),
access: access(symbol),
provenance,
symbol: (*symbol).to_owned(),
};
match edges.get_mut(&key) {
Some(existing) if existing.provenance <= candidate.provenance => {
if candidate.access == Access::Write {
existing.access = Access::Write;
}
}
Some(existing) => {
if existing.access == Access::Write {
candidate.access = Access::Write;
}
*existing = candidate;
}
None => {
edges.insert(key, candidate);
}
}
}
}
}
}
#[must_use]
pub fn build(
mounted: &[MountedRoute],
opaque_mounted_routers: usize,
routes: &[RouteGraphDescriptor],
models: &[ModelGraphDescriptor],
repositories: &[RepositoryGraphDescriptor],
jobs: &[JobGraphDescriptor],
) -> ArchitectureGraph {
let ids = NodeIds::new(routes, models, repositories, jobs);
let index = symbol_index(&ids, models, repositories);
let sql_tables = sql_table_index(&ids, models);
let mut nodes: BTreeMap<String, GraphNode> = BTreeMap::new();
let mut edges: BTreeMap<(String, String), GraphEdge> = BTreeMap::new();
add_model_nodes(&ids, models, &mut nodes);
add_repository_nodes(&ids, repositories, models, &mut nodes, &mut edges);
let route_totals = add_route_nodes(
&ids,
routes,
mounted,
&index,
&sql_tables,
&mut nodes,
&mut edges,
);
add_job_nodes(&ids, jobs, &index, &sql_tables, &mut nodes, &mut edges);
let surface = add_auto_api_nodes(
&ids,
mounted,
repositories,
&route_totals.matched,
&mut nodes,
&mut edges,
);
let mut unmounted = route_totals.unmounted;
unmounted.sort();
unmounted.dedup();
ArchitectureGraph {
schema_version: MANIFEST_SCHEMA_VERSION,
completeness: Completeness {
declared_routes: routes.len(),
mounted_routes: route_totals.mounted,
models: models.len(),
repositories: repositories.len(),
jobs: jobs.len(),
generated_routes: surface.generated,
opaque_mounted_routers,
unmounted_routes: unmounted,
unmodelled_mounted_routes: surface.unmodelled,
declared_mounts: surface.declared,
},
nodes: nodes.into_values().collect(),
edges: edges.into_values().collect(),
limits: LIMITS.iter().map(|l| (*l).to_owned()).collect(),
}
}
fn add_model_nodes(
ids: &NodeIds,
models: &[ModelGraphDescriptor],
nodes: &mut BTreeMap<String, GraphNode>,
) {
for model in models {
let id = ids.model(model);
nodes.insert(
id.clone(),
GraphNode {
id,
kind: NodeKind::Model,
name: model.model.to_owned(),
module: model.module_path.to_owned(),
location: location(model.file, model.line),
route: None,
model: Some(ModelFacts {
table: model.table.to_owned(),
relations: model.relations.iter().map(|t| (*t).to_owned()).collect(),
}),
repository: None,
job: None,
},
);
}
}
fn add_repository_nodes<'a>(
ids: &NodeIds,
repositories: &[RepositoryGraphDescriptor],
models: &'a [ModelGraphDescriptor],
nodes: &mut BTreeMap<String, GraphNode>,
edges: &mut BTreeMap<(String, String), GraphEdge>,
) {
for repo in repositories {
let id = ids.repository(repo);
nodes.insert(
id.clone(),
GraphNode {
id: id.clone(),
kind: NodeKind::Repository,
name: repo.repository.to_owned(),
module: repo.module_path.to_owned(),
location: location(repo.file, repo.line),
route: None,
model: None,
repository: Some(RepositoryFacts {
implementation: repo.implementation.to_owned(),
model: repo.model.to_owned(),
table: repo.table.to_owned(),
api: (!repo.api.is_empty()).then(|| repo.api.to_owned()),
}),
job: None,
},
);
let mine = |m: &ModelGraphDescriptor| {
crate_root(m.module_path) == crate_root(repo.module_path)
&& m.crate_version == repo.crate_version
};
let own_version = |found: Vec<&'a ModelGraphDescriptor>| {
let has_mine = found.iter().any(|m| mine(m));
found
.into_iter()
.filter(|m| {
!has_mine
|| mine(m)
|| crate_root(m.module_path) != crate_root(repo.module_path)
})
.collect::<Vec<_>>()
};
let named = own_version(models.iter().filter(|m| m.model == repo.model).collect());
let exact = named.iter().any(|m| m.table == repo.table);
let own: Vec<&ModelGraphDescriptor> = named
.into_iter()
.filter(|m| !exact || m.table == repo.table)
.collect();
let mut targets: BTreeSet<String> = own.iter().map(|m| ids.model(m)).collect();
for relation in own.iter().flat_map(|m| m.relations.iter().copied()) {
let mapping = models
.iter()
.filter(|m| m.table == relation || m.model == relation)
.collect();
targets.extend(own_version(mapping).into_iter().map(|m| ids.model(m)));
}
for target in &targets {
edges.insert(
(id.clone(), target.clone()),
GraphEdge {
from: id.clone(),
to: target.clone(),
access: Access::ReadWrite,
provenance: Provenance::Declaration,
symbol: String::new(),
},
);
}
}
}
struct RouteTotals {
mounted: usize,
unmounted: Vec<String>,
matched: BTreeSet<(String, String)>,
}
fn add_route_nodes(
ids: &NodeIds,
routes: &[RouteGraphDescriptor],
mounted: &[MountedRoute],
index: &BTreeMap<String, BTreeSet<String>>,
sql_tables: &BTreeMap<String, BTreeSet<String>>,
nodes: &mut BTreeMap<String, GraphNode>,
edges: &mut BTreeMap<(String, String), GraphEdge>,
) -> RouteTotals {
let mut mounted_count = 0usize;
let mut unmounted: Vec<String> = Vec::new();
let mut matched_mounted: BTreeSet<(String, String)> = BTreeSet::new();
for route in routes {
let id = ids.route(route);
let hits: Vec<&MountedRoute> = mounted
.iter()
.filter(|m| {
m.handler == route.handler
&& m.module_path == route.module_path
&& m.method.eq_ignore_ascii_case(route.method)
})
.collect();
if hits.is_empty() {
unmounted.push(id.clone());
} else {
mounted_count += 1;
for m in &hits {
matched_mounted.insert((m.method.clone(), m.path.clone()));
}
}
let mut paths: Vec<String> = hits.iter().map(|m| m.path.clone()).collect();
paths.sort();
paths.dedup();
let hit = hits.first().copied();
let access = method_access(route.method);
nodes.insert(
id.clone(),
GraphNode {
id: id.clone(),
kind: if route.static_route {
NodeKind::StaticRoute
} else {
NodeKind::Route
},
name: route.handler.to_owned(),
module: route.module_path.to_owned(),
location: location(route.file, route.line),
route: Some(RouteFacts {
method: route.method.to_owned(),
path: paths
.first()
.cloned()
.unwrap_or_else(|| route.path.to_owned()),
also_mounted_at: paths.iter().skip(1).cloned().collect(),
mounted: hit.is_some(),
auth: hit.map(|m| m.auth.clone()),
generated_by: None,
}),
model: None,
repository: None,
job: None,
},
);
resolve_edges(
&id,
Candidates {
signature: route.signature_symbols,
body: route.body_symbols,
sql: route.sql_symbols,
},
|_| access,
index,
sql_tables,
edges,
);
}
RouteTotals {
mounted: mounted_count,
unmounted,
matched: matched_mounted,
}
}
fn job_write_symbols<'a>(
job: &'a JobGraphDescriptor,
index: &BTreeMap<String, BTreeSet<String>>,
) -> BTreeSet<&'a str> {
let mut writes: BTreeSet<&str> = job.mutating_symbols.iter().copied().collect();
for receiver in job.dot_mutation_receivers {
for (param, ty) in job.signature_bindings {
let is_repository = index
.get(*ty)
.is_some_and(|ids| ids.iter().any(|id| id.starts_with("repository:")));
if param == receiver && is_repository {
writes.insert(ty);
}
}
}
writes
}
fn add_job_nodes(
ids: &NodeIds,
jobs: &[JobGraphDescriptor],
index: &BTreeMap<String, BTreeSet<String>>,
sql_tables: &BTreeMap<String, BTreeSet<String>>,
nodes: &mut BTreeMap<String, GraphNode>,
edges: &mut BTreeMap<(String, String), GraphEdge>,
) {
for job in jobs {
let id = ids.job(job);
nodes.insert(
id.clone(),
GraphNode {
id: id.clone(),
kind: match job.kind {
JobKind::Job => NodeKind::Job,
JobKind::Scheduled => NodeKind::ScheduledTask,
JobKind::Task => NodeKind::OneOffTask,
},
name: job.name.to_owned(),
module: job.module_path.to_owned(),
location: location(job.file, job.line),
route: None,
model: None,
repository: None,
job: Some(JobFacts {
kind: job.kind,
schedule: (!job.schedule.is_empty()).then(|| job.schedule.to_owned()),
}),
},
);
let writes = job_write_symbols(job, index);
resolve_edges(
&id,
Candidates {
signature: job.signature_symbols,
body: job.body_symbols,
sql: job.sql_symbols,
},
|symbol| {
if job.mutating || writes.contains(symbol) {
Access::Write
} else {
Access::Read
}
},
index,
sql_tables,
edges,
);
}
}
struct MountedSurface {
generated: usize,
unmodelled: Vec<String>,
declared: Vec<DeclaredMount>,
}
fn add_auto_api_nodes(
ids: &NodeIds,
mounted: &[MountedRoute],
repositories: &[RepositoryGraphDescriptor],
matched_mounted: &BTreeSet<(String, String)>,
nodes: &mut BTreeMap<String, GraphNode>,
edges: &mut BTreeMap<(String, String), GraphEdge>,
) -> MountedSurface {
let mut generated_routes = 0usize;
let mut unmodelled: BTreeSet<String> = BTreeSet::new();
let mut declared: BTreeMap<(String, String), RouteAuth> = BTreeMap::new();
for route in mounted {
if matched_mounted.contains(&(route.method.clone(), route.path.clone())) {
continue;
}
let mut owners: Vec<(String, &RepositoryGraphDescriptor)> = route
.repository_api
.as_ref()
.map(|identity| {
repositories
.iter()
.filter(|repo| repo.api == identity.api && repo.model == identity.model)
.map(|repo| (ids.repository(repo), repo))
.collect()
})
.unwrap_or_default();
owners.sort_by(|a, b| a.0.cmp(&b.0));
let Some(&(_, repo)) = owners.first() else {
if route.auth_declared {
declared.insert(
(route.method.clone(), route.path.clone()),
route.auth.clone(),
);
} else {
unmodelled.insert(format!("{} {}", route.method, route.path));
}
continue;
};
generated_routes += 1;
let id = format!("{AUTO_API_ID_PREFIX}{} {}", route.method, route.path);
let access = method_access(&route.method);
nodes.insert(
id.clone(),
GraphNode {
id: id.clone(),
kind: NodeKind::Route,
name: format!("{}::auto_api", repo.repository),
module: repo.module_path.to_owned(),
location: location(repo.file, repo.line),
route: Some(RouteFacts {
method: route.method.clone(),
path: route.path.clone(),
also_mounted_at: Vec::new(),
mounted: true,
auth: Some(route.auth.clone()),
generated_by: Some(repo.repository.to_owned()),
}),
model: None,
repository: None,
job: None,
},
);
for (repo_node, _) in owners {
edges.insert(
(id.clone(), repo_node.clone()),
GraphEdge {
from: id.clone(),
to: repo_node,
access,
provenance: Provenance::Declaration,
symbol: String::new(),
},
);
}
}
MountedSurface {
generated: generated_routes,
unmodelled: unmodelled.into_iter().collect(),
declared: declared
.into_iter()
.map(|((method, path), auth)| DeclaredMount { method, path, auth })
.collect(),
}
}
#[must_use]
pub fn audit(mounted: &[MountedRoute], opaque_mounted_routers: usize) -> ArchitectureGraph {
let routes: Vec<RouteGraphDescriptor> = inventory::iter::<RouteGraphDescriptor>
.into_iter()
.copied()
.collect();
let models: Vec<ModelGraphDescriptor> = inventory::iter::<ModelGraphDescriptor>
.into_iter()
.copied()
.collect();
let repositories: Vec<RepositoryGraphDescriptor> = inventory::iter::<RepositoryGraphDescriptor>
.into_iter()
.copied()
.collect();
let jobs: Vec<JobGraphDescriptor> = inventory::iter::<JobGraphDescriptor>
.into_iter()
.copied()
.collect();
build(
mounted,
opaque_mounted_routers,
&routes,
&models,
&repositories,
&jobs,
)
}
#[must_use]
pub fn is_dump_mode() -> bool {
std::env::var(DUMP_ENV).as_deref() == Ok("1")
}
pub fn print_manifest_dump(graph: &ArchitectureGraph) {
let json = serde_json::to_string(graph).unwrap_or_else(|e| {
panic!("architecture graph is not serializable: {e}");
});
println!("{ARCHITECTURE_GRAPH_MARKER}{json}");
}
#[must_use]
pub fn parse_manifest_dump(stdout: &str) -> Option<ArchitectureGraph> {
stdout
.lines()
.find_map(|line| line.strip_prefix(ARCHITECTURE_GRAPH_MARKER))
.and_then(|json| serde_json::from_str(json).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::RepositoryApiOwner;
fn model(name: &'static str, path: &'static str, table: &'static str) -> ModelGraphDescriptor {
ModelGraphDescriptor {
model: name,
model_path: path,
table,
relations: &[],
module_path: "app::models",
file: "src/models.rs",
line: 10,
crate_version: "0.1.0",
}
}
fn repository(
name: &'static str,
implementation: &'static str,
model: &'static str,
table: &'static str,
) -> RepositoryGraphDescriptor {
RepositoryGraphDescriptor {
repository: name,
implementation,
model,
table,
api: "",
module_path: "app::repositories",
file: "src/repositories.rs",
line: 20,
crate_version: "0.1.0",
}
}
fn repository_with_api(
name: &'static str,
implementation: &'static str,
model: &'static str,
table: &'static str,
api: &'static str,
) -> RepositoryGraphDescriptor {
RepositoryGraphDescriptor {
api,
..repository(name, implementation, model, table)
}
}
fn route(
handler: &'static str,
method: &'static str,
path: &'static str,
signature_symbols: &'static [&'static str],
body_symbols: &'static [&'static str],
) -> RouteGraphDescriptor {
RouteGraphDescriptor {
handler,
module_path: "app::routes::posts",
method,
path,
static_route: false,
file: "src/routes/posts.rs",
line: 30,
crate_version: "0.1.0",
signature_symbols,
body_symbols,
sql_symbols: &[],
}
}
fn job(
name: &'static str,
kind: JobKind,
body_symbols: &'static [&'static str],
) -> JobGraphDescriptor {
JobGraphDescriptor {
name,
kind,
handler: name,
module_path: "app::jobs",
schedule: "",
mutating: false,
mutating_symbols: &[],
file: "src/jobs.rs",
line: 40,
crate_version: "0.1.0",
signature_symbols: &[],
signature_bindings: &[],
dot_mutation_receivers: &[],
body_symbols,
sql_symbols: &[],
}
}
fn mounted(method: &str, path: &str, handler: &str) -> MountedRoute {
MountedRoute {
method: method.to_owned(),
path: path.to_owned(),
handler: handler.to_owned(),
module_path: "app::routes::posts".to_owned(),
auth: RouteAuth {
secured: true,
roles: vec!["admin".to_owned()],
..RouteAuth::default()
},
repository_api: None,
auth_declared: false,
}
}
fn mounted_by_repository(method: &str, path: &str, api: &str) -> MountedRoute {
mounted_by_model(method, path, api, "Post")
}
fn mounted_by_model(method: &str, path: &str, api: &str, model: &str) -> MountedRoute {
MountedRoute {
repository_api: Some(RepositoryApiOwner {
api: api.to_owned(),
model: model.to_owned(),
}),
..mounted(method, path, "auto")
}
}
fn declared_mount(method: &str, path: &str, public: bool) -> MountedRoute {
MountedRoute {
auth: RouteAuth {
secured: !public,
public,
..RouteAuth::default()
},
auth_declared: true,
..mounted(method, path, "plugin")
}
}
#[test]
fn every_declared_element_becomes_a_node() {
let graph = build(
&[],
0,
&[route("index", "GET", "/posts", &[], &[])],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[job("send_digest", JobKind::Job, &[])],
);
let kinds: Vec<NodeKind> = graph.nodes.iter().map(|n| n.kind).collect();
assert!(kinds.contains(&NodeKind::Route), "{kinds:?}");
assert!(kinds.contains(&NodeKind::Model), "{kinds:?}");
assert!(kinds.contains(&NodeKind::Repository), "{kinds:?}");
assert!(kinds.contains(&NodeKind::Job), "{kinds:?}");
assert_eq!(graph.completeness.declared_routes, 1);
assert_eq!(graph.completeness.models, 1);
assert_eq!(graph.completeness.repositories, 1);
assert_eq!(graph.completeness.jobs, 1);
}
#[test]
fn a_repository_declares_its_model_edge() {
let graph = build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
let edge = graph
.edges
.iter()
.find(|e| e.to == "model:app::models::Post")
.expect("repository must declare an edge to its model");
assert_eq!(edge.provenance, Provenance::Declaration);
assert_eq!(edge.access, Access::ReadWrite);
}
#[test]
fn a_repository_extractor_links_the_route_to_the_repository() {
let graph = build(
&[],
0,
&[route(
"show",
"GET",
"/posts/{id}",
&["PgPostRepository"],
&[],
)],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
let edge = graph
.edges
.iter()
.find(|e| e.from.starts_with("route:") && e.to.starts_with("repository:"))
.expect("a repository extractor must link the route to the repository");
assert_eq!(edge.provenance, Provenance::Signature);
assert_eq!(edge.symbol, "PgPostRepository");
}
#[test]
fn a_table_module_named_in_the_body_links_the_route_to_the_model() {
let graph = build(
&[],
0,
&[route("show", "GET", "/posts/{id}", &[], &["posts"])],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
let edge = graph
.edges
.iter()
.find(|e| e.to == "model:app::models::Post")
.expect("a table module named in the body must link the route to the model");
assert_eq!(edge.provenance, Provenance::Body);
assert_eq!(edge.access, Access::Read);
}
#[test]
fn a_non_safe_method_makes_the_edge_a_write() {
let graph = build(
&[],
0,
&[route("create", "POST", "/posts", &[], &["posts"])],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
assert_eq!(graph.edges[0].access, Access::Write);
}
#[test]
fn a_signature_edge_outranks_a_body_edge_for_the_same_pair() {
let graph = build(
&[],
0,
&[route(
"show",
"GET",
"/posts/{id}",
&["PgPostRepository"],
&["PgPostRepository"],
)],
&[],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
assert_eq!(graph.edges.len(), 1, "{:?}", graph.edges);
assert_eq!(graph.edges[0].provenance, Provenance::Signature);
}
#[test]
fn a_job_that_names_a_table_gets_a_model_edge() {
let graph = build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[],
&[job("hot_rank", JobKind::Scheduled, &["posts"])],
);
let edge = graph
.edges
.iter()
.find(|e| e.from.starts_with("job:"))
.expect("a job that names a table must link to the model");
assert_eq!(edge.to, "model:app::models::Post");
}
#[test]
fn a_mounted_route_carries_its_served_path_and_auth() {
let graph = build(
&[mounted("GET", "/api/v1/posts", "index")],
0,
&[route("index", "GET", "/posts", &[], &[])],
&[],
&[],
&[],
);
let facts = graph.nodes[0].route.as_ref().expect("route facts");
assert!(facts.mounted);
assert_eq!(facts.path, "/api/v1/posts", "the mounted path must win");
assert_eq!(facts.auth.as_ref().expect("auth").roles, vec!["admin"]);
assert_eq!(graph.completeness.mounted_routes, 1);
assert!(graph.completeness.unmounted_routes.is_empty());
}
#[test]
fn a_declared_route_the_app_never_mounts_is_still_a_node() {
let graph = build(
&[],
0,
&[route("orphan", "GET", "/orphan", &[], &[])],
&[],
&[],
&[],
);
assert_eq!(graph.nodes.len(), 1);
assert!(!graph.nodes[0].route.as_ref().expect("route facts").mounted);
assert_eq!(
graph.completeness.unmounted_routes,
vec!["route:app::routes::posts::orphan"]
);
}
#[test]
fn a_mounted_route_with_no_declaration_is_named_not_dropped() {
let graph = build(
&[mounted("GET", "/actuator/health", "actuator")],
0,
&[],
&[],
&[],
&[],
);
assert_eq!(
graph.completeness.unmodelled_mounted_routes,
vec!["GET /actuator/health"]
);
}
#[test]
fn an_unresolvable_symbol_produces_no_edge() {
let graph = build(
&[],
0,
&[route(
"show",
"GET",
"/posts",
&["Db"],
&["Ok", "Vec", "String"],
)],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
assert!(graph.edges.is_empty(), "{:?}", graph.edges);
}
#[test]
fn a_repository_also_declares_its_models_relation_tables() {
let mut post = model("Post", "app::models::Post", "posts");
post.relations = &["votes"];
let graph = build(
&[],
0,
&[route(
"upvote",
"POST",
"/upvote",
&["PgPostRepository"],
&[],
)],
&[post, model("Vote", "app::models::Vote", "votes")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
let answer = crate::graph::query::touches(&graph, "votes").expect("votes must resolve");
assert_eq!(
answer
.routes
.iter()
.map(|n| n.name.as_str())
.collect::<Vec<_>>(),
vec!["upvote"],
);
let edge = graph
.edges
.iter()
.find(|e| e.from.starts_with("repository:") && e.to.ends_with("::Vote"))
.expect("the repository must declare the relation edge");
assert_eq!(edge.provenance, Provenance::Declaration);
assert_eq!(edge.access, Access::ReadWrite);
}
#[test]
fn a_relation_table_no_model_maps_produces_no_edge() {
let mut post = model("Post", "app::models::Post", "posts");
post.relations = &["comments"];
let graph = build(
&[],
0,
&[],
&[post],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
assert_eq!(
graph.edges.len(),
1,
"only the repository's own model edge: {:?}",
graph.edges
);
}
#[test]
fn a_models_relation_tables_are_visible_in_the_document() {
let mut post = model("Post", "app::models::Post", "posts");
post.relations = &["votes"];
let graph = build(&[], 0, &[], &[post], &[], &[]);
assert_eq!(
graph.nodes[0]
.model
.as_ref()
.expect("model facts")
.relations,
vec!["votes"]
);
}
#[test]
fn markup_that_merely_names_a_mutation_does_not_make_a_get_a_write() {
let graph = build(
&[],
0,
&[route("show", "GET", "/posts/{id}", &[], &["posts"])],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
assert_eq!(graph.edges[0].access, Access::Read);
}
#[test]
fn a_jobs_access_comes_from_its_mutation_evidence() {
let mut writer = job("hot_rank", JobKind::Scheduled, &["posts"]);
writer.mutating = true;
let graph = build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[],
&[writer],
);
assert_eq!(graph.edges[0].access, Access::Write);
}
fn edge_access(graph: &ArchitectureGraph, to_suffix: &str) -> Access {
graph
.edges
.iter()
.find(|e| e.to.ends_with(to_suffix) && e.provenance != Provenance::Declaration)
.unwrap_or_else(|| panic!("no edge to {to_suffix}: {:?}", graph.edges))
.access
}
#[test]
fn a_job_that_reads_one_table_and_writes_another_has_two_accesses() {
let mut writer = job("audit", JobKind::Job, &["audit_events", "users"]);
writer.mutating_symbols = &["audit_events"];
let graph = build(
&[],
0,
&[],
&[
model("User", "app::models::User", "users"),
model("AuditEvent", "app::models::AuditEvent", "audit_events"),
],
&[],
&[writer],
);
assert_eq!(edge_access(&graph, "AuditEvent"), Access::Write);
assert_eq!(edge_access(&graph, "User"), Access::Read);
}
#[test]
fn unattributed_mutation_evidence_still_writes_every_edge() {
let mut writer = job("audit", JobKind::Job, &["audit_events", "users"]);
writer.mutating = true;
writer.mutating_symbols = &["audit_events"];
let graph = build(
&[],
0,
&[],
&[
model("User", "app::models::User", "users"),
model("AuditEvent", "app::models::AuditEvent", "audit_events"),
],
&[],
&[writer],
);
assert_eq!(edge_access(&graph, "AuditEvent"), Access::Write);
assert_eq!(edge_access(&graph, "User"), Access::Write);
}
#[test]
fn a_write_through_one_symbol_beats_a_read_through_another_for_one_target() {
let mut writer = job("rank", JobKind::Job, &["Post", "posts"]);
writer.mutating_symbols = &["posts"];
let graph = build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[],
&[writer],
);
assert_eq!(edge_access(&graph, "Post"), Access::Write);
}
fn repo_job(
bindings: &'static [(&'static str, &'static str)],
receivers: &'static [&'static str],
) -> JobGraphDescriptor {
JobGraphDescriptor {
signature_symbols: &["PgPostRepository"],
signature_bindings: bindings,
dot_mutation_receivers: receivers,
..job("edit", JobKind::Job, &[])
}
}
fn post_graph(job: JobGraphDescriptor) -> ArchitectureGraph {
build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[job],
)
}
#[test]
fn a_dot_call_on_a_repository_parameter_is_a_write() {
let graph = post_graph(repo_job(&[("repo", "PgPostRepository")], &["repo"]));
assert_eq!(edge_access(&graph, "PostRepository"), Access::Write);
}
#[test]
fn a_repository_parameter_with_no_dot_mutation_stays_a_read() {
let graph = post_graph(repo_job(&[("repo", "PgPostRepository")], &[]));
assert_eq!(edge_access(&graph, "PostRepository"), Access::Read);
}
#[test]
fn a_dot_call_on_a_name_that_is_not_a_repository_parameter_is_a_read() {
let unbound = post_graph(repo_job(&[("repo", "PgPostRepository")], &["cache"]));
assert_eq!(edge_access(&unbound, "PostRepository"), Access::Read);
let other = JobGraphDescriptor {
signature_symbols: &["PgPostRepository", "Cache"],
signature_bindings: &[("repo", "PgPostRepository"), ("cache", "Cache")],
dot_mutation_receivers: &["cache"],
..job("edit", JobKind::Job, &[])
};
assert_eq!(
edge_access(&post_graph(other), "PostRepository"),
Access::Read
);
}
#[test]
fn a_repository_receiver_makes_only_its_own_repository_a_write() {
let both = JobGraphDescriptor {
signature_symbols: &["PgPostRepository", "PgUserRepository"],
signature_bindings: &[("posts", "PgPostRepository"), ("users", "PgUserRepository")],
dot_mutation_receivers: &["posts"],
..job("edit", JobKind::Job, &[])
};
let graph = build(
&[],
0,
&[],
&[
model("Post", "app::models::Post", "posts"),
model("User", "app::models::User", "users"),
],
&[
repository("PostRepository", "PgPostRepository", "Post", "posts"),
repository("UserRepository", "PgUserRepository", "User", "users"),
],
&[both],
);
assert_eq!(edge_access(&graph, "PostRepository"), Access::Write);
assert_eq!(edge_access(&graph, "UserRepository"), Access::Read);
}
#[test]
fn a_repository_auto_api_route_is_attributed_to_its_repository() {
let graph = build(
&[
mounted_by_repository("GET", "/api/posts", "/api/posts"),
mounted_by_repository("POST", "/api/posts", "/api/posts"),
],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
let generated = graph
.nodes
.iter()
.filter(|n| n.route.as_ref().is_some_and(|r| r.generated_by.is_some()))
.count();
assert_eq!(generated, 2, "{:?}", graph.nodes);
assert!(
graph.completeness.unmodelled_mounted_routes.is_empty(),
"an attributed auto-API route is no longer unaccounted for: {:?}",
graph.completeness.unmodelled_mounted_routes
);
assert_eq!(graph.completeness.generated_routes, 2);
assert_eq!(
graph.completeness.declared_routes, 0,
"a generated route is not a macro-declared handler"
);
let post_edge = graph
.edges
.iter()
.find(|e| e.from.contains("POST") && e.to.starts_with("repository:"))
.expect("the mutating auto-API route must link to its repository");
assert_eq!(post_edge.access, Access::Write);
assert_eq!(post_edge.provenance, Provenance::Declaration);
}
#[test]
fn an_auto_api_route_reaches_the_model_through_its_repository() {
let graph = build(
&[mounted_by_repository(
"GET",
"/api/posts/{id}",
"/api/posts",
)],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
let answer = crate::graph::query::touches(&graph, "posts").expect("posts must resolve");
assert_eq!(
answer.routes.len(),
1,
"the auto-API route must reach the model through its repository: {:?}",
answer.routes
);
}
#[test]
fn a_ws_mount_under_an_api_prefix_is_not_claimed_as_an_auto_api_route() {
let graph = build(
&[mounted("WS", "/api/posts/live", "live")],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
assert_eq!(graph.completeness.generated_routes, 0, "{:?}", graph.nodes);
assert_eq!(
graph.completeness.unmodelled_mounted_routes,
vec!["WS /api/posts/live"]
);
}
#[test]
fn a_scoped_auto_api_route_is_still_attributed_to_its_repository() {
let graph = build(
&[mounted_by_repository("GET", "/v1/api/posts", "/api/posts")],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
assert_eq!(graph.completeness.generated_routes, 1, "{:?}", graph.nodes);
assert!(graph.completeness.unmodelled_mounted_routes.is_empty());
}
#[test]
fn a_hand_written_route_under_an_api_prefix_is_not_claimed_as_generated() {
let graph = build(
&[mounted("GET", "/api/posts/search", "search")],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
assert_eq!(graph.completeness.generated_routes, 0);
assert_eq!(
graph.completeness.unmodelled_mounted_routes,
vec!["GET /api/posts/search"]
);
}
#[test]
fn a_handler_mounted_twice_accounts_for_both_mounts() {
let graph = build(
&[
mounted("GET", "/posts", "index"),
mounted("GET", "/admin/posts", "index"),
],
0,
&[route("index", "GET", "/posts", &[], &[])],
&[],
&[],
&[],
);
assert!(
graph.completeness.unmodelled_mounted_routes.is_empty(),
"{:?}",
graph.completeness.unmodelled_mounted_routes
);
let facts = graph.nodes[0].route.as_ref().expect("route facts");
assert_eq!(facts.path, "/admin/posts");
assert_eq!(facts.also_mounted_at, vec!["/posts"]);
}
#[test]
fn the_document_counts_routers_it_cannot_enumerate() {
let graph = build(&[], 3, &[], &[], &[], &[]);
assert_eq!(graph.completeness.opaque_mounted_routers, 3);
assert!(
graph.summary().contains("cannot enumerate"),
"{}",
graph.summary()
);
}
#[test]
fn a_mounted_route_no_repository_generated_stays_unmodelled() {
let graph = build(
&[mounted("GET", "/api/postscript", "other")],
0,
&[],
&[],
&[repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/posts",
)],
&[],
);
assert_eq!(
graph.completeness.unmodelled_mounted_routes,
vec!["GET /api/postscript"],
"a prefix match must respect segment boundaries"
);
}
#[test]
fn a_dependencys_registry_path_is_shortened_to_its_crate_relative_tail() {
assert_eq!(
location(
"/home/someone/.cargo/registry/src/index.crates.io-6f17d22b/plug-0.1.0/src/r.rs",
7
),
"plug-0.1.0/src/r.rs:7"
);
assert_eq!(location("src/models.rs", 7), "src/models.rs:7");
}
#[test]
fn an_uppercase_raw_sql_table_still_resolves() {
let graph = build(
&[],
0,
&[RouteGraphDescriptor {
sql_symbols: &["FROM", "POSTS", "SELECT"],
..route(
"report",
"GET",
"/report",
&[],
&["POSTS", "SELECT", "FROM"],
)
}],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
assert_eq!(
graph.edges.len(),
1,
"an uppercase table name must resolve: {:?}",
graph.edges
);
assert_eq!(graph.edges[0].to, "model:app::models::Post");
}
#[test]
fn a_rust_type_named_like_a_table_does_not_resolve_to_it() {
let graph = build(
&[],
0,
&[route("show", "GET", "/show", &[], &["Posts"])],
&[model("Post", "app::models::Post", "posts")],
&[],
&[],
);
assert!(graph.edges.is_empty(), "{:?}", graph.edges);
}
#[test]
fn a_type_name_is_not_matched_case_insensitively() {
let graph = build(
&[],
0,
&[route("show", "GET", "/show", &[], &["POST"])],
&[model("Comment", "app::models::Comment", "comments")],
&[],
&[],
);
assert!(graph.edges.is_empty(), "{:?}", graph.edges);
}
#[test]
fn a_windows_source_path_is_normalized() {
assert_eq!(
location(r"C:\src\app\src\models.rs", 3),
"C:/src/app/src/models.rs:3"
);
}
#[test]
fn the_document_carries_its_own_limits() {
let graph = build(&[], 0, &[], &[], &[], &[]);
assert_eq!(graph.limits.len(), LIMITS.len());
assert!(graph.limits.iter().any(|l| l.contains("helper function")));
}
#[test]
fn the_graph_round_trips_through_json() {
let graph = build(
&[mounted("POST", "/posts", "create")],
0,
&[route(
"create",
"POST",
"/posts",
&["PgPostRepository"],
&["posts"],
)],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[job("digest", JobKind::Job, &["posts"])],
);
let decoded: ArchitectureGraph =
serde_json::from_str(&graph.to_json()).expect("graph must round-trip");
assert_eq!(decoded, graph);
}
#[test]
fn the_dump_is_recovered_from_a_noisy_stdout() {
let graph = build(&[], 0, &[], &[], &[], &[]);
let json = serde_json::to_string(&graph).expect("serialize");
let stdout = format!("starting up\n{ARCHITECTURE_GRAPH_MARKER}{json}\ndone\n");
assert_eq!(parse_manifest_dump(&stdout), Some(graph));
}
#[test]
fn parsing_stdout_without_the_marker_yields_nothing() {
assert!(parse_manifest_dump("nothing to see here").is_none());
}
#[test]
fn nodes_and_edges_are_emitted_in_a_stable_order() {
let models = [
model("Zebra", "app::models::Zebra", "zebras"),
model("Alpha", "app::models::Alpha", "alphas"),
];
let first = build(&[], 0, &[], &models, &[], &[]);
let reversed: Vec<ModelGraphDescriptor> = models.iter().rev().copied().collect();
let second = build(&[], 0, &[], &reversed, &[], &[]);
assert_eq!(
first, second,
"registration order must not change the document"
);
assert_eq!(first.nodes[0].name, "Alpha");
}
#[test]
fn the_summary_names_every_section_it_has_rows_for() {
let graph = build(
&[mounted("POST", "/posts", "create")],
0,
&[route(
"create",
"POST",
"/posts",
&["PgPostRepository"],
&[],
)],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[job("digest", JobKind::Job, &["posts"])],
);
let summary = graph.summary();
assert!(summary.contains("POST /posts"), "{summary}");
assert!(summary.contains("Post (table posts)"), "{summary}");
assert!(summary.contains("PostRepository over Post"), "{summary}");
assert!(summary.contains("Limits of this derivation"), "{summary}");
}
fn plugin_post() -> ModelGraphDescriptor {
ModelGraphDescriptor {
model_path: "plugin::models::Post",
table: "plugin_posts",
relations: &["plugin_votes"],
..model("Post", "plugin::models::Post", "plugin_posts")
}
}
#[test]
fn a_repository_edge_requires_the_model_name_and_table_to_match() {
let mut app_post = model("Post", "app::models::Post", "posts");
app_post.relations = &["votes"];
let graph = build(
&[],
0,
&[],
&[
app_post,
plugin_post(),
model("Vote", "app::models::Vote", "votes"),
model("PluginVote", "plugin::models::PluginVote", "plugin_votes"),
],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
)],
&[],
);
let targets: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
assert_eq!(
targets,
vec!["model:app::models::Post", "model:app::models::Vote"],
"the other crate's Post and its relations must not be claimed"
);
}
#[test]
fn a_repository_over_an_overridden_table_still_finds_its_model_by_name() {
let graph = build(
&[],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[repository(
"PostRepository",
"PgPostRepository",
"Post",
"archive",
)],
&[],
);
assert_eq!(graph.edges.len(), 1, "{:?}", graph.edges);
}
#[test]
fn a_git_checkout_path_is_shortened_to_its_crate_relative_tail() {
assert_eq!(
location(
"/home/someone/.cargo/git/checkouts/plug-1a2b3c4d5e6f7a8b/9f8e7d6/src/lib.rs",
4
),
"plug/src/lib.rs:4"
);
assert_eq!(
location(
r"C:\Users\dev\.cargo\git\checkouts\plug-1a2b3c4d5e6f7a8b\9f8e7d6\crates\x\src\lib.rs",
4
),
"plug/crates/x/src/lib.rs:4"
);
}
#[test]
fn the_same_prefix_under_two_models_keeps_each_route_with_its_own_repository() {
let mut other = repository_with_api(
"ArticleRepository",
"PgArticleRepository",
"Article",
"articles",
"/api/items",
);
other.module_path = "app::other";
let graph = build(
&[
mounted_by_model("GET", "/a/api/items", "/api/items", "Post"),
mounted_by_model("GET", "/b/api/items", "/api/items", "Article"),
],
0,
&[],
&[
model("Post", "app::models::Post", "posts"),
model("Article", "app::models::Article", "articles"),
],
&[
repository_with_api(
"PostRepository",
"PgPostRepository",
"Post",
"posts",
"/api/items",
),
other,
],
&[],
);
let owner_of = |path: &str| {
graph
.nodes
.iter()
.find(|n| n.route.as_ref().is_some_and(|r| r.path == path))
.and_then(|n| n.route.as_ref()?.generated_by.clone())
};
assert_eq!(owner_of("/a/api/items").as_deref(), Some("PostRepository"));
assert_eq!(
owner_of("/b/api/items").as_deref(),
Some("ArticleRepository")
);
}
#[test]
fn the_limits_do_not_claim_a_process_global_graph() {
let graph = build(&[], 0, &[], &[], &[], &[]);
assert!(
!graph.limits.iter().any(|l| l.contains("process-global")),
"{:?}",
graph.limits
);
assert!(
graph.limits.iter().any(|l| l.contains("@<version>")),
"{:?}",
graph.limits
);
}
fn post_at(version: &'static str) -> ModelGraphDescriptor {
ModelGraphDescriptor {
crate_version: version,
module_path: "plugin::models",
..model("Post", "plugin::models::Post", "plugin_posts")
}
}
fn repository_at(version: &'static str) -> RepositoryGraphDescriptor {
RepositoryGraphDescriptor {
crate_version: version,
module_path: "plugin::repositories",
..repository("PostRepository", "PgPostRepository", "Post", "plugin_posts")
}
}
fn ids(graph: &ArchitectureGraph) -> Vec<&str> {
graph.nodes.iter().map(|n| n.id.as_str()).collect()
}
#[test]
fn two_versions_of_one_crate_keep_a_node_each() {
let route_at = |version| RouteGraphDescriptor {
crate_version: version,
..route("show", "GET", "/p", &[], &[])
};
let job_at = |version| JobGraphDescriptor {
crate_version: version,
..job("prune", JobKind::Job, &[])
};
let graph = build(
&[],
0,
&[route_at("1.0.0"), route_at("2.0.0")],
&[post_at("1.0.0"), post_at("2.0.0")],
&[repository_at("1.0.0"), repository_at("2.0.0")],
&[job_at("1.0.0"), job_at("2.0.0")],
);
assert_eq!(
ids(&graph),
vec![
"job:app::jobs::prune@1.0.0",
"job:app::jobs::prune@2.0.0",
"model:plugin::models::Post@1.0.0",
"model:plugin::models::Post@2.0.0",
"repository:plugin::repositories::PostRepository@1.0.0",
"repository:plugin::repositories::PostRepository@2.0.0",
"route:app::routes::posts::show@1.0.0",
"route:app::routes::posts::show@2.0.0",
]
);
}
#[test]
fn an_id_that_collides_with_nothing_has_no_version() {
let graph = build(&[], 0, &[], &[post_at("1.0.0")], &[], &[]);
assert_eq!(ids(&graph), vec!["model:plugin::models::Post"]);
}
#[test]
fn a_repository_binds_the_model_of_its_own_crate_version() {
let graph = build(
&[],
0,
&[],
&[post_at("1.0.0"), post_at("2.0.0")],
&[repository_at("1.0.0"), repository_at("2.0.0")],
&[],
);
let pairs: Vec<(&str, &str)> = graph
.edges
.iter()
.map(|e| (e.from.as_str(), e.to.as_str()))
.collect();
assert_eq!(
pairs,
vec![
(
"repository:plugin::repositories::PostRepository@1.0.0",
"model:plugin::models::Post@1.0.0"
),
(
"repository:plugin::repositories::PostRepository@2.0.0",
"model:plugin::models::Post@2.0.0"
),
]
);
}
#[test]
fn a_route_naming_a_model_of_two_versions_links_both() {
let graph = build(
&[],
0,
&[route("show", "GET", "/p", &[], &["Post"])],
&[post_at("1.0.0"), post_at("2.0.0")],
&[],
&[],
);
let targets: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
assert_eq!(
targets,
vec![
"model:plugin::models::Post@1.0.0",
"model:plugin::models::Post@2.0.0"
]
);
}
#[test]
fn an_auto_api_route_of_two_versions_links_both_repositories() {
let at = |version| RepositoryGraphDescriptor {
api: "/api/posts",
..repository_at(version)
};
let graph = build(
&[mounted_by_repository("GET", "/api/posts", "/api/posts")],
0,
&[],
&[post_at("1.0.0"), post_at("2.0.0")],
&[at("1.0.0"), at("2.0.0")],
&[],
);
let targets: Vec<&str> = graph
.edges
.iter()
.filter(|e| e.from.starts_with("route:auto-api:"))
.map(|e| e.to.as_str())
.collect();
assert_eq!(
targets,
vec![
"repository:plugin::repositories::PostRepository@1.0.0",
"repository:plugin::repositories::PostRepository@2.0.0",
]
);
}
#[test]
fn a_moved_model_of_the_other_version_is_not_linked() {
let moved = ModelGraphDescriptor {
model_path: "plugin::model::Post",
module_path: "plugin::model",
..post_at("2.0.0")
};
let graph = build(
&[],
0,
&[],
&[post_at("1.0.0"), moved],
&[repository_at("1.0.0")],
&[],
);
let targets: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
assert_eq!(targets, vec!["model:plugin::models::Post"]);
}
#[test]
fn a_relation_table_links_the_own_version_only() {
let post = |version| ModelGraphDescriptor {
relations: &["plugin_votes"],
..post_at(version)
};
let vote = |version| ModelGraphDescriptor {
crate_version: version,
module_path: "plugin::models",
..model("Vote", "plugin::models::Vote", "plugin_votes")
};
let graph = build(
&[],
0,
&[],
&[post("1.0.0"), post("2.0.0"), vote("1.0.0"), vote("2.0.0")],
&[repository_at("1.0.0")],
&[],
);
let targets: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
assert_eq!(
targets,
vec![
"model:plugin::models::Post@1.0.0",
"model:plugin::models::Vote@1.0.0"
]
);
}
#[test]
fn a_model_of_another_crate_keeps_its_edge() {
let app_post = ModelGraphDescriptor {
crate_version: "9.0.0",
module_path: "app::models",
..model("Post", "app::models::Post", "plugin_posts")
};
let graph = build(
&[],
0,
&[],
&[post_at("1.0.0"), app_post],
&[repository_at("1.0.0")],
&[],
);
assert_eq!(graph.edges.len(), 2, "{:?}", graph.edges);
}
#[test]
fn the_own_version_wins_before_the_table_match() {
let v2 = ModelGraphDescriptor {
table: "posts",
..post_at("2.0.0")
};
let repo = RepositoryGraphDescriptor {
table: "posts",
..repository_at("1.0.0")
};
let graph = build(&[], 0, &[], &[post_at("1.0.0"), v2], &[repo], &[]);
let targets: Vec<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
assert_eq!(targets, vec!["model:plugin::models::Post@1.0.0"]);
}
#[test]
fn a_collision_with_an_empty_version_keeps_two_nodes() {
let graph = build(&[], 0, &[], &[post_at(""), post_at("1.0.0")], &[], &[]);
assert_eq!(
ids(&graph),
vec![
"model:plugin::models::Post",
"model:plugin::models::Post@1.0.0"
]
);
}
#[test]
fn a_repository_with_no_model_of_its_version_links_every_version() {
let graph = build(
&[],
0,
&[],
&[post_at("1.0.0"), post_at("2.0.0")],
&[repository_at("3.0.0")],
&[],
);
assert_eq!(graph.edges.len(), 2, "{:?}", graph.edges);
}
#[test]
fn a_versioned_node_shows_its_version_in_the_label() {
let graph = build(&[], 0, &[], &[post_at("1.0.0"), post_at("2.0.0")], &[], &[]);
let labels: Vec<String> = graph.nodes.iter().map(GraphNode::label).collect();
assert_eq!(
labels,
vec![
"Post (table plugin_posts) @1.0.0",
"Post (table plugin_posts) @2.0.0"
]
);
}
#[test]
fn an_at_sign_in_an_auto_api_path_is_not_a_version() {
let graph = build(
&[mounted_by_repository("GET", "/api/@me", "/api")],
0,
&[],
&[model("Post", "app::models::Post", "posts")],
&[RepositoryGraphDescriptor {
api: "/api",
..repository("PostRepository", "PgPostRepository", "Post", "posts")
}],
&[],
);
let node = graph
.node("route:auto-api:GET /api/@me")
.expect("auto-API node");
assert_eq!(node.crate_version(), None);
assert_eq!(node.base_id(), "route:auto-api:GET /api/@me");
assert_eq!(node.label(), "GET /api/@me");
}
#[test]
fn an_auto_api_route_edge_points_at_a_node_that_exists() {
let at = |version| RepositoryGraphDescriptor {
api: "/api/posts",
..repository_at(version)
};
let graph = build(
&[mounted_by_repository("GET", "/api/posts", "/api/posts")],
0,
&[],
&[post_at("1.0.0"), post_at("2.0.0")],
&[at("1.0.0"), at("2.0.0")],
&[],
);
for edge in &graph.edges {
assert!(graph.node(&edge.to).is_some(), "dangling edge: {edge:?}");
}
assert!(
graph
.edges
.iter()
.any(|e| e.from.starts_with("route:auto-api:"))
);
}
#[test]
fn a_declared_plugin_mount_keeps_its_auth_in_a_structured_entry() {
let graph = build(
&[
declared_mount("GET", "/admin/plugins", false),
mounted("GET", "/health", "probe"),
],
0,
&[],
&[],
&[],
&[],
);
let c = &graph.completeness;
assert_eq!(c.declared_mounts.len(), 1, "{:?}", c.declared_mounts);
assert_eq!(c.declared_mounts[0].method, "GET");
assert_eq!(c.declared_mounts[0].path, "/admin/plugins");
assert!(c.declared_mounts[0].auth.secured);
assert_eq!(
c.unmodelled_mounted_routes,
vec!["GET /health"],
"a mount with known auth is not also a name-only string"
);
}
#[test]
fn flipping_a_declared_mounts_auth_changes_the_document() {
let secured = build(&[declared_mount("GET", "/p", false)], 0, &[], &[], &[], &[]);
let public = build(&[declared_mount("GET", "/p", true)], 0, &[], &[], &[], &[]);
assert_ne!(secured.completeness, public.completeness);
}
#[test]
fn declared_mounts_are_omitted_when_there_are_none_and_sorted_otherwise() {
let none = build(&[], 0, &[], &[], &[], &[]);
assert!(!none.to_json().contains("declared_mounts"));
let graph = build(
&[
declared_mount("POST", "/z", false),
declared_mount("GET", "/a", false),
],
0,
&[],
&[],
&[],
&[],
);
let paths: Vec<&str> = graph
.completeness
.declared_mounts
.iter()
.map(|m| m.path.as_str())
.collect();
assert_eq!(paths, vec!["/a", "/z"]);
assert_eq!(graph.schema_version, 1);
assert!(graph.summary().contains("GET /a"), "{}", graph.summary());
}
#[test]
fn a_cron_schedule_is_not_labelled_as_every() {
let node = |schedule: &str| GraphNode {
id: "job:x".to_owned(),
kind: NodeKind::ScheduledTask,
name: "digest".to_owned(),
module: String::new(),
location: String::new(),
route: None,
model: None,
repository: None,
job: Some(JobFacts {
kind: JobKind::Scheduled,
schedule: Some(schedule.to_owned()),
}),
};
assert_eq!(node("15m").label(), "digest (every 15m)");
assert_eq!(node("1h 30m").label(), "digest (every 1h 30m)");
assert_eq!(node("1h30m").label(), "digest (every 1h30m)");
assert_eq!(node("1 h").label(), "digest (every 1 h)");
assert_eq!(node("0 0 1 1 1").label(), "digest (0 0 1 1 1)");
assert_eq!(
node("0 3 * * * (America/New_York)").label(),
"digest (0 3 * * * (America/New_York))"
);
}
}