lean_ctx_patterns/
artisan.rs1macro_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 migration_status_re() -> &'static regex::Regex {
13 static_regex!(r"\|\s*(Ran|Pending)\s*\|\s*(.+?)\s*\|")
14}
15fn route_re() -> &'static regex::Regex {
16 static_regex!(r"(GET|POST|PUT|PATCH|DELETE|ANY)\s*\|\s*(\S+)\s*\|\s*(\S+)")
17}
18fn test_result_re() -> &'static regex::Regex {
19 static_regex!(r"Tests:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?")
20}
21fn pest_result_re() -> &'static regex::Regex {
22 static_regex!(r"(\d+)\s*passed.*?(\d+)\s*failed|(\d+)\s*passed")
23}
24
25pub fn compress(command: &str, output: &str) -> Option<String> {
26 let trimmed = output.trim();
27 if trimmed.is_empty() {
28 return Some("ok".to_string());
29 }
30
31 if command.contains("migrate") && command.contains("--status") {
32 return Some(compress_migrate_status(trimmed));
33 }
34 if command.contains("migrate") {
35 return Some(compress_migrate(trimmed));
36 }
37 if command.contains("test") {
38 return Some(compress_test(trimmed));
39 }
40 if command.contains("route:list") {
41 return Some(compress_routes(trimmed));
42 }
43 if command.contains("make:") {
44 return Some(compress_make(trimmed));
45 }
46 if command.contains("queue:work") || command.contains("queue:listen") {
47 return Some(compress_queue(trimmed));
48 }
49 if command.contains("tinker") {
50 return Some(compress_tinker(trimmed));
51 }
52
53 Some(compact_lines(trimmed, 10))
54}
55
56fn compress_migrate(output: &str) -> String {
57 let mut ran = 0u32;
58 let mut errors = Vec::new();
59
60 for line in output.lines() {
61 let t = line.trim();
62 if t.contains("Migrating:") || t.contains("DONE") {
63 ran += 1;
64 }
65 if t.starts_with("SQLSTATE") || t.contains("ERROR") || t.contains("Exception") {
66 errors.push(t.to_string());
67 }
68 }
69
70 if !errors.is_empty() {
71 return format!("migrate FAILED:\n{}", errors.join("\n"));
72 }
73 if ran > 0 {
74 format!("migrated {ran} tables")
75 } else if output.contains("Nothing to migrate") {
76 "nothing to migrate".to_string()
77 } else {
78 compact_lines(output, 5)
79 }
80}
81
82fn compress_migrate_status(output: &str) -> String {
83 let statuses: Vec<String> = migration_status_re()
84 .captures_iter(output)
85 .map(|c| {
86 let status = if &c[1] == "Ran" { "+" } else { "-" };
87 format!("{} {}", status, c[2].trim())
88 })
89 .collect();
90
91 if statuses.is_empty() {
92 return compact_lines(output, 10);
93 }
94
95 let ran = statuses.iter().filter(|s| s.starts_with('+')).count();
96 let pending = statuses.iter().filter(|s| s.starts_with('-')).count();
97 let mut result = format!("{ran} ran, {pending} pending:");
98
99 for s in statuses.iter().rev().take(10) {
100 result.push_str(&format!("\n {s}"));
101 }
102 if statuses.len() > 10 {
103 result.push_str(&format!("\n ... +{} more", statuses.len() - 10));
104 }
105 result
106}
107
108fn compress_test(output: &str) -> String {
109 let mut passed = 0u32;
110 let mut failed = 0u32;
111 let mut failures = Vec::new();
112 let mut time = String::new();
113
114 for line in output.lines() {
115 let t = line.trim();
116 if let Some(caps) = test_result_re().captures(t) {
117 passed = caps[1].parse().unwrap_or(0);
118 failed = caps
119 .get(2)
120 .and_then(|m| m.as_str().parse().ok())
121 .unwrap_or(0);
122 }
123 if let Some(caps) = pest_result_re().captures(t) {
124 if let Some(p) = caps.get(3) {
125 passed = p.as_str().parse().unwrap_or(0);
126 } else {
127 passed = caps[1].parse().unwrap_or(0);
128 failed = caps[2].parse().unwrap_or(0);
129 }
130 }
131 if t.starts_with("FAIL") || t.starts_with("✕") || t.starts_with("×") {
132 failures.push(t.to_string());
133 }
134 if t.contains("Time:") || t.contains("Duration:") {
135 time = t.to_string();
136 }
137 }
138
139 let status = if failed > 0 { "FAIL" } else { "ok" };
140 let mut result = format!("{status}: {passed} passed, {failed} failed");
141 if !time.is_empty() {
142 result.push_str(&format!(" ({})", time.trim()));
143 }
144 if !failures.is_empty() {
145 result.push_str("\nfailed:");
146 for f in failures.iter().take(10) {
147 result.push_str(&format!("\n {f}"));
148 }
149 }
150 result
151}
152
153fn compress_routes(output: &str) -> String {
154 let routes: Vec<String> = route_re()
155 .captures_iter(output)
156 .map(|c| format!("{} {} → {}", &c[1], &c[2], &c[3]))
157 .collect();
158
159 if routes.is_empty() {
160 return compact_lines(output, 15);
161 }
162
163 let mut result = format!("{} routes:", routes.len());
164 for r in routes.iter().take(20) {
165 result.push_str(&format!("\n {r}"));
166 }
167 if routes.len() > 20 {
168 result.push_str(&format!("\n ... +{} more", routes.len() - 20));
169 }
170 result
171}
172
173fn compress_make(output: &str) -> String {
174 let lines: Vec<&str> = output
175 .lines()
176 .filter(|l| {
177 let t = l.trim();
178 !t.is_empty() && !t.starts_with("INFO")
179 })
180 .collect();
181
182 if lines.is_empty() {
183 return "created".to_string();
184 }
185
186 let created = output
187 .lines()
188 .find(|l| l.contains("created successfully") || l.contains(".php"));
189
190 if let Some(c) = created {
191 c.trim().to_string()
192 } else {
193 compact_lines(output, 3)
194 }
195}
196
197fn compress_queue(output: &str) -> String {
198 let mut processed = 0u32;
199 let mut failed = 0u32;
200 let mut last_job = String::new();
201
202 for line in output.lines() {
203 let t = line.trim();
204 if t.contains("Processed") || t.contains("[DONE]") {
205 processed += 1;
206 if let Some(job) = t.split_whitespace().last() {
207 last_job = job.to_string();
208 }
209 }
210 if t.contains("FAILED") || t.contains("[ERROR]") {
211 failed += 1;
212 }
213 }
214
215 if processed == 0 && failed == 0 {
216 return compact_lines(output, 5);
217 }
218
219 let mut result = format!("queue: {processed} processed");
220 if failed > 0 {
221 result.push_str(&format!(", {failed} failed"));
222 }
223 if !last_job.is_empty() {
224 result.push_str(&format!(" (last: {last_job})"));
225 }
226 result
227}
228
229fn compress_tinker(output: &str) -> String {
230 let lines: Vec<&str> = output
231 .lines()
232 .filter(|l| {
233 let t = l.trim();
234 !t.is_empty()
235 && !t.starts_with("Psy Shell")
236 && !t.starts_with(">>>")
237 && !t.starts_with("...")
238 })
239 .collect();
240
241 if lines.is_empty() {
242 return "tinker (no output)".to_string();
243 }
244 if lines.len() <= 10 {
245 return lines.join("\n");
246 }
247 format!(
248 "{}\n{}",
249 lines[..8].join("\n"),
250 super::elision_marker(lines.len() - 8)
251 )
252}
253
254fn compact_lines(text: &str, max: usize) -> String {
255 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
256 if lines.len() <= max {
257 return lines.join("\n");
258 }
259 format!(
260 "{}\n{}",
261 lines[..max].join("\n"),
262 super::elision_marker(lines.len() - max)
263 )
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn artisan_migrate_success() {
272 let output =
273 "Migrating: 2026_01_01_create_users_table\nMigrating: 2026_01_02_create_posts_table";
274 let result = compress("php artisan migrate", output).unwrap();
275 assert!(result.contains("migrated 2"), "shows count: {result}");
276 }
277
278 #[test]
279 fn artisan_migrate_nothing() {
280 let output = "Nothing to migrate.";
281 let result = compress("php artisan migrate", output).unwrap();
282 assert!(result.contains("nothing to migrate"), "{result}");
283 }
284
285 #[test]
286 fn artisan_test_success() {
287 let output = " PASS Tests\\Unit\\UserTest\n ✓ it can create user\n ✓ it validates email\n\n Tests: 2 passed\n Time: 0.45s";
288 let result = compress("php artisan test", output).unwrap();
289 assert!(result.contains("ok: 2 passed"), "{result}");
290 }
291
292 #[test]
293 fn artisan_test_failure() {
294 let output = " FAIL Tests\\Unit\\UserTest\n ✕ it validates email\n\n Tests: 1 passed, 1 failed\n Time: 0.52s";
295 let result = compress("php artisan test", output).unwrap();
296 assert!(result.contains("FAIL: 1 passed, 1 failed"), "{result}");
297 }
298
299 #[test]
300 fn artisan_make_model() {
301 let output = "\n INFO Model [app/Models/Invoice.php] created successfully.\n";
302 let result = compress("php artisan make:model Invoice", output).unwrap();
303 assert!(
304 result.contains("Invoice") || result.contains("created"),
305 "{result}"
306 );
307 }
308
309 #[test]
310 fn pest_test_output() {
311 let output = " PASS Tests\\Feature\\AuthTest\n ✓ login works\n ✓ register works\n\n 3 passed (0.8s)";
312 let result = compress("./vendor/bin/pest", output).unwrap();
313 assert!(result.contains("3 passed"), "{result}");
314 }
315
316 #[test]
317 fn route_list_compression() {
318 let output = " GET|HEAD /api/users ................. UserController@index\n POST /api/users ................. UserController@store\n GET|HEAD /api/users/{user} .......... UserController@show\n PUT|PATCH /api/users/{user} .......... UserController@update\n DELETE /api/users/{user} .......... UserController@destroy";
319 let result = compress("php artisan route:list", output).unwrap();
320 assert!(result.len() < output.len(), "should compress");
321 }
322}