Skip to main content

pacenotes_server/reports/
overlap_summary.rs

1/// How far back `manifest gen` looks.
2pub const SUMMARY_DAYS: i64 = 30;
3
4/// Per `(game, package, package)`: the reports naming the pair, and the files.
5type PairTally = std::collections::BTreeMap<
6    (String, String, String),
7    (usize, std::collections::BTreeSet<String>),
8>;
9
10/// One warning per pair of packages reported overlapping in the last
11/// `SUMMARY_DAYS`, so a user's bad day becomes something the operator sees
12/// when publishing; unreadable files and lines are skipped, since this is advice.
13pub 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
54/// Counts a report once per pair however many files it names them on.
55fn 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}