Skip to main content

reposcry_rules/
lib.rs

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                    // Pattern-based rules checked during import resolution
151                    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; // rough estimate
193                        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}