semantic_diff/review/
mod.rs1pub mod llm;
2
3use serde::{Serialize, Deserialize};
4use std::collections::{HashMap, VecDeque};
5use std::path::PathBuf;
6
7#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub enum ReviewSource {
49 Skill { name: String, path: PathBuf },
51 BuiltIn,
53}
54
55#[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
65pub 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 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 self.order.retain(|h| *h != hash);
103 } else if self.entries.len() >= MAX_CACHED_REVIEWS {
104 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
119pub 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
136pub fn detect_review_skill() -> ReviewSource {
139 if let Some(found) = scan_skills_dir(".claude/skills") {
141 return found;
142 }
143 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#[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
200pub fn save_review_to_disk(review: &GroupReview) {
203 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, }
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
238pub 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
279pub fn delete_review_from_disk(content_hash: u64) {
281 let path = review_cache_path(content_hash);
282 let _ = std::fs::remove_file(path);
283}