use crate::diff::{self, FileDiff, Kind};
use crate::gh::{Comment, Thread};
use anyhow::{Context, Result};
use serde_json::Value;
use std::path::PathBuf;
pub const AUTHOR: &str = "claude";
const USER: &str = "user";
pub struct Review {
pub head: String,
pub summary: String,
pub threads: Vec<Thread>,
}
pub fn path(pr: Option<u64>) -> Result<PathBuf> {
let common = diff::git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
let name = match pr {
Some(n) => format!("pr-{n}"),
None => match diff::git(&["branch", "--show-current"])?.trim() {
"" => "detached".into(),
b => format!("branch-{}", b.replace('/', "-")),
},
};
Ok(PathBuf::from(common.trim()).join("dv").join("reviews").join(format!("{name}.json")))
}
pub fn load_with_ids(path: &std::path::Path) -> Result<Option<Review>> {
let r = load(path)?;
if r.as_ref().is_some_and(|r| r.threads.iter().any(|t| t.claude.as_deref().is_some_and(|k| k.starts_with('#')))) {
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
if v["comments"].as_array_mut().is_some_and(|c| assign_ids(c)) {
write(path, &v)?;
}
return load(path);
}
Ok(r)
}
pub fn load(path: &std::path::Path) -> Result<Option<Review>> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).context(format!("reading {}", path.display())),
};
parse(&text).map(Some).with_context(|| format!("in {}", path.display()))
}
fn parse(text: &str) -> Result<Review> {
let v: Value = serde_json::from_str(text)?;
let s = |v: &Value| v.as_str().unwrap_or("").to_owned();
let created = s(&v["created"]);
let comments = v["comments"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let threads = comments
.iter()
.enumerate()
.map(|(i, c)| {
let line = |k: &str| c[k].as_u64().map(|n| n as u32);
let path = s(&c["path"]);
anyhow::ensure!(!path.is_empty(), "comment {} has no \"path\"", i + 1);
let end = line("line").with_context(|| format!("comment {} has no \"line\"", i + 1))?;
Ok(Thread {
id: String::new(),
path,
left: c["side"] == "old",
line: Some(end),
start_line: line("start_line").filter(|&s| s < end),
original_line: Some(end),
resolved: c["resolved"] == true,
outdated: false,
hunk: c["code"]
.as_array()
.into_iter()
.flatten()
.map(|l| format!("{}{}", if c["side"] == "old" { '-' } else { '+' }, l.as_str().unwrap_or("")))
.collect(),
claude: Some(c["id"].as_str().map_or(format!("#{i}"), str::to_owned)),
comments: std::iter::once((AUTHOR.to_owned(), s(&c["body"]), created.clone()))
.chain(c["replies"].as_array().into_iter().flatten().map(|r| {
let who = s(&r["author"]);
(if who == USER { "you".into() } else { who }, s(&r["body"]), s(&r["created"]))
}))
.enumerate()
.map(|(k, (author, body, created))| Comment {
id: comment_id(c["id"].as_str().unwrap_or(&format!("#{i}")), k),
node_id: String::new(),
can_edit: false,
can_delete: false,
author,
body,
created,
pending: false,
})
.collect(),
})
})
.collect::<Result<_>>()?;
Ok(Review { head: s(&v["head"]), summary: s(&v["summary"]), threads })
}
fn comment_id(key: &str, k: usize) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(key, k).hash(&mut h);
h.finish() | 1 << 63
}
fn assign_ids(comments: &mut [Value]) -> bool {
let num = |c: &Value| c["id"].as_str().and_then(|s| s.strip_prefix('c')?.parse::<u64>().ok());
let mut next = comments.iter().filter_map(num).max().unwrap_or(0) + 1;
let mut changed = false;
for c in comments.iter_mut().filter(|c| !c["id"].is_string()) {
c["id"] = format!("c{next}").into();
(next, changed) = (next + 1, true);
}
changed
}
fn write(path: &std::path::Path, v: &Value) -> Result<()> {
let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
std::fs::write(&tmp, serde_json::to_string_pretty(v)? + "\n")?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn update(path: &std::path::Path, key: &str, f: impl FnOnce(&mut Vec<Value>, usize)) -> Result<()> {
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
let comments = v["comments"].as_array_mut().context("no comments")?;
let i = match key.strip_prefix('#') {
Some(n) => n.parse::<usize>().ok().filter(|&i| comments.get(i).is_some_and(|c| !c["id"].is_string())),
None => comments.iter().position(|c| c["id"] == key),
};
let i = i.context("that comment is no longer in the file (Claude may have just changed it): try again")?;
assign_ids(comments);
f(comments, i);
write(path, &v)
}
pub fn dismiss(path: &std::path::Path, key: &str) -> Result<()> {
update(path, key, |c, i| _ = c.remove(i))
}
pub fn reply(path: &std::path::Path, key: &str, body: &str) -> Result<()> {
update(path, key, |c, i| {
if !c[i]["replies"].is_array() {
c[i]["replies"] = serde_json::json!([]);
}
let r = serde_json::json!({ "author": USER, "body": body, "created": now() });
if let Some(a) = c[i]["replies"].as_array_mut() {
a.push(r);
}
})
}
pub fn set_resolved(path: &std::path::Path, key: &str, on: bool) -> Result<()> {
update(path, key, |c, i| c[i]["resolved"] = Value::Bool(on))
}
fn now() -> String {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
let (days, secs) = (t.div_euclid(86400), t.rem_euclid(86400));
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + (m <= 2) as i64;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
}
#[derive(Debug, PartialEq)]
pub enum Found {
Unknown,
Here,
Moved(Option<u32>, u32),
Gone,
}
fn side_lines(f: &FileDiff, left: bool) -> Vec<(u32, &str)> {
f.full
.iter()
.filter_map(|l| match (left, l.kind) {
(true, Kind::Ctx | Kind::Del) => Some((l.old?, l.text.as_str())),
(false, Kind::Ctx | Kind::Add) => Some((l.new?, l.text.as_str())),
_ => None,
})
.collect()
}
pub fn locate(t: &Thread, f: &FileDiff) -> Found {
let code: Vec<&str> = t.hunk.iter().map(|l| l.get(1..).unwrap_or("").trim_end()).collect();
let Some(line) = t.line.or(t.original_line) else { return Found::Gone };
if code.is_empty() {
return Found::Unknown;
}
let start = t.start_line.unwrap_or(line);
let lines = side_lines(f, t.left);
let at = |i: usize| lines[i..].iter().take(code.len()).map(|l| l.1.trim_end()).eq(code.iter().copied());
let hits = (0..lines.len()).filter(|&i| at(i));
match hits.min_by_key(|&i| (lines[i].0 as i64 - start as i64).abs()) {
Some(i) if lines[i].0 == start => Found::Here,
Some(i) => {
let s = lines[i].0;
Found::Moved(t.start_line.map(|_| s), s + (line - start))
}
None => Found::Gone,
}
}
pub fn reanchor(threads: &mut [Thread], files: &[FileDiff]) {
for t in threads.iter_mut().filter(|t| t.claude.is_some()) {
let found = match files.iter().find(|f| f.path == t.path) {
Some(f) => locate(t, f),
None => Found::Gone,
};
match found {
Found::Unknown | Found::Here => {}
Found::Moved(s, l) => (t.start_line, t.line) = (s, Some(l)),
Found::Gone => (t.outdated, t.line) = (true, None),
}
}
}
pub struct Report {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub notes: Vec<String>,
pub comments: usize,
}
pub fn check(path: &std::path::Path, files: &[FileDiff]) -> Result<Report> {
let r = load(path)?.with_context(|| format!("no review at {}", path.display()))?;
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
let mut rep = Report { errors: vec![], warnings: vec![], notes: vec![], comments: r.threads.len() };
let mut changed = v["comments"].as_array_mut().is_some_and(|c| assign_ids(c));
for (i, t) in r.threads.iter().enumerate() {
let c = &mut v["comments"][i];
let at = format!("comment {} ({}:{})", i + 1, t.path, t.line.unwrap_or(0));
let Some(f) = files.iter().find(|f| f.path == t.path) else {
rep.errors.push(format!("{at}: {} isn't in the diff", t.path));
continue;
};
let (mut start, mut line) = (t.start_line, t.line.unwrap_or(0));
match locate(t, f) {
Found::Here => {}
Found::Moved(s, l) => {
rep.notes.push(format!("{at}: its code moved, now at line {l}"));
(start, line) = (s, l);
c["line"] = l.into();
if let Some(s) = s {
c["start_line"] = s.into();
}
changed = true;
}
Found::Gone => {
rep.warnings.push(format!(
"{at}: the code it was on has changed, so dv shows it as outdated; to re-anchor it, set its \"line\" (and \"start_line\") and delete its \"code\""
));
continue;
}
Found::Unknown => {
let lines = side_lines(f, t.left);
let code: Option<Vec<&str>> =
(start.unwrap_or(line)..=line).map(|n| lines.iter().find(|l| l.0 == n).map(|l| l.1)).collect();
match code {
Some(code) => {
c["code"] = serde_json::json!(code);
changed = true;
}
None => {
let side = if t.left { "old" } else { "new" };
rep.errors.push(format!("{at}: lines {}-{line} don't all exist on the {side} side", start.unwrap_or(line)));
continue;
}
}
}
}
let full_at = |n: u32| f.full.iter().position(|l| if t.left { l.old == Some(n) && l.kind != Kind::Add } else { l.new == Some(n) && l.kind != Kind::Del });
for n in start.into_iter().chain([line]) {
let Some(i) = full_at(n) else { continue };
let near = &f.full[i.saturating_sub(3)..(i + 4).min(f.full.len())];
if !near.iter().any(|l| matches!(l.kind, Kind::Add | Kind::Del)) {
rep.warnings.push(format!("{at}: line {n} is outside the diff's hunks, so it can't become a GitHub comment"));
}
}
}
if changed {
write(path, &v)?;
}
Ok(rep)
}
#[test]
fn parse_and_dismiss() {
let text = r#"{ "head": "abc", "summary": "ok", "comments": [
{ "path": "src/a.rs", "line": 3, "start_line": 2, "body": "x" },
{ "path": "src/a.rs", "line": 1, "side": "old", "body": "y", "extra": true } ] }"#;
let r = parse(text).unwrap();
assert_eq!((r.head.as_str(), r.summary.as_str(), r.threads.len()), ("abc", "ok", 2));
assert_eq!((r.threads[0].start_line, r.threads[0].line, r.threads[0].left), (Some(2), Some(3), false));
assert!(r.threads[1].left);
assert!(parse(r#"{ "comments": [ { "line": 1 } ] }"#).is_err()); let p = std::env::temp_dir().join(format!("dv-claude-test-{}.json", std::process::id()));
std::fs::write(&p, text).unwrap();
reply(&p, "#1", "why?").unwrap(); assert!(set_resolved(&p, "#1", true).is_err()); set_resolved(&p, "c2", true).unwrap();
let r = load(&p).unwrap().unwrap();
let t = &r.threads[1];
assert!(t.resolved);
assert_eq!((t.comments[1].author.as_str(), t.comments[1].body.as_str()), ("you", "why?"));
assert_eq!(t.comments[1].created.len(), "2026-10-16T12:00:00Z".len());
assert_eq!(r.threads[0].claude.as_deref(), Some("c1"));
dismiss(&p, "c1").unwrap();
assert_eq!(load(&p).unwrap().unwrap().threads[0].claude.as_deref(), Some("c2")); let left = std::fs::read_to_string(&p).unwrap();
_ = std::fs::remove_file(&p);
assert!(!left.contains("\"x\"") && left.contains("\"extra\": true"));
}
#[test]
fn anchoring() {
let mut f = diff::parse(diff::SAMPLE).remove(0);
f.prepare();
let p = std::env::temp_dir().join(format!("dv-claude-anchor-{}.json", std::process::id()));
std::fs::write(&p, r#"{ "comments": [
{ "path": "src/a.rs", "line": 2, "body": "a" },
{ "path": "src/a.rs", "line": 2, "side": "old", "body": "b" },
{ "path": "src/a.rs", "line": 9, "body": "c" } ] }"#).unwrap();
let rep = check(&p, std::slice::from_ref(&f)).unwrap();
assert_eq!(rep.errors.len(), 1); let mut r = load(&p).unwrap().unwrap();
assert_eq!(r.threads[0].hunk, ["+ new_call(x);"]); assert_eq!(r.threads[1].hunk, ["- old();"]);
assert_eq!(locate(&r.threads[0], &f), Found::Here);
r.threads[0].line = Some(1);
assert_eq!(locate(&r.threads[0], &f), Found::Moved(None, 2));
r.threads[1].hunk = vec!["- gone();".into()];
reanchor(&mut r.threads, std::slice::from_ref(&f));
assert_eq!((r.threads[0].line, r.threads[0].outdated), (Some(2), false));
assert_eq!((r.threads[1].line, r.threads[1].outdated), (None, true));
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
v["comments"][0]["line"] = 1.into();
std::fs::write(&p, v.to_string()).unwrap();
let rep = check(&p, std::slice::from_ref(&f)).unwrap();
assert_eq!(rep.notes.len(), 1);
assert_eq!(load(&p).unwrap().unwrap().threads[0].line, Some(2));
_ = std::fs::remove_file(&p);
}