1use std::fs;
20use std::io;
21use std::path::{Path, PathBuf};
22
23use bock_air::{AIRNode, NodeKind};
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::cache::compute_key;
28use crate::request::CandidateRule;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Provenance {
36 Builtin,
38 Extracted,
40 Manual,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct Rule {
49 pub id: String,
51 pub target_id: String,
53 pub node_kind: String,
55 pub pattern: String,
59 pub template: String,
61 pub provenance: Provenance,
63 pub pinned: bool,
65 pub confidence: f64,
67 pub priority: i32,
69 pub created: DateTime<Utc>,
71}
72
73impl Rule {
74 #[must_use]
82 pub fn from_candidate(candidate: &CandidateRule, node_kind: &str, confidence: f64) -> Self {
83 let id = compute_rule_id(&candidate.target_id, node_kind, &candidate.template);
84 Self {
85 id,
86 target_id: candidate.target_id.clone(),
87 node_kind: node_kind.into(),
88 pattern: candidate.pattern.clone(),
89 template: candidate.template.clone(),
90 provenance: Provenance::Extracted,
91 pinned: false,
92 confidence,
93 priority: candidate.priority,
94 created: Utc::now(),
95 }
96 }
97}
98
99#[must_use]
105pub fn compute_rule_id(target_id: &str, node_kind: &str, template: &str) -> String {
106 #[derive(Serialize)]
107 struct Keyed<'a> {
108 target: &'a str,
109 kind: &'a str,
110 template: &'a str,
111 }
112 let keyed = Keyed {
113 target: target_id,
114 kind: node_kind,
115 template,
116 };
117 compute_key(&keyed).unwrap_or_else(|_| format!("fallback-{target_id}-{node_kind}"))
118}
119
120#[derive(Debug, thiserror::Error)]
124pub enum RuleCacheError {
125 #[error("rule cache I/O error: {0}")]
127 Io(#[from] io::Error),
128 #[error("rule parse error in {path}: {source}")]
130 Parse {
131 path: PathBuf,
133 #[source]
135 source: serde_json::Error,
136 },
137 #[error("rule serialize error: {0}")]
139 Serialize(#[from] serde_json::Error),
140}
141
142#[derive(Debug, Clone)]
144pub struct RuleCache {
145 root: PathBuf,
146}
147
148impl RuleCache {
149 #[must_use]
154 pub fn new(project_root: &Path) -> Self {
155 Self {
156 root: project_root.join(".bock").join("rules"),
157 }
158 }
159
160 #[must_use]
162 pub fn with_root(root: PathBuf) -> Self {
163 Self { root }
164 }
165
166 #[must_use]
168 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 #[must_use]
174 pub fn target_dir(&self, target_id: &str) -> PathBuf {
175 self.root.join(target_id)
176 }
177
178 pub fn insert(&self, rule: &Rule) -> Result<(), RuleCacheError> {
185 let dir = self.target_dir(&rule.target_id);
186 fs::create_dir_all(&dir)?;
187 let path = dir.join(format!("{}.json", rule.id));
188 let bytes = serde_json::to_vec_pretty(rule)?;
189 fs::write(&path, bytes)?;
190 Ok(())
191 }
192
193 pub fn load_for_target(&self, target_id: &str) -> Result<Vec<Rule>, RuleCacheError> {
198 let dir = self.target_dir(target_id);
199 if !dir.exists() {
200 return Ok(Vec::new());
201 }
202 let mut out = Vec::new();
203 for entry in fs::read_dir(&dir)? {
204 let entry = entry?;
205 let path = entry.path();
206 if path.extension().and_then(|e| e.to_str()) != Some("json") {
207 continue;
208 }
209 let bytes = fs::read(&path)?;
210 let rule: Rule =
211 serde_json::from_slice(&bytes).map_err(|source| RuleCacheError::Parse {
212 path: path.clone(),
213 source,
214 })?;
215 out.push(rule);
216 }
217 Ok(out)
218 }
219
220 pub fn lookup(
231 &self,
232 target_id: &str,
233 node: &AIRNode,
234 production_only_pinned: bool,
235 ) -> Result<Option<Rule>, RuleCacheError> {
236 let kind = node_kind_name(&node.kind);
237 let rules = self.load_for_target(target_id)?;
238 let best = rules
239 .into_iter()
240 .filter(|r| r.node_kind == kind)
241 .filter(|r| !production_only_pinned || r.pinned)
242 .max_by(|a, b| {
243 a.priority
244 .cmp(&b.priority)
245 .then(a.pinned.cmp(&b.pinned))
246 .then(a.created.cmp(&b.created))
247 });
248 Ok(best)
249 }
250}
251
252#[must_use]
257pub fn node_kind_name(kind: &NodeKind) -> &'static str {
258 match kind {
259 NodeKind::Module { .. } => "Module",
260 NodeKind::ImportDecl { .. } => "ImportDecl",
261 NodeKind::FnDecl { .. } => "FnDecl",
262 NodeKind::RecordDecl { .. } => "RecordDecl",
263 NodeKind::EnumDecl { .. } => "EnumDecl",
264 NodeKind::ClassDecl { .. } => "ClassDecl",
265 NodeKind::TraitDecl { .. } => "TraitDecl",
266 NodeKind::ImplBlock { .. } => "ImplBlock",
267 NodeKind::EffectDecl { .. } => "EffectDecl",
268 NodeKind::ConstDecl { .. } => "ConstDecl",
269 NodeKind::TypeAlias { .. } => "TypeAlias",
270 NodeKind::Param { .. } => "Param",
271 NodeKind::Block { .. } => "Block",
272 NodeKind::If { .. } => "If",
273 NodeKind::For { .. } => "For",
274 NodeKind::While { .. } => "While",
275 NodeKind::Loop { .. } => "Loop",
276 NodeKind::Match { .. } => "Match",
277 NodeKind::MatchArm { .. } => "MatchArm",
278 NodeKind::Guard { .. } => "Guard",
279 NodeKind::HandlingBlock { .. } => "HandlingBlock",
280 NodeKind::LetBinding { .. } => "LetBinding",
281 NodeKind::Return { .. } => "Return",
282 NodeKind::Break { .. } => "Break",
283 NodeKind::Assign { .. } => "Assign",
284 NodeKind::BinaryOp { .. } => "BinaryOp",
285 NodeKind::UnaryOp { .. } => "UnaryOp",
286 NodeKind::Call { .. } => "Call",
287 NodeKind::MethodCall { .. } => "MethodCall",
288 NodeKind::Lambda { .. } => "Lambda",
289 NodeKind::FieldAccess { .. } => "FieldAccess",
290 NodeKind::Index { .. } => "Index",
291 NodeKind::Pipe { .. } => "Pipe",
292 NodeKind::Compose { .. } => "Compose",
293 NodeKind::Await { .. } => "Await",
294 NodeKind::Propagate { .. } => "Propagate",
295 NodeKind::Move { .. } => "Move",
296 NodeKind::Borrow { .. } => "Borrow",
297 NodeKind::MutableBorrow { .. } => "MutableBorrow",
298 NodeKind::ListLiteral { .. } => "ListLiteral",
299 NodeKind::SetLiteral { .. } => "SetLiteral",
300 NodeKind::TupleLiteral { .. } => "TupleLiteral",
301 NodeKind::MapLiteral { .. } => "MapLiteral",
302 NodeKind::RecordConstruct { .. } => "RecordConstruct",
303 NodeKind::Range { .. } => "Range",
304 NodeKind::ResultConstruct { .. } => "ResultConstruct",
305 NodeKind::TypeNamed { .. } => "TypeNamed",
306 NodeKind::TypeTuple { .. } => "TypeTuple",
307 NodeKind::TypeFunction { .. } => "TypeFunction",
308 NodeKind::TypeOptional { .. } => "TypeOptional",
309 NodeKind::ModuleHandle { .. } => "ModuleHandle",
310 NodeKind::PropertyTest { .. } => "PropertyTest",
311 NodeKind::ConstructorPat { .. } => "ConstructorPat",
312 NodeKind::RecordPat { .. } => "RecordPat",
313 NodeKind::TuplePat { .. } => "TuplePat",
314 NodeKind::ListPat { .. } => "ListPat",
315 NodeKind::OrPat { .. } => "OrPat",
316 NodeKind::GuardPat { .. } => "GuardPat",
317 NodeKind::RangePat { .. } => "RangePat",
318 _ => "Other",
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use bock_air::{NodeIdGen, NodeKind};
326 use bock_errors::Span;
327
328 fn match_node() -> AIRNode {
329 let gen = NodeIdGen::new();
330 let scrutinee = AIRNode::new(
331 gen.next(),
332 Span::dummy(),
333 NodeKind::Block {
334 stmts: Vec::new(),
335 tail: None,
336 },
337 );
338 AIRNode::new(
339 gen.next(),
340 Span::dummy(),
341 NodeKind::Match {
342 scrutinee: Box::new(scrutinee),
343 arms: Vec::new(),
344 },
345 )
346 }
347
348 fn candidate() -> CandidateRule {
349 CandidateRule {
350 target_id: "js".into(),
351 pattern: "match on string scrutinee".into(),
352 template: "switch ({{ scrutinee }}) { {{ arms }} }".into(),
353 priority: 10,
354 }
355 }
356
357 #[test]
358 fn candidate_lifts_to_extracted_rule() {
359 let rule = Rule::from_candidate(&candidate(), "Match", 0.88);
360 assert_eq!(rule.provenance, Provenance::Extracted);
361 assert_eq!(rule.node_kind, "Match");
362 assert_eq!(rule.target_id, "js");
363 assert!(!rule.pinned);
364 assert!((rule.confidence - 0.88).abs() < f64::EPSILON);
365 }
366
367 #[test]
368 fn rule_id_is_stable_across_calls() {
369 let a = compute_rule_id("js", "Match", "switch x {}");
370 let b = compute_rule_id("js", "Match", "switch x {}");
371 assert_eq!(a, b);
372 let c = compute_rule_id("js", "Match", "switch y {}");
373 assert_ne!(a, c);
374 }
375
376 #[test]
377 fn insert_then_load() {
378 let dir = tempfile::tempdir().unwrap();
379 let cache = RuleCache::new(dir.path());
380 let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
381 cache.insert(&rule).unwrap();
382
383 let loaded = cache.load_for_target("js").unwrap();
384 assert_eq!(loaded.len(), 1);
385 assert_eq!(loaded[0].id, rule.id);
386 assert_eq!(loaded[0].node_kind, "Match");
387 }
388
389 #[test]
390 fn lookup_matches_by_node_kind() {
391 let dir = tempfile::tempdir().unwrap();
392 let cache = RuleCache::new(dir.path());
393 let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
394 cache.insert(&rule).unwrap();
395
396 let hit = cache.lookup("js", &match_node(), false).unwrap();
397 assert!(hit.is_some());
398 assert_eq!(hit.unwrap().node_kind, "Match");
399 }
400
401 #[test]
402 fn lookup_misses_on_different_kind() {
403 let dir = tempfile::tempdir().unwrap();
404 let cache = RuleCache::new(dir.path());
405 let rule = Rule::from_candidate(&candidate(), "Call", 0.9);
406 cache.insert(&rule).unwrap();
407
408 let hit = cache.lookup("js", &match_node(), false).unwrap();
409 assert!(hit.is_none());
410 }
411
412 #[test]
413 fn production_mode_ignores_unpinned_rules() {
414 let dir = tempfile::tempdir().unwrap();
415 let cache = RuleCache::new(dir.path());
416 let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
417 cache.insert(&rule).unwrap();
418
419 assert!(cache.lookup("js", &match_node(), true).unwrap().is_none());
420
421 let mut pinned = rule.clone();
422 pinned.pinned = true;
423 pinned.id = format!("{}-pinned", rule.id);
424 cache.insert(&pinned).unwrap();
425
426 let hit = cache.lookup("js", &match_node(), true).unwrap().unwrap();
427 assert!(hit.pinned);
428 }
429
430 #[test]
431 fn lookup_misses_on_empty_directory() {
432 let dir = tempfile::tempdir().unwrap();
433 let cache = RuleCache::new(dir.path());
434 let hit = cache.lookup("js", &match_node(), false).unwrap();
435 assert!(hit.is_none());
436 }
437
438 #[test]
439 fn load_skips_non_json_files() {
440 let dir = tempfile::tempdir().unwrap();
441 let cache = RuleCache::new(dir.path());
442 fs::create_dir_all(cache.target_dir("js")).unwrap();
443 fs::write(cache.target_dir("js").join("junk.txt"), "not json").unwrap();
444 let rules = cache.load_for_target("js").unwrap();
445 assert!(rules.is_empty());
446 }
447
448 #[test]
449 fn priority_breaks_ties() {
450 let dir = tempfile::tempdir().unwrap();
451 let cache = RuleCache::new(dir.path());
452
453 let low = Rule {
454 id: "low".into(),
455 target_id: "js".into(),
456 node_kind: "Match".into(),
457 pattern: "low".into(),
458 template: "low()".into(),
459 provenance: Provenance::Extracted,
460 pinned: false,
461 confidence: 0.5,
462 priority: 1,
463 created: Utc::now(),
464 };
465 let high = Rule {
466 id: "high".into(),
467 priority: 99,
468 template: "high()".into(),
469 ..low.clone()
470 };
471 cache.insert(&low).unwrap();
472 cache.insert(&high).unwrap();
473
474 let hit = cache.lookup("js", &match_node(), false).unwrap().unwrap();
475 assert_eq!(hit.id, "high");
476 }
477
478 #[test]
479 fn node_kind_name_covers_common_variants() {
480 let gen = NodeIdGen::new();
481 let block = AIRNode::new(
482 gen.next(),
483 Span::dummy(),
484 NodeKind::Block {
485 stmts: Vec::new(),
486 tail: None,
487 },
488 );
489 assert_eq!(node_kind_name(&block.kind), "Block");
490 }
491}