Skip to main content

lean_ctx/core/patterns/
artisan.rs

1macro_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}