1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::Path;
4
5#[derive(Debug, Serialize, Deserialize, Default, Clone)]
9pub struct FeedbackTracker {
10 rules: HashMap<String, RuleStats>,
12}
13
14#[derive(Debug, Serialize, Deserialize, Default, Clone)]
16pub struct RuleStats {
17 pub shown: u64,
19 pub dismissed: u64,
21 pub fixed: u64,
23}
24
25impl RuleStats {
26 #[must_use]
28 #[allow(clippy::cast_precision_loss)]
29 pub fn dismiss_rate(&self) -> f64 {
30 if self.shown == 0 {
31 0.0
32 } else {
33 self.dismissed as f64 / self.shown as f64
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct DisableSuggestion {
41 pub rule_id: String,
42 pub dismiss_rate: f64,
43 pub dismissed_count: u64,
44}
45
46macro_rules! counters {
52 ($($(#[$doc:meta])* $method:ident => $field:ident),+ $(,)?) => {
53 $(
54 $(#[$doc])*
55 pub fn $method(&mut self, rule_id: &str) {
56 self.rules.entry(rule_id.to_string()).or_default().$field += 1;
57 }
58 )+
59 };
60}
61
62impl FeedbackTracker {
63 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 counters! {
70 record_shown => shown,
72 record_dismissed => dismissed,
74 record_fixed => fixed,
76 }
77
78 #[must_use]
80 pub fn get_stats(&self, rule_id: &str) -> Option<&RuleStats> {
81 self.rules.get(rule_id)
82 }
83
84 #[must_use]
88 pub fn suggest_disable(&self, threshold: f64, min_shown: u64) -> Vec<DisableSuggestion> {
89 let mut suggestions: Vec<DisableSuggestion> = self
90 .rules
91 .iter()
92 .filter(|(_, stats)| stats.shown >= min_shown && stats.dismiss_rate() > threshold)
93 .map(|(rule_id, stats)| DisableSuggestion {
94 rule_id: rule_id.clone(),
95 dismiss_rate: stats.dismiss_rate(),
96 dismissed_count: stats.dismissed,
97 })
98 .collect();
99
100 suggestions.sort_by(|a, b| {
102 b.dismiss_rate
103 .partial_cmp(&a.dismiss_rate)
104 .unwrap_or(std::cmp::Ordering::Equal)
105 });
106 suggestions
107 }
108
109 #[must_use]
111 pub fn rule_count(&self) -> usize {
112 self.rules.len()
113 }
114
115 pub fn load(path: &Path) -> anyhow::Result<Self> {
117 if path.exists() {
118 let content = std::fs::read_to_string(path)?;
119 let tracker: Self = serde_json::from_str(&content)?;
120 Ok(tracker)
121 } else {
122 Ok(Self::new())
123 }
124 }
125
126 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
128 let content = serde_json::to_string_pretty(self)?;
129 std::fs::write(path, content)?;
130 Ok(())
131 }
132
133 #[must_use]
135 pub fn create_false_positive_report(
136 rule_id: &str,
137 text_snippet: &str,
138 max_snippet_len: usize,
139 ) -> FalsePositiveReport {
140 let snippet = if text_snippet.len() > max_snippet_len {
142 &text_snippet[..max_snippet_len]
143 } else {
144 text_snippet
145 };
146
147 FalsePositiveReport {
148 rule_id: rule_id.to_string(),
149 snippet: snippet.to_string(),
150 }
151 }
152}
153
154#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
156pub struct FalsePositiveReport {
157 pub rule_id: String,
158 pub snippet: String,
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn new_tracker_is_empty() {
167 let tracker = FeedbackTracker::new();
168 assert_eq!(tracker.rule_count(), 0);
169 }
170
171 #[test]
172 fn record_and_retrieve_stats() {
173 let mut tracker = FeedbackTracker::new();
174 tracker.record_shown("spelling.typo");
175 tracker.record_shown("spelling.typo");
176 tracker.record_dismissed("spelling.typo");
177 tracker.record_fixed("spelling.typo");
178
179 let stats = tracker.get_stats("spelling.typo").unwrap();
180 assert_eq!(stats.shown, 2);
181 assert_eq!(stats.dismissed, 1);
182 assert_eq!(stats.fixed, 1);
183 }
184
185 #[test]
186 fn dismiss_rate_calculation() {
187 let stats = RuleStats {
188 shown: 10,
189 dismissed: 8,
190 fixed: 2,
191 };
192 let rate = stats.dismiss_rate();
193 assert!((rate - 0.8).abs() < f64::EPSILON);
194 }
195
196 #[test]
197 fn dismiss_rate_zero_shown() {
198 let stats = RuleStats::default();
199 assert!((stats.dismiss_rate()).abs() < f64::EPSILON);
200 }
201
202 #[test]
203 fn suggest_disable_above_threshold() {
204 let mut tracker = FeedbackTracker::new();
205
206 for _ in 0..10 {
208 tracker.record_shown("noisy.rule");
209 tracker.record_dismissed("noisy.rule");
210 }
211
212 for _ in 0..10 {
214 tracker.record_shown("useful.rule");
215 }
216 tracker.record_dismissed("useful.rule");
217
218 tracker.record_shown("rare.rule");
220 tracker.record_dismissed("rare.rule");
221
222 let suggestions = tracker.suggest_disable(0.5, 5);
223 assert_eq!(suggestions.len(), 1);
224 assert_eq!(suggestions[0].rule_id, "noisy.rule");
225 assert!((suggestions[0].dismiss_rate - 1.0).abs() < f64::EPSILON);
226 }
227
228 #[test]
229 fn save_and_load_roundtrip() {
230 let dir = std::env::temp_dir().join("lang_check_feedback_test");
231 let _ = std::fs::remove_dir_all(&dir);
232 std::fs::create_dir_all(&dir).unwrap();
233 let path = dir.join("feedback.json");
234
235 let mut tracker = FeedbackTracker::new();
236 tracker.record_shown("test.rule");
237 tracker.record_dismissed("test.rule");
238 tracker.save(&path).unwrap();
239
240 let loaded = FeedbackTracker::load(&path).unwrap();
241 let stats = loaded.get_stats("test.rule").unwrap();
242 assert_eq!(stats.shown, 1);
243 assert_eq!(stats.dismissed, 1);
244
245 let _ = std::fs::remove_dir_all(&dir);
246 }
247
248 #[test]
249 fn load_missing_file_returns_empty() {
250 let path = std::env::temp_dir().join("lang_check_feedback_nonexistent.json");
251 let tracker = FeedbackTracker::load(&path).unwrap();
252 assert_eq!(tracker.rule_count(), 0);
253 }
254
255 #[test]
256 fn false_positive_report() {
257 let report = FeedbackTracker::create_false_positive_report(
258 "spelling.typo",
259 "This is a perfectly valid sentence.",
260 50,
261 );
262 assert_eq!(report.rule_id, "spelling.typo");
263 assert_eq!(report.snippet, "This is a perfectly valid sentence.");
264 }
265
266 #[test]
267 fn false_positive_report_truncation() {
268 let long_text = "a".repeat(200);
269 let report = FeedbackTracker::create_false_positive_report("test.rule", &long_text, 50);
270 assert_eq!(report.snippet.len(), 50);
271 }
272}