use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::ast::imports::get_imports;
use crate::fs::tree::{collect_files, get_file_tree};
use crate::types::{
ArchRule, ArchRuleType, ArchRulesFile, ArchitectureReport, IgnoreSpec, ImportInfo, Language,
LayerDefinition, LayerDefinitions, LayerType, RulesGenerationContext, Violation,
ViolationInfo, ViolationReport,
};
use crate::TldrResult;
#[derive(Debug, Clone)]
pub struct ImportEdge {
pub from_file: PathBuf,
pub to_file: PathBuf,
pub module: String,
pub line: u32,
}
#[derive(Debug, Clone, Default)]
pub struct ImportGraph {
pub edges: Vec<ImportEdge>,
pub file_to_imports: HashMap<PathBuf, Vec<ImportEdge>>,
pub files: HashSet<PathBuf>,
}
impl ImportGraph {
pub fn new() -> Self {
Self::default()
}
pub fn add_edge(&mut self, edge: ImportEdge) {
self.files.insert(edge.from_file.clone());
self.files.insert(edge.to_file.clone());
self.file_to_imports
.entry(edge.from_file.clone())
.or_default()
.push(edge.clone());
self.edges.push(edge);
}
}
pub fn build_import_graph(root: &Path, language: Language) -> TldrResult<ImportGraph> {
let extensions: HashSet<String> = language
.extensions()
.iter()
.map(|s| s.to_string())
.collect();
let tree = get_file_tree(root, Some(&extensions), true, Some(&IgnoreSpec::default()))?;
let files = collect_files(&tree, root);
let mut graph = ImportGraph::new();
let mut all_files: HashSet<PathBuf> = HashSet::new();
for file_path in &files {
all_files.insert(file_path.clone());
}
for file_path in &files {
graph.files.insert(file_path.clone());
match get_imports(file_path, language) {
Ok(imports) => {
for import in imports {
if let Some(resolved) =
resolve_import(&import, file_path, root, &all_files, language)
{
graph.add_edge(ImportEdge {
from_file: file_path.clone(),
to_file: resolved,
module: import.module.clone(),
line: 1, });
}
}
}
Err(e) => {
if e.is_recoverable() {
continue;
}
}
}
}
Ok(graph)
}
fn resolve_import(
import: &ImportInfo,
from_file: &Path,
project_root: &Path,
all_files: &HashSet<PathBuf>,
language: Language,
) -> Option<PathBuf> {
match language {
Language::Python => resolve_python_import(import, from_file, project_root, all_files),
Language::TypeScript | Language::JavaScript => {
resolve_ts_import(import, from_file, project_root, all_files)
}
Language::Go => resolve_go_import(import, all_files),
Language::Rust => resolve_rust_import(import, from_file, project_root, all_files),
_ => None,
}
}
fn resolve_python_import(
import: &ImportInfo,
from_file: &Path,
project_root: &Path,
all_files: &HashSet<PathBuf>,
) -> Option<PathBuf> {
let module = &import.module;
let (dots, module_path) = count_leading_dots(module);
if dots > 0 {
let from_dir = from_file.parent()?;
let mut base_dir = from_dir.to_path_buf();
for _ in 0..(dots.saturating_sub(1)) {
base_dir = base_dir.parent()?.to_path_buf();
}
let relative_path = module_path.replace('.', "/");
let candidate = base_dir.join(format!("{}.py", relative_path));
if all_files.contains(&candidate) {
return Some(candidate);
}
let candidate = base_dir.join(&relative_path).join("__init__.py");
if all_files.contains(&candidate) {
return Some(candidate);
}
} else {
let relative_path = module.replace('.', "/");
let candidate = project_root.join(format!("{}.py", relative_path));
if all_files.contains(&candidate) {
return Some(candidate);
}
let candidate = project_root.join(&relative_path).join("__init__.py");
if all_files.contains(&candidate) {
return Some(candidate);
}
let candidate = project_root
.join("src")
.join(format!("{}.py", relative_path));
if all_files.contains(&candidate) {
return Some(candidate);
}
}
None }
fn count_leading_dots(module: &str) -> (usize, &str) {
let dots = module.chars().take_while(|&c| c == '.').count();
let rest = &module[dots..];
(dots, rest)
}
fn resolve_ts_import(
import: &ImportInfo,
from_file: &Path,
project_root: &Path,
all_files: &HashSet<PathBuf>,
) -> Option<PathBuf> {
let module = &import.module;
if !module.starts_with('.') && !module.starts_with('/') {
return None;
}
let from_dir = from_file.parent()?;
let base_dir = if module.starts_with('.') {
from_dir.to_path_buf()
} else {
project_root.to_path_buf()
};
let clean_path = module.trim_start_matches("./").trim_start_matches("../");
let path_part = if module.starts_with("../") {
let ups = module.matches("../").count();
let mut dir = base_dir.clone();
for _ in 0..ups {
dir = dir.parent()?.to_path_buf();
}
dir.join(clean_path)
} else {
base_dir.join(clean_path)
};
for ext in &[
"",
".ts",
".tsx",
".js",
".jsx",
"/index.ts",
"/index.tsx",
"/index.js",
] {
let candidate = PathBuf::from(format!("{}{}", path_part.display(), ext));
if all_files.contains(&candidate) {
return Some(candidate);
}
}
None
}
fn resolve_go_import(
import: &ImportInfo,
all_files: &HashSet<PathBuf>,
) -> Option<PathBuf> {
let module = &import.module;
let parts: Vec<&str> = module.split('/').collect();
if let Some(last) = parts.last() {
for file in all_files {
if let Some(parent) = file.parent() {
if parent.ends_with(last) && file.extension().map(|e| e == "go").unwrap_or(false) {
return Some(file.clone());
}
}
}
}
None
}
fn resolve_rust_import(
import: &ImportInfo,
from_file: &Path,
project_root: &Path,
all_files: &HashSet<PathBuf>,
) -> Option<PathBuf> {
let module = &import.module;
let path_parts: Vec<&str> = module.split("::").collect();
let start_idx = if matches!(path_parts.first(), Some(&"crate") | Some(&"self")) {
1
} else if path_parts.first() == Some(&"super") {
let from_dir = from_file.parent()?;
let parent = from_dir.parent()?;
let rest_path = path_parts[1..].join("/");
let candidate = parent.join(format!("{}.rs", rest_path));
if all_files.contains(&candidate) {
return Some(candidate);
}
let candidate = parent.join(&rest_path).join("mod.rs");
if all_files.contains(&candidate) {
return Some(candidate);
}
return None;
} else {
return None;
};
let relative_path = path_parts[start_idx..].join("/");
let candidate = project_root
.join("src")
.join(format!("{}.rs", relative_path));
if all_files.contains(&candidate) {
return Some(candidate);
}
let candidate = project_root.join("src").join(&relative_path).join("mod.rs");
if all_files.contains(&candidate) {
return Some(candidate);
}
None
}
pub fn generate_rules(
arch_report: &ArchitectureReport,
context: &RulesGenerationContext,
) -> ArchRulesFile {
let mut rules_file = ArchRulesFile::new();
let now = std::time::SystemTime::now();
let timestamp = now
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}", d.as_secs()))
.unwrap_or_else(|_| "0".to_string());
rules_file.generated_at = Some(timestamp);
rules_file.layers = build_layer_definitions(&arch_report.inferred_layers);
let layer_rules = generate_layer_rules(&arch_report.inferred_layers);
for rule in layer_rules {
rules_file.rules.push(rule);
}
let cycle_rules = generate_cycle_rules(&arch_report.circular_dependencies, context);
for rule in cycle_rules {
rules_file.rules.push(rule);
}
rules_file
}
fn build_layer_definitions(inferred_layers: &HashMap<PathBuf, LayerType>) -> LayerDefinitions {
let mut high_dirs: Vec<String> = Vec::new();
let mut middle_dirs: Vec<String> = Vec::new();
let mut low_dirs: Vec<String> = Vec::new();
for (dir, layer) in inferred_layers {
let dir_str = format!("{}/", dir.display());
match layer {
LayerType::Entry | LayerType::DynamicDispatch => {
high_dirs.push(dir_str);
}
LayerType::Service => {
middle_dirs.push(dir_str);
}
LayerType::Utility => {
low_dirs.push(dir_str);
}
}
}
high_dirs.sort();
middle_dirs.sort();
low_dirs.sort();
LayerDefinitions {
high: if high_dirs.is_empty() {
None
} else {
Some(LayerDefinition::new(
"Entry/Controller layer - handles external requests",
high_dirs,
))
},
middle: if middle_dirs.is_empty() {
None
} else {
Some(LayerDefinition::new(
"Service/Business layer - core logic",
middle_dirs,
))
},
low: if low_dirs.is_empty() {
None
} else {
Some(LayerDefinition::new(
"Utility/Data layer - shared utilities",
low_dirs,
))
},
}
}
fn generate_layer_rules(inferred_layers: &HashMap<PathBuf, LayerType>) -> Vec<ArchRule> {
let mut rules = Vec::new();
let has_high = inferred_layers
.values()
.any(|l| matches!(l, LayerType::Entry | LayerType::DynamicDispatch));
let has_middle = inferred_layers
.values()
.any(|l| matches!(l, LayerType::Service));
let has_low = inferred_layers
.values()
.any(|l| matches!(l, LayerType::Utility));
if has_low && has_high {
rules.push(ArchRule::layer(
"L1",
"LOW may not import HIGH",
vec!["LOW".to_string()],
vec!["HIGH".to_string()],
"Utility layers should not depend on entry layers",
));
}
if has_middle && has_high {
rules.push(ArchRule::layer(
"L2",
"MIDDLE may not import HIGH",
vec!["MIDDLE".to_string()],
vec!["HIGH".to_string()],
"Service layers should not depend on entry layers",
));
}
rules
}
fn generate_cycle_rules(
circular_deps: &[crate::types::CircularDep],
_context: &RulesGenerationContext,
) -> Vec<ArchRule> {
let mut rules = Vec::new();
for (i, dep) in circular_deps.iter().enumerate() {
let id = format!("C{}", i + 1);
let constraint = format!(
"Break cycle: {} should not import {}",
dep.a.display(),
dep.b.display()
);
let files = vec![
dep.a.to_string_lossy().to_string(),
dep.b.to_string_lossy().to_string(),
];
rules.push(ArchRule::cycle_break(
id,
constraint,
files,
format!(
"Circular dependency between {} and {}",
dep.a.display(),
dep.b.display()
),
));
}
rules
}
pub fn check_rules(
rules: &ArchRulesFile,
import_graph: &ImportGraph,
layers: &HashMap<PathBuf, LayerType>,
) -> ViolationReport {
let mut report = ViolationReport::new();
report.summary.rules_checked = rules.rules.len();
report.summary.files_scanned = import_graph.files.len();
let file_layers = compute_file_layers(layers, rules);
for edge in &import_graph.edges {
let from_layer = get_file_layer(&edge.from_file, &file_layers);
let to_layer = get_file_layer(&edge.to_file, &file_layers);
if let (Some(from), Some(to)) = (from_layer, to_layer) {
for rule in &rules.rules {
if let Some(violation) = check_layer_rule(rule, edge, from, to) {
report.add_violation(violation);
}
}
}
}
for rule in &rules.rules {
if rule.rule_type == ArchRuleType::CycleBreak {
check_cycle_rule(rule, import_graph, &mut report);
}
}
report
}
fn compute_file_layers(
layers: &HashMap<PathBuf, LayerType>,
rules: &ArchRulesFile,
) -> HashMap<PathBuf, String> {
let mut file_layers: HashMap<PathBuf, String> = HashMap::new();
for (dir, layer_type) in layers {
let layer_name = match layer_type {
LayerType::Entry | LayerType::DynamicDispatch => "HIGH",
LayerType::Service => "MIDDLE",
LayerType::Utility => "LOW",
};
file_layers.insert(dir.clone(), layer_name.to_string());
}
if let Some(high) = &rules.layers.high {
for dir in &high.directories {
let dir_path = PathBuf::from(dir.trim_end_matches('/'));
file_layers.insert(dir_path, "HIGH".to_string());
}
}
if let Some(middle) = &rules.layers.middle {
for dir in &middle.directories {
let dir_path = PathBuf::from(dir.trim_end_matches('/'));
file_layers.insert(dir_path, "MIDDLE".to_string());
}
}
if let Some(low) = &rules.layers.low {
for dir in &low.directories {
let dir_path = PathBuf::from(dir.trim_end_matches('/'));
file_layers.insert(dir_path, "LOW".to_string());
}
}
file_layers
}
fn get_file_layer<'a>(
file: &Path,
file_layers: &'a HashMap<PathBuf, String>,
) -> Option<&'a String> {
let mut current = file.parent();
while let Some(dir) = current {
if let Some(layer) = file_layers.get(dir) {
return Some(layer);
}
current = dir.parent();
}
for (layer_dir, layer) in file_layers {
if file.starts_with(layer_dir) {
return Some(layer);
}
}
None
}
fn check_layer_rule(
rule: &ArchRule,
edge: &ImportEdge,
from_layer: &String,
to_layer: &String,
) -> Option<Violation> {
if rule.rule_type != ArchRuleType::Layer {
return None;
}
let from_matches = rule.from_layers.iter().any(|l| l == from_layer);
let to_matches = rule.to_layers.iter().any(|l| l == to_layer);
if from_matches && to_matches {
Some(Violation::direct(ViolationInfo {
rule_id: rule.id.clone(),
rule_constraint: rule.constraint.clone(),
from_file: edge.from_file.clone(),
from_line: edge.line,
imports_file: edge.to_file.clone(),
from_layer: from_layer.clone(),
to_layer: to_layer.clone(),
severity: rule.severity,
}))
} else {
None
}
}
fn check_cycle_rule(rule: &ArchRule, import_graph: &ImportGraph, report: &mut ViolationReport) {
if rule.files.len() < 2 {
return;
}
let files: HashSet<&str> = rule.files.iter().map(|s| s.as_str()).collect();
for edge in &import_graph.edges {
let from_str = edge.from_file.to_string_lossy();
let to_str = edge.to_file.to_string_lossy();
let from_in_rule = files.iter().any(|f| from_str.ends_with(*f));
let to_in_rule = files.iter().any(|f| to_str.ends_with(*f));
if from_in_rule && to_in_rule {
report.add_violation(Violation::direct(ViolationInfo {
rule_id: rule.id.clone(),
rule_constraint: rule.constraint.clone(),
from_file: edge.from_file.clone(),
from_line: edge.line,
imports_file: edge.to_file.clone(),
from_layer: "CYCLE".to_string(),
to_layer: "CYCLE".to_string(),
severity: rule.severity,
}));
}
}
}
pub fn check_transitive_violations(
rules: &ArchRulesFile,
import_graph: &ImportGraph,
layers: &HashMap<PathBuf, LayerType>,
) -> Vec<Violation> {
let mut violations = Vec::new();
let file_layers = compute_file_layers(layers, rules);
let mut adjacency: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for edge in &import_graph.edges {
adjacency
.entry(edge.from_file.clone())
.or_default()
.push(edge.to_file.clone());
}
for rule in &rules.rules {
if rule.rule_type != ArchRuleType::Layer {
continue;
}
for (dir, layer_name) in &file_layers {
if !rule.from_layers.contains(layer_name) {
continue;
}
for start_file in import_graph.files.iter() {
if !start_file.starts_with(dir) {
continue;
}
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: Vec<(PathBuf, Vec<PathBuf>)> =
vec![(start_file.clone(), vec![start_file.clone()])];
while let Some((current, path)) = queue.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if let Some(current_layer) = get_file_layer(¤t, &file_layers) {
if rule.to_layers.contains(current_layer) && path.len() > 2 {
violations.push(Violation::transitive(
ViolationInfo {
rule_id: rule.id.clone(),
rule_constraint: rule.constraint.clone(),
from_file: start_file.clone(),
from_line: 1,
imports_file: current.clone(),
from_layer: layer_name.clone(),
to_layer: current_layer.clone(),
severity: rule.severity,
},
path.clone(),
));
}
}
if let Some(neighbors) = adjacency.get(¤t) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
let mut new_path = path.clone();
new_path.push(neighbor.clone());
queue.push((neighbor.clone(), new_path));
}
}
}
}
}
}
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CircularDep, RuleSeverity};
#[test]
fn generate_rules_yaml_format() {
let mut inferred_layers = HashMap::new();
inferred_layers.insert(PathBuf::from("api"), LayerType::Entry);
inferred_layers.insert(PathBuf::from("services"), LayerType::Service);
inferred_layers.insert(PathBuf::from("utils"), LayerType::Utility);
let arch_report = ArchitectureReport {
entry_layer: Vec::new(),
middle_layer: Vec::new(),
leaf_layer: Vec::new(),
directories: HashMap::new(),
circular_dependencies: Vec::new(),
inferred_layers,
};
let context = RulesGenerationContext::new(PathBuf::from("."));
let rules = generate_rules(&arch_report, &context);
assert_eq!(rules.version, "1.0");
assert!(rules.generated_at.is_some());
assert!(rules.layers.high.is_some());
assert!(rules.layers.middle.is_some());
assert!(rules.layers.low.is_some());
assert!(!rules.rules.is_empty());
}
#[test]
fn generate_rules_includes_layer_constraints() {
let mut inferred_layers = HashMap::new();
inferred_layers.insert(PathBuf::from("api"), LayerType::Entry);
inferred_layers.insert(PathBuf::from("services"), LayerType::Service);
inferred_layers.insert(PathBuf::from("utils"), LayerType::Utility);
let arch_report = ArchitectureReport {
entry_layer: Vec::new(),
middle_layer: Vec::new(),
leaf_layer: Vec::new(),
directories: HashMap::new(),
circular_dependencies: Vec::new(),
inferred_layers,
};
let context = RulesGenerationContext::new(PathBuf::from("."));
let rules = generate_rules(&arch_report, &context);
let l1 = rules.rules.iter().find(|r| r.id == "L1");
assert!(l1.is_some(), "Expected L1 rule");
let l1 = l1.unwrap();
assert_eq!(l1.from_layers, vec!["LOW"]);
assert_eq!(l1.to_layers, vec!["HIGH"]);
assert_eq!(l1.rule_type, ArchRuleType::Layer);
let l2 = rules.rules.iter().find(|r| r.id == "L2");
assert!(l2.is_some(), "Expected L2 rule");
}
#[test]
fn generate_rules_includes_cycle_breaks() {
let mut inferred_layers = HashMap::new();
inferred_layers.insert(PathBuf::from("api"), LayerType::Entry);
inferred_layers.insert(PathBuf::from("services"), LayerType::Service);
let circular_deps = vec![CircularDep {
a: PathBuf::from("services/auth.py"),
b: PathBuf::from("api/routes.py"),
}];
let arch_report = ArchitectureReport {
entry_layer: Vec::new(),
middle_layer: Vec::new(),
leaf_layer: Vec::new(),
directories: HashMap::new(),
circular_dependencies: circular_deps,
inferred_layers,
};
let context = RulesGenerationContext::new(PathBuf::from("."));
let rules = generate_rules(&arch_report, &context);
let c1 = rules.rules.iter().find(|r| r.id == "C1");
assert!(c1.is_some(), "Expected C1 cycle break rule");
let c1 = c1.unwrap();
assert_eq!(c1.rule_type, ArchRuleType::CycleBreak);
assert_eq!(c1.severity, RuleSeverity::Warn);
assert_eq!(c1.files.len(), 2);
}
#[test]
fn check_rules_detects_layer_violation() {
let rules = ArchRulesFile::new().with_rule(ArchRule::layer(
"L1",
"LOW may not import HIGH",
vec!["LOW".to_string()],
vec!["HIGH".to_string()],
"Test rationale",
));
let mut import_graph = ImportGraph::new();
import_graph.add_edge(ImportEdge {
from_file: PathBuf::from("utils/helpers.py"),
to_file: PathBuf::from("api/routes.py"),
module: "api.routes".to_string(),
line: 5,
});
let mut layers = HashMap::new();
layers.insert(PathBuf::from("api"), LayerType::Entry);
layers.insert(PathBuf::from("utils"), LayerType::Utility);
let report = check_rules(&rules, &import_graph, &layers);
assert!(!report.pass, "Expected violations");
assert!(!report.violations.is_empty());
assert_eq!(report.violations[0].rule_id, "L1");
}
#[test]
fn check_rules_detects_cycle_violation() {
let rules = ArchRulesFile::new().with_rule(ArchRule::cycle_break(
"C1",
"Break cycle",
vec!["services/auth.py".to_string(), "api/routes.py".to_string()],
"Test rationale",
));
let mut import_graph = ImportGraph::new();
import_graph.add_edge(ImportEdge {
from_file: PathBuf::from("services/auth.py"),
to_file: PathBuf::from("api/routes.py"),
module: "api.routes".to_string(),
line: 1,
});
import_graph.add_edge(ImportEdge {
from_file: PathBuf::from("api/routes.py"),
to_file: PathBuf::from("services/auth.py"),
module: "services.auth".to_string(),
line: 1,
});
let layers = HashMap::new();
let report = check_rules(&rules, &import_graph, &layers);
assert!(report.has_violations());
let cycle_violations: Vec<_> = report
.violations
.iter()
.filter(|v| v.rule_id == "C1")
.collect();
assert!(!cycle_violations.is_empty());
}
#[test]
fn check_rules_allows_valid_dependencies() {
let rules = ArchRulesFile::new().with_rule(ArchRule::layer(
"L1",
"LOW may not import HIGH",
vec!["LOW".to_string()],
vec!["HIGH".to_string()],
"Test",
));
let mut import_graph = ImportGraph::new();
import_graph.add_edge(ImportEdge {
from_file: PathBuf::from("api/routes.py"),
to_file: PathBuf::from("services/user.py"),
module: "services.user".to_string(),
line: 1,
});
import_graph.add_edge(ImportEdge {
from_file: PathBuf::from("services/user.py"),
to_file: PathBuf::from("utils/db.py"),
module: "utils.db".to_string(),
line: 1,
});
let mut layers = HashMap::new();
layers.insert(PathBuf::from("api"), LayerType::Entry);
layers.insert(PathBuf::from("services"), LayerType::Service);
layers.insert(PathBuf::from("utils"), LayerType::Utility);
let report = check_rules(&rules, &import_graph, &layers);
assert!(report.pass, "Valid dependencies should pass");
assert!(report.violations.is_empty());
}
#[test]
fn import_graph_add_edge() {
let mut graph = ImportGraph::new();
graph.add_edge(ImportEdge {
from_file: PathBuf::from("a.py"),
to_file: PathBuf::from("b.py"),
module: "b".to_string(),
line: 1,
});
assert_eq!(graph.edges.len(), 1);
assert!(graph.files.contains(&PathBuf::from("a.py")));
assert!(graph.files.contains(&PathBuf::from("b.py")));
}
#[test]
fn resolve_python_import_absolute() {
let all_files: HashSet<PathBuf> = [
PathBuf::from("/project/services/user.py"),
PathBuf::from("/project/utils/db.py"),
]
.into_iter()
.collect();
let import = ImportInfo {
module: "services.user".to_string(),
names: Vec::new(),
is_from: false,
alias: None,
};
let resolved = resolve_python_import(
&import,
&PathBuf::from("/project/api/routes.py"),
&PathBuf::from("/project"),
&all_files,
);
assert_eq!(resolved, Some(PathBuf::from("/project/services/user.py")));
}
#[test]
fn resolve_python_import_relative() {
let all_files: HashSet<PathBuf> = [
PathBuf::from("/project/api/routes.py"),
PathBuf::from("/project/api/utils.py"),
]
.into_iter()
.collect();
let import = ImportInfo {
module: ".utils".to_string(),
names: Vec::new(),
is_from: true,
alias: None,
};
let resolved = resolve_python_import(
&import,
&PathBuf::from("/project/api/routes.py"),
&PathBuf::from("/project"),
&all_files,
);
assert_eq!(resolved, Some(PathBuf::from("/project/api/utils.py")));
}
}