1use globset::{GlobBuilder, GlobSetBuilder};
13use uuid::Uuid;
14
15use crate::store::record::{
16 Category, ConfidenceScore, GotchaRecord, Priority, QualityScore, QualityTier, Record,
17 RecordSource,
18};
19
20const MARKERS: &[&str] = &[
23 "DO NOT REMOVE",
24 "DO NOT EDIT",
25 "DO NOT MODIFY",
26 "DO NOT DELETE",
27 "SECURITY:",
28 "SECURITY-CRITICAL",
29];
30
31const MAX_LINE_LEN: usize = 400;
33
34pub const MAX_MARKER_CANDIDATES: usize = 200;
36
37pub const MAX_CODEOWNERS_AFFECTED_FILES: usize = 50;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct OwnerRule {
45 pub pattern: String,
46 pub owners: Vec<String>,
47}
48
49pub fn parse_codeowners(content: &str) -> Vec<OwnerRule> {
52 let mut rules = Vec::new();
53 for raw in content.lines() {
54 let line = raw.split('#').next().unwrap_or("").trim();
55 if line.is_empty() {
56 continue;
57 }
58 let mut parts = line.split_whitespace();
59 let Some(pattern) = parts.next() else {
60 continue;
61 };
62 let owners: Vec<String> = parts.map(str::to_string).collect();
63 if owners.is_empty() {
64 continue;
65 }
66 rules.push(OwnerRule {
67 pattern: pattern.to_string(),
68 owners,
69 });
70 }
71 rules
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MarkerHit {
79 pub path: String,
80 pub line: usize,
81 pub marker: String,
82 pub text: String,
83}
84
85pub fn scan_markers(path: &str, content: &str) -> Vec<MarkerHit> {
87 let mut hits = Vec::new();
88 for (i, raw) in content.lines().enumerate() {
89 if raw.len() > MAX_LINE_LEN {
90 continue;
91 }
92 let upper = raw.to_uppercase();
93 if let Some(marker) = MARKERS.iter().find(|m| upper.contains(**m)) {
94 hits.push(MarkerHit {
95 path: path.to_string(),
96 line: i + 1,
97 marker: (*marker).to_string(),
98 text: raw.trim().to_string(),
99 });
100 }
101 }
102 hits
103}
104
105#[allow(clippy::too_many_arguments)]
110fn candidate_record(
111 key: String,
112 rule: String,
113 reason: String,
114 severity: Priority,
115 affected_files: Vec<String>,
116 tags: Vec<String>,
117 device_id: Uuid,
118 logical_clock: u64,
119 now: u64,
120) -> Record {
121 let gotcha = GotchaRecord {
122 rule: rule.clone(),
123 reason,
124 severity: severity.clone(),
125 affected_files,
126 ref_url: None,
127 discovered_session: now,
128 confirmed: false,
129 confirmed_content: Default::default(),
130 };
131 let mut rec = Record::layer0_file_stub(&key, device_id, logical_clock, now);
132 rec.category = Category::Gotcha;
133 rec.source = RecordSource::Import;
134 rec.priority = severity;
135 rec.value = rule;
136 rec.quality = QualityScore {
137 value: 0.50,
138 tier: QualityTier::Acceptable,
139 signals: vec![],
140 computed_at: now,
141 };
142 rec.confidence = ConfidenceScore::for_new_record(&RecordSource::Import);
145 rec.tags = tags;
146 rec.payload = serde_json::to_value(&gotcha).ok();
147 rec
148}
149
150fn codeowners_glob_pattern(pattern: &str) -> String {
154 let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
155 if pattern == "*" {
156 return "**".to_string();
157 }
158 if pattern.ends_with('/') {
159 return format!("{pattern}**");
160 }
161 if pattern.contains('/') {
162 pattern.to_string()
163 } else {
164 format!("**/{pattern}")
165 }
166}
167
168pub(crate) fn expand_codeowners_pattern(
172 pattern: &str,
173 repo_files: &[String],
174) -> Option<Vec<String>> {
175 let base = codeowners_glob_pattern(pattern);
178 let subtree = format!("{base}/**");
179 let mut builder = GlobSetBuilder::new();
180 for expr in [base.as_str(), subtree.as_str()] {
181 builder.add(
182 GlobBuilder::new(expr)
183 .literal_separator(false)
184 .build()
185 .ok()?,
186 );
187 }
188 let globset = builder.build().ok()?;
189
190 let mut matches: Vec<String> = repo_files
191 .iter()
192 .filter(|path| globset.is_match(path))
193 .cloned()
194 .collect();
195 matches.sort();
196 matches.dedup();
197 if matches.is_empty() || matches.len() > MAX_CODEOWNERS_AFFECTED_FILES {
198 None
199 } else {
200 Some(matches)
201 }
202}
203
204pub fn codeowners_candidates(
208 rules: &[OwnerRule],
209 repo_files: &[String],
210 device_id: Uuid,
211 clock_start: u64,
212 now: u64,
213) -> Vec<Record> {
214 rules
215 .iter()
216 .enumerate()
217 .filter_map(|(i, r)| {
218 let affected_files = expand_codeowners_pattern(&r.pattern, repo_files)?;
219 let owners = r.owners.join(", ");
220 let rule = format!(
221 "`{}` is owned by {} (CODEOWNERS) — coordinate changes with them.",
222 r.pattern, owners
223 );
224 let reason = format!("Listed in CODEOWNERS: {} → {}.", r.pattern, owners);
225 let key = format!("gotcha:codeowners:{}", r.pattern);
226 Some(candidate_record(
227 key,
228 rule,
229 reason,
230 Priority::Normal,
231 affected_files,
232 vec!["codeowners".into(), "auto-generated".into()],
233 device_id,
234 clock_start + i as u64,
235 now,
236 ))
237 })
238 .collect()
239}
240
241pub fn marker_candidates(
243 hits: &[MarkerHit],
244 device_id: Uuid,
245 clock_start: u64,
246 now: u64,
247) -> Vec<Record> {
248 hits.iter()
249 .take(MAX_MARKER_CANDIDATES)
250 .enumerate()
251 .map(|(i, h)| {
252 let rule = format!(
253 "`{}` carries a `{}` marker at line {} — preserve it through edits.",
254 h.path, h.marker, h.line
255 );
256 let reason = format!("Developer marker in source: {}", h.text);
257 let key = format!("gotcha:marker:{}:{}", h.path, h.line);
258 candidate_record(
260 key,
261 rule,
262 reason,
263 Priority::High,
264 vec![h.path.clone()],
265 vec!["code-marker".into(), "auto-generated".into()],
266 device_id,
267 clock_start + i as u64,
268 now,
269 )
270 })
271 .collect()
272}
273
274pub fn build_candidates(
278 codeowners: Option<&str>,
279 files: &[(String, String)],
280 device_id: Uuid,
281 clock_start: u64,
282 now: u64,
283) -> Vec<Record> {
284 let mut out = Vec::new();
285 let mut clock = clock_start;
286
287 if let Some(content) = codeowners {
288 let rules = parse_codeowners(content);
289 let repo_files: Vec<String> = files.iter().map(|(path, _)| path.clone()).collect();
290 let recs = codeowners_candidates(&rules, &repo_files, device_id, clock, now);
291 clock += rules.len() as u64;
294 out.extend(recs);
295 }
296
297 let mut hits = Vec::new();
298 for (path, content) in files {
299 hits.extend(scan_markers(path, content));
300 }
301 out.extend(marker_candidates(&hits, device_id, clock, now));
302
303 out
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn dev() -> Uuid {
311 Uuid::nil()
312 }
313
314 fn is_unconfirmed_gotcha(rec: &Record) -> bool {
315 rec.category == Category::Gotcha
316 && rec.source == RecordSource::Import
317 && rec
318 .payload
319 .as_ref()
320 .and_then(|p| serde_json::from_value::<GotchaRecord>(p.clone()).ok())
321 .is_some_and(|g| !g.confirmed)
322 }
323
324 #[test]
325 fn parse_codeowners_ignores_comments_and_blank_and_ownerless() {
326 let content = "\
327# comment\n\
328\n\
329src/payments/** @pay-team @alice\n\
330docs/ # trailing comment\n\
331*.rs @rustfolk\n";
332 let rules = parse_codeowners(content);
333 assert_eq!(rules.len(), 2, "ownerless `docs/` line is skipped");
334 assert_eq!(rules[0].pattern, "src/payments/**");
335 assert_eq!(rules[0].owners, vec!["@pay-team", "@alice"]);
336 assert_eq!(rules[1].pattern, "*.rs");
337 }
338
339 #[test]
340 fn scan_markers_is_case_insensitive_and_skips_long_lines() {
341 let content = "\
342let x = 1;\n\
343// do not remove: load-bearing init order\n\
344// SECURITY: validate before deref\n\
345let normal = 2;\n";
346 let hits = scan_markers("src/lib.rs", content);
347 assert_eq!(hits.len(), 2);
348 assert_eq!(hits[0].marker, "DO NOT REMOVE");
349 assert_eq!(hits[0].line, 2);
350 assert_eq!(hits[1].marker, "SECURITY:");
351
352 let long = format!("// DO NOT REMOVE {}", "x".repeat(MAX_LINE_LEN));
354 assert!(scan_markers("min.js", &long).is_empty());
355 }
356
357 #[test]
358 fn codeowners_candidates_are_unconfirmed_gotchas_keyed_by_pattern() {
359 let rules = parse_codeowners("src/payments/** @pay-team\n");
360 let repo_files = vec![
361 "src/payments/card.rs".to_string(),
362 "src/payments/wallet.rs".to_string(),
363 ];
364 let recs = codeowners_candidates(&rules, &repo_files, dev(), 0, 100);
365 assert_eq!(recs.len(), 1);
366 assert!(is_unconfirmed_gotcha(&recs[0]));
367 assert_eq!(recs[0].key, "gotcha:codeowners:src/payments/**");
368 let g: GotchaRecord = serde_json::from_value(recs[0].payload.clone().unwrap()).unwrap();
369 assert_eq!(
370 g.affected_files,
371 vec![
372 "src/payments/card.rs".to_string(),
373 "src/payments/wallet.rs".to_string()
374 ]
375 );
376 assert!(!g.confirmed);
377 }
378
379 #[test]
380 fn codeowners_patterns_translate_to_globs() {
381 let cases = [
382 ("*", "**"),
383 ("docs/", "docs/**"),
384 ("/build/logs/", "build/logs/**"),
385 ("src/payments/**", "src/payments/**"),
386 ("*.js", "**/*.js"),
387 ("docs/API.md", "docs/API.md"),
388 ];
389 for (pattern, expected) in cases {
390 assert_eq!(codeowners_glob_pattern(pattern), expected);
391 }
392 }
393
394 #[test]
395 fn codeowners_expansion_matches_scoped_paths_and_skips_empty() {
396 let files = vec![
397 "docs/API.md".to_string(),
398 "docs/guide.md".to_string(),
399 "src/docs/guide.md".to_string(),
400 "src/main.rs".to_string(),
401 ];
402 assert_eq!(
403 expand_codeowners_pattern("docs/", &files),
404 Some(vec!["docs/API.md".to_string(), "docs/guide.md".to_string()])
405 );
406 assert_eq!(expand_codeowners_pattern("missing/*.rs", &files), None);
407 assert_eq!(
410 expand_codeowners_pattern("docs", &files),
411 Some(vec![
412 "docs/API.md".to_string(),
413 "docs/guide.md".to_string(),
414 "src/docs/guide.md".to_string()
415 ])
416 );
417 let rules = parse_codeowners("missing/*.rs @team\n");
418 assert!(codeowners_candidates(&rules, &files, dev(), 0, 100).is_empty());
419 }
420
421 #[test]
422 fn codeowners_expansion_skips_over_cap_and_repo_wide_star() {
423 let files: Vec<String> = (0..=MAX_CODEOWNERS_AFFECTED_FILES)
424 .map(|i| format!("src/f{i}.rs"))
425 .collect();
426 assert_eq!(expand_codeowners_pattern("src/**", &files), None);
427 assert_eq!(expand_codeowners_pattern("*", &files), None);
428 let over_cap = parse_codeowners("src/** @team\n");
429 let repo_wide = parse_codeowners("* @team\n");
430 assert!(codeowners_candidates(&over_cap, &files, dev(), 0, 100).is_empty());
431 assert!(codeowners_candidates(&repo_wide, &files, dev(), 0, 100).is_empty());
432 }
433
434 #[test]
435 fn marker_candidates_cap_and_key_format() {
436 let hits: Vec<MarkerHit> = (0..MAX_MARKER_CANDIDATES + 50)
438 .map(|i| MarkerHit {
439 path: format!("src/f{i}.rs"),
440 line: i + 1,
441 marker: "DO NOT REMOVE".into(),
442 text: "// DO NOT REMOVE".into(),
443 })
444 .collect();
445 let recs = marker_candidates(&hits, dev(), 0, 100);
446 assert_eq!(recs.len(), MAX_MARKER_CANDIDATES, "capped");
447 assert_eq!(recs[0].key, "gotcha:marker:src/f0.rs:1");
448 assert_eq!(recs[0].priority, Priority::High);
449 assert!(is_unconfirmed_gotcha(&recs[0]));
450 }
451
452 #[test]
453 fn build_candidates_combines_both_sources() {
454 let files = vec![(
455 "src/auth.rs".to_string(),
456 "// SECURITY: constant-time compare\n".to_string(),
457 )];
458 let recs = build_candidates(Some("src/** @team\n"), &files, dev(), 0, 100);
459 assert_eq!(recs.len(), 2);
460 assert!(recs.iter().all(is_unconfirmed_gotcha));
461 assert!(recs.iter().any(|r| r.key.starts_with("gotcha:codeowners:")));
462 assert!(recs.iter().any(|r| r.key.starts_with("gotcha:marker:")));
463 let clocks: std::collections::HashSet<u64> =
465 recs.iter().map(|r| r.version.logical_clock).collect();
466 assert_eq!(clocks.len(), recs.len());
467
468 let codeowners = recs
469 .iter()
470 .find(|r| r.key == "gotcha:codeowners:src/**")
471 .unwrap();
472 let gotcha: GotchaRecord =
473 serde_json::from_value(codeowners.payload.clone().unwrap()).unwrap();
474 assert_eq!(gotcha.affected_files, vec!["src/auth.rs"]);
475 }
476}