1macro_rules! static_regex {
4 ($pattern:expr_2021) => {{
5 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6 RE.get_or_init(|| {
7 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8 })
9 }};
10}
11
12fn pr_line_re() -> &'static regex::Regex {
13 static_regex!(r"#(\d+)\s+(.+?)\s{2,}(\S+)\s+(\S+)")
14}
15fn issue_line_re() -> &'static regex::Regex {
16 static_regex!(r"#(\d+)\s+(.+?)\s{2,}")
17}
18fn pr_created_re() -> &'static regex::Regex {
19 static_regex!(r"https://github\.com/\S+/pull/(\d+)")
20}
21
22pub fn compress(command: &str, output: &str) -> Option<String> {
23 if command.contains(" api ") || command.starts_with("gh api") {
24 return None;
25 }
26 if command.contains("pr") {
27 if command.contains("diff") {
28 return None;
29 }
30 if command.contains("list") {
31 return Some(compress_pr_list(output));
32 }
33 if command.contains("view") {
34 return Some(compress_pr_view(output));
35 }
36 if command.contains("create") {
37 return Some(compress_pr_create(output));
38 }
39 if command.contains("merge") {
40 return Some(compress_simple_action(output, "merged"));
41 }
42 if command.contains("close") {
43 return Some(compress_simple_action(output, "closed"));
44 }
45 if command.contains("checkout") || command.contains("co") {
46 return Some(compress_simple_action(output, "checked out"));
47 }
48 }
49 if command.contains("issue") {
50 if command.contains("list") {
51 return Some(compress_issue_list(output));
52 }
53 if command.contains("view") {
54 return Some(compress_issue_view(output));
55 }
56 if command.contains("create") {
57 return Some(compress_simple_action(output, "created"));
58 }
59 }
60 if command.contains("run") {
61 if command.contains("list") {
62 return Some(compress_run_list(output));
63 }
64 if command.contains("view") {
65 return Some(compress_run_view(output));
66 }
67 }
68 if command.contains("repo") {
69 return Some(compress_repo(output));
70 }
71 if command.contains("release") {
72 return Some(compress_release(output));
73 }
74
75 None
76}
77
78fn compress_pr_list(output: &str) -> String {
79 let trimmed = output.trim();
80 if trimmed.is_empty() || trimmed.contains("no pull requests") {
81 return "no PRs".to_string();
82 }
83
84 let mut prs = Vec::new();
85 for line in trimmed.lines() {
86 if let Some(caps) = pr_line_re().captures(line) {
87 let num = &caps[1];
88 let title = caps[2].trim();
89 let branch = &caps[3];
90 prs.push(format!("#{num} {title} ({branch})"));
91 } else {
92 let l = line.trim();
93 if !l.is_empty() && l.starts_with('#') {
94 prs.push(l.to_string());
95 }
96 }
97 }
98
99 if prs.is_empty() {
100 return compact_output(trimmed, 10);
101 }
102 format!("{} PRs:\n{}", prs.len(), prs.join("\n"))
103}
104
105fn compress_pr_view(output: &str) -> String {
106 let lines: Vec<&str> = output.lines().collect();
107 if lines.len() <= 5 {
108 return output.to_string();
109 }
110
111 let mut title = String::new();
112 let mut state = String::new();
113 let mut labels = Vec::new();
114 let mut checks = Vec::new();
115
116 for line in &lines {
117 let l = line.trim();
118 if l.starts_with("title:") || (title.is_empty() && l.starts_with('#')) {
119 title = l.replace("title:", "").replace('#', "").trim().to_string();
120 }
121 if l.starts_with("state:") {
122 state = l.replace("state:", "").trim().to_string();
123 }
124 if l.starts_with("labels:") {
125 labels = l
126 .replace("labels:", "")
127 .split(',')
128 .map(|s| s.trim().to_string())
129 .collect();
130 }
131 if l.contains("✓") || l.contains("✗") || l.contains("pass") || l.contains("fail") {
132 checks.push(l.to_string());
133 }
134 }
135
136 let mut parts = Vec::new();
137 if !title.is_empty() {
138 parts.push(title);
139 }
140 if !state.is_empty() {
141 parts.push(format!("state: {state}"));
142 }
143 if !labels.is_empty() {
144 parts.push(format!("labels: {}", labels.join(", ")));
145 }
146 if !checks.is_empty() && checks.len() <= 5 {
147 parts.push(format!("checks: {}", checks.join("; ")));
148 }
149
150 if parts.is_empty() {
151 return compact_output(output, 10);
152 }
153 parts.join("\n")
154}
155
156fn compress_pr_create(output: &str) -> String {
157 if let Some(caps) = pr_created_re().captures(output) {
158 return format!("#{} created", &caps[1]);
159 }
160 let trimmed = output.trim();
161 if trimmed.contains("http") {
162 for line in trimmed.lines() {
163 if line.contains("http") {
164 return format!("created: {}", line.trim());
165 }
166 }
167 }
168 compact_output(trimmed, 3)
169}
170
171fn compress_issue_list(output: &str) -> String {
172 let trimmed = output.trim();
173 if trimmed.is_empty() || trimmed.contains("no issues") {
174 return "no issues".to_string();
175 }
176
177 let mut issues = Vec::new();
178 for line in trimmed.lines() {
179 if let Some(caps) = issue_line_re().captures(line) {
180 let num = &caps[1];
181 let title = caps[2].trim();
182 issues.push(format!("#{num} {title}"));
183 } else {
184 let l = line.trim();
185 if !l.is_empty() && l.starts_with('#') {
186 issues.push(l.to_string());
187 }
188 }
189 }
190
191 if issues.is_empty() {
192 return compact_output(trimmed, 10);
193 }
194 format!("{} issues:\n{}", issues.len(), issues.join("\n"))
195}
196
197fn compress_issue_view(output: &str) -> String {
198 compact_head_tail(output, 40, 40)
203}
204
205fn compress_run_list(output: &str) -> String {
206 let trimmed = output.trim();
207 if trimmed.is_empty() {
208 return "no runs".to_string();
209 }
210
211 let mut runs = Vec::new();
212 for line in trimmed.lines() {
213 let l = line.trim();
214 if l.is_empty() || l.starts_with("STATUS") || l.starts_with("--") {
215 continue;
216 }
217 if l.contains("completed")
218 || l.contains("in_progress")
219 || l.contains("queued")
220 || l.contains("failure")
221 || l.contains("success")
222 {
223 runs.push(l.to_string());
224 }
225 }
226
227 if runs.is_empty() {
228 return compact_output(trimmed, 10);
229 }
230 format!("{} runs:\n{}", runs.len(), runs.join("\n"))
231}
232
233fn compress_run_view(output: &str) -> String {
234 compact_head_tail(output, 40, 40)
237}
238
239fn compress_repo(output: &str) -> String {
240 compact_output(output, 10)
241}
242
243fn compress_release(output: &str) -> String {
244 compact_output(output, 10)
245}
246
247fn compress_simple_action(output: &str, action: &str) -> String {
248 let trimmed = output.trim();
249 if trimmed.is_empty() {
250 return format!("ok ({action})");
251 }
252 for line in trimmed.lines() {
253 if line.contains("http") || line.contains('#') {
254 return format!("{action}: {}", line.trim());
255 }
256 }
257 action.to_string()
258}
259
260fn compact_output(text: &str, max: usize) -> String {
261 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
262 if lines.len() <= max {
263 return lines.join("\n");
264 }
265 format!(
266 "{}\n{}",
267 lines[..max].join("\n"),
268 super::elision_marker(lines.len() - max)
269 )
270}
271
272fn compact_head_tail(text: &str, head: usize, tail: usize) -> String {
277 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
278 if lines.len() <= head + tail {
279 return lines.join("\n");
280 }
281 let omitted = lines.len() - head - tail;
282 format!(
283 "{}\n{}\n{}",
284 lines[..head].join("\n"),
285 super::elision_marker(omitted),
286 lines[lines.len() - tail..].join("\n"),
287 )
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn issue_view_keeps_body_not_just_metadata() {
296 let mut out = String::new();
299 for f in [
300 "title:\tSomething broke",
301 "state:\tOPEN",
302 "author:\tandig",
303 "labels:",
304 "comments:\t0",
305 "assignees:",
306 "projects:",
307 "milestone:",
308 "number:\t875",
309 "--",
310 ] {
311 out.push_str(f);
312 out.push('\n');
313 }
314 for i in 0..40 {
315 out.push_str(&format!("body line {i} - the actual payload\n"));
316 }
317 let compressed = compress_issue_view(&out);
318 assert!(
319 compressed.contains("body line 39 - the actual payload"),
320 "the end of the body must survive compaction: {compressed}"
321 );
322 assert!(
323 compressed.contains("title:\tSomething broke"),
324 "metadata head must still be present: {compressed}"
325 );
326 }
327
328 #[test]
329 fn head_tail_returns_small_output_verbatim() {
330 assert_eq!(compact_head_tail("a\nb\nc\n", 40, 40), "a\nb\nc");
331 }
332
333 #[test]
334 fn head_tail_elides_only_the_middle() {
335 let mut lines = String::new();
336 for i in 0..200 {
337 use std::fmt::Write;
338 let _ = writeln!(lines, "L{i}");
339 }
340 let out = compact_head_tail(&lines, 5, 5);
341 assert!(out.contains("L0") && out.contains("L4"));
342 assert!(out.contains("L195") && out.contains("L199"));
343 assert!(out.contains("[lean-ctx: 190 lines elided"), "{out}");
347 assert!(out.contains("raw=true"), "{out}");
348 assert!(
349 !out.contains("... (190 more lines)"),
350 "the content-shaped marker must be gone: {out}"
351 );
352 assert!(!out.contains("L100"));
353 }
354}