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