pacenotes_server/reports/
overlap_summary.rs1pub const SUMMARY_DAYS: i64 = 30;
3
4type PairTally = std::collections::BTreeMap<
6 (String, String, String),
7 (usize, std::collections::BTreeSet<String>),
8>;
9
10pub fn overlap_warnings(reports_dir: &std::path::Path, now: i64) -> Vec<String> {
14 let mut pairs = PairTally::new();
15 for record in recent_records(reports_dir, now) {
16 tally(&record, &mut pairs);
17 }
18 pairs
19 .into_iter()
20 .map(|((game, a, b), (reports, files))| {
21 let example = files.iter().next().cloned().unwrap_or_default();
22 format!(
23 "{game}: {a} and {b} were reported overlapping by {reports} installer \
24 run(s) in the last {SUMMARY_DAYS} days, on {} file(s) (e.g. {example}) — \
25 give them an exclusive_group, or move the shared file into one package",
26 files.len()
27 )
28 })
29 .collect()
30}
31
32fn recent_records(reports_dir: &std::path::Path, now: i64) -> Vec<super::Record> {
33 let Ok(entries) = std::fs::read_dir(reports_dir) else {
34 return Vec::new();
35 };
36 let oldest = super::file_name(now - SUMMARY_DAYS * 86_400);
37 let mut records = Vec::new();
38 for entry in entries.flatten() {
39 let name = entry.file_name().to_string_lossy().into_owned();
40 if !name.starts_with("conflicts-") || !name.ends_with(".jsonl") || name < oldest {
41 continue;
42 }
43 let Ok(text) = std::fs::read_to_string(entry.path()) else {
44 continue;
45 };
46 records.extend(
47 text.lines()
48 .filter_map(|line| serde_json::from_str::<super::Record>(line).ok()),
49 );
50 }
51 records
52}
53
54fn tally(record: &super::Record, pairs: &mut PairTally) {
56 let mut in_this_report = std::collections::BTreeSet::new();
57 for conflict in &record.conflicts {
58 let mut references: Vec<&String> = conflict.packages.iter().collect();
59 references.sort();
60 references.dedup();
61 for (index, a) in references.iter().enumerate() {
62 for b in &references[index + 1..] {
63 let key = (record.game.clone(), (*a).clone(), (*b).clone());
64 pairs
65 .entry(key.clone())
66 .or_default()
67 .1
68 .insert(conflict.file.clone());
69 in_this_report.insert(key);
70 }
71 }
72 }
73 for key in in_this_report {
74 pairs.entry(key).or_default().0 += 1;
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 const OCTOBER_25: i64 = 1_792_886_400;
81
82 fn line(file: &str) -> String {
83 serde_json::to_string(&super::super::Record {
84 received_at: OCTOBER_25,
85 game: "rbr".into(),
86 installer: "0.2.0".into(),
87 conflicts: vec![pacenotes_shared::Conflict {
88 file: file.into(),
89 packages: vec![
90 "public/language/polish".into(),
91 "public/codriver/polish/male/Kamil".into(),
92 ],
93 }],
94 dropped: 0,
95 })
96 .unwrap()
97 }
98
99 #[test]
100 fn overlaps_are_summarised_per_pair_for_recent_days_only() {
101 let tmp = tempfile::tempdir().unwrap();
102 let dir = tmp.path();
103 let recent = format!("{}\n{}\nnot json\n", line("a.ini"), line("b.ini"));
104 std::fs::write(dir.join("conflicts-2026-10-24.jsonl"), recent).unwrap();
105 std::fs::write(
106 dir.join("conflicts-2026-01-01.jsonl"),
107 line("old.ini") + "\n",
108 )
109 .unwrap();
110
111 let warnings = super::overlap_warnings(dir, OCTOBER_25);
112 assert_eq!(warnings.len(), 1, "{warnings:?}");
113 let warning = &warnings[0];
114 assert!(
115 warning
116 .starts_with("rbr: public/codriver/polish/male/Kamil and public/language/polish"),
117 "{warning}"
118 );
119 assert!(warning.contains("by 2 installer run(s)"), "{warning}");
120 assert!(warning.contains("on 2 file(s)"), "{warning}");
121 }
122
123 #[test]
124 fn no_reports_directory_means_nothing_to_say() {
125 let tmp = tempfile::tempdir().unwrap();
126 assert!(super::overlap_warnings(&tmp.path().join("absent"), OCTOBER_25).is_empty());
127 }
128}