use std::{error::Error, fs, path::Path};
use super::{
Action, Format, check, discover,
model::{Declaration, Role, Scope},
render,
};
fn fixture() -> Result<tempfile::TempDir, Box<dyn Error>> {
let directory = tempfile::Builder::new()
.prefix("architecture with spaces ")
.tempdir()?;
let lints = include_str!("../../template/Cargo.toml.in")
.split_once("[workspace.lints.rust]")
.ok_or("missing template lints")?
.1;
fs::write(
directory.path().join("Cargo.toml"),
format!(
"[workspace]\nmembers = [\"core\", \"apps/bakery/pipes\", \"apps/bakery/switchboard\", \"apps/bakery/wiring\"]\nresolver = \"3\"\n[workspace.lints.rust]{lints}"
),
)?;
for (path, name, dependencies) in [
("core", "stock", ""),
(
"apps/bakery/pipes",
"bakery-pipes",
"stock = { path = \"../../../core\" }",
),
(
"apps/bakery/switchboard",
"bakery-routing",
"bakery-pipes = { path = \"../pipes\" }",
),
(
"apps/bakery/wiring",
"bakery-wiring",
"bakery-routing = { path = \"../switchboard\" }",
),
] {
let root = directory.path().join(path);
fs::create_dir_all(root.join("src"))?;
fs::write(
root.join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n[lints]\nworkspace = true\n[dependencies]\n{dependencies}\n"
),
)?;
fs::write(
root.join("src/lib.rs"),
"#![no_std]\npub struct Contract;\n",
)?;
}
Ok(directory)
}
fn analyze(root: &Path) -> Result<super::model::Architecture, Box<dyn Error>> {
let mut architecture = discover::load(&root.join("Cargo.toml"))?;
check::validate(&mut architecture)?;
Ok(architecture)
}
#[test]
fn canonical_roles_preserve_ownership_and_shared_contracts() {
let root = Path::new("/project");
let core = discover::infer(root, &root.join("core/subsystem"));
let pipe = discover::infer(root, &root.join("apps/bakery/pipes"));
let board = discover::infer(root, &root.join("apps/bakery/switchboard"));
let foreign = discover::infer(root, &root.join("apps/shop/switchboard"));
let shared = discover::infer(root, &root.join("apps/shared/pipes/auth"));
assert_eq!(core.as_ref().map(|role| role.role), Some(Role::Core));
assert_eq!(
shared.as_ref().and_then(|role| role.scope),
Some(Scope::Shared)
);
for (source, target, allowed) in [
(&core, &board, false),
(&pipe, &board, false),
(&board, &pipe, true),
(&board, &core, true),
(&board, &foreign, false),
(&shared, &pipe, false),
(&pipe, &shared, true),
] {
assert_eq!(
source
.as_ref()
.zip(target.as_ref())
.map(|(source, target)| source.allows(target)),
Some(allowed)
);
}
assert!(discover::infer(root, &root.join("utilities")).is_none());
}
#[test]
fn canonical_project_checks_once_and_renders_deterministically() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let architecture = analyze(directory.path())?;
assert!(
architecture.findings.is_empty(),
"{:?}",
architecture.findings
);
assert_eq!(architecture.work.metadata_invocations, 1);
assert_eq!(architecture.work.manifests_read, 5);
assert_eq!(architecture.work.sources_read, 3);
assert_eq!(architecture.work.dependency_edges_checked, 3);
let json = render(&architecture, Action::Check, Format::Json)?;
assert_eq!(
json,
render(&analyze(directory.path())?, Action::Check, Format::Json)?
);
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(
value.get("schema").and_then(serde_json::Value::as_str),
Some("pipecircuit.arch/1")
);
assert!(render(&architecture, Action::Visualize, Format::Text)?.contains("not message routes"));
assert!(render(&architecture, Action::Visualize, Format::Mermaid)?.starts_with("flowchart LR"));
assert!(
render(&architecture, Action::Visualize, Format::Graphviz)?
.starts_with("digraph architecture")
);
Ok(())
}
#[test]
fn role_conflicts_cannot_disable_purity_checks() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let manifest = directory.path().join("apps/bakery/pipes/Cargo.toml");
let source = fs::read_to_string(&manifest)?;
fs::write(
&manifest,
format!("{source}\n[package.metadata.pipecircuit]\nrole = \"wiring\"\napp = \"bakery\"\n"),
)?;
fs::write(
directory.path().join("apps/bakery/pipes/src/lib.rs"),
"// #![no_std]\npub struct NotPure;\n",
)?;
let architecture = analyze(directory.path())?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.role.conflict")
);
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.purity.no-std")
);
Ok(())
}
#[test]
fn optional_target_specific_outward_dependencies_are_not_hidden() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let mut architecture = discover::load(&directory.path().join("Cargo.toml"))?;
let wiring = directory.path().join("apps/bakery/wiring");
let package = architecture
.packages
.values_mut()
.find(|package| package.name == "bakery-pipes")
.ok_or("pipes missing")?;
package.dependencies.push(super::model::Dependency {
alias: None,
name: "bakery-wiring".into(),
path: Some(wiring),
kind: "normal".into(),
optional: true,
target: Some("cfg(windows)".into()),
default_features: false,
features: vec![],
});
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.dependency.outward"
&& finding.explanation.contains("cfg(windows)"))
);
Ok(())
}
#[test]
fn isolation_checks_transitive_dependencies_and_stale_restrictions() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let mut architecture = discover::load(&directory.path().join("Cargo.toml"))?;
let package = architecture
.packages
.values_mut()
.find(|package| package.name == "bakery-wiring")
.ok_or("wiring missing")?;
package
.declaration
.as_mut()
.ok_or("role missing")?
.forbid_dependencies = vec!["stock".into(), "missing-worker".into()];
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.isolation.reachable")
);
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.isolation.target")
);
Ok(())
}
#[test]
fn custom_layout_declares_roles_without_weakening_rules() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let old = directory.path().join("apps/bakery/switchboard");
let new = directory.path().join("apps/bakery/routes");
fs::rename(old, &new)?;
for path in [
directory.path().join("Cargo.toml"),
directory.path().join("apps/bakery/wiring/Cargo.toml"),
] {
fs::write(
&path,
fs::read_to_string(&path)?.replace("switchboard", "routes"),
)?;
}
let manifest = new.join("Cargo.toml");
fs::write(
&manifest,
format!(
"{}\n[package.metadata.pipecircuit]\nrole = \"switchboard\"\napp = \"bakery\"\n",
fs::read_to_string(&manifest)?
),
)?;
assert!(analyze(directory.path())?.findings.is_empty());
Ok(())
}
#[test]
fn declarations_require_unambiguous_ownership() {
assert!(
!Declaration {
role: Role::Switchboard,
app: None,
scope: None,
forbid_dependencies: vec![]
}
.valid()
);
assert!(
!Declaration {
role: Role::Pipes,
app: Some("bakery".into()),
scope: Some(Scope::Shared),
forbid_dependencies: vec![]
}
.valid()
);
}
#[test]
fn missing_manifest_and_malformed_rust_are_incomplete_analysis() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
assert!(discover::load(&directory.path().join("absent.toml")).is_err());
fs::write(directory.path().join("core/src/lib.rs"), "this is not Rust")?;
assert!(analyze(directory.path()).is_err());
Ok(())
}
#[test]
fn application_guard_regressions_remain_rejected_by_the_framework() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
for (source, target) in [
("stock", "bakery-routing"),
("bakery-routing", "bakery-wiring"),
("bakery-pipes", "bakery-routing"),
] {
let mut architecture = discover::load(&directory.path().join("Cargo.toml"))?;
let target_path = architecture
.packages
.values()
.find(|package| package.name == target)
.and_then(|package| package.manifest.parent())
.ok_or("target missing")?
.to_owned();
let package = architecture
.packages
.values_mut()
.find(|package| package.name == source)
.ok_or("source missing")?;
package.dependencies.push(super::model::Dependency {
alias: None,
name: target.into(),
path: Some(target_path),
kind: "normal".into(),
optional: false,
target: None,
default_features: false,
features: vec![],
});
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.dependency.outward"),
"{source} → {target}"
);
}
for (name, defaults, features) in [
("tokio", false, vec![]),
("pipecircuit", true, vec![]),
("pipecircuit", false, vec!["std".into()]),
] {
let mut architecture = discover::load(&directory.path().join("Cargo.toml"))?;
let package = architecture
.packages
.values_mut()
.find(|package| package.name == "bakery-routing")
.ok_or("routing missing")?;
package.dependencies.push(super::model::Dependency {
alias: None,
name: name.into(),
path: None,
kind: "normal".into(),
optional: false,
target: None,
default_features: defaults,
features,
});
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.dependency.external")
);
}
Ok(())
}
#[test]
fn every_application_boundary_combination_is_checked() {
let core = Declaration {
role: Role::Core,
app: None,
scope: None,
forbid_dependencies: vec![],
};
let shared = Declaration {
role: Role::Pipes,
app: None,
scope: Some(Scope::Shared),
forbid_dependencies: vec![],
};
for app in ["bakery", "vault", "browser"] {
for role in [Role::Pipes, Role::Switchboard] {
let source = Declaration {
role,
app: Some(app.into()),
scope: None,
forbid_dependencies: vec![],
};
assert!(source.allows(&core));
assert!(source.allows(&shared));
for owner in ["bakery", "vault", "browser"] {
for target_role in [
Role::Pipes,
Role::Switchboard,
Role::Wiring,
Role::Application,
] {
let target = Declaration {
role: target_role,
app: Some(owner.into()),
scope: None,
forbid_dependencies: vec![],
};
assert_eq!(
source.allows(&target),
target_role == Role::Pipes && owner == app
);
assert!(!shared.allows(&target));
assert!(!core.allows(&target));
}
}
}
}
}
#[test]
fn weakened_lints_and_unclassified_helpers_cannot_pass() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let manifest = directory.path().join("Cargo.toml");
fs::write(
&manifest,
fs::read_to_string(&manifest)?.replace("unwrap_used = \"deny\"", "unwrap_used = \"allow\""),
)?;
let mut architecture = discover::load(&manifest)?;
let package = architecture
.packages
.values_mut()
.find(|package| package.name == "bakery-wiring")
.ok_or("wiring missing")?;
package.declaration = None;
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.role.missing")
);
assert_eq!(
architecture
.findings
.iter()
.filter(|finding| finding.code == "arch.purity.lint")
.count(),
3
);
Ok(())
}
#[test]
fn workspace_lists_are_deduplicated_and_checking_does_not_write_lockfiles()
-> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let manifest = directory.path().join("Cargo.toml");
fs::write(
&manifest,
format!(
"{}\n[workspace.metadata.pipecircuit]\nworkspaces = [\"Cargo.toml\", \"core/Cargo.toml\"]\n",
fs::read_to_string(&manifest)?
),
)?;
let before = fs::read(directory.path().join("Cargo.lock")).ok();
let architecture = analyze(directory.path())?;
assert_eq!(architecture.work.metadata_invocations, 1);
assert_eq!(before, fs::read(directory.path().join("Cargo.lock")).ok());
Ok(())
}
#[test]
fn cli_options_do_not_silently_change_scope() -> Result<(), Box<dyn Error>> {
use std::ffi::OsString;
for arguments in [
vec!["check", "--format", "mermaid"],
vec!["check", "--machine", "Stock"],
vec!["check", "--timings", "--timings"],
vec!["visualize", "--format", "xml"],
vec!["check", "--manifest-path"],
] {
assert!(super::parse(arguments.into_iter().map(OsString::from)).is_err());
}
assert!(matches!(
super::parse(
["check", "--format", "json"]
.into_iter()
.map(OsString::from)
)?,
super::Command::Arch(_)
));
assert!(matches!(
super::parse(
["visualize", "--machine", "Stock"]
.into_iter()
.map(OsString::from)
)?,
super::Command::Diagram(_)
));
Ok(())
}
#[test]
fn renamed_optional_framework_cannot_forward_std_through_a_feature() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let mut architecture = discover::load(&directory.path().join("Cargo.toml"))?;
let package = architecture
.packages
.values_mut()
.find(|package| package.name == "bakery-routing")
.ok_or("routing missing")?;
package.document = std::rc::Rc::new(toml::from_str(
"[features]\nhidden = [\"framework?/std\"]\n[lints]\nworkspace = true\n",
)?);
package.dependencies.push(super::model::Dependency {
name: "pipecircuit".into(),
alias: Some("framework".into()),
path: None,
kind: "normal".into(),
target: None,
optional: true,
default_features: false,
features: vec![],
});
check::validate(&mut architecture)?;
assert!(
architecture
.findings
.iter()
.any(|finding| finding.code == "arch.dependency.external")
);
Ok(())
}
#[test]
fn empty_workspaces_do_not_produce_a_vacuous_pass() -> Result<(), Box<dyn Error>> {
let directory = tempfile::tempdir()?;
fs::write(
directory.path().join("Cargo.toml"),
"[workspace]\nmembers = []\n",
)?;
assert!(discover::load(&directory.path().join("Cargo.toml")).is_err());
Ok(())
}
#[test]
fn workspace_root_disagreement_is_an_incomplete_analysis() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let manifest = directory.path().join("Cargo.toml");
fs::write(
&manifest,
format!(
"{}\n[workspace.metadata.pipecircuit]\nworkspaces = [\"foreign/Cargo.toml\"]\n",
fs::read_to_string(&manifest)?
),
)?;
let foreign = directory.path().join("foreign");
fs::create_dir_all(foreign.join("src"))?;
fs::write(
foreign.join("Cargo.toml"),
"[workspace]\n[workspace.metadata.pipecircuit]\nroot = \".\"\n[package]\nname = \"foreign\"\nversion = \"0.1.0\"\n",
)?;
fs::write(foreign.join("src/lib.rs"), "pub struct Foreign;")?;
let result = discover::load(&manifest);
assert!(matches!(result, Err(super::ArchError::Configuration(_, _))));
Ok(())
}
#[cfg(unix)]
#[test]
fn symlinked_workspace_entry_preserves_package_identity() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let entry = tempfile::tempdir()?;
let alias = entry.path().join("workspace-link");
std::os::unix::fs::symlink(directory.path(), &alias)?;
let mut architecture = discover::load(&alias.join("Cargo.toml"))?;
check::validate(&mut architecture)?;
assert!(
architecture.findings.is_empty(),
"{:?}",
architecture.findings
);
assert_eq!(architecture.packages.len(), 4);
assert_eq!(architecture.root, directory.path());
Ok(())
}
#[test]
fn role_names_and_ownership_constraints_are_exact() {
for (role, label) in [
(Role::Core, "core"),
(Role::Pipes, "pipes"),
(Role::Switchboard, "switchboard"),
(Role::Wiring, "wiring"),
(Role::Application, "application"),
(Role::Tooling, "tooling"),
(Role::ThirdParty, "third-party"),
] {
assert_eq!(role.to_string(), label);
}
for (role, app, scope, valid) in [
(Role::Core, None, None, true),
(Role::Core, Some("bakery"), None, false),
(Role::Core, None, Some(Scope::Shared), false),
(Role::Tooling, None, None, true),
(Role::Tooling, Some("bakery"), None, false),
(Role::Tooling, None, Some(Scope::Shared), false),
(Role::ThirdParty, None, None, true),
(Role::ThirdParty, Some("bakery"), None, false),
(Role::ThirdParty, None, Some(Scope::Shared), false),
(Role::Pipes, None, Some(Scope::Shared), true),
(Role::Pipes, Some("bakery"), None, true),
(Role::Wiring, None, Some(Scope::Shared), true),
(Role::Wiring, Some("bakery"), None, true),
(Role::Switchboard, Some("bakery"), None, true),
(
Role::Switchboard,
Some("bakery"),
Some(Scope::Shared),
false,
),
(Role::Switchboard, None, None, false),
(Role::Application, None, None, false),
(Role::Application, Some("bakery"), None, true),
(
Role::Application,
Some("bakery"),
Some(Scope::Shared),
false,
),
(Role::Pipes, Some(""), None, false),
(Role::Pipes, Some("shared"), None, false),
] {
let declaration = Declaration {
role,
app: app.map(str::to_owned),
scope,
forbid_dependencies: vec![],
};
assert_eq!(declaration.valid(), valid, "{declaration:?}");
}
}
#[cfg(unix)]
#[test]
fn invalid_utf8_action_is_not_mistaken_for_help() {
use std::os::unix::ffi::OsStringExt as _;
assert!(super::parse([std::ffi::OsString::from_vec(vec![255])].into_iter()).is_err());
}
#[test]
fn project_root_finds_linked_conventional_workspaces_without_scanning() -> Result<(), Box<dyn Error>>
{
let root = tempfile::tempdir()?;
for (directory, others) in [
("apps", "../core/Cargo.toml"),
("core", "../apps/Cargo.toml"),
] {
fs::create_dir(root.path().join(directory))?;
fs::write(
root.path().join(directory).join("Cargo.toml"),
format!(
"[workspace]\n[workspace.metadata.pipecircuit]\nroot = \"..\"\nworkspaces = [\"{others}\"]\n"
),
)?;
}
let entry = root.path().join("apps/Cargo.toml");
assert_eq!(discover::find_entry(root.path())?, entry);
let nested = root.path().join("notes/review");
fs::create_dir_all(&nested)?;
assert_eq!(discover::find_entry(&nested)?, entry);
fs::write(root.path().join("Cargo.toml"), "[workspace]\n")?;
assert_eq!(
discover::find_entry(root.path())?,
root.path().join("Cargo.toml")
);
Ok(())
}
#[test]
fn discovery_refuses_unrelated_ambiguous_or_invalid_roots() -> Result<(), Box<dyn Error>> {
let root = tempfile::tempdir()?;
fs::create_dir(root.path().join("apps"))?;
let apps = root.path().join("apps/Cargo.toml");
fs::write(&apps, "[workspace]\n")?;
assert!(discover::find_entry(root.path()).is_err());
fs::write(
&apps,
"[workspace]\n[workspace.metadata.pipecircuit]\nroot = \".\"\n",
)?;
assert!(discover::find_entry(root.path()).is_err());
let declaration = "[workspace]\n[workspace.metadata.pipecircuit]\nroot = \"..\"\n";
fs::write(&apps, declaration)?;
assert_eq!(discover::find_entry(root.path())?, apps);
fs::create_dir(root.path().join("core"))?;
let core = root.path().join("core/Cargo.toml");
fs::write(&core, declaration)?;
assert!(discover::find_entry(root.path()).is_err());
fs::write(&apps, "[malformed")?;
assert!(matches!(
discover::find_entry(root.path()),
Err(super::ArchError::Manifest(_, _))
));
fs::remove_file(apps)?;
assert_eq!(discover::find_entry(root.path())?, core);
Ok(())
}
#[test]
fn human_output_is_concise_and_html_keeps_manifest_text_inert() -> Result<(), Box<dyn Error>> {
let directory = fixture()?;
let mut architecture = analyze(directory.path())?;
let check = render(&architecture, Action::Check, Format::Text)?;
assert_eq!(check.lines().count(), 3);
assert!(check.contains("pipecircuit review -- <repository-evidence-command>"));
assert!(check.starts_with("PASS "));
assert!(check.contains("4 packages checked, 0 architectural violations"));
let text = render(&architecture, Action::Visualize, Format::Text)?;
assert!(text.contains("App: bakery"));
assert!(text.contains("apps/bakery/switchboard/Cargo.toml"));
assert!(text.contains("Shared core"));
let package = architecture
.packages
.values_mut()
.next()
.ok_or("missing package")?;
package.name = "</script><script>globalThis.injected=true</script>&".into();
let html = render(&architecture, Action::Visualize, Format::Html)?;
assert!(!html.contains("<script>globalThis.injected=true"));
let data = html
.split_once("type=\"application/json\">")
.ok_or("missing data element")?
.1
.split_once("</script>")
.ok_or("missing data end")?
.0;
assert_eq!(
serde_json::from_str::<serde_json::Value>(data)?,
serde_json::to_value(&architecture)?
);
assert!(html.contains("default-src 'none'"));
assert_eq!(
html,
render(&architecture, Action::Visualize, Format::Html)?
);
Ok(())
}
#[test]
fn browser_is_the_project_default_but_exports_stay_explicit() -> Result<(), Box<dyn Error>> {
use std::ffi::OsString;
for (arguments, browser, html) in [
(vec!["visualize"], true, false),
(vec!["visualize", "--format", "html"], false, true),
(vec!["visualize", "--format", "text"], false, false),
(vec!["check"], false, false),
] {
let super::Command::Arch(command) =
super::parse(arguments.into_iter().map(OsString::from))?
else {
return Err("wrong command".into());
};
assert_eq!(matches!(command.format, Format::Browser), browser);
assert_eq!(matches!(command.format, Format::Html), html);
}
for arguments in [
vec!["check", "--format", "html"],
vec!["visualize", "--machine", "Stock", "--format", "html"],
] {
assert!(super::parse(arguments.into_iter().map(OsString::from)).is_err());
}
Ok(())
}
mod tooling;