1use std::collections::HashSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10use crate::diff::RowKind;
11use crate::git::git;
12use crate::review::{DiffModes, FileReview};
13
14pub struct ReviewStore {
21 path: Option<PathBuf>,
22 marks: HashSet<(String, u64)>,
23}
24
25impl ReviewStore {
26 pub fn open(dir: &Path) -> ReviewStore {
29 match state_dir(dir) {
30 Some(state) => ReviewStore::at(state.join("reviewed.tsv")),
31 None => ReviewStore::in_memory(),
32 }
33 }
34
35 pub fn at(path: PathBuf) -> ReviewStore {
36 let marks = fs::read_to_string(&path)
37 .unwrap_or_default()
38 .lines()
39 .filter_map(|line| {
40 let (hash, file) = line.split_once('\t')?;
41 Some((file.to_string(), u64::from_str_radix(hash, 16).ok()?))
42 })
43 .collect();
44 ReviewStore {
45 path: Some(path),
46 marks,
47 }
48 }
49
50 pub fn in_memory() -> ReviewStore {
51 ReviewStore {
52 path: None,
53 marks: HashSet::new(),
54 }
55 }
56
57 pub fn is_reviewed(&self, file: &FileReview) -> bool {
58 self.marks.contains(&key(file))
59 }
60
61 pub fn set_reviewed(&mut self, file: &FileReview, reviewed: bool) -> Result<()> {
62 if reviewed {
63 self.marks.insert(key(file));
64 } else {
65 self.marks.remove(&key(file));
66 }
67 self.save()
68 }
69
70 fn save(&self) -> Result<()> {
71 let Some(path) = &self.path else {
72 return Ok(());
73 };
74 let mut lines: Vec<String> = self
75 .marks
76 .iter()
77 .map(|(file, hash)| format!("{hash:016x}\t{file}"))
78 .collect();
79 lines.sort();
80 write_atomically(path, &(lines.join("\n") + "\n"))
81 }
82}
83
84pub fn state_dir(dir: &Path) -> Option<PathBuf> {
86 let common = git(
87 dir,
88 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
89 )
90 .ok()?;
91 Some(PathBuf::from(String::from_utf8(common).ok()?.trim()).join("declutter"))
92}
93
94fn write_atomically(path: &Path, contents: &str) -> Result<()> {
95 let dir = path.parent().context("state file has no directory")?;
96 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
97 let tmp = path.with_extension("tmp");
98 fs::write(&tmp, contents).with_context(|| format!("writing {}", tmp.display()))?;
99 fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum NoteSide {
106 Old,
108 New,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Note {
115 pub path: String,
116 pub side: NoteSide,
117 pub line: usize,
118 pub code: String,
120 pub text: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub review: Option<String>,
125 #[serde(default, skip_serializing_if = "is_false")]
128 pub draft: bool,
129}
130
131fn is_false(value: &bool) -> bool {
132 !value
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Added {
138 New,
139 Appended,
141 Duplicate,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Link {
148 pub id: String,
149 pub url: String,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Posted {
155 #[serde(flatten)]
156 pub note: Note,
157 pub posted_at: u64,
159 #[serde(flatten)]
160 pub link: Link,
161}
162
163pub struct NoteStore {
167 path: Option<PathBuf>,
168 notes: Vec<Note>,
169 scope: Option<String>,
170 posted: Vec<Posted>,
172}
173
174impl NoteStore {
175 pub fn open(dir: &Path) -> NoteStore {
176 match state_dir(dir) {
177 Some(state) => NoteStore::at(state.join("notes.json")),
178 None => NoteStore::in_memory(),
179 }
180 }
181
182 pub fn at(path: PathBuf) -> NoteStore {
183 let notes = fs::read(&path)
184 .ok()
185 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
186 .unwrap_or_default();
187 NoteStore {
188 path: Some(path),
189 notes,
190 scope: None,
191 posted: Vec::new(),
192 }
193 }
194
195 pub fn in_memory() -> NoteStore {
196 NoteStore {
197 path: None,
198 notes: Vec::new(),
199 scope: None,
200 posted: Vec::new(),
201 }
202 }
203
204 pub fn scoped(mut self, review: impl Into<String>) -> NoteStore {
206 self.scope = Some(review.into());
207 self
208 }
209
210 fn in_scope(&self, note: &Note) -> bool {
211 self.scope.is_none() || note.review == self.scope
212 }
213
214 pub fn notes(&self) -> Vec<&Note> {
215 self.notes
216 .iter()
217 .filter(|note| self.in_scope(note))
218 .collect()
219 }
220
221 pub fn export_path(&self) -> Option<PathBuf> {
223 Some(self.path.as_ref()?.with_file_name("review-notes.md"))
224 }
225
226 pub fn find(&self, path: &str, side: NoteSide, line: usize) -> Option<&Note> {
227 self.notes.iter().find(|note| {
228 self.in_scope(note) && note.path == path && note.side == side && note.line == line
229 })
230 }
231
232 pub fn general(&self) -> Option<&Note> {
234 self.find("", NoteSide::New, 0)
235 }
236
237 pub fn set(&mut self, mut note: Note) -> Result<()> {
239 note.review = self.scope.clone();
240 self.notes.retain(|n| !n.same_place(¬e));
241 if !note.text.trim().is_empty() {
242 self.notes.push(note);
243 self.notes
244 .sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
245 }
246 self.save()
247 }
248
249 pub fn add(&mut self, mut note: Note) -> Result<Added> {
253 note.review = self.scope.clone();
254 let text = note.text.trim().to_string();
255 let added = match self.notes.iter_mut().find(|n| n.same_place(¬e)) {
256 Some(existing) if existing.text.contains(&text) => return Ok(Added::Duplicate),
257 Some(existing) => {
258 existing.text = format!("{}\n\n{text}", existing.text.trim_end());
259 existing.draft |= note.draft;
260 Added::Appended
261 }
262 None => {
263 note.text = text;
264 self.notes.push(note);
265 self.notes
266 .sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
267 Added::New
268 }
269 };
270 self.save()?;
271 Ok(added)
272 }
273
274 pub fn record_posted(&mut self, posted: &[(Note, Link)]) -> Result<()> {
276 let posted_at = SystemTime::now()
277 .duration_since(UNIX_EPOCH)
278 .map_or(0, |since| since.as_secs());
279 let records: Vec<Posted> = posted
280 .iter()
281 .map(|(note, link)| Posted {
282 note: Note {
283 review: self.scope.clone().or_else(|| note.review.clone()),
284 ..note.clone()
285 },
286 posted_at,
287 link: link.clone(),
288 })
289 .collect();
290 match self.log_path() {
291 Some(log) => {
292 let mut lines = String::new();
293 for record in &records {
294 lines.push_str(&serde_json::to_string(record)?);
295 lines.push('\n');
296 }
297 fs::OpenOptions::new()
298 .create(true)
299 .append(true)
300 .open(&log)
301 .and_then(|mut file| file.write_all(lines.as_bytes()))
302 .with_context(|| format!("writing {}", log.display()))?;
303 }
304 None => self.posted.extend(records),
305 }
306 let gone: Vec<Note> = posted.iter().map(|(note, _)| note.clone()).collect();
307 self.remove(&gone)
308 }
309
310 pub fn posted(&self) -> Vec<Posted> {
312 let all = match self.log_path() {
313 Some(log) => fs::read_to_string(log)
314 .unwrap_or_default()
315 .lines()
316 .filter_map(|line| serde_json::from_str(line).ok())
317 .collect(),
318 None => self.posted.clone(),
319 };
320 all.into_iter()
321 .filter(|posted: &Posted| self.in_scope(&posted.note))
322 .collect()
323 }
324
325 fn log_path(&self) -> Option<PathBuf> {
326 Some(self.path.as_ref()?.with_file_name("posted.jsonl"))
327 }
328
329 pub fn clear(&mut self) -> Result<()> {
331 let scope = self.scope.clone();
332 self.notes
333 .retain(|note| scope.is_some() && note.review != scope);
334 self.save()
335 }
336
337 pub fn remove(&mut self, gone: &[Note]) -> Result<()> {
339 self.notes.retain(|note| !gone.contains(note));
340 self.save()
341 }
342
343 pub fn prompt(&self) -> String {
346 let mut out = String::from(
347 "Please address these review comments. Line numbers refer to the version under review.\n",
348 );
349 let opened = self.notes().into_iter().filter(|note| !note.draft);
350 for (i, note) in opened.enumerate() {
351 let text = note.text.trim().replace('\n', "\n ");
352 if note.is_general() {
353 out.push_str(&format!("\n{}. On the change as a whole: {text}\n", i + 1));
354 } else {
355 out.push_str(&format!(
356 "\n{}. {}: {text}\n ```\n {}\n ```\n",
357 i + 1,
358 note.place(),
359 note.code.trim()
360 ));
361 }
362 }
363 out
364 }
365
366 fn save(&self) -> Result<()> {
367 let Some(path) = &self.path else {
368 return Ok(());
369 };
370 write_atomically(path, &serde_json::to_string_pretty(&self.notes)?)
371 }
372}
373
374impl Note {
375 pub fn on_pull_request(text: impl Into<String>) -> Note {
377 Note {
378 path: String::new(),
379 side: NoteSide::New,
380 line: 0,
381 code: String::new(),
382 text: text.into(),
383 review: None,
384 draft: true,
385 }
386 }
387
388 pub fn on_line(
392 files: &[FileReview],
393 path: &str,
394 side: NoteSide,
395 line: usize,
396 text: impl Into<String>,
397 ) -> Result<Note> {
398 let Some(file) = files.iter().find(|file| file.path == path) else {
399 bail!("`{path}` is not part of this change");
400 };
401 let view = file.view(DiffModes::SHOWN);
402 let rows = view.hunks.iter().flat_map(|hunk| &hunk.rows);
403 let lines: Vec<(usize, &str)> = rows
404 .filter_map(|row| match (side, row.kind) {
405 (NoteSide::Old, RowKind::Removed) => Some((row.old_line?, row.text.as_str())),
406 (NoteSide::New, RowKind::Added | RowKind::Context) => {
407 Some((row.new_line?, row.text.as_str()))
408 }
409 _ => None,
410 })
411 .collect();
412 let Some((_, code)) = lines.iter().find(|(n, _)| *n == line) else {
413 let (place, which) = match side {
414 NoteSide::New => (format!("`{path}:{line}`"), "the lines"),
415 NoteSide::Old => (
416 format!("removed line {line} of `{path}`"),
417 "the removed lines",
418 ),
419 };
420 if lines.is_empty() {
421 bail!("{place} is not in the diff, which has no such lines");
422 }
423 let numbers: Vec<usize> = lines.iter().map(|(n, _)| *n).collect();
424 bail!(
425 "{place} is not in the diff; {which} in it are {}",
426 ranges(&numbers)
427 );
428 };
429 Ok(Note {
430 path: path.to_string(),
431 side,
432 line,
433 code: code.to_string(),
434 text: text.into().trim().to_string(),
435 review: None,
436 draft: true,
437 })
438 }
439
440 pub fn is_general(&self) -> bool {
441 self.path.is_empty()
442 }
443
444 fn same_place(&self, other: &Note) -> bool {
445 self.review == other.review
446 && self.path == other.path
447 && self.side == other.side
448 && self.line == other.line
449 }
450
451 pub fn place(&self) -> String {
453 if self.is_general() {
454 return "the change as a whole".to_string();
455 }
456 match self.side {
457 NoteSide::New => format!("`{}:{}`", self.path, self.line),
458 NoteSide::Old => format!("`{}` (removed line {})", self.path, self.line),
459 }
460 }
461}
462
463fn ranges(numbers: &[usize]) -> String {
465 let mut sorted = numbers.to_vec();
466 sorted.sort_unstable();
467 sorted.dedup();
468 let mut parts: Vec<String> = Vec::new();
469 let mut start = 0;
470 for i in 0..sorted.len() {
471 if i + 1 == sorted.len() || sorted[i + 1] != sorted[i] + 1 {
472 parts.push(if start == i {
473 sorted[i].to_string()
474 } else {
475 format!("{}–{}", sorted[start], sorted[i])
476 });
477 start = i + 1;
478 }
479 }
480 parts.join(", ")
481}
482
483fn key(file: &FileReview) -> (String, u64) {
484 (file.path.clone(), file.fingerprint)
485}
486
487pub(crate) fn fingerprint(parts: &[&str]) -> u64 {
489 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
490 for (i, part) in parts.iter().enumerate() {
491 if i > 0 {
492 hash ^= 0xff;
493 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
494 }
495 for byte in part.bytes() {
496 hash ^= u64::from(byte);
497 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
498 }
499 }
500 hash
501}