use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::cli::DiffRange;
use crate::diff::FileDiff;
use crate::state::{Comment, Side};
#[derive(Serialize, Deserialize)]
struct StoredComment {
file: String,
side: Side,
lineno: u32,
lineno_end: Option<u32>,
body: String,
#[serde(default)]
replies: Vec<String>,
}
fn storage_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".shuire").join("comments")
}
fn range_key(range: &DiffRange) -> String {
let mut hasher = DefaultHasher::new();
let repo_root = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
repo_root.hash(&mut hasher);
range.label().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn diff_fingerprint(files: &[FileDiff]) -> String {
let mut hasher = DefaultHasher::new();
for f in files {
f.path.hash(&mut hasher);
f.old_path.hash(&mut hasher);
std::mem::discriminant(&f.status).hash(&mut hasher);
for line in &f.lines {
std::mem::discriminant(&line.kind).hash(&mut hasher);
line.text.hash(&mut hasher);
line.old_lineno.hash(&mut hasher);
line.new_lineno.hash(&mut hasher);
}
}
format!("{:016x}", hasher.finish())
}
fn storage_path(range: &DiffRange, fingerprint: &str) -> PathBuf {
storage_dir().join(format!("{}-{}.json", range_key(range), fingerprint))
}
pub fn clear_comments(range: &DiffRange) {
clear_comments_in(&storage_dir(), range);
}
fn clear_comments_in(dir: &Path, range: &DiffRange) {
let prefix = format!("{}-", range_key(range));
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!("storage: read_dir {dir:?} failed: {e}");
return;
}
};
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name) = name.to_str()
&& name.starts_with(&prefix)
&& name.ends_with(".json")
{
let _ = std::fs::remove_file(entry.path());
}
}
}
pub fn save_comments(comments: &[Comment], range: &DiffRange, fingerprint: &str) {
save_comments_at(&storage_path(range, fingerprint), comments);
}
pub fn load_comments(range: &DiffRange, fingerprint: &str) -> Vec<Comment> {
load_comments_at(&storage_path(range, fingerprint))
}
fn save_comments_at(path: &Path, comments: &[Comment]) {
if comments.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
let stored: Vec<StoredComment> = comments
.iter()
.map(|c| StoredComment {
file: c.file.clone(),
side: c.side,
lineno: c.lineno,
lineno_end: c.lineno_end,
body: c.body.clone(),
replies: c.replies.clone(),
})
.collect();
if let Some(dir) = path.parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("storage: create_dir_all {dir:?} failed: {e}");
return;
}
}
match serde_json::to_string_pretty(&stored) {
Ok(json) => {
if let Err(e) = std::fs::write(path, json) {
warn!("storage: write {path:?} failed: {e}");
}
}
Err(e) => warn!("storage: serialize comments failed: {e}"),
}
}
fn load_comments_at(path: &Path) -> Vec<Comment> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
warn!("storage: read {path:?} failed: {e}");
return Vec::new();
}
};
let stored: Vec<StoredComment> = match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
warn!("storage: parse {path:?} failed: {e}");
return Vec::new();
}
};
stored
.into_iter()
.map(|s| Comment {
file: s.file,
side: s.side,
lineno: s.lineno,
lineno_end: s.lineno_end,
body: s.body,
replies: s.replies,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::parse_unified;
fn diff_a() -> Vec<FileDiff> {
parse_unified(
"diff --git a/src/x.rs b/src/x.rs\n\
--- a/src/x.rs\n\
+++ b/src/x.rs\n\
@@ -1,3 +1,3 @@\n\
-old line\n\
+new line\n\
context\n",
)
}
fn diff_b_textual() -> Vec<FileDiff> {
parse_unified(
"diff --git a/src/x.rs b/src/x.rs\n\
--- a/src/x.rs\n\
+++ b/src/x.rs\n\
@@ -1,3 +1,3 @@\n\
-old line\n\
+DIFFERENT new line\n\
context\n",
)
}
fn tmp_path(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"shuire-storage-{}-{}-{}",
std::process::id(),
name,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
p
}
fn sample_comment() -> Comment {
Comment {
file: "src/main.rs".to_string(),
side: Side::New,
lineno: 42,
lineno_end: Some(44),
body: "Looks wrong\n日本語 body".to_string(),
replies: vec!["ack".to_string(), "lgtm 🎉".to_string()],
}
}
#[test]
fn save_and_load_round_trip_preserves_all_fields() {
let path = tmp_path("roundtrip.json");
let comments = vec![sample_comment()];
save_comments_at(&path, &comments);
let loaded = load_comments_at(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(loaded.len(), 1);
let a = &loaded[0];
let b = &comments[0];
assert_eq!(a.file, b.file);
assert_eq!(a.side, b.side);
assert_eq!(a.lineno, b.lineno);
assert_eq!(a.lineno_end, b.lineno_end);
assert_eq!(a.body, b.body);
assert_eq!(a.replies, b.replies);
}
#[test]
fn save_empty_clears_file_on_disk() {
let path = tmp_path("empty.json");
save_comments_at(&path, &[sample_comment()]);
assert!(path.exists(), "precondition: file written");
save_comments_at(&path, &[]);
assert!(!path.exists(), "empty save should remove the file");
}
#[test]
fn load_missing_file_returns_empty_vec() {
let path = tmp_path("never-exists.json");
let loaded = load_comments_at(&path);
assert!(loaded.is_empty());
}
#[test]
fn load_malformed_json_returns_empty_without_panic() {
let path = tmp_path("malformed.json");
std::fs::write(&path, "not json").unwrap();
let loaded = load_comments_at(&path);
let _ = std::fs::remove_file(&path);
assert!(loaded.is_empty());
}
#[test]
fn diff_fingerprint_is_stable_for_identical_diff() {
let a = diff_a();
let b = diff_a();
assert_eq!(diff_fingerprint(&a), diff_fingerprint(&b));
}
#[test]
fn diff_fingerprint_changes_when_diff_text_changes() {
let a = diff_fingerprint(&diff_a());
let b = diff_fingerprint(&diff_b_textual());
assert_ne!(a, b, "different diff text must yield different fingerprint");
}
#[test]
fn clear_comments_removes_all_fingerprints_for_range() {
let dir = tmp_path("clear-fingerprints");
std::fs::create_dir_all(&dir).unwrap();
let range = DiffRange::Stdin;
let prefix = format!("{}-", range_key(&range));
let path_a = dir.join(format!("{prefix}fp_one.json"));
let path_b = dir.join(format!("{prefix}fp_two.json"));
let unrelated = dir.join("deadbeef-other.json");
save_comments_at(&path_a, &[sample_comment()]);
save_comments_at(&path_b, &[sample_comment()]);
save_comments_at(&unrelated, &[sample_comment()]);
clear_comments_in(&dir, &range);
assert!(!path_a.exists(), "fingerprint variant a should be removed");
assert!(!path_b.exists(), "fingerprint variant b should be removed");
assert!(unrelated.exists(), "unrelated range must be untouched");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_creates_parent_directories() {
let mut path = std::env::temp_dir();
path.push(format!(
"shuire-storage-mkdir-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
path.push("nested");
path.push("dir");
path.push("comments.json");
save_comments_at(&path, &[sample_comment()]);
assert!(path.exists(), "parent dirs should have been created");
let _ = std::fs::remove_file(&path);
if let Some(p) = path.parent() {
let _ = std::fs::remove_dir_all(p);
}
}
}