sokuto_server/guardrails/
compactor.rs1use std::borrow::Cow;
7use std::sync::LazyLock;
8
9use regex::Regex;
10
11static EXCESSIVE_NEWLINES_REGEX: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"\n{3,}").expect("連続改行正規表現のコンパイルに成功すること。"));
14
15static EXCESSIVE_WHITESPACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
17 Regex::new(r"[^\S\r\n]{2,}").expect("連続空白正規表現のコンパイルに成功すること。")
18});
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct CompactorConfig {
23 pub strip_invisible_chars: bool,
25 pub collapse_whitespace: bool,
27 pub collapse_repeated_chars: bool,
29 pub max_identical_consecutive_lines: usize,
31}
32
33impl Default for CompactorConfig {
34 fn default() -> Self {
35 Self {
36 strip_invisible_chars: true,
37 collapse_whitespace: true,
38 collapse_repeated_chars: true,
39 max_identical_consecutive_lines: 5,
40 }
41 }
42}
43
44#[inline]
46fn is_invisible_or_control(c: char) -> bool {
47 matches!(
48 c,
49 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{00AD}'
50 ) || (c.is_control() && c != '\n' && c != '\t' && c != '\r')
51}
52
53fn collapse_consecutive_lines(text: &str, max_consecutive: usize) -> Option<String> {
55 if max_consecutive == 0 || !text.contains('\n') {
56 return None;
57 }
58
59 let mut result = String::with_capacity(text.len());
60 let mut current_line: Option<&str> = None;
61 let mut repeat_count = 0;
62 let mut modified = false;
63
64 let flush = |line: &str, count: usize, out: &mut String, modified: &mut bool| {
65 if count <= max_consecutive {
66 for _ in 0..count {
67 out.push_str(line);
68 out.push('\n');
69 }
70 } else {
71 *modified = true;
72 for _ in 0..max_consecutive {
73 out.push_str(line);
74 out.push('\n');
75 }
76 out.push_str(&format!(
77 "[... 同一行がさらに {} 回繰り返されたため省略 ...]\n",
78 count - max_consecutive
79 ));
80 }
81 };
82
83 for line in text.split('\n') {
84 if line.is_empty() {
85 if let Some(prev) = current_line.take() {
86 flush(prev, repeat_count, &mut result, &mut modified);
87 repeat_count = 0;
88 }
89 result.push('\n');
90 continue;
91 }
92
93 match current_line {
94 Some(prev) if prev == line => {
95 repeat_count += 1;
96 }
97 Some(prev) => {
98 flush(prev, repeat_count, &mut result, &mut modified);
99 current_line = Some(line);
100 repeat_count = 1;
101 }
102 None => {
103 current_line = Some(line);
104 repeat_count = 1;
105 }
106 }
107 }
108
109 if let Some(prev) = current_line {
110 flush(prev, repeat_count, &mut result, &mut modified);
111 }
112
113 if !text.ends_with('\n') && result.ends_with('\n') {
115 result.pop();
116 }
117
118 if modified { Some(result) } else { None }
119}
120
121fn collapse_repeated_chars_fast(text: &str) -> Option<String> {
123 let mut chars = text.chars().peekable();
124 let mut modified = false;
125 let mut result = String::with_capacity(text.len());
126
127 while let Some(c) = chars.next() {
128 let mut count = 1;
129 while let Some(&next_c) = chars.peek() {
130 if next_c == c {
131 count += 1;
132 chars.next();
133 } else {
134 break;
135 }
136 }
137
138 if count >= 20 {
139 modified = true;
140 for _ in 0..5 {
141 result.push(c);
142 }
143 result.push_str("[...省略...]");
144 } else {
145 for _ in 0..count {
146 result.push(c);
147 }
148 }
149 }
150
151 if modified { Some(result) } else { None }
152}
153
154pub fn compact_text<'a>(text: &'a str, config: &CompactorConfig) -> Cow<'a, str> {
160 if text.is_empty() {
161 return Cow::Borrowed(text);
162 }
163
164 let mut current = Cow::Borrowed(text);
165
166 if config.strip_invisible_chars && current.chars().any(is_invisible_or_control) {
168 let cleaned: String = current
169 .chars()
170 .filter(|&c| !is_invisible_or_control(c))
171 .collect();
172 current = Cow::Owned(cleaned);
173 }
174
175 if config.max_identical_consecutive_lines > 0
177 && let Some(collapsed) =
178 collapse_consecutive_lines(¤t, config.max_identical_consecutive_lines)
179 {
180 current = Cow::Owned(collapsed);
181 }
182
183 if config.collapse_repeated_chars
185 && let Some(collapsed) = collapse_repeated_chars_fast(¤t)
186 {
187 current = Cow::Owned(collapsed);
188 }
189
190 if config.collapse_whitespace {
192 let has_excessive_newlines = EXCESSIVE_NEWLINES_REGEX.is_match(¤t);
193 let has_excessive_whitespace = EXCESSIVE_WHITESPACE_REGEX.is_match(¤t);
194
195 if has_excessive_newlines || has_excessive_whitespace {
196 let mut s = current.into_owned();
197 if has_excessive_newlines {
198 s = EXCESSIVE_NEWLINES_REGEX
199 .replace_all(&s, "\n\n")
200 .into_owned();
201 }
202 if has_excessive_whitespace {
203 s = EXCESSIVE_WHITESPACE_REGEX.replace_all(&s, " ").into_owned();
204 }
205 current = Cow::Owned(s);
206 }
207 }
208
209 current
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn test_strip_invisible_chars() {
218 let config = CompactorConfig {
219 collapse_whitespace: false,
220 collapse_repeated_chars: false,
221 max_identical_consecutive_lines: 0,
222 ..Default::default()
223 };
224
225 let raw = "こんにちは\u{200B}世界\u{FEFF}!";
226 let res = compact_text(raw, &config);
227 assert_eq!(res, "こんにちは世界!");
228 }
229
230 #[test]
231 fn test_collapse_whitespace_and_newlines() {
232 let config = CompactorConfig {
233 strip_invisible_chars: false,
234 collapse_repeated_chars: false,
235 max_identical_consecutive_lines: 0,
236 collapse_whitespace: true,
237 };
238
239 let raw = "行1\n\n\n\n\n行2 空白 テスト";
240 let res = compact_text(raw, &config);
241 assert_eq!(res, "行1\n\n行2 空白 テスト");
242 }
243
244 #[test]
245 fn test_collapse_consecutive_lines() {
246 let config = CompactorConfig {
247 collapse_whitespace: false,
248 strip_invisible_chars: false,
249 collapse_repeated_chars: false,
250 max_identical_consecutive_lines: 3,
251 };
252
253 let log = "INFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: healthcheck\nINFO: done";
254 let res = compact_text(log, &config);
255 assert!(res.contains("[... 同一行がさらに 2 回繰り返されたため省略 ...]"));
256 assert!(res.contains("INFO: done"));
257 }
258}