use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::{CodeLoreError, Result};
pub const ARCH_RULES_FILENAME: &str = ".codelore-arch-rules.toml";
#[derive(Debug, Clone)]
pub struct Layer {
pub name: String,
pub paths: Vec<String>,
pub may_depend_on: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct LayerRules {
pub layers: Vec<Layer>,
}
impl LayerRules {
pub fn discover(repo_root: &Path) -> Result<Self> {
let path = repo_root.join(ARCH_RULES_FILENAME);
if !path.exists() {
return Ok(Self::default());
}
Self::from_path(&path)
}
pub fn from_path(path: &Path) -> Result<Self> {
let raw = fs::read_to_string(path).map_err(|e| {
CodeLoreError::RepoIo(std::io::Error::new(
e.kind(),
format!("read arch-rules file {}: {e}", path.display()),
))
})?;
Self::from_text(&raw).map_err(|e| {
CodeLoreError::Analysis(format!("parse arch-rules file {}: {e}", path.display()))
})
}
pub fn from_text(raw: &str) -> std::result::Result<Self, String> {
let table: toml::Table = toml::from_str(raw).map_err(|e| e.to_string())?;
let mut layers: Vec<Layer> = Vec::new();
if let Some(layer_section) = table.get("layer") {
let layer_table = layer_section
.as_table()
.ok_or_else(|| "`layer` must be a TOML table".to_string())?;
for (name, body_val) in layer_table {
let body: WireLayer = body_val
.clone()
.try_into()
.map_err(|e: toml::de::Error| format!("layer `{name}`: {e}"))?;
layers.push(Layer {
name: name.clone(),
paths: body.paths,
may_depend_on: body.may_depend_on,
});
}
}
let declared_names: std::collections::HashSet<&str> =
layers.iter().map(|l| l.name.as_str()).collect();
for layer in &layers {
for dep in &layer.may_depend_on {
if !declared_names.contains(dep.as_str()) {
return Err(format!(
"layer `{}` may_depend_on references undeclared layer `{dep}`",
layer.name
));
}
}
}
Ok(Self { layers })
}
#[must_use]
pub fn classify(&self, file_path: &str) -> Option<&str> {
for layer in &self.layers {
for prefix in &layer.paths {
if file_path.starts_with(prefix.as_str()) {
return Some(&layer.name);
}
}
}
None
}
#[must_use]
pub fn validate(&self, src_path: &str, target_path: &str) -> Option<Violation> {
let src_layer = self.classify(src_path)?;
let target_layer = self.classify(target_path)?;
if src_layer == target_layer {
return None;
}
let src = self.layers.iter().find(|l| l.name == src_layer)?;
if src.may_depend_on.iter().any(|d| d == target_layer) {
return None;
}
Some(Violation {
src_layer: src_layer.to_string(),
target_layer: target_layer.to_string(),
})
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub src_layer: String,
pub target_layer: String,
}
#[derive(Deserialize)]
struct WireLayer {
#[serde(default)]
paths: Vec<String>,
#[serde(default)]
may_depend_on: Vec<String>,
}
#[must_use]
pub fn arch_rules_path(repo_root: &Path) -> PathBuf {
repo_root.join(ARCH_RULES_FILENAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_text_yields_empty_ruleset() {
let rules = LayerRules::from_text("").unwrap();
assert!(rules.is_empty());
}
#[test]
fn three_layer_pyramid_parses() {
let raw = r#"
[layer.domain]
paths = ["src/domain/"]
may_depend_on = []
[layer.app]
paths = ["src/app/"]
may_depend_on = ["domain"]
[layer.infra]
paths = ["src/infra/"]
may_depend_on = ["app", "domain"]
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(rules.layers.len(), 3);
}
#[test]
fn undeclared_dependency_fails_parse() {
let raw = r#"
[layer.app]
paths = ["src/app/"]
may_depend_on = ["ghost"]
"#;
let err = LayerRules::from_text(raw).unwrap_err();
assert!(err.contains("ghost"), "got {err}");
}
#[test]
fn classification_matches_first_declared_prefix() {
let raw = r#"
[layer.api]
paths = ["src/api/"]
may_depend_on = []
[layer.app]
paths = ["src/"]
may_depend_on = []
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(rules.classify("src/api/foo.rs"), Some("api"));
}
#[test]
fn classification_respects_declaration_order_not_alphabetical() {
let raw = r#"
[layer.zeta_specific]
paths = ["src/api/"]
may_depend_on = []
[layer.alpha_general]
paths = ["src/"]
may_depend_on = []
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(rules.layers[0].name, "zeta_specific");
assert_eq!(rules.classify("src/api/foo.rs"), Some("zeta_specific"));
}
#[test]
fn same_layer_imports_pass() {
let raw = r#"
[layer.app]
paths = ["src/app/"]
may_depend_on = []
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(rules.validate("src/app/a.rs", "src/app/b.rs"), None);
}
#[test]
fn allowed_downward_dependency_passes() {
let raw = r#"
[layer.domain]
paths = ["src/domain/"]
may_depend_on = []
[layer.app]
paths = ["src/app/"]
may_depend_on = ["domain"]
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(
rules.validate("src/app/handler.rs", "src/domain/user.rs"),
None
);
}
#[test]
fn forbidden_upward_dependency_is_violation() {
let raw = r#"
[layer.domain]
paths = ["src/domain/"]
may_depend_on = []
[layer.app]
paths = ["src/app/"]
may_depend_on = ["domain"]
"#;
let rules = LayerRules::from_text(raw).unwrap();
let v = rules
.validate("src/domain/user.rs", "src/app/handler.rs")
.expect("expected a violation");
assert_eq!(v.src_layer, "domain");
assert_eq!(v.target_layer, "app");
}
#[test]
fn unclassified_files_skip_validation() {
let raw = r#"
[layer.app]
paths = ["src/app/"]
may_depend_on = []
"#;
let rules = LayerRules::from_text(raw).unwrap();
assert_eq!(rules.validate("misc/util.rs", "src/app/x.rs"), None);
assert_eq!(rules.validate("src/app/x.rs", "misc/util.rs"), None);
}
}