Skip to main content

nodex_core/rules/
naming.rs

1use globset::Glob;
2use regex::Regex;
3use std::collections::BTreeMap;
4
5use crate::config::Config;
6use crate::model::Graph;
7
8use super::{Rule, Severity, Violation};
9
10/// Check that filenames match the configured pattern for their directory.
11pub struct FilenamePatternRule;
12
13impl Rule for FilenamePatternRule {
14    fn id(&self) -> &str {
15        "filename_pattern"
16    }
17
18    fn severity(&self) -> Severity {
19        Severity::Error
20    }
21
22    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
23        let mut violations = Vec::new();
24
25        for rule in &config.rules.naming {
26            let Ok(glob) = Glob::new(&rule.glob) else {
27                continue;
28            };
29            let matcher = glob.compile_matcher();
30            let Ok(re) = Regex::new(&rule.pattern) else {
31                continue;
32            };
33
34            for node in graph.nodes().values() {
35                let path_str = node.path.to_string_lossy().replace('\\', "/");
36                if !matcher.is_match(&path_str) {
37                    continue;
38                }
39
40                let filename = node.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
41
42                if !re.is_match(filename) {
43                    violations.push(Violation {
44                        rule_id: self.id().to_string(),
45                        severity: self.severity(),
46                        node_id: Some(node.id.clone()),
47                        path: Some(path_str),
48                        message: format!(
49                            "filename {filename:?} does not match pattern {:?}",
50                            rule.pattern
51                        ),
52                    });
53                }
54            }
55        }
56
57        violations
58    }
59}
60
61/// Check that numbered files in a directory are sequential (no gaps).
62pub struct SequentialNumberingRule;
63
64impl Rule for SequentialNumberingRule {
65    fn id(&self) -> &str {
66        "sequential_numbering"
67    }
68
69    fn severity(&self) -> Severity {
70        Severity::Warning
71    }
72
73    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
74        let mut violations = Vec::new();
75        let number_re = Regex::new(r"^(\d+)").expect("hardcoded regex is valid");
76
77        for rule in &config.rules.naming {
78            if !rule.sequential {
79                continue;
80            }
81
82            let Ok(glob) = Glob::new(&rule.glob) else {
83                continue;
84            };
85            let matcher = glob.compile_matcher();
86
87            let mut numbers: Vec<(u32, String)> = Vec::new();
88
89            for node in graph.nodes().values() {
90                let path_str = node.path.to_string_lossy().replace('\\', "/");
91                if !matcher.is_match(&path_str) {
92                    continue;
93                }
94                let filename = node.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
95                if let Some(caps) = number_re.captures(filename)
96                    && let Ok(n) = caps[1].parse::<u32>()
97                {
98                    numbers.push((n, path_str));
99                }
100            }
101
102            numbers.sort_by_key(|(n, _)| *n);
103
104            for window in numbers.windows(2) {
105                let (prev, _) = &window[0];
106                let (curr, path) = &window[1];
107                if *curr != prev + 1 {
108                    violations.push(Violation {
109                        rule_id: self.id().to_string(),
110                        severity: self.severity(),
111                        node_id: None,
112                        path: Some(path.clone()),
113                        message: format!("gap in numbering: {prev} → {curr}"),
114                    });
115                }
116            }
117        }
118
119        violations
120    }
121}
122
123/// Check that numbered files have unique numbers.
124pub struct UniqueNumberingRule;
125
126impl Rule for UniqueNumberingRule {
127    fn id(&self) -> &str {
128        "unique_numbering"
129    }
130
131    fn severity(&self) -> Severity {
132        Severity::Error
133    }
134
135    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
136        let mut violations = Vec::new();
137        let number_re = Regex::new(r"^(\d+)").expect("hardcoded regex is valid");
138
139        for rule in &config.rules.naming {
140            if !rule.unique {
141                continue;
142            }
143
144            let Ok(glob) = Glob::new(&rule.glob) else {
145                continue;
146            };
147            let matcher = glob.compile_matcher();
148
149            let mut seen: BTreeMap<u32, Vec<String>> = BTreeMap::new();
150
151            for node in graph.nodes().values() {
152                let path_str = node.path.to_string_lossy().replace('\\', "/");
153                if !matcher.is_match(&path_str) {
154                    continue;
155                }
156                let filename = node.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
157                if let Some(caps) = number_re.captures(filename)
158                    && let Ok(n) = caps[1].parse::<u32>()
159                {
160                    seen.entry(n).or_default().push(path_str);
161                }
162            }
163
164            for (num, paths) in &seen {
165                if paths.len() > 1 {
166                    violations.push(Violation {
167                        rule_id: self.id().to_string(),
168                        severity: self.severity(),
169                        node_id: None,
170                        path: Some(paths[0].clone()),
171                        message: format!("duplicate number {num} in files: {}", paths.join(", ")),
172                    });
173                }
174            }
175        }
176
177        violations
178    }
179}