1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23pub struct ProjectDigest {
24 pub project: String,
26 pub digest: String,
28 pub issues: usize,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct CorpusDigest {
36 pub combined: String,
39 pub projects: Vec<ProjectDigest>,
41 pub issues: usize,
43 pub generation: u64,
45}
46
47fn hex(value: u64) -> String {
48 format!("{value:016x}")
49}
50
51pub 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
65pub 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 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 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 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 pub fn to_json(&self) -> Value {
135 serde_json::to_value(self).unwrap_or(Value::Null)
136 }
137
138 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 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 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 #[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 #[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}