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 };
179 rule.evaluate(&ctx).unwrap()
180 }
181
182 #[test]
183 fn passes_when_partner_exists() {
184 let r = rule("**/*.c", "{dir}/{stem}.h", None);
185 let v = eval(&r, &["src/mod/foo.c", "src/mod/foo.h"]);
186 assert!(v.is_empty(), "unexpected: {v:?}");
187 }
188
189 #[test]
190 fn violates_when_partner_missing() {
191 let r = rule("**/*.c", "{dir}/{stem}.h", None);
192 let v = eval(&r, &["src/mod/foo.c"]);
193 assert_eq!(v.len(), 1);
194 assert_eq!(v[0].path.as_deref(), Some(Path::new("src/mod/foo.c")));
195 assert!(v[0].message.contains("src/mod/foo.h"));
196 }
197
198 #[test]
199 fn violates_per_missing_primary() {
200 let r = rule("**/*.c", "{dir}/{stem}.h", None);
201 let v = eval(
202 &r,
203 &[
204 "src/mod/foo.c",
205 "src/mod/foo.h", "src/mod/bar.c", "src/mod/baz.c", ],
209 );
210 assert_eq!(v.len(), 2);
211 }
212
213 #[test]
214 fn no_primary_matches_means_no_violation() {
215 let r = rule("**/*.c", "{dir}/{stem}.h", None);
216 let v = eval(&r, &["README.md", "src/mod/other.rs"]);
217 assert!(v.is_empty());
218 }
219
220 #[test]
221 fn user_message_with_ctx_substitution() {
222 let r = rule(
223 "**/*.c",
224 "{dir}/{stem}.h",
225 Some("missing header {{ctx.partner}} for {{ctx.primary}}"),
226 );
227 let v = eval(&r, &["src/foo.c"]);
228 assert_eq!(v.len(), 1);
229 assert_eq!(v[0].message, "missing header src/foo.h for src/foo.c");
230 }
231
232 #[test]
233 fn rejects_partner_resolving_to_self() {
234 let r = rule("**/*.c", "{path}", None);
237 let v = eval(&r, &["src/foo.c"]);
238 assert_eq!(v.len(), 1);
239 assert!(v[0].message.contains("primary file itself"));
240 }
241
242 #[test]
243 fn empty_partner_after_substitution_is_a_violation() {
244 let r = rule("**/*.c", "", None);
247 let v = eval(&r, &["src/foo.c"]);
250 assert_eq!(v.len(), 1);
251 assert!(v[0].message.contains("empty path"));
252 }
253}