Skip to main content

lean_ctx_patterns/
artisan.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_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}