1use serde::{Deserialize, Serialize};
16use sha2::{Digest as Sha2Digest, Sha256};
17use std::fs;
18use std::path::{Path, PathBuf};
19use time::OffsetDateTime;
20
21const DEFAULT_TTL_DAYS: u32 = 30;
23
24const MAX_ACTIVE_RULES: usize = 15;
26
27const DORMANT_THRESHOLD_DAYS: i64 = 30;
29
30const SETTLED_THRESHOLD_DAYS: i64 = 60;
32
33const DEAD_THRESHOLD_DAYS: i64 = 90;
35
36const MIN_CONFIRMATIONS: u64 = 2;
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RuleStatus {
43 Proposed,
45 Active,
47 Dormant,
49 Settled,
51 Dead,
53 Superseded,
55}
56
57impl std::fmt::Display for RuleStatus {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::Proposed => write!(f, "proposed"),
61 Self::Active => write!(f, "active"),
62 Self::Dormant => write!(f, "dormant"),
63 Self::Settled => write!(f, "settled"),
64 Self::Dead => write!(f, "dead"),
65 Self::Superseded => write!(f, "superseded"),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum RuleCategory {
74 PreCommit,
76 PrePush,
78 CodePattern,
80 Workflow,
82}
83
84impl std::fmt::Display for RuleCategory {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::PreCommit => write!(f, "pre_commit"),
88 Self::PrePush => write!(f, "pre_push"),
89 Self::CodePattern => write!(f, "code_pattern"),
90 Self::Workflow => write!(f, "workflow"),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Rule {
98 pub id: String,
99 pub trigger: String,
100 pub action: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub anchor_file: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub anchor_hash: Option<String>,
105 pub created: String,
106 pub last_hit: String,
107 pub hits: u64,
108 pub ttl_days: u32,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub superseded_by: Option<String>,
111 pub status: RuleStatus,
112 pub source_session: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub source_event: Option<String>,
115 pub category: RuleCategory,
116}
117
118impl Rule {
119 pub fn is_enforceable(&self) -> bool {
121 self.status == RuleStatus::Active
122 }
123
124 pub fn is_alive(&self) -> bool {
126 !matches!(self.status, RuleStatus::Dead | RuleStatus::Superseded)
127 }
128
129 pub fn record_hit(&mut self) {
131 self.hits += 1;
132 self.last_hit = now_rfc3339();
133 if self.status == RuleStatus::Proposed && self.hits >= MIN_CONFIRMATIONS {
135 self.status = RuleStatus::Active;
136 }
137 if matches!(self.status, RuleStatus::Dormant | RuleStatus::Settled) {
139 self.status = RuleStatus::Active;
140 }
141 }
142
143 pub fn days_since_last_hit(&self) -> Option<i64> {
145 let last = parse_rfc3339(&self.last_hit)?;
146 let now = OffsetDateTime::now_utc();
147 Some((now - last).whole_days())
148 }
149
150 pub fn apply_time_decay(&mut self) {
152 if matches!(
153 self.status,
154 RuleStatus::Dead | RuleStatus::Superseded | RuleStatus::Proposed
155 ) {
156 return;
157 }
158
159 let days = match self.days_since_last_hit() {
160 Some(d) => d,
161 None => return,
162 };
163
164 if days >= DEAD_THRESHOLD_DAYS {
165 self.status = RuleStatus::Dead;
166 } else if days >= SETTLED_THRESHOLD_DAYS {
167 self.status = RuleStatus::Settled;
168 } else if days >= DORMANT_THRESHOLD_DAYS {
169 self.status = RuleStatus::Dormant;
170 }
171 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, Default)]
177pub struct RulesStore {
178 pub rules: Vec<Rule>,
179 #[serde(default)]
180 pub last_decay_run: Option<String>,
181}
182
183impl RulesStore {
184 pub fn load(path: &Path) -> Self {
186 match fs::read_to_string(path) {
187 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
188 Err(_) => Self::default(),
189 }
190 }
191
192 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
194 let json = serde_json::to_string_pretty(self)?;
195 edda_store::write_atomic(path, json.as_bytes())
196 }
197
198 pub fn project_rules_path(project_id: &str) -> PathBuf {
200 edda_store::project_dir(project_id)
201 .join("state")
202 .join("rules.json")
203 }
204
205 pub fn global_rules_path() -> PathBuf {
207 edda_store::store_root().join("rules.json")
208 }
209
210 pub fn load_project(project_id: &str) -> Self {
212 Self::load(&Self::project_rules_path(project_id))
213 }
214
215 pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
217 self.save(&Self::project_rules_path(project_id))
218 }
219
220 pub fn active_rules(&self) -> Vec<&Rule> {
222 self.rules.iter().filter(|r| r.is_enforceable()).collect()
223 }
224
225 pub fn alive_rules(&self) -> Vec<&Rule> {
227 self.rules.iter().filter(|r| r.is_alive()).collect()
228 }
229
230 pub fn propose_rule(
233 &mut self,
234 trigger: String,
235 action: String,
236 anchor_file: Option<String>,
237 category: RuleCategory,
238 source_session: String,
239 source_event: Option<String>,
240 ) -> String {
241 let mut superseded_ids = Vec::new();
243 for rule in &self.rules {
244 if rule.trigger == trigger && rule.is_alive() {
245 if rule.action == action {
246 let rule_id = rule.id.clone();
249 if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule_id) {
250 existing.record_hit();
251 }
252 return rule_id;
253 }
254 superseded_ids.push(rule.id.clone());
256 }
257 }
258
259 let new_id = new_rule_id();
261 for sid in &superseded_ids {
262 if let Some(old_rule) = self.rules.iter_mut().find(|r| r.id == *sid) {
263 old_rule.status = RuleStatus::Superseded;
264 old_rule.superseded_by = Some(new_id.clone());
265 }
266 }
267
268 let anchor_hash = anchor_file.as_ref().and_then(|f| file_sha256(f));
270
271 let now = now_rfc3339();
272 let rule = Rule {
273 id: new_id.clone(),
274 trigger,
275 action,
276 anchor_file,
277 anchor_hash,
278 created: now.clone(),
279 last_hit: now,
280 hits: 1,
281 ttl_days: DEFAULT_TTL_DAYS,
282 superseded_by: None,
283 status: RuleStatus::Proposed,
284 source_session,
285 source_event,
286 category,
287 };
288
289 self.rules.push(rule);
290 new_id
291 }
292
293 pub fn run_decay_cycle(&mut self) {
299 for rule in &mut self.rules {
301 rule.apply_time_decay();
302 }
303
304 for rule in &mut self.rules {
306 if !rule.is_alive() {
307 continue;
308 }
309 if let (Some(ref anchor_file), Some(ref stored_hash)) =
310 (&rule.anchor_file, &rule.anchor_hash)
311 {
312 if let Some(current_hash) = file_sha256(anchor_file) {
313 if current_hash != *stored_hash && rule.status == RuleStatus::Active {
314 rule.status = RuleStatus::Dormant;
315 }
316 } else if !Path::new(anchor_file).exists() && rule.status == RuleStatus::Active {
317 rule.status = RuleStatus::Dormant;
318 }
319 }
320 }
321
322 let mut active_ids: Vec<(String, u64)> = self
324 .rules
325 .iter()
326 .filter(|r| r.status == RuleStatus::Active)
327 .map(|r| (r.id.clone(), r.hits))
328 .collect();
329 active_ids.sort_by_key(|entry| std::cmp::Reverse(entry.1)); if active_ids.len() > MAX_ACTIVE_RULES {
331 let demote_ids: Vec<String> = active_ids[MAX_ACTIVE_RULES..]
332 .iter()
333 .map(|(id, _)| id.clone())
334 .collect();
335 for rule in &mut self.rules {
336 if demote_ids.contains(&rule.id) {
337 rule.status = RuleStatus::Dormant;
338 }
339 }
340 }
341
342 self.last_decay_run = Some(now_rfc3339());
343 }
344
345 pub fn gc_dead_rules(&mut self) -> usize {
347 let before = self.rules.len();
348 self.rules.retain(|r| !matches!(r.status, RuleStatus::Dead));
349 before - self.rules.len()
350 }
351
352 pub fn find_by_trigger(&self, trigger_pattern: &str) -> Vec<&Rule> {
354 self.rules
355 .iter()
356 .filter(|r| r.trigger.contains(trigger_pattern))
357 .collect()
358 }
359
360 pub fn get(&self, id: &str) -> Option<&Rule> {
362 self.rules.iter().find(|r| r.id == id)
363 }
364
365 pub fn get_mut(&mut self, id: &str) -> Option<&mut Rule> {
367 self.rules.iter_mut().find(|r| r.id == id)
368 }
369
370 pub fn stats(&self) -> StoreStats {
372 let mut stats = StoreStats::default();
373 for rule in &self.rules {
374 match rule.status {
375 RuleStatus::Proposed => stats.proposed += 1,
376 RuleStatus::Active => stats.active += 1,
377 RuleStatus::Dormant => stats.dormant += 1,
378 RuleStatus::Settled => stats.settled += 1,
379 RuleStatus::Dead => stats.dead += 1,
380 RuleStatus::Superseded => stats.superseded += 1,
381 }
382 }
383 stats.total = self.rules.len();
384 stats
385 }
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390pub struct StoreStats {
391 pub total: usize,
392 pub proposed: usize,
393 pub active: usize,
394 pub dormant: usize,
395 pub settled: usize,
396 pub dead: usize,
397 pub superseded: usize,
398}
399
400fn new_rule_id() -> String {
403 format!("rule_{}", ulid::Ulid::new().to_string().to_lowercase())
404}
405
406fn now_rfc3339() -> String {
407 let now = OffsetDateTime::now_utc();
408 now.format(&time::format_description::well_known::Rfc3339)
409 .expect("RFC3339 formatting should not fail")
410}
411
412fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
413 OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
414}
415
416fn file_sha256(path: &str) -> Option<String> {
418 let data = fs::read(path).ok()?;
419 let hash = Sha256::digest(&data);
420 Some(hex::encode(hash))
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn make_rule(trigger: &str, action: &str, status: RuleStatus) -> Rule {
428 Rule {
429 id: new_rule_id(),
430 trigger: trigger.to_string(),
431 action: action.to_string(),
432 anchor_file: None,
433 anchor_hash: None,
434 created: now_rfc3339(),
435 last_hit: now_rfc3339(),
436 hits: 2,
437 ttl_days: DEFAULT_TTL_DAYS,
438 superseded_by: None,
439 status,
440 source_session: "test-session".to_string(),
441 source_event: None,
442 category: RuleCategory::PreCommit,
443 }
444 }
445
446 #[test]
447 fn new_store_is_empty() {
448 let store = RulesStore::default();
449 assert!(store.rules.is_empty());
450 assert!(store.active_rules().is_empty());
451 }
452
453 #[test]
454 fn propose_creates_proposed_rule() {
455 let mut store = RulesStore::default();
456 let id = store.propose_rule(
457 "test failure".into(),
458 "run tests before commit".into(),
459 None,
460 RuleCategory::PreCommit,
461 "s1".into(),
462 None,
463 );
464 assert!(!id.is_empty());
465 let rule = store.get(&id).unwrap();
466 assert_eq!(rule.status, RuleStatus::Proposed);
467 assert_eq!(rule.hits, 1);
468 }
469
470 #[test]
471 fn duplicate_proposal_confirms_existing() {
472 let mut store = RulesStore::default();
473 let id1 = store.propose_rule(
474 "test failure".into(),
475 "run tests before commit".into(),
476 None,
477 RuleCategory::PreCommit,
478 "s1".into(),
479 None,
480 );
481 let id2 = store.propose_rule(
482 "test failure".into(),
483 "run tests before commit".into(),
484 None,
485 RuleCategory::PreCommit,
486 "s2".into(),
487 None,
488 );
489 assert_eq!(id1, id2);
491 let rule = store.get(&id1).unwrap();
492 assert_eq!(rule.hits, 2);
493 assert_eq!(rule.status, RuleStatus::Active);
495 }
496
497 #[test]
498 fn contradiction_supersedes_old_rule() {
499 let mut store = RulesStore::default();
500 let id1 = store.propose_rule(
501 "test failure".into(),
502 "run tests before commit".into(),
503 None,
504 RuleCategory::PreCommit,
505 "s1".into(),
506 None,
507 );
508 let id2 = store.propose_rule(
510 "test failure".into(),
511 "run linter before commit".into(),
512 None,
513 RuleCategory::PreCommit,
514 "s2".into(),
515 None,
516 );
517 assert_ne!(id1, id2);
518 let old = store.get(&id1).unwrap();
519 assert_eq!(old.status, RuleStatus::Superseded);
520 assert_eq!(old.superseded_by.as_deref(), Some(id2.as_str()));
521 }
522
523 #[test]
524 fn record_hit_resets_ttl() {
525 let mut rule = make_rule("trigger", "action", RuleStatus::Active);
526 let before = rule.last_hit.clone();
527 std::thread::sleep(std::time::Duration::from_millis(10));
528 rule.record_hit();
529 assert_ne!(rule.last_hit, before);
530 assert_eq!(rule.hits, 3);
531 }
532
533 #[test]
534 fn dormant_reactivates_on_hit() {
535 let mut rule = make_rule("trigger", "action", RuleStatus::Dormant);
536 rule.record_hit();
537 assert_eq!(rule.status, RuleStatus::Active);
538 }
539
540 #[test]
541 fn active_window_cap_enforced() {
542 let mut store = RulesStore::default();
543 for i in 0..20 {
545 let mut rule = make_rule(
546 &format!("trigger_{i}"),
547 &format!("action_{i}"),
548 RuleStatus::Active,
549 );
550 rule.hits = 20 - i;
551 store.rules.push(rule);
552 }
553 store.run_decay_cycle();
554 let active_count = store.active_rules().len();
555 assert!(
556 active_count <= MAX_ACTIVE_RULES,
557 "active_count={active_count} exceeds cap={MAX_ACTIVE_RULES}"
558 );
559 }
560
561 #[test]
562 fn gc_removes_dead_rules() {
563 let mut store = RulesStore::default();
564 store.rules.push(make_rule("a", "b", RuleStatus::Active));
565 store.rules.push(make_rule("c", "d", RuleStatus::Dead));
566 store
567 .rules
568 .push(make_rule("e", "f", RuleStatus::Superseded));
569 let removed = store.gc_dead_rules();
570 assert_eq!(removed, 1);
571 assert_eq!(store.rules.len(), 2);
572 }
573
574 #[test]
575 fn store_round_trip() {
576 let tmp = tempfile::tempdir().unwrap();
577 let path = tmp.path().join("rules.json");
578
579 let mut store = RulesStore::default();
580 store.propose_rule(
581 "trigger".into(),
582 "action".into(),
583 None,
584 RuleCategory::Workflow,
585 "s1".into(),
586 None,
587 );
588 store.save(&path).unwrap();
589
590 let loaded = RulesStore::load(&path);
591 assert_eq!(loaded.rules.len(), 1);
592 assert_eq!(loaded.rules[0].trigger, "trigger");
593 }
594
595 #[test]
596 fn stats_counts_correctly() {
597 let mut store = RulesStore::default();
598 store.rules.push(make_rule("a", "b", RuleStatus::Active));
599 store.rules.push(make_rule("c", "d", RuleStatus::Proposed));
600 store.rules.push(make_rule("e", "f", RuleStatus::Dormant));
601 store.rules.push(make_rule("g", "h", RuleStatus::Dead));
602 let stats = store.stats();
603 assert_eq!(stats.total, 4);
604 assert_eq!(stats.active, 1);
605 assert_eq!(stats.proposed, 1);
606 assert_eq!(stats.dormant, 1);
607 assert_eq!(stats.dead, 1);
608 }
609}