Skip to main content

lean_ctx/core/patterns/
artisan.rs

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