1use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone)]
9pub struct DetectorConfig {
10 pub min_occurrences: u32,
12 pub min_success_rate: f64,
14 pub max_age_days: u32,
16}
17
18impl Default for DetectorConfig {
19 fn default() -> Self {
20 Self {
21 min_occurrences: 3,
22 min_success_rate: 0.9,
23 max_age_days: 7,
24 }
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ActionRecord {
31 pub action: String,
32 pub tool: String,
33 pub params_hash: String,
34 pub success: bool,
35 pub timestamp: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DetectedPattern {
41 pub signature: String,
42 pub actions: Vec<String>,
43 pub tools: Vec<String>,
44 pub occurrences: u32,
45 pub success_rate: f64,
46 pub compilable: bool,
47}
48
49pub struct PatternDetector {
51 config: DetectorConfig,
52 signatures: parking_lot::Mutex<HashMap<String, PatternStats>>,
54}
55
56#[derive(Debug, Clone)]
57struct PatternStats {
58 actions: Vec<String>,
59 tools: Vec<String>,
60 total: u32,
61 successes: u32,
62 _first_seen: String,
63 last_seen: String,
64}
65
66impl PatternDetector {
67 pub fn new(config: DetectorConfig) -> Self {
68 Self {
69 config,
70 signatures: parking_lot::Mutex::new(HashMap::new()),
71 }
72 }
73
74 pub fn with_defaults() -> Self {
75 Self::new(DetectorConfig::default())
76 }
77
78 pub fn record(&self, signature: &str, actions: &[String], tools: &[String], success: bool) {
80 let mut sigs = self.signatures.lock();
81 let now = chrono::Utc::now().to_rfc3339();
82
83 let entry = sigs
84 .entry(signature.to_string())
85 .or_insert_with(|| PatternStats {
86 actions: actions.to_vec(),
87 tools: tools.to_vec(),
88 total: 0,
89 successes: 0,
90 _first_seen: now.clone(),
91 last_seen: now.clone(),
92 });
93
94 entry.total += 1;
95 if success {
96 entry.successes += 1;
97 }
98 entry.last_seen = now;
99 }
100
101 pub fn detect(&self) -> Vec<DetectedPattern> {
103 let sigs = self.signatures.lock();
104 let mut patterns = Vec::new();
105
106 for (sig, stats) in sigs.iter() {
107 if stats.total < self.config.min_occurrences {
108 continue;
109 }
110
111 let success_rate = stats.successes as f64 / stats.total as f64;
112 if success_rate < self.config.min_success_rate {
113 continue;
114 }
115
116 patterns.push(DetectedPattern {
117 signature: sig.clone(),
118 actions: stats.actions.clone(),
119 tools: stats.tools.clone(),
120 occurrences: stats.total,
121 success_rate,
122 compilable: true,
123 });
124 }
125
126 patterns.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
128 patterns
129 }
130
131 pub fn signature_count(&self) -> usize {
133 self.signatures.lock().len()
134 }
135
136 pub fn clear(&self) {
138 self.signatures.lock().clear();
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn test_no_patterns_initially() {
148 let detector = PatternDetector::with_defaults();
149 assert!(detector.detect().is_empty());
150 }
151
152 #[test]
153 fn test_pattern_detection() {
154 let detector = PatternDetector::with_defaults();
155 let actions = vec!["git add".into(), "git commit".into(), "git push".into()];
156 let tools = vec!["git_add".into(), "git_commit".into(), "git_push".into()];
157
158 for _ in 0..5 {
160 detector.record("git-push-flow", &actions, &tools, true);
161 }
162
163 let patterns = detector.detect();
164 assert_eq!(patterns.len(), 1);
165 assert_eq!(patterns[0].signature, "git-push-flow");
166 assert_eq!(patterns[0].occurrences, 5);
167 assert!((patterns[0].success_rate - 1.0).abs() < 0.01);
168 assert!(patterns[0].compilable);
169 }
170
171 #[test]
172 fn test_below_threshold() {
173 let detector = PatternDetector::with_defaults();
174 detector.record("rare", &["a".into()], &["t".into()], true);
176 detector.record("rare", &["a".into()], &["t".into()], true);
177 assert!(detector.detect().is_empty());
178 }
179
180 #[test]
181 fn test_low_success_rate() {
182 let detector = PatternDetector::with_defaults();
183 detector.record("flaky", &["a".into()], &["t".into()], true);
185 detector.record("flaky", &["a".into()], &["t".into()], false);
186 detector.record("flaky", &["a".into()], &["t".into()], false);
187 assert!(detector.detect().is_empty());
188 }
189
190 #[test]
191 fn test_signature_count() {
192 let detector = PatternDetector::with_defaults();
193 detector.record("a", &["x".into()], &["t".into()], true);
194 detector.record("b", &["y".into()], &["t".into()], true);
195 assert_eq!(detector.signature_count(), 2);
196 }
197
198 #[test]
199 fn test_clear() {
200 let detector = PatternDetector::with_defaults();
201 detector.record("a", &["x".into()], &["t".into()], true);
202 detector.clear();
203 assert_eq!(detector.signature_count(), 0);
204 }
205
206 #[test]
207 fn test_detector_config_default() {
208 let config = DetectorConfig::default();
209 assert_eq!(config.min_occurrences, 3);
210 assert_eq!(config.min_success_rate, 0.9);
211 assert_eq!(config.max_age_days, 7);
212 }
213
214 #[test]
215 fn test_custom_config_lower_threshold() {
216 let config = DetectorConfig { min_occurrences: 1, min_success_rate: 0.5, max_age_days: 30 };
217 let detector = PatternDetector::new(config);
218 detector.record("once", &["a".into()], &["t".into()], true);
219 let patterns = detector.detect();
220 assert_eq!(patterns.len(), 1);
221 }
222
223 #[test]
224 fn test_multiple_patterns_sorted() {
225 let config = DetectorConfig { min_occurrences: 1, min_success_rate: 0.0, max_age_days: 30 };
226 let detector = PatternDetector::new(config);
227 detector.record("rare", &["a".into()], &["t".into()], true);
228 for _ in 0..5 {
229 detector.record("frequent", &["b".into()], &["t".into()], true);
230 }
231 let patterns = detector.detect();
232 assert_eq!(patterns[0].signature, "frequent"); }
234
235 #[test]
236 fn test_detected_pattern_serde() {
237 let pattern = DetectedPattern {
238 signature: "sig".into(),
239 actions: vec!["a".into()],
240 tools: vec!["t".into()],
241 occurrences: 5,
242 success_rate: 1.0,
243 compilable: true,
244 };
245 let json = serde_json::to_string(&pattern).unwrap();
246 let restored: DetectedPattern = serde_json::from_str(&json).unwrap();
247 assert_eq!(restored.signature, "sig");
248 assert_eq!(restored.occurrences, 5);
249 }
250
251 #[test]
252 fn test_action_record_serde() {
253 let record = ActionRecord {
254 action: "deploy".into(),
255 tool: "deploy_tool".into(),
256 params_hash: "abc123".into(),
257 success: true,
258 timestamp: "2026-01-01".into(),
259 };
260 let json = serde_json::to_string(&record).unwrap();
261 let restored: ActionRecord = serde_json::from_str(&json).unwrap();
262 assert_eq!(restored.action, "deploy");
263 }
264}