1use std::path::{Path, PathBuf};
21
22use alint_core::template::{PathTokens, render_message, render_path};
23use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
24use serde::Deserialize;
25
26#[derive(Debug, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct Options {
29 primary: String,
30 partner: String,
31}
32
33#[derive(Debug)]
34pub struct PairRule {
35 id: String,
36 level: Level,
37 policy_url: Option<String>,
38 message: Option<String>,
39 primary_scope: Scope,
40 partner_template: String,
41}
42
43impl Rule for PairRule {
44 fn id(&self) -> &str {
45 &self.id
46 }
47 fn level(&self) -> Level {
48 self.level
49 }
50 fn policy_url(&self) -> Option<&str> {
51 self.policy_url.as_deref()
52 }
53
54 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
55 let mut violations = Vec::new();
56 for entry in ctx.index.files() {
57 if !self.primary_scope.matches(&entry.path) {
58 continue;
59 }
60 let tokens = PathTokens::from_path(&entry.path);
61 let partner_rel = render_path(&self.partner_template, &tokens);
62 if partner_rel.is_empty() {
63 violations.push(
64 Violation::new(format!(
65 "partner template {:?} resolved to an empty path for {}",
66 self.partner_template,
67 entry.path.display(),
68 ))
69 .with_path(&entry.path),
70 );
71 continue;
72 }
73 let partner_path = PathBuf::from(&partner_rel);
74 if resolves_to_self(&partner_path, &entry.path) {
75 violations.push(
76 Violation::new(format!(
77 "partner template {:?} resolves to the primary file itself ({}) — \
78 check that the template differs from the primary",
79 self.partner_template,
80 entry.path.display(),
81 ))
82 .with_path(&entry.path),
83 );
84 continue;
85 }
86 if ctx.index.find_file(&partner_path).is_some() {
87 continue;
88 }
89 let message = self.format_message(&entry.path, &partner_path);
90 violations.push(Violation::new(message).with_path(&entry.path));
91 }
92 Ok(violations)
93 }
94}
95
96fn resolves_to_self(partner: &Path, primary: &Path) -> bool {
97 partner == primary
98}
99
100impl PairRule {
101 fn format_message(&self, primary: &Path, partner: &Path) -> String {
102 let primary_str = primary.display().to_string();
103 let partner_str = partner.display().to_string();
104 if let Some(user_msg) = self.message.as_deref() {
105 return render_message(user_msg, |ns, key| match (ns, key) {
106 ("ctx", "primary") => Some(primary_str.clone()),
107 ("ctx", "partner") => Some(partner_str.clone()),
108 _ => None,
109 });
110 }
111 format!(
112 "{} has no matching partner at {} (template: {})",
113 primary_str, partner_str, self.partner_template,
114 )
115 }
116}
117
118pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
119 let opts: Options = spec
120 .deserialize_options()
121 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
122 if opts.partner.trim().is_empty() {
123 return Err(Error::rule_config(
124 &spec.id,
125 "pair `partner` template must not be empty",
126 ));
127 }
128 let primary_patterns = vec![opts.primary.clone()];
129 let primary_scope = Scope::from_patterns(&primary_patterns)?;
130 Ok(Box::new(PairRule {
131 id: spec.id.clone(),
132 level: spec.level,
133 policy_url: spec.policy_url.clone(),
134 message: spec.message.clone(),
135 primary_scope,
136 partner_template: opts.partner,
137 }))
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use alint_core::{FileEntry, FileIndex};
144 use std::path::{Path, PathBuf};
145
146 fn idx(paths: &[&str]) -> FileIndex {
147 FileIndex {
148 entries: paths
149 .iter()
150 .map(|p| FileEntry {
151 path: PathBuf::from(p),
152 is_dir: false,
153 size: 1,
154 })
155 .collect(),
156 }
157 }
158
159 fn rule(primary: &str, partner: &str, message: Option<&str>) -> PairRule {
160 PairRule {
161 id: "t".into(),
162 level: Level::Error,
163 policy_url: None,
164 message: message.map(ToString::to_string),
165 primary_scope: Scope::from_patterns(&[primary.to_string()]).unwrap(),
166 partner_template: partner.into(),
167 }
168 }
169
170 fn eval(rule: &PairRule, files: &[&str]) -> Vec<Violation> {
171 let index = idx(files);
172 let ctx = Context {
173 root: Path::new("/"),
174 index: &index,
175 registry: None,
176 facts: None,
177 vars: None,
178 git_tracked: None,
179 };
180 rule.evaluate(&ctx).unwrap()
181 }
182
183 #[test]
184 fn passes_when_partner_exists() {
185 let r = rule("**/*.c", "{dir}/{stem}.h", None);
186 let v = eval(&r, &["src/mod/foo.c", "src/mod/foo.h"]);
187 assert!(v.is_empty(), "unexpected: {v:?}");
188 }
189
190 #[test]
191 fn violates_when_partner_missing() {
192 let r = rule("**/*.c", "{dir}/{stem}.h", None);
193 let v = eval(&r, &["src/mod/foo.c"]);
194 assert_eq!(v.len(), 1);
195 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/mod/foo.c")));
196 assert!(v[0].message.contains("src/mod/foo.h"));
197 }
198
199 #[test]
200 fn violates_per_missing_primary() {
201 let r = rule("**/*.c", "{dir}/{stem}.h", None);
202 let v = eval(
203 &r,
204 &[
205 "src/mod/foo.c",
206 "src/mod/foo.h", "src/mod/bar.c", "src/mod/baz.c", ],
210 );
211 assert_eq!(v.len(), 2);
212 }
213
214 #[test]
215 fn no_primary_matches_means_no_violation() {
216 let r = rule("**/*.c", "{dir}/{stem}.h", None);
217 let v = eval(&r, &["README.md", "src/mod/other.rs"]);
218 assert!(v.is_empty());
219 }
220
221 #[test]
222 fn user_message_with_ctx_substitution() {
223 let r = rule(
224 "**/*.c",
225 "{dir}/{stem}.h",
226 Some("missing header {{ctx.partner}} for {{ctx.primary}}"),
227 );
228 let v = eval(&r, &["src/foo.c"]);
229 assert_eq!(v.len(), 1);
230 assert_eq!(v[0].message, "missing header src/foo.h for src/foo.c");
231 }
232
233 #[test]
234 fn rejects_partner_resolving_to_self() {
235 let r = rule("**/*.c", "{path}", None);
238 let v = eval(&r, &["src/foo.c"]);
239 assert_eq!(v.len(), 1);
240 assert!(v[0].message.contains("primary file itself"));
241 }
242
243 #[test]
244 fn empty_partner_after_substitution_is_a_violation() {
245 let r = rule("**/*.c", "", None);
248 let v = eval(&r, &["src/foo.c"]);
251 assert_eq!(v.len(), 1);
252 assert!(v[0].message.contains("empty path"));
253 }
254}