use crate::comments::model::CommentStore;
use crate::diff::{model::Changeset, parse};
use anyhow::{Context, Result};
use std::io::Read;
use std::path::Path;
pub fn load_patch(path: Option<&Path>) -> Result<Changeset> {
let text = read_patch(path)?;
let (changeset, parse_err) = parse::parse_report(&text);
if let Some(err) = parse_err {
eprintln!("hew: warning: input looks like a patch but failed to parse: {err}");
}
Ok(changeset)
}
pub fn load_comments(path: &Path) -> Result<CommentStore> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading comments file {}", path.display()))?;
let is_array = text.trim_start().starts_with('[');
if is_array {
let threads = serde_json::from_str(&text)
.with_context(|| format!("parsing comments JSON array {}", path.display()))?;
Ok(CommentStore { threads })
} else {
serde_json::from_str::<CommentStore>(&text)
.with_context(|| format!("parsing comments JSON {}", path.display()))
}
}
pub fn load_comments_or_default(path: &Path) -> Result<CommentStore> {
if path.exists() {
load_comments(path)
} else {
Ok(CommentStore::default())
}
}
fn read_patch(path: Option<&Path>) -> Result<String> {
match path {
Some(p) if p.as_os_str() != "-" => {
std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))
}
_ => {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading patch from stdin")?;
Ok(buf)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_temp(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("hew_test_{tag}_{}.json", uuid::Uuid::new_v4()))
}
#[test]
fn loads_handwritten_comments() {
let json = r#"{
"threads": [
{
"file": "src/main.rs",
"side": "new",
"range": { "start": 10, "end": 12 },
"comments": [
{ "author": "agent", "body": "this range looks off" },
{ "author": "you", "body": "good catch" }
]
}
]
}"#;
let path = unique_temp("comments");
std::fs::write(&path, json).unwrap();
let store = load_comments(&path).unwrap();
assert_eq!(store.threads.len(), 1);
let t = &store.threads[0];
assert_eq!(t.range.start, 10);
assert_eq!(t.comments.len(), 2);
assert!(!t.resolved); let _ = std::fs::remove_file(&path);
}
#[test]
fn loads_bare_thread_array() {
let json = r#"[
{ "file": "a.rs", "side": "new", "range": { "start": 1, "end": 1 },
"comments": [ { "author": "x", "body": "hi" } ] }
]"#;
let path = unique_temp("array");
std::fs::write(&path, json).unwrap();
let store = load_comments(&path).unwrap();
assert_eq!(store.threads.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn store_shape_error_is_not_masked_by_array_fallback() {
let json = r#"{ "threads": [ { "file": "a.rs", "side": "sideways",
"range": { "start": 1, "end": 1 }, "comments": [] } ] }"#;
let path = unique_temp("bad_store");
std::fs::write(&path, json).unwrap();
let err = load_comments(&path).unwrap_err().to_string();
assert!(err.contains("parsing comments JSON"), "got: {err}");
assert!(!err.contains("array"), "should not mention array: {err}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn preserves_non_uuid_ids_verbatim_and_deterministically() {
let json = r#"{
"threads": [
{
"id": "PRRT_kwDOS",
"file": "a.rs", "side": "new",
"range": { "start": 1, "end": 1 },
"comments": [
{ "id": "1234567890", "author": "x", "body": "hi" }
]
}
]
}"#;
let path = unique_temp("non_uuid_ids");
std::fs::write(&path, json).unwrap();
let a = load_comments(&path).unwrap();
assert_eq!(a.threads.len(), 1);
assert_eq!(a.threads[0].comments.len(), 1);
assert_eq!(a.threads[0].id, "PRRT_kwDOS");
assert_eq!(a.threads[0].comments[0].id, "1234567890");
let b = load_comments(&path).unwrap();
assert_eq!(a.threads[0].id, b.threads[0].id);
assert_eq!(a.threads[0].comments[0].id, b.threads[0].comments[0].id);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_or_default_is_empty_when_missing() {
let path = unique_temp("missing");
let _ = std::fs::remove_file(&path);
let store = load_comments_or_default(&path).unwrap();
assert!(store.threads.is_empty());
}
}