1use anyhow::Result;
10use serde_json::{json, Value};
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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ProjectDigest {
22 pub project: String,
23 pub digest: String,
24 pub issues: usize,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CorpusDigest {
30 pub combined: String,
33 pub projects: Vec<ProjectDigest>,
34 pub issues: usize,
35 pub generation: u64,
36}
37
38fn hex(value: u64) -> String {
39 format!("{value:016x}")
40}
41
42pub fn project_digest(layout: &Layout, project: &str) -> Result<ProjectDigest> {
44 let export = report::export(layout, Some(project))?;
45 Ok(ProjectDigest {
46 project: project.to_string(),
47 digest: hex(xxh3_64(export.as_bytes())),
48 issues: export.lines().filter(|l| !l.trim().is_empty()).count(),
49 })
50}
51
52pub fn corpus_digest(layout: &Layout, projects: &[String]) -> Result<CorpusDigest> {
54 let mut selected: Vec<String> = if projects.is_empty() {
55 list_projects(layout)?
56 } else {
57 projects.to_vec()
58 };
59 selected.sort();
60 selected.dedup();
61
62 let grouped = report::export_by_project(layout)?;
66 let per_project: Vec<ProjectDigest> = selected
67 .iter()
68 .map(|project| {
69 let export = grouped.get(project).map(String::as_str).unwrap_or("");
70 ProjectDigest {
71 project: project.clone(),
72 digest: hex(xxh3_64(export.as_bytes())),
73 issues: export.lines().filter(|l| !l.trim().is_empty()).count(),
74 }
75 })
76 .collect();
77
78 let mut material = String::new();
81 for p in &per_project {
82 let _ = writeln!(material, "{}={}", p.project, p.digest);
83 }
84
85 Ok(CorpusDigest {
86 combined: hex(xxh3_64(material.as_bytes())),
87 issues: per_project.iter().map(|p| p.issues).sum(),
88 projects: per_project,
89 generation: events::generation(layout),
90 })
91}
92
93impl CorpusDigest {
94 pub fn render(&self) -> String {
99 let mut out = format!(
100 "combined={} issues={} generation={} projects={}\n",
101 self.combined,
102 self.issues,
103 self.generation,
104 self.projects.len()
105 );
106 for p in &self.projects {
107 let _ = writeln!(out, "{} {:>6} {}", p.digest, p.issues, p.project);
108 }
109 out
110 }
111
112 pub fn to_json(&self) -> Value {
113 json!({
114 "combined": self.combined,
115 "issues": self.issues,
116 "generation": self.generation,
117 "projects": self.projects.iter().map(|p| json!({
118 "project": p.project,
119 "digest": p.digest,
120 "issues": p.issues,
121 })).collect::<Vec<_>>(),
122 })
123 }
124
125 pub fn digest_of(&self, project: &str) -> Option<&str> {
126 self.projects
127 .iter()
128 .find(|p| p.project == project)
129 .map(|p| p.digest.as_str())
130 }
131
132 pub fn project_names(&self) -> Vec<String> {
133 self.projects.iter().map(|p| p.project.clone()).collect()
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::config::DEFAULT_PREFIX;
141 use crate::ops::{create, CreateOpts};
142 use std::fs;
143
144 fn many_projects(projects: usize) -> (tempfile::TempDir, Layout) {
146 let dir = tempfile::tempdir().unwrap();
147 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
148 fs::create_dir_all(layout.projects_dir()).unwrap();
149 for p in 0..projects {
150 let project = format!("p{p:03}");
151 for i in 0..2 {
152 create(
153 &layout,
154 &project,
155 &format!("issue {i}"),
156 CreateOpts {
157 quiet: true,
158 body: Some("Body text, so a row is not trivially short."),
159 ..CreateOpts::default()
160 },
161 )
162 .unwrap();
163 }
164 }
165 (dir, layout)
166 }
167
168 #[test]
172 fn a_project_digest_matches_its_own_export() {
173 let (_dir, layout) = many_projects(4);
174 let grouped = crate::report::export_by_project(&layout).unwrap();
175 for project in crate::store::list_projects(&layout).unwrap() {
176 let alone = crate::report::export(&layout, Some(&project)).unwrap();
177 assert_eq!(
178 grouped.get(&project).map(String::as_str).unwrap_or(""),
179 alone,
180 "{project}: grouped export differs from its own"
181 );
182 let single = project_digest(&layout, &project).unwrap();
183 let combined = corpus_digest(&layout, &[]).unwrap();
184 assert_eq!(
185 combined.digest_of(&project),
186 Some(single.digest.as_str()),
187 "{project}: corpus digest disagrees with the project's own"
188 );
189 }
190 }
191
192 #[test]
200 fn the_corpus_digest_does_not_read_once_per_project() {
201 let (_dir, layout) = many_projects(120);
202 let started = std::time::Instant::now();
203 let _ = crate::report::export(&layout, None).unwrap();
204 let one_read = started.elapsed();
205
206 let started = std::time::Instant::now();
207 let digest = corpus_digest(&layout, &[]).unwrap();
208 let whole = started.elapsed();
209
210 assert_eq!(digest.projects.len(), 120);
211 assert!(
212 whole < one_read * 5 + std::time::Duration::from_millis(25),
213 "digest took {whole:?} against a single export of {one_read:?}, \
214 which is the shape of one read per project"
215 );
216 }
217
218 fn seeded() -> (tempfile::TempDir, Layout) {
219 let dir = tempfile::tempdir().unwrap();
220 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
221 fs::create_dir_all(layout.projects_dir()).unwrap();
222 create(&layout, "alpha", "first", CreateOpts::default()).unwrap();
223 create(&layout, "beta", "second", CreateOpts::default()).unwrap();
224 (dir, layout)
225 }
226
227 #[test]
228 fn a_digest_is_stable_across_runs() {
229 let (_dir, layout) = seeded();
230 let once = corpus_digest(&layout, &[]).unwrap();
231 let twice = corpus_digest(&layout, &[]).unwrap();
232 assert_eq!(once.combined, twice.combined);
233 assert_eq!(once.projects, twice.projects);
234 assert_eq!(once.combined.len(), 16, "{}", once.combined);
235 }
236
237 #[test]
238 fn the_combined_digest_follows_the_parts() {
239 let (_dir, layout) = seeded();
240 let before = corpus_digest(&layout, &[]).unwrap();
241 create(&layout, "alpha", "a third issue", CreateOpts::default()).unwrap();
242 let after = corpus_digest(&layout, &[]).unwrap();
243
244 assert_ne!(before.combined, after.combined, "the corpus moved");
245 assert_ne!(
246 before.digest_of("alpha"),
247 after.digest_of("alpha"),
248 "the changed project moved"
249 );
250 assert_eq!(
251 before.digest_of("beta"),
252 after.digest_of("beta"),
253 "an untouched project must not move"
254 );
255 }
256
257 #[test]
258 fn selecting_projects_narrows_the_digest() {
259 let (_dir, layout) = seeded();
260 let all = corpus_digest(&layout, &[]).unwrap();
261 let one = corpus_digest(&layout, &["alpha".to_string()]).unwrap();
262 assert_eq!(one.projects.len(), 1);
263 assert_ne!(all.combined, one.combined);
264 assert_eq!(all.digest_of("alpha"), one.digest_of("alpha"));
265 }
266
267 #[test]
268 fn selection_order_does_not_change_the_digest() {
269 let (_dir, layout) = seeded();
270 let forward = corpus_digest(&layout, &["alpha".into(), "beta".into()]).unwrap();
271 let backward = corpus_digest(&layout, &["beta".into(), "alpha".into()]).unwrap();
272 assert_eq!(forward.combined, backward.combined);
273 }
274
275 #[test]
276 fn the_rendered_form_names_every_project() {
277 let (_dir, layout) = seeded();
278 let digest = corpus_digest(&layout, &[]).unwrap();
279 let text = digest.render();
280 assert!(
281 text.starts_with(&format!("combined={}", digest.combined)),
282 "{text}"
283 );
284 assert!(text.contains("alpha"), "{text}");
285 assert!(text.contains("beta"), "{text}");
286 assert_eq!(text.lines().count(), 3, "{text}");
287
288 let value = digest.to_json();
289 assert_eq!(value["combined"], digest.combined);
290 assert_eq!(value["projects"].as_array().unwrap().len(), 2);
291 }
292}