1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ProjectDigest {
22 pub project: String,
24 pub digest: String,
26 pub issues: usize,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CorpusDigest {
33 pub combined: String,
36 pub projects: Vec<ProjectDigest>,
38 pub issues: usize,
40 pub generation: u64,
42}
43
44fn hex(value: u64) -> String {
45 format!("{value:016x}")
46}
47
48pub 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
62pub 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 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 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 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 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 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 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 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 #[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 #[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}