1use std::path::Path;
2
3use anyhow::Result;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use serde::{Deserialize, Serialize};
6use tracing::{debug, warn};
7
8use reposcry_graph::edge::EdgeKind;
9use reposcry_graph::graph::CodeGraph;
10use reposcry_indexer::scanner::ScannedFile;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Rule {
14 pub name: String,
15 pub description: Option<String>,
16 pub from: Option<String>,
17 pub to: Option<String>,
18 pub path: Option<String>,
19 pub max_lines: Option<u32>,
20 pub severity: Severity,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub enum Severity {
25 Error,
26 Warning,
27 Info,
28}
29
30impl Severity {
31 pub fn as_str(&self) -> &'static str {
32 match self {
33 Severity::Error => "error",
34 Severity::Warning => "warning",
35 Severity::Info => "info",
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RuleViolation {
42 pub rule: String,
43 pub severity: Severity,
44 pub message: String,
45 pub source_path: Option<String>,
46 pub target_path: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RulesConfig {
51 pub rules: Vec<Rule>,
52}
53
54impl RulesConfig {
55 pub fn default_rules() -> Self {
56 Self {
57 rules: vec![
58 Rule {
59 name: "no-ui-to-db".into(),
60 description: Some("UI must not import database code directly.".into()),
61 from: Some("src/components/**".into()),
62 to: Some("src/server/db/**".into()),
63 path: None,
64 max_lines: None,
65 severity: Severity::Error,
66 },
67 Rule {
68 name: "no-api-to-ui".into(),
69 description: Some("API routes must not import UI components.".into()),
70 from: Some("src/app/api/**".into()),
71 to: Some("src/components/**".into()),
72 path: None,
73 max_lines: None,
74 severity: Severity::Error,
75 },
76 Rule {
77 name: "no-large-files".into(),
78 description: Some("Files should be under 800 lines.".into()),
79 from: None,
80 to: None,
81 path: None,
82 max_lines: Some(800),
83 severity: Severity::Warning,
84 },
85 Rule {
86 name: "no-cycles".into(),
87 description: Some("No dependency cycles allowed.".into()),
88 from: None,
89 to: None,
90 path: None,
91 max_lines: None,
92 severity: Severity::Error,
93 },
94 Rule {
95 name: "no-build-artifacts".into(),
96 description: Some("Build artifacts should not be committed.".into()),
97 from: None,
98 to: None,
99 path: Some("target/**".into()),
100 max_lines: None,
101 severity: Severity::Error,
102 },
103 ],
104 }
105 }
106
107 pub fn from_yaml(path: &Path) -> Result<Self> {
108 let content = std::fs::read_to_string(path)?;
109 Ok(serde_yaml::from_str(&content)?)
110 }
111}
112
113pub struct RulesEngine {
114 config: RulesConfig,
115}
116
117impl RulesEngine {
118 pub fn new(config: RulesConfig) -> Self {
119 Self { config }
120 }
121
122 pub fn check_graph(&self, graph: &CodeGraph) -> Vec<RuleViolation> {
123 let mut violations = Vec::new();
124 for rule in &self.config.rules {
125 debug!("Checking rule: {}", rule.name);
126 match rule.name.as_str() {
127 "no-cycles" => {
128 let cycles = graph.detect_cycles();
129 if !cycles.is_empty() {
130 for cycle in &cycles {
131 let node_names: Vec<String> = cycle
132 .iter()
133 .filter_map(|id| graph.get_node(*id))
134 .map(|n| n.name.clone())
135 .collect();
136 violations.push(RuleViolation {
137 rule: rule.name.clone(),
138 severity: rule.severity.clone(),
139 message: format!(
140 "Dependency cycle detected: {}",
141 node_names.join(" → ")
142 ),
143 source_path: None,
144 target_path: None,
145 });
146 }
147 }
148 }
149 _ => {
150 if let (Some(from_pattern), Some(to_pattern)) = (&rule.from, &rule.to) {
152 let from_set = build_globset(from_pattern);
153 let to_set = build_globset(to_pattern);
154 if let (Some(from_set), Some(to_set)) = (from_set, to_set) {
155 for edge in &graph.edges {
156 if edge.kind != EdgeKind::Imports {
157 continue;
158 }
159 let source = graph.get_node(edge.source_id);
160 let target = graph.get_node(edge.target_id);
161 if let (Some(src), Some(tgt)) = (source, target) {
162 let src_path = src.file_path.as_deref().unwrap_or("");
163 let tgt_path = tgt.file_path.as_deref().unwrap_or("");
164 if from_set.is_match(src_path) && to_set.is_match(tgt_path) {
165 violations.push(RuleViolation {
166 rule: rule.name.clone(),
167 severity: rule.severity.clone(),
168 message: format!(
169 "{} imports {} (violates {} rule)",
170 src_path, tgt_path, rule.name
171 ),
172 source_path: Some(src_path.to_string()),
173 target_path: Some(tgt_path.to_string()),
174 });
175 }
176 }
177 }
178 }
179 }
180 }
181 }
182 }
183 violations
184 }
185
186 pub fn check_files(&self, files: &[ScannedFile]) -> Vec<RuleViolation> {
187 let mut violations = Vec::new();
188 for rule in &self.config.rules {
189 if let Some(max_lines) = rule.max_lines {
190 for file in files {
191 if file.size_bytes > 0 {
192 let estimated_lines = file.size_bytes / 40; if estimated_lines > max_lines as u64 {
194 violations.push(RuleViolation {
195 rule: rule.name.clone(),
196 severity: rule.severity.clone(),
197 message: format!(
198 "{} has ~{} lines (max: {})",
199 file.relative_path, estimated_lines, max_lines
200 ),
201 source_path: Some(file.relative_path.clone()),
202 target_path: None,
203 });
204 }
205 }
206 }
207 }
208 if let Some(path_pattern) = &rule.path {
209 let set = build_globset(path_pattern);
210 if let Some(set) = set {
211 for file in files {
212 if set.is_match(&file.relative_path) {
213 violations.push(RuleViolation {
214 rule: rule.name.clone(),
215 severity: rule.severity.clone(),
216 message: format!(
217 "{} matches excluded path pattern: {}",
218 file.relative_path, path_pattern
219 ),
220 source_path: Some(file.relative_path.clone()),
221 target_path: None,
222 });
223 }
224 }
225 }
226 }
227 }
228 violations
229 }
230}
231
232fn build_globset(pattern: &str) -> Option<GlobSet> {
233 let mut builder = GlobSetBuilder::new();
234 match Glob::new(pattern) {
235 Ok(glob) => {
236 builder.add(glob);
237 match builder.build() {
238 Ok(set) => Some(set),
239 Err(e) => {
240 warn!("Failed to build glob set for '{}': {}", pattern, e);
241 None
242 }
243 }
244 }
245 Err(e) => {
246 warn!("Invalid glob pattern '{}': {}", pattern, e);
247 None
248 }
249 }
250}