Skip to main content

declutter/
store.rs

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
14/// Which changes the reviewer has marked as reviewed. A mark belongs to a file's exact
15/// change — its path and both versions — so it lapses as soon as either version moves,
16/// and it carries over between ranges that contain the same change.
17///
18/// Kept in `<git common dir>/declutter/reviewed.tsv`: private to this clone, shared by its
19/// worktrees, never committed.
20pub struct ReviewStore {
21    path: Option<PathBuf>,
22    marks: HashSet<(String, u64)>,
23}
24
25impl ReviewStore {
26    /// Opens the store of the repository containing `dir`, or an in-memory one if the
27    /// repository's git directory can't be found.
28    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
84/// `<git common dir>/declutter`: per-clone state, shared by worktrees, never committed.
85pub 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/// Which version of the file a note's line number refers to.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum NoteSide {
106    /// A removed line: the number is in the old version.
107    Old,
108    New,
109}
110
111/// A reviewer's note on one line of a change, or — with an empty path — on the change
112/// as a whole.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct Note {
115    pub path: String,
116    pub side: NoteSide,
117    pub line: usize,
118    /// The line the note is about, so the note still makes sense once lines move.
119    pub code: String,
120    pub text: String,
121    /// The review the note was left in — a pull request, or a range such as
122    /// `origin/main...feature` — so it only shows up, and is only posted, there.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub review: Option<String>,
125    /// Added from outside the viewer (`declutter notes add`) and not yet opened by the
126    /// reviewer. Drafts are never posted: opening one with `m` makes it the reviewer's.
127    #[serde(default, skip_serializing_if = "is_false")]
128    pub draft: bool,
129}
130
131fn is_false(value: &bool) -> bool {
132    !value
133}
134
135/// What [`NoteStore::add`] did.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Added {
138    New,
139    /// The line already had a note; the text went under it.
140    Appended,
141    /// The line's note already says this.
142    Duplicate,
143}
144
145/// Where a posted note ended up on the host.
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Link {
148    pub id: String,
149    pub url: String,
150}
151
152/// A note as it was posted, kept in `posted.jsonl` next to the notes.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Posted {
155    #[serde(flatten)]
156    pub note: Note,
157    /// Seconds since the Unix epoch.
158    pub posted_at: u64,
159    #[serde(flatten)]
160    pub link: Link,
161}
162
163/// Review notes, kept in `<git common dir>/declutter/notes.json` until cleared or
164/// posted. A store can be scoped to one review; it then reads and writes only that
165/// review's notes, and leaves the others alone.
166pub struct NoteStore {
167    path: Option<PathBuf>,
168    notes: Vec<Note>,
169    scope: Option<String>,
170    /// What an in-memory store has posted; a stored one keeps it in `posted.jsonl`.
171    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    /// The same store, seeing only the notes of `review`.
205    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    /// Where exported prompts are written, next to the notes themselves.
222    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    /// The note on the change as a whole, if there is one.
233    pub fn general(&self) -> Option<&Note> {
234        self.find("", NoteSide::New, 0)
235    }
236
237    /// Adds or replaces the note on the same line of this review; an empty text removes it.
238    pub fn set(&mut self, mut note: Note) -> Result<()> {
239        note.review = self.scope.clone();
240        self.notes.retain(|n| !n.same_place(&note));
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    /// Adds a note, putting its text under any note already on that line rather than
250    /// replacing it. A note that gains text it didn't have takes the new note's draft
251    /// state, so text nobody has read is never posted as read.
252    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(&note)) {
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    /// Logs these notes as posted, each with where it landed, and drops them from the store.
275    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    /// This review's posted notes, oldest first.
311    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    /// Deletes this review's notes (every note, when the store is not scoped).
330    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    /// Deletes exactly these notes, as after posting them.
338    pub fn remove(&mut self, gone: &[Note]) -> Result<()> {
339        self.notes.retain(|note| !gone.contains(note));
340        self.save()
341    }
342
343    /// The notes as one prompt to hand to a coding agent. Drafts are left out: nobody
344    /// has read them, so they are no one's review comments yet.
345    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    /// A draft note on the change as a whole rather than on a line.
376    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    /// A draft note on `line` of `path`, which must be a row of the change's diff —
389    /// with every layer shown — since that is where the viewer can show it. A removed
390    /// line is on the old side; added and unchanged lines are on the new side.
391    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    /// "`Cart.swift:12`", or "`Cart.swift` (removed line 12)".
452    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
463/// "3, 12–18, 40–52" for sorted line numbers.
464fn 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
487/// FNV-1a, 64-bit: stable across Rust releases, unlike the standard library's hasher.
488pub(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}