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 dv_dir() -> Result<PathBuf> {
let common = PathBuf::from(diff::git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?.trim());
let data = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.context("no $HOME")?;
let dir = data.join("dv").join(repo_key(&common));
let old = common.join("dv");
if old.is_dir() && !dir.exists() {
std::fs::create_dir_all(dir.parent().unwrap_or(&dir))?;
if std::fs::rename(&old, &dir).is_err() {
copy_dir(&old, &dir)?; }
}
Ok(dir)
}
fn repo_key(common: &std::path::Path) -> String {
let main = if common.file_name().is_some_and(|f| f == ".git") { common.parent().unwrap_or(common) } else { common };
main.to_string_lossy().trim_start_matches('/').replace(['/', '\\', ':'], "-")
}
fn copy_dir(from: &std::path::Path, to: &std::path::Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
let e = e?;
match e.file_type()?.is_dir() {
true => copy_dir(&e.path(), &to.join(e.file_name()))?,
false => _ = std::fs::copy(e.path(), to.join(e.file_name()))?,
}
}
Ok(())
}
pub fn path(pr: Option<u64>) -> Result<PathBuf> {
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(dv_dir()?.join("reviews").join(format!("{name}.json")))
}
pub fn notes_path() -> Result<PathBuf> {
Ok(dv_dir()?.join("notes.md"))
}
pub fn session_path(review: &std::path::Path) -> PathBuf {
review.with_extension("session")
}
pub fn load_session(review: &std::path::Path, dir: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(session_path(review)).ok()?;
let key = dir.to_string_lossy();
let id = text.lines().find_map(|l| l.split_once('\t').filter(|(d, _)| *d == key).map(|(_, id)| id.trim().to_owned()))?;
session_exists(&id).then_some(id)
}
pub fn save_session(review: &std::path::Path, dir: &std::path::Path, id: Option<&str>) -> Result<()> {
let p = session_path(review);
let key = dir.to_string_lossy();
let text = std::fs::read_to_string(&p).unwrap_or_default();
let mut lines: Vec<String> = text.lines().filter(|l| l.split_once('\t').is_none_or(|(d, _)| d != key)).map(str::to_owned).collect();
lines.extend(id.map(|id| format!("{key}\t{id}")));
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
match lines.is_empty() {
true => _ = std::fs::remove_file(&p),
false => std::fs::write(&p, lines.join("\n") + "\n")?,
}
Ok(())
}
pub fn session_exists(id: &str) -> bool {
let config = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude")));
let Some(Ok(dirs)) = config.map(|c| std::fs::read_dir(c.join("projects"))) else { return false };
dirs.flatten().any(|d| d.path().join(format!("{id}.jsonl")).exists())
}
pub fn code_at(f: &FileDiff, left: bool, first: u32, last: u32) -> Option<Vec<String>> {
let lines = side_lines(f, left);
(first..=last).map(|n| lines.iter().find(|l| l.0 == n).map(|l| l.1.to_owned())).collect()
}
pub fn new_session_id() -> String {
use std::io::Read;
let mut b = [0u8; 16];
if std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b)).is_err() {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
b = (t ^ ((std::process::id() as u128) << 64)).to_le_bytes();
}
(b[6], b[8]) = (b[6] & 0x0f | 0x40, b[8] & 0x3f | 0x80); let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[..8], &h[8..12], &h[12..16], &h[16..20], &h[20..])
}
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((root_author(c), s(&c["body"]), Some(s(&c["created"])).filter(|t| !t.is_empty()).unwrap_or(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)
}
fn root_author(c: &Value) -> String {
if c["author"] == USER { "you".into() } else { AUTHOR.into() }
}
pub fn add_comment(path: &std::path::Path, a: &crate::gh::Anchor, code: Vec<String>, body: &str) -> Result<String> {
let mut v: Value = match std::fs::read_to_string(path) {
Ok(t) => serde_json::from_str(&t)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({ "comments": [] }),
Err(e) => return Err(e.into()),
};
if !v["comments"].is_array() {
v["comments"] = serde_json::json!([]);
}
let comments = v["comments"].as_array_mut().context("no comments")?;
let mut c = serde_json::json!({
"path": a.path, "line": a.line, "side": if a.left { "old" } else { "new" },
"author": USER, "body": body, "created": now(), "code": code,
});
if let Some((s, _)) = a.start {
c["start_line"] = s.into();
}
comments.push(c);
assign_ids(comments);
let id = comments.last().and_then(|c| c["id"].as_str()).unwrap_or_default().to_owned();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
write(path, &v)?;
Ok(id)
}
pub fn dismiss(path: &std::path::Path, key: &str) -> Result<()> {
update(path, key, |c, i| _ = c.remove(i))
}
pub fn dismiss_all(path: &std::path::Path, keys: &[String]) -> Result<usize> {
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 before = comments.len();
comments.retain(|c| !c["id"].as_str().is_some_and(|id| keys.iter().any(|k| k == id)));
let gone = before - comments.len();
write(path, &v)?;
Ok(gone)
}
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 => {
match code_at(f, t.left, start.unwrap_or(line), line) {
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 comments_for_claude() {
let p = std::env::temp_dir().join(format!("dv-claude-ask-{}", std::process::id())).join("pr-1.json");
let a = crate::gh::Anchor { path: "src/a.rs".into(), line: 3, left: false, start: Some((2, false)) };
let id = add_comment(&p, &a, vec!["x".into(), "y".into()], "why?").unwrap(); let r = load(&p).unwrap().unwrap();
_ = std::fs::remove_dir_all(p.parent().unwrap());
let t = &r.threads[0];
assert_eq!((t.claude.as_deref(), t.start_line, t.line), (Some(id.as_str()), Some(2), Some(3)));
assert_eq!((t.comments[0].author.as_str(), t.comments[0].body.as_str()), ("you", "why?"));
assert_eq!(t.hunk, ["+x", "+y"]); }
#[test]
fn session_ids() {
let (a, b) = (new_session_id(), new_session_id());
assert_ne!(a, b);
assert_eq!(a.len(), 36);
assert_eq!(&a[14..15], "4"); assert_eq!(session_path(std::path::Path::new("/d/dv/r/reviews/pr-7.json")), std::path::Path::new("/d/dv/r/reviews/pr-7.session"));
assert_eq!(repo_key(std::path::Path::new("/home/me/Git/bdk/.git")), "home-me-Git-bdk"); let home = std::env::temp_dir().join(format!("dv-sessions-{}", std::process::id()));
let review = home.join("pr-7.json");
save_session(&review, std::path::Path::new("/a"), Some("id-a")).unwrap();
save_session(&review, std::path::Path::new("/b"), Some("id-b")).unwrap();
let text = std::fs::read_to_string(session_path(&review)).unwrap();
assert_eq!(text, "/a\tid-a\n/b\tid-b\n");
assert_eq!(load_session(&review, std::path::Path::new("/a")), None); save_session(&review, std::path::Path::new("/a"), None).unwrap();
assert_eq!(std::fs::read_to_string(session_path(&review)).unwrap(), "/b\tid-b\n");
_ = std::fs::remove_dir_all(&home);
}
#[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);
}