Skip to main content

vissue_core/
digest.rs

1//! A content digest of the corpus, so a consumer can tell whether a projection
2//! it holds is still current.
3//!
4//! The digest is taken over the canonical JSONL export, which is already
5//! deterministic and byte-for-byte stable. Hashing that rather than the org
6//! files means formatting churn that does not change an issue does not change
7//! the digest either. Every function here reads; none writes.
8
9use crate::error::Result;
10use serde_json::{Value, json};
11use std::fmt::Write as _;
12use xxhash_rust::xxh3::xxh3_64;
13
14use crate::config::Layout;
15use crate::events;
16use crate::report;
17use crate::store::list_projects;
18
19/// One project's contribution to the digest.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ProjectDigest {
22    /// Project directory name.
23    pub project: String,
24    /// 16-digit hex of the xxh3 hash over that project's export.
25    pub digest: String,
26    /// Number of JSONL rows in that export.
27    pub issues: usize,
28}
29
30/// The digest of a selected set of projects, plus what it was taken against.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CorpusDigest {
33    /// Hash over the per-project digests, so it moves exactly when one of them
34    /// does.
35    pub combined: String,
36    /// Per-project digests, sorted by project name.
37    pub projects: Vec<ProjectDigest>,
38    /// Sum of per-project issue counts.
39    pub issues: usize,
40    /// Event-log generation at the time of the digest.
41    pub generation: u64,
42}
43
44fn hex(value: u64) -> String {
45    format!("{value:016x}")
46}
47
48/// Digest one project's export bytes.
49///
50/// # Errors
51///
52/// Returns an error if the corpus cannot be read or exported.
53pub fn project_digest(layout: &Layout, project: &str) -> Result<ProjectDigest> {
54    let export = report::export(layout, Some(project))?;
55    Ok(ProjectDigest {
56        project: project.to_string(),
57        digest: hex(xxh3_64(export.as_bytes())),
58        issues: export.lines().filter(|l| !l.trim().is_empty()).count(),
59    })
60}
61
62/// Digest the named projects, or every project when the list is empty.
63///
64/// # Errors
65///
66/// Returns an error if the project list or the corpus cannot be read.
67pub fn corpus_digest(layout: &Layout, projects: &[String]) -> Result<CorpusDigest> {
68    let mut selected: Vec<String> = if projects.is_empty() {
69        list_projects(layout)?
70    } else {
71        projects.to_vec()
72    };
73    selected.sort();
74    selected.dedup();
75
76    // One read of the corpus, grouped, rather than one read per project.
77    // `project_digest` filters a whole-corpus export down to a single
78    // project, so calling it in a loop is quadratic in the project count.
79    let grouped = report::export_by_project(layout)?;
80    let per_project: Vec<ProjectDigest> = selected
81        .iter()
82        .map(|project| {
83            let export = grouped.get(project).map(String::as_str).unwrap_or("");
84            ProjectDigest {
85                project: project.clone(),
86                digest: hex(xxh3_64(export.as_bytes())),
87                issues: export.lines().filter(|l| !l.trim().is_empty()).count(),
88            }
89        })
90        .collect();
91
92    // Combine over the sub-digests rather than over the raw bytes, so the
93    // combined value is a pure function of the parts a reader can inspect.
94    let mut material = String::new();
95    for p in &per_project {
96        let _ = writeln!(material, "{}={}", p.project, p.digest);
97    }
98
99    Ok(CorpusDigest {
100        combined: hex(xxh3_64(material.as_bytes())),
101        issues: per_project.iter().map(|p| p.issues).sum(),
102        projects: per_project,
103        generation: events::generation(layout),
104    })
105}
106
107impl CorpusDigest {
108    /// A summary line, then one line per project.
109    ///
110    /// The per-project lines are what turn "something moved" into "this moved",
111    /// which is why they are not folded into the summary.
112    pub fn render(&self) -> String {
113        let mut out = format!(
114            "combined={} issues={} generation={} projects={}\n",
115            self.combined,
116            self.issues,
117            self.generation,
118            self.projects.len()
119        );
120        for p in &self.projects {
121            let _ = writeln!(out, "{}  {:>6}  {}", p.digest, p.issues, p.project);
122        }
123        out
124    }
125
126    /// The digest as a JSON object: combined hash, counts, and per-project rows.
127    pub fn to_json(&self) -> Value {
128        json!({
129            "combined": self.combined,
130            "issues": self.issues,
131            "generation": self.generation,
132            "projects": self.projects.iter().map(|p| json!({
133                "project": p.project,
134                "digest": p.digest,
135                "issues": p.issues,
136            })).collect::<Vec<_>>(),
137        })
138    }
139
140    /// The per-project digest for `project`, when that name is in this corpus.
141    pub fn digest_of(&self, project: &str) -> Option<&str> {
142        self.projects
143            .iter()
144            .find(|p| p.project == project)
145            .map(|p| p.digest.as_str())
146    }
147
148    /// Project names in this digest, in the same order as [`Self::projects`].
149    pub fn project_names(&self) -> Vec<String> {
150        self.projects.iter().map(|p| p.project.clone()).collect()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::config::DEFAULT_PREFIX;
158    use crate::ops::{CreateOpts, create};
159    use std::fs;
160
161    /// A tracker with `projects` projects, two issues each.
162    fn many_projects(projects: usize) -> (tempfile::TempDir, Layout) {
163        let dir = tempfile::tempdir().unwrap();
164        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
165        fs::create_dir_all(layout.projects_dir()).unwrap();
166        for p in 0..projects {
167            let project = format!("p{p:03}");
168            for i in 0..2 {
169                create(
170                    &layout,
171                    &project,
172                    &format!("issue {i}"),
173                    CreateOpts {
174                        quiet: true,
175                        body: Some("Body text, so a row is not trivially short."),
176                        ..CreateOpts::default()
177                    },
178                )
179                .unwrap();
180            }
181        }
182        (dir, layout)
183    }
184
185    /// Each project's digest is taken over exactly what `export` returns for
186    /// it. The grouped read exists for speed, and a digest that moved would
187    /// mark every mirror ever stamped as stale.
188    #[test]
189    fn a_project_digest_matches_its_own_export() {
190        let (_dir, layout) = many_projects(4);
191        let grouped = crate::report::export_by_project(&layout).unwrap();
192        for project in crate::store::list_projects(&layout).unwrap() {
193            let alone = crate::report::export(&layout, Some(&project)).unwrap();
194            assert_eq!(
195                grouped.get(&project).map(String::as_str).unwrap_or(""),
196                alone,
197                "{project}: grouped export differs from its own"
198            );
199            let single = project_digest(&layout, &project).unwrap();
200            let combined = corpus_digest(&layout, &[]).unwrap();
201            assert_eq!(
202                combined.digest_of(&project),
203                Some(single.digest.as_str()),
204                "{project}: corpus digest disagrees with the project's own"
205            );
206        }
207    }
208
209    /// Digesting the corpus reads it once, not once per project.
210    ///
211    /// Compared against `export`, which reads it once by definition, so the
212    /// bound calibrates itself to the machine rather than to a clock. Taking
213    /// one export per project costs one read each; the project count has to
214    /// be high enough that the difference clears the fixed slack, or a
215    /// quadratic version passes on a small corpus.
216    #[test]
217    fn the_corpus_digest_does_not_read_once_per_project() {
218        let (_dir, layout) = many_projects(120);
219        let started = std::time::Instant::now();
220        let _ = crate::report::export(&layout, None).unwrap();
221        let one_read = started.elapsed();
222
223        let started = std::time::Instant::now();
224        let digest = corpus_digest(&layout, &[]).unwrap();
225        let whole = started.elapsed();
226
227        assert_eq!(digest.projects.len(), 120);
228        assert!(
229            whole < one_read * 5 + std::time::Duration::from_millis(25),
230            "digest took {whole:?} against a single export of {one_read:?}, \
231             which is the shape of one read per project"
232        );
233    }
234
235    fn seeded() -> (tempfile::TempDir, Layout) {
236        let dir = tempfile::tempdir().unwrap();
237        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
238        fs::create_dir_all(layout.projects_dir()).unwrap();
239        create(&layout, "alpha", "first", CreateOpts::default()).unwrap();
240        create(&layout, "beta", "second", CreateOpts::default()).unwrap();
241        (dir, layout)
242    }
243
244    #[test]
245    fn a_digest_is_stable_across_runs() {
246        let (_dir, layout) = seeded();
247        let once = corpus_digest(&layout, &[]).unwrap();
248        let twice = corpus_digest(&layout, &[]).unwrap();
249        assert_eq!(once.combined, twice.combined);
250        assert_eq!(once.projects, twice.projects);
251        assert_eq!(once.combined.len(), 16, "{}", once.combined);
252    }
253
254    #[test]
255    fn the_combined_digest_follows_the_parts() {
256        let (_dir, layout) = seeded();
257        let before = corpus_digest(&layout, &[]).unwrap();
258        create(&layout, "alpha", "a third issue", CreateOpts::default()).unwrap();
259        let after = corpus_digest(&layout, &[]).unwrap();
260
261        assert_ne!(before.combined, after.combined, "the corpus moved");
262        assert_ne!(
263            before.digest_of("alpha"),
264            after.digest_of("alpha"),
265            "the changed project moved"
266        );
267        assert_eq!(
268            before.digest_of("beta"),
269            after.digest_of("beta"),
270            "an untouched project must not move"
271        );
272    }
273
274    #[test]
275    fn selecting_projects_narrows_the_digest() {
276        let (_dir, layout) = seeded();
277        let all = corpus_digest(&layout, &[]).unwrap();
278        let one = corpus_digest(&layout, &["alpha".to_string()]).unwrap();
279        assert_eq!(one.projects.len(), 1);
280        assert_ne!(all.combined, one.combined);
281        assert_eq!(all.digest_of("alpha"), one.digest_of("alpha"));
282    }
283
284    #[test]
285    fn selection_order_does_not_change_the_digest() {
286        let (_dir, layout) = seeded();
287        let forward = corpus_digest(&layout, &["alpha".into(), "beta".into()]).unwrap();
288        let backward = corpus_digest(&layout, &["beta".into(), "alpha".into()]).unwrap();
289        assert_eq!(forward.combined, backward.combined);
290    }
291
292    #[test]
293    fn the_rendered_form_names_every_project() {
294        let (_dir, layout) = seeded();
295        let digest = corpus_digest(&layout, &[]).unwrap();
296        let text = digest.render();
297        assert!(
298            text.starts_with(&format!("combined={}", digest.combined)),
299            "{text}"
300        );
301        assert!(text.contains("alpha"), "{text}");
302        assert!(text.contains("beta"), "{text}");
303        assert_eq!(text.lines().count(), 3, "{text}");
304
305        let value = digest.to_json();
306        assert_eq!(value["combined"], digest.combined);
307        assert_eq!(value["projects"].as_array().unwrap().len(), 2);
308    }
309}