1use std::collections::BTreeMap;
2
3pub const CAP_ERRORS: usize = 20;
4pub const CAP_WARNINGS: usize = 10;
5pub const CAP_LIST: usize = 20;
6pub const CAP_INVENTORY: usize = 50;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub enum DropClass {
10 Error,
11 Warning,
12 Failure,
13 Issue,
14 List,
15 Inventory,
16 Timing,
17}
18
19impl DropClass {
20 pub fn default_cap(self) -> usize {
21 match self {
22 Self::Error | Self::Failure => CAP_ERRORS,
23 Self::Warning => CAP_WARNINGS,
24 Self::Issue | Self::List | Self::Timing => CAP_LIST,
25 Self::Inventory => CAP_INVENTORY,
26 }
27 }
28
29 pub fn singular(self) -> &'static str {
30 match self {
31 Self::Error => "error",
32 Self::Warning => "warning",
33 Self::Failure => "failure",
34 Self::Issue => "issue",
35 Self::List => "list item",
36 Self::Inventory => "inventory item",
37 Self::Timing => "timing line",
38 }
39 }
40
41 pub fn plural(self) -> &'static str {
42 match self {
43 Self::Error => "errors",
44 Self::Warning => "warnings",
45 Self::Failure => "failures",
46 Self::Issue => "issues",
47 Self::List => "list items",
48 Self::Inventory => "inventory items",
49 Self::Timing => "timing lines",
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ClassifiedBlock {
56 pub class: Option<DropClass>,
57 pub text: String,
58}
59
60impl ClassifiedBlock {
61 pub fn new(class: DropClass, text: impl Into<String>) -> Self {
62 Self {
63 class: Some(class),
64 text: text.into(),
65 }
66 }
67
68 pub fn unclassified(text: impl Into<String>) -> Self {
69 Self {
70 class: None,
71 text: text.into(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Default)]
77pub struct ClassCapResult {
78 pub text: String,
79 pub dropped_by_class: BTreeMap<DropClass, usize>,
80}
81
82pub fn cap_classified_blocks(blocks: Vec<ClassifiedBlock>) -> ClassCapResult {
83 cap_classified_blocks_with(blocks, DropClass::default_cap)
84}
85
86pub fn cap_classified_blocks_with<F>(blocks: Vec<ClassifiedBlock>, cap_for: F) -> ClassCapResult
87where
88 F: Fn(DropClass) -> usize,
89{
90 let mut seen_by_class: BTreeMap<DropClass, usize> = BTreeMap::new();
91 let mut dropped_by_class: BTreeMap<DropClass, usize> = BTreeMap::new();
92 let mut kept = Vec::new();
93
94 for block in blocks {
95 let Some(class) = block.class else {
96 kept.push(block.text);
97 continue;
98 };
99
100 let seen = seen_by_class.entry(class).or_default();
101 *seen += 1;
102 if *seen <= cap_for(class) {
103 kept.push(block.text);
104 } else {
105 *dropped_by_class.entry(class).or_default() += 1;
106 }
107 }
108
109 ClassCapResult {
110 text: join_blocks(kept),
111 dropped_by_class,
112 }
113}
114
115pub fn join_blocks(blocks: Vec<String>) -> String {
116 blocks
117 .into_iter()
118 .map(|block| block.trim_end().to_string())
119 .filter(|block| !block.is_empty())
120 .collect::<Vec<_>>()
121 .join("\n")
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn keeps_error_and_warning_blocks_spread_through_long_stream() {
130 let mut blocks = Vec::new();
131 for index in 0..40 {
132 blocks.push(ClassifiedBlock::new(
133 DropClass::Error,
134 format!("error {index}\n error context {index}"),
135 ));
136 if index < 20 {
137 blocks.push(ClassifiedBlock::new(
138 DropClass::Warning,
139 format!("warning {index}\n warning context {index}"),
140 ));
141 }
142 blocks.push(ClassifiedBlock::unclassified(format!("progress {index}")));
143 }
144
145 let capped = cap_classified_blocks(blocks);
146
147 assert_eq!(capped.text.matches("error context").count(), CAP_ERRORS);
148 assert_eq!(capped.text.matches("warning context").count(), CAP_WARNINGS);
149 assert_eq!(capped.dropped_by_class.get(&DropClass::Error), Some(&20));
150 assert_eq!(capped.dropped_by_class.get(&DropClass::Warning), Some(&10));
151 assert!(capped.text.contains("progress 39"));
152 assert!(!capped.text.contains("error 39\n error context 39"));
153 }
154
155 #[test]
156 fn caps_by_class_without_splitting_blocks() {
157 let blocks = vec![
158 ClassifiedBlock::new(DropClass::Error, "error 1\n context"),
159 ClassifiedBlock::new(DropClass::Warning, "warning 1\n context"),
160 ClassifiedBlock::new(DropClass::Error, "error 2\n context"),
161 ];
162
163 let capped = cap_classified_blocks_with(blocks, |class| match class {
164 DropClass::Error => 1,
165 DropClass::Warning => 10,
166 _ => 0,
167 });
168
169 assert!(capped.text.contains("error 1\n context"));
170 assert!(capped.text.contains("warning 1\n context"));
171 assert!(!capped.text.contains("error 2"));
172 assert_eq!(capped.dropped_by_class.get(&DropClass::Error), Some(&1));
173 }
174}