pub mod llm;
use serde::{Serialize, Deserialize};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ReviewSection {
Why,
What,
How,
Verdict,
}
impl ReviewSection {
pub fn label(&self) -> &'static str {
match self {
Self::Why => "WHY",
Self::What => "WHAT",
Self::How => "HOW",
Self::Verdict => "VERDICT",
}
}
pub fn all() -> [ReviewSection; 4] {
[Self::Why, Self::What, Self::How, Self::Verdict]
}
}
#[derive(Debug, Clone)]
pub enum SectionState {
Loading,
Ready(String),
Error(String),
Skipped,
}
impl SectionState {
pub fn is_complete(&self) -> bool {
matches!(self, Self::Ready(_) | Self::Error(_) | Self::Skipped)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReviewSource {
Skill { name: String, path: PathBuf },
BuiltIn,
}
#[derive(Debug, Clone)]
pub struct GroupReview {
pub content_hash: u64,
pub sections: HashMap<ReviewSection, SectionState>,
pub source: ReviewSource,
}
const MAX_CACHED_REVIEWS: usize = 20;
pub struct ReviewCache {
entries: HashMap<u64, GroupReview>,
order: VecDeque<u64>,
}
impl Default for ReviewCache {
fn default() -> Self {
Self::new()
}
}
impl ReviewCache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
order: VecDeque::new(),
}
}
pub fn get(&self, hash: &u64) -> Option<&GroupReview> {
self.entries.get(hash)
}
pub fn get_mut(&mut self, hash: &u64) -> Option<&mut GroupReview> {
if self.entries.contains_key(hash) {
self.order.retain(|h| h != hash);
self.order.push_back(*hash);
}
self.entries.get_mut(hash)
}
pub fn insert(&mut self, review: GroupReview) {
let hash = review.content_hash;
if self.entries.contains_key(&hash) {
self.order.retain(|h| *h != hash);
} else if self.entries.len() >= MAX_CACHED_REVIEWS {
if let Some(old) = self.order.pop_front() {
self.entries.remove(&old);
}
}
self.order.push_back(hash);
self.entries.insert(hash, review);
}
pub fn remove(&mut self, hash: &u64) {
self.entries.remove(hash);
self.order.retain(|h| h != hash);
}
}
pub fn group_content_hash(group: &crate::grouper::SemanticGroup) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
group.label.hash(&mut hasher);
let mut changes = group.changes();
changes.sort_by(|a, b| a.file.cmp(&b.file));
for c in &changes {
c.file.hash(&mut hasher);
c.hunks.hash(&mut hasher);
}
hasher.finish()
}
pub fn detect_review_skill() -> ReviewSource {
if let Some(found) = scan_skills_dir(".claude/skills") {
return found;
}
if let Some(home) = dirs::home_dir() {
let global = home.join(".claude").join("skills");
if let Some(found) = scan_skills_dir(global) {
return found;
}
}
ReviewSource::BuiltIn
}
fn scan_skills_dir(dir: impl AsRef<std::path::Path>) -> Option<ReviewSource> {
let dir = dir.as_ref();
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_stem()?.to_string_lossy().to_string();
if name.to_lowercase().contains("review") {
return Some(ReviewSource::Skill {
name,
path: path.clone(),
});
}
}
}
None
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedReview {
pub content_hash: u64,
pub source: ReviewSource,
pub sections: HashMap<String, CachedSection>,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CachedSection {
Ready(String),
Skipped,
}
fn review_cache_dir() -> PathBuf {
let git_dir = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| PathBuf::from(s.trim()))
.unwrap_or_else(|| PathBuf::from(".git"));
git_dir.join("semantic-diff-cache").join("reviews")
}
fn review_cache_path(content_hash: u64) -> PathBuf {
review_cache_dir().join(format!("{}.json", content_hash))
}
pub fn save_review_to_disk(review: &GroupReview) {
let has_errors = review.sections.values().any(|s| matches!(s, SectionState::Error(_)));
if has_errors {
return;
}
let mut sections = HashMap::new();
for (sec, state) in &review.sections {
match state {
SectionState::Ready(content) => {
sections.insert(sec.label().to_string(), CachedSection::Ready(content.clone()));
}
SectionState::Skipped => {
sections.insert(sec.label().to_string(), CachedSection::Skipped);
}
_ => return, }
}
if sections.len() < 4 {
return;
}
let cached = CachedReview {
content_hash: review.content_hash,
source: review.source.clone(),
sections,
};
let dir = review_cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = review_cache_path(review.content_hash);
if let Ok(json) = serde_json::to_string_pretty(&cached) {
let _ = std::fs::write(path, json);
}
}
pub fn load_review_from_disk(content_hash: u64, current_source: &ReviewSource) -> Option<GroupReview> {
let path = review_cache_path(content_hash);
let data = std::fs::read_to_string(path).ok()?;
let cached: CachedReview = serde_json::from_str(&data).ok()?;
match (&cached.source, current_source) {
(ReviewSource::BuiltIn, ReviewSource::BuiltIn) => {}
(ReviewSource::Skill { path: p1, .. }, ReviewSource::Skill { path: p2, .. }) => {
if p1 != p2 {
return None;
}
}
_ => return None,
}
let mut sections = HashMap::new();
for sec in ReviewSection::all() {
if let Some(cached_sec) = cached.sections.get(sec.label()) {
match cached_sec {
CachedSection::Ready(content) => {
sections.insert(sec, SectionState::Ready(content.clone()));
}
CachedSection::Skipped => {
sections.insert(sec, SectionState::Skipped);
}
}
}
}
if sections.len() < 4 {
return None;
}
Some(GroupReview {
content_hash,
sections,
source: current_source.clone(),
})
}
pub fn delete_review_from_disk(content_hash: u64) {
let path = review_cache_path(content_hash);
let _ = std::fs::remove_file(path);
}