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