Skip to main content

semantic_diff/review/
mod.rs

1pub mod llm;
2
3use serde::{Serialize, Deserialize};
4use std::collections::{HashMap, VecDeque};
5use std::path::PathBuf;
6
7/// Identifies a review section.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub enum ReviewSection {
10    Why,
11    What,
12    How,
13    Verdict,
14}
15
16impl ReviewSection {
17    pub fn label(&self) -> &'static str {
18        match self {
19            Self::Why => "WHY",
20            Self::What => "WHAT",
21            Self::How => "HOW",
22            Self::Verdict => "VERDICT",
23        }
24    }
25
26    pub fn all() -> [ReviewSection; 4] {
27        [Self::Why, Self::What, Self::How, Self::Verdict]
28    }
29}
30
31/// Loading state for a single review section.
32#[derive(Debug, Clone)]
33pub enum SectionState {
34    Loading,
35    Ready(String),
36    Error(String),
37    Skipped,
38}
39
40impl SectionState {
41    pub fn is_complete(&self) -> bool {
42        matches!(self, Self::Ready(_) | Self::Error(_) | Self::Skipped)
43    }
44}
45
46/// Tracks which review source was used.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub enum ReviewSource {
49    /// A review SKILL was discovered and injected into the VERDICT prompt.
50    Skill { name: String, path: PathBuf },
51    /// No SKILL found; used built-in generic reviewer.
52    BuiltIn,
53}
54
55/// Aggregate review state for one semantic group.
56#[derive(Debug, Clone)]
57pub struct GroupReview {
58    pub content_hash: u64,
59    pub sections: HashMap<ReviewSection, SectionState>,
60    pub source: ReviewSource,
61}
62
63const MAX_CACHED_REVIEWS: usize = 20;
64
65/// Cache of reviews keyed by group content hash. Bounded LRU.
66pub struct ReviewCache {
67    entries: HashMap<u64, GroupReview>,
68    order: VecDeque<u64>,
69}
70
71impl Default for ReviewCache {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl ReviewCache {
78    pub fn new() -> Self {
79        Self {
80            entries: HashMap::new(),
81            order: VecDeque::new(),
82        }
83    }
84
85    pub fn get(&self, hash: &u64) -> Option<&GroupReview> {
86        self.entries.get(hash)
87    }
88
89    pub fn get_mut(&mut self, hash: &u64) -> Option<&mut GroupReview> {
90        if self.entries.contains_key(hash) {
91            // Promote to MRU
92            self.order.retain(|h| h != hash);
93            self.order.push_back(*hash);
94        }
95        self.entries.get_mut(hash)
96    }
97
98    pub fn insert(&mut self, review: GroupReview) {
99        let hash = review.content_hash;
100        if self.entries.contains_key(&hash) {
101            // Move to back (most recent)
102            self.order.retain(|h| *h != hash);
103        } else if self.entries.len() >= MAX_CACHED_REVIEWS {
104            // Evict oldest
105            if let Some(old) = self.order.pop_front() {
106                self.entries.remove(&old);
107            }
108        }
109        self.order.push_back(hash);
110        self.entries.insert(hash, review);
111    }
112
113    pub fn remove(&mut self, hash: &u64) {
114        self.entries.remove(hash);
115        self.order.retain(|h| h != hash);
116    }
117}
118
119/// Compute a stable hash for a group's review identity.
120/// Includes label, file paths, and hunk indices so the cache invalidates
121/// when group membership or hunk assignment changes.
122pub fn group_content_hash(group: &crate::grouper::SemanticGroup) -> u64 {
123    use std::collections::hash_map::DefaultHasher;
124    use std::hash::{Hash, Hasher};
125    let mut hasher = DefaultHasher::new();
126    group.label.hash(&mut hasher);
127    let mut changes = group.changes();
128    changes.sort_by(|a, b| a.file.cmp(&b.file));
129    for c in &changes {
130        c.file.hash(&mut hasher);
131        c.hunks.hash(&mut hasher);
132    }
133    hasher.finish()
134}
135
136/// Discover a review SKILL by scanning local then global paths.
137/// Returns the first match whose filename contains "review" (case-insensitive).
138pub fn detect_review_skill() -> ReviewSource {
139    // 1. Codebase-level: .claude/skills/
140    if let Some(found) = scan_skills_dir(".claude/skills") {
141        return found;
142    }
143    // 2. Global: ~/.claude/skills/
144    if let Some(home) = dirs::home_dir() {
145        let global = home.join(".claude").join("skills");
146        if let Some(found) = scan_skills_dir(global) {
147            return found;
148        }
149    }
150    ReviewSource::BuiltIn
151}
152
153fn scan_skills_dir(dir: impl AsRef<std::path::Path>) -> Option<ReviewSource> {
154    let dir = dir.as_ref();
155    let entries = std::fs::read_dir(dir).ok()?;
156    for entry in entries.flatten() {
157        let path = entry.path();
158        if path.is_file() {
159            let name = path.file_stem()?.to_string_lossy().to_string();
160            if name.to_lowercase().contains("review") {
161                return Some(ReviewSource::Skill {
162                    name,
163                    path: path.clone(),
164                });
165            }
166        }
167    }
168    None
169}
170
171/// Disk-cached review entry. Only stores completed section content.
172#[derive(Debug, Serialize, Deserialize)]
173pub struct CachedReview {
174    pub content_hash: u64,
175    pub source: ReviewSource,
176    pub sections: HashMap<String, CachedSection>,
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180pub enum CachedSection {
181    Ready(String),
182    Skipped,
183}
184
185fn review_cache_dir() -> PathBuf {
186    let git_dir = std::process::Command::new("git")
187        .args(["rev-parse", "--git-dir"])
188        .output()
189        .ok()
190        .and_then(|o| String::from_utf8(o.stdout).ok())
191        .map(|s| PathBuf::from(s.trim()))
192        .unwrap_or_else(|| PathBuf::from(".git"));
193    git_dir.join("semantic-diff-cache").join("reviews")
194}
195
196fn review_cache_path(content_hash: u64) -> PathBuf {
197    review_cache_dir().join(format!("{}.json", content_hash))
198}
199
200/// Save a completed review to disk. Only saves if all sections succeeded
201/// (Ready or Skipped). Reviews with errors are not cached so they can be retried.
202pub fn save_review_to_disk(review: &GroupReview) {
203    // Don't cache if any section errored — those should be retried
204    let has_errors = review.sections.values().any(|s| matches!(s, SectionState::Error(_)));
205    if has_errors {
206        return;
207    }
208    let mut sections = HashMap::new();
209    for (sec, state) in &review.sections {
210        match state {
211            SectionState::Ready(content) => {
212                sections.insert(sec.label().to_string(), CachedSection::Ready(content.clone()));
213            }
214            SectionState::Skipped => {
215                sections.insert(sec.label().to_string(), CachedSection::Skipped);
216            }
217            _ => return, // Loading state shouldn't happen here, but bail if it does
218        }
219    }
220    if sections.len() < 4 {
221        return;
222    }
223    let cached = CachedReview {
224        content_hash: review.content_hash,
225        source: review.source.clone(),
226        sections,
227    };
228    let dir = review_cache_dir();
229    if std::fs::create_dir_all(&dir).is_err() {
230        return;
231    }
232    let path = review_cache_path(review.content_hash);
233    if let Ok(json) = serde_json::to_string_pretty(&cached) {
234        let _ = std::fs::write(path, json);
235    }
236}
237
238/// Load a review from disk cache, validating against the current review source.
239pub fn load_review_from_disk(content_hash: u64, current_source: &ReviewSource) -> Option<GroupReview> {
240    let path = review_cache_path(content_hash);
241    let data = std::fs::read_to_string(path).ok()?;
242    let cached: CachedReview = serde_json::from_str(&data).ok()?;
243
244    match (&cached.source, current_source) {
245        (ReviewSource::BuiltIn, ReviewSource::BuiltIn) => {}
246        (ReviewSource::Skill { path: p1, .. }, ReviewSource::Skill { path: p2, .. }) => {
247            if p1 != p2 {
248                return None;
249            }
250        }
251        _ => return None,
252    }
253
254    let mut sections = HashMap::new();
255    for sec in ReviewSection::all() {
256        if let Some(cached_sec) = cached.sections.get(sec.label()) {
257            match cached_sec {
258                CachedSection::Ready(content) => {
259                    sections.insert(sec, SectionState::Ready(content.clone()));
260                }
261                CachedSection::Skipped => {
262                    sections.insert(sec, SectionState::Skipped);
263                }
264            }
265        }
266    }
267
268    if sections.len() < 4 {
269        return None;
270    }
271
272    Some(GroupReview {
273        content_hash,
274        sections,
275        source: current_source.clone(),
276    })
277}
278
279/// Delete a disk cache entry (used for force-refresh).
280pub fn delete_review_from_disk(content_hash: u64) {
281    let path = review_cache_path(content_hash);
282    let _ = std::fs::remove_file(path);
283}