1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8use crate::git::git;
9use crate::review::FileReview;
10
11pub struct ReviewStore {
18 path: Option<PathBuf>,
19 marks: HashSet<(String, u64)>,
20}
21
22impl ReviewStore {
23 pub fn open(dir: &Path) -> ReviewStore {
26 match state_dir(dir) {
27 Some(state) => ReviewStore::at(state.join("reviewed.tsv")),
28 None => ReviewStore::in_memory(),
29 }
30 }
31
32 pub fn at(path: PathBuf) -> ReviewStore {
33 let marks = fs::read_to_string(&path)
34 .unwrap_or_default()
35 .lines()
36 .filter_map(|line| {
37 let (hash, file) = line.split_once('\t')?;
38 Some((file.to_string(), u64::from_str_radix(hash, 16).ok()?))
39 })
40 .collect();
41 ReviewStore {
42 path: Some(path),
43 marks,
44 }
45 }
46
47 pub fn in_memory() -> ReviewStore {
48 ReviewStore {
49 path: None,
50 marks: HashSet::new(),
51 }
52 }
53
54 pub fn is_reviewed(&self, file: &FileReview) -> bool {
55 self.marks.contains(&key(file))
56 }
57
58 pub fn set_reviewed(&mut self, file: &FileReview, reviewed: bool) -> Result<()> {
59 if reviewed {
60 self.marks.insert(key(file));
61 } else {
62 self.marks.remove(&key(file));
63 }
64 self.save()
65 }
66
67 fn save(&self) -> Result<()> {
68 let Some(path) = &self.path else {
69 return Ok(());
70 };
71 let mut lines: Vec<String> = self
72 .marks
73 .iter()
74 .map(|(file, hash)| format!("{hash:016x}\t{file}"))
75 .collect();
76 lines.sort();
77 write_atomically(path, &(lines.join("\n") + "\n"))
78 }
79}
80
81pub fn state_dir(dir: &Path) -> Option<PathBuf> {
83 let common = git(
84 dir,
85 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
86 )
87 .ok()?;
88 Some(PathBuf::from(String::from_utf8(common).ok()?.trim()).join("declutter"))
89}
90
91fn write_atomically(path: &Path, contents: &str) -> Result<()> {
92 let dir = path.parent().context("state file has no directory")?;
93 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
94 let tmp = path.with_extension("tmp");
95 fs::write(&tmp, contents).with_context(|| format!("writing {}", tmp.display()))?;
96 fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum NoteSide {
103 Old,
105 New,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Note {
111 pub path: String,
112 pub side: NoteSide,
113 pub line: usize,
114 pub code: String,
116 pub text: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub review: Option<String>,
121}
122
123pub struct NoteStore {
127 path: Option<PathBuf>,
128 notes: Vec<Note>,
129 scope: Option<String>,
130}
131
132impl NoteStore {
133 pub fn open(dir: &Path) -> NoteStore {
134 match state_dir(dir) {
135 Some(state) => NoteStore::at(state.join("notes.json")),
136 None => NoteStore::in_memory(),
137 }
138 }
139
140 pub fn at(path: PathBuf) -> NoteStore {
141 let notes = fs::read(&path)
142 .ok()
143 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
144 .unwrap_or_default();
145 NoteStore {
146 path: Some(path),
147 notes,
148 scope: None,
149 }
150 }
151
152 pub fn in_memory() -> NoteStore {
153 NoteStore {
154 path: None,
155 notes: Vec::new(),
156 scope: None,
157 }
158 }
159
160 pub fn scoped(mut self, review: impl Into<String>) -> NoteStore {
162 self.scope = Some(review.into());
163 self
164 }
165
166 fn in_scope(&self, note: &Note) -> bool {
167 self.scope.is_none() || note.review == self.scope
168 }
169
170 pub fn notes(&self) -> Vec<&Note> {
171 self.notes
172 .iter()
173 .filter(|note| self.in_scope(note))
174 .collect()
175 }
176
177 pub fn export_path(&self) -> Option<PathBuf> {
179 Some(self.path.as_ref()?.with_file_name("review-notes.md"))
180 }
181
182 pub fn find(&self, path: &str, side: NoteSide, line: usize) -> Option<&Note> {
183 self.notes.iter().find(|note| {
184 self.in_scope(note) && note.path == path && note.side == side && note.line == line
185 })
186 }
187
188 pub fn set(&mut self, mut note: Note) -> Result<()> {
190 note.review = self.scope.clone();
191 self.notes.retain(|n| {
192 !(n.review == note.review
193 && n.path == note.path
194 && n.side == note.side
195 && n.line == note.line)
196 });
197 if !note.text.trim().is_empty() {
198 self.notes.push(note);
199 self.notes
200 .sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
201 }
202 self.save()
203 }
204
205 pub fn clear(&mut self) -> Result<()> {
207 let scope = self.scope.clone();
208 self.notes
209 .retain(|note| scope.is_some() && note.review != scope);
210 self.save()
211 }
212
213 pub fn remove(&mut self, gone: &[Note]) -> Result<()> {
215 self.notes.retain(|note| !gone.contains(note));
216 self.save()
217 }
218
219 pub fn prompt(&self) -> String {
221 let mut out = String::from(
222 "Please address these review comments. Line numbers refer to the version under review.\n",
223 );
224 for (i, note) in self.notes().into_iter().enumerate() {
225 out.push_str(&format!(
226 "\n{}. {}: {}\n ```\n {}\n ```\n",
227 i + 1,
228 note.place(),
229 note.text.trim(),
230 note.code.trim()
231 ));
232 }
233 out
234 }
235
236 fn save(&self) -> Result<()> {
237 let Some(path) = &self.path else {
238 return Ok(());
239 };
240 write_atomically(path, &serde_json::to_string_pretty(&self.notes)?)
241 }
242}
243
244impl Note {
245 pub fn place(&self) -> String {
247 match self.side {
248 NoteSide::New => format!("`{}:{}`", self.path, self.line),
249 NoteSide::Old => format!("`{}` (removed line {})", self.path, self.line),
250 }
251 }
252}
253
254fn key(file: &FileReview) -> (String, u64) {
255 (file.path.clone(), file.fingerprint)
256}
257
258pub(crate) fn fingerprint(parts: &[&str]) -> u64 {
260 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
261 for (i, part) in parts.iter().enumerate() {
262 if i > 0 {
263 hash ^= 0xff;
264 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
265 }
266 for byte in part.bytes() {
267 hash ^= u64::from(byte);
268 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
269 }
270 }
271 hash
272}