lean_ctx/core/patterns/
artisan.rs1use 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}