Skip to main content

declutter/
store.rs

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
11/// Which changes the reviewer has marked as reviewed. A mark belongs to a file's exact
12/// change — its path and both versions — so it lapses as soon as either version moves,
13/// and it carries over between ranges that contain the same change.
14///
15/// Kept in `<git common dir>/declutter/reviewed.tsv`: private to this clone, shared by its
16/// worktrees, never committed.
17pub struct ReviewStore {
18    path: Option<PathBuf>,
19    marks: HashSet<(String, u64)>,
20}
21
22impl ReviewStore {
23    /// Opens the store of the repository containing `dir`, or an in-memory one if the
24    /// repository's git directory can't be found.
25    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
81/// `<git common dir>/declutter`: per-clone state, shared by worktrees, never committed.
82pub 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/// Which version of the file a note's line number refers to.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum NoteSide {
103    /// A removed line: the number is in the old version.
104    Old,
105    New,
106}
107
108/// A reviewer's note on one line of a change.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Note {
111    pub path: String,
112    pub side: NoteSide,
113    pub line: usize,
114    /// The line the note is about, so the note still makes sense once lines move.
115    pub code: String,
116    pub text: String,
117    /// The review the note was left in — a pull request, or a range such as
118    /// `origin/main...feature` — so it only shows up, and is only posted, there.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub review: Option<String>,
121}
122
123/// Review notes, kept in `<git common dir>/declutter/notes.json` until cleared or
124/// posted. A store can be scoped to one review; it then reads and writes only that
125/// review's notes, and leaves the others alone.
126pub 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    /// The same store, seeing only the notes of `review`.
161    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    /// Where exported prompts are written, next to the notes themselves.
178    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    /// Adds or replaces the note on the same line of this review; an empty text removes it.
189    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    /// Deletes this review's notes (every note, when the store is not scoped).
206    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    /// Deletes exactly these notes, as after posting them.
214    pub fn remove(&mut self, gone: &[Note]) -> Result<()> {
215        self.notes.retain(|note| !gone.contains(note));
216        self.save()
217    }
218
219    /// The notes as one prompt to hand to a coding agent.
220    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    /// "`Cart.swift:12`", or "`Cart.swift` (removed line 12)".
246    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
258/// FNV-1a, 64-bit: stable across Rust releases, unlike the standard library's hasher.
259pub(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}