Skip to main content

lang_check/
feedback.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::Path;
4
5/// Tracks how often each rule's diagnostics are dismissed/ignored by the user.
6///
7/// This data is stored per-project and used to suggest disabling noisy rules.
8#[derive(Debug, Serialize, Deserialize, Default, Clone)]
9pub struct FeedbackTracker {
10    /// Map from `unified_rule_id` -> stats
11    rules: HashMap<String, RuleStats>,
12}
13
14/// Per-rule feedback statistics.
15#[derive(Debug, Serialize, Deserialize, Default, Clone)]
16pub struct RuleStats {
17    /// Number of times this rule's diagnostic was shown.
18    pub shown: u64,
19    /// Number of times the user dismissed/ignored this rule's diagnostic.
20    pub dismissed: u64,
21    /// Number of times the user applied the suggested fix.
22    pub fixed: u64,
23}
24
25impl RuleStats {
26    /// Fraction of shown diagnostics that were dismissed (0.0 to 1.0).
27    #[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/// A suggestion to disable a frequently-ignored rule.
39#[derive(Debug, Clone, PartialEq)]
40pub struct DisableSuggestion {
41    pub rule_id: String,
42    pub dismiss_rate: f64,
43    pub dismissed_count: u64,
44}
45
46/// Generate one `+= 1` method per [`RuleStats`] counter.
47///
48/// The field being incremented is the only thing that varies, and a field name
49/// cannot be a generic parameter, so the three bodies were written out three
50/// times. Adding a fourth counter is now one line here.
51macro_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    /// Create a new empty tracker.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    counters! {
70        /// Record that a diagnostic was shown to the user.
71        record_shown => shown,
72        /// Record that the user dismissed a diagnostic.
73        record_dismissed => dismissed,
74        /// Record that the user applied the suggested fix.
75        record_fixed => fixed,
76    }
77
78    /// Get stats for a specific rule.
79    #[must_use]
80    pub fn get_stats(&self, rule_id: &str) -> Option<&RuleStats> {
81        self.rules.get(rule_id)
82    }
83
84    /// Get rules that are frequently dismissed and should be considered for disabling.
85    ///
86    /// Returns rules where `dismiss_rate` > `threshold` and at least `min_shown` occurrences.
87    #[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        // Sort by dismiss rate descending
101        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    /// Number of tracked rules.
110    #[must_use]
111    pub fn rule_count(&self) -> usize {
112        self.rules.len()
113    }
114
115    /// Load feedback data from a JSON file.
116    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    /// Save feedback data to a JSON file.
127    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    /// Create an anonymized false-positive report for a specific diagnostic.
134    #[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        // Truncate and anonymize the snippet
141        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/// An anonymized false-positive report that can be sent to engine maintainers.
155#[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        // Rule with high dismiss rate
207        for _ in 0..10 {
208            tracker.record_shown("noisy.rule");
209            tracker.record_dismissed("noisy.rule");
210        }
211
212        // Rule with low dismiss rate
213        for _ in 0..10 {
214            tracker.record_shown("useful.rule");
215        }
216        tracker.record_dismissed("useful.rule");
217
218        // Rule below min_shown
219        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}