1use serde::{Deserialize, Serialize};
7use std::process::{Command, Stdio};
8use thiserror::Error;
9
10use crate::commands::doctor::OutputFormat;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct StepResult {
15 pub command: String,
17 pub success: bool,
19 pub stdout: String,
21 pub stderr: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExecutionReport {
28 pub results: Vec<StepResult>,
30 pub remaining: Vec<String>,
32}
33
34#[derive(Debug, Error)]
36pub enum ExecutionError {
37 #[error("failed to spawn command: {0}")]
38 SpawnFailed(String),
39 #[error("failed to capture command output: {0}")]
40 OutputCaptureFailed(String),
41}
42
43pub fn execute_steps(steps: &[String]) -> Result<ExecutionReport, ExecutionError> {
58 let mut results = Vec::new();
59 let mut workspace_name: Option<String> = None;
60
61 for (idx, step) in steps.iter().enumerate() {
62 let effective_step = if let Some(ref ws) = workspace_name {
64 step.replace("$WS", ws)
65 } else {
66 step.clone()
67 };
68
69 let output = Command::new("sh")
71 .arg("-c")
72 .arg(&effective_step)
73 .stdout(Stdio::piped())
74 .stderr(Stdio::piped())
75 .output()
76 .map_err(|e| ExecutionError::SpawnFailed(format!("{}: {}", effective_step, e)))?;
77
78 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
79 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
80 let success = output.status.success();
81
82 if step.contains("maw ws create") && success {
84 workspace_name = extract_workspace_name(&stdout);
85 }
86
87 results.push(StepResult {
88 command: effective_step,
89 success,
90 stdout,
91 stderr,
92 });
93
94 if !success {
96 let remaining = steps[idx + 1..].iter().map(|s| s.clone()).collect();
97 return Ok(ExecutionReport { results, remaining });
98 }
99 }
100
101 Ok(ExecutionReport {
103 results,
104 remaining: Vec::new(),
105 })
106}
107
108fn extract_workspace_name(stdout: &str) -> Option<String> {
116 if let Some(start) = stdout.find("Creating workspace '") {
118 let after = &stdout[start + 20..];
119 if let Some(end) = after.find('\'') {
120 let ws_name = &after[..end];
121 if !ws_name.is_empty()
124 && ws_name
125 .chars()
126 .all(|c| c.is_ascii_alphanumeric() || c == '-')
127 && ws_name.chars().next().unwrap().is_ascii_alphanumeric()
128 {
129 return Some(ws_name.to_string());
130 }
131 }
132 }
133
134 for line in stdout.lines() {
136 let trimmed = line.trim();
137 if !trimmed.is_empty()
138 && trimmed
139 .chars()
140 .all(|c| c.is_ascii_alphanumeric() || c == '-')
141 && trimmed.chars().next().unwrap().is_ascii_alphanumeric()
142 {
143 return Some(trimmed.to_string());
144 }
145 }
146
147 None
148}
149
150pub fn render_report(report: &ExecutionReport, format: OutputFormat) -> String {
156 match format {
157 OutputFormat::Text => render_text(report),
158 OutputFormat::Json => render_json(report),
159 OutputFormat::Pretty => render_pretty(report),
160 }
161}
162
163fn render_text(report: &ExecutionReport) -> String {
174 let total = report.results.len() + report.remaining.len();
175 let mut out = String::new();
176
177 for (idx, result) in report.results.iter().enumerate() {
178 let step_num = idx + 1;
179 let status = if result.success { "ok" } else { "FAILED" };
180
181 out.push_str(&format!(
182 "step {}/{} {} {}",
183 step_num, total, result.command, status
184 ));
185
186 if result.command.contains("maw ws create") && result.success {
188 if let Some(ws) = extract_workspace_name(&result.stdout) {
189 out.push_str(&format!(" ws={}", ws));
190 }
191 }
192
193 out.push('\n');
194 }
195
196 for (idx, _remaining) in report.remaining.iter().enumerate() {
197 let step_num = report.results.len() + idx + 1;
198 out.push_str(&format!("step {}/{} (not executed)\n", step_num, total));
199 }
200
201 out
202}
203
204fn render_json(report: &ExecutionReport) -> String {
208 use serde_json::json;
209
210 let total_steps = report.results.len() + report.remaining.len();
211 let success = report.remaining.is_empty() && report.results.iter().all(|r| r.success);
212
213 let results_json: Vec<_> = report
214 .results
215 .iter()
216 .map(|r| {
217 json!({
218 "command": r.command,
219 "success": r.success,
220 "stdout": r.stdout,
221 "stderr": r.stderr,
222 })
223 })
224 .collect();
225
226 let report_json = json!({
227 "steps_run": report.results.len(),
228 "steps_total": total_steps,
229 "success": success,
230 "results": results_json,
231 "remaining": report.remaining,
232 });
233
234 serde_json::to_string_pretty(&report_json).unwrap()
235}
236
237fn render_pretty(report: &ExecutionReport) -> String {
241 let total = report.results.len() + report.remaining.len();
242 let mut out = String::new();
243
244 let green = "\x1b[32m";
245 let red = "\x1b[31m";
246 let gray = "\x1b[90m";
247 let reset = "\x1b[0m";
248
249 for (idx, result) in report.results.iter().enumerate() {
250 let step_num = idx + 1;
251 let (symbol, color) = if result.success {
252 ("✓", green)
253 } else {
254 ("✗", red)
255 };
256
257 out.push_str(&format!(
258 "step {}/{} {} {}{}{}",
259 step_num, total, result.command, color, symbol, reset
260 ));
261
262 if result.command.contains("maw ws create") && result.success {
264 if let Some(ws) = extract_workspace_name(&result.stdout) {
265 out.push_str(&format!(" {}ws={}{}", gray, ws, reset));
266 }
267 }
268
269 out.push('\n');
270 }
271
272 for (idx, _remaining) in report.remaining.iter().enumerate() {
273 let step_num = report.results.len() + idx + 1;
274 out.push_str(&format!(
275 "step {}/{} {}(not executed){}\n",
276 step_num, total, gray, reset
277 ));
278 }
279
280 out
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
290 fn extract_workspace_name_from_quoted_output() {
291 let stdout = "Creating workspace 'frost-castle'\nWorkspace created successfully\n";
292 assert_eq!(
293 extract_workspace_name(stdout),
294 Some("frost-castle".to_string())
295 );
296 }
297
298 #[test]
299 fn extract_workspace_name_from_plain_output() {
300 let stdout = "amber-reef\n";
301 assert_eq!(
302 extract_workspace_name(stdout),
303 Some("amber-reef".to_string())
304 );
305 }
306
307 #[test]
308 fn extract_workspace_name_with_whitespace() {
309 let stdout = " crimson-wave \n";
310 assert_eq!(
311 extract_workspace_name(stdout),
312 Some("crimson-wave".to_string())
313 );
314 }
315
316 #[test]
317 fn extract_workspace_name_no_match() {
318 let stdout = "Error: workspace creation failed\n";
319 assert_eq!(extract_workspace_name(stdout), None);
320 }
321
322 #[test]
323 fn extract_workspace_name_rejects_shell_metacharacters() {
324 let stdout = "Creating workspace 'foo; rm -rf /'\n";
326 assert_eq!(extract_workspace_name(stdout), None);
327 }
328
329 #[test]
330 fn extract_workspace_name_rejects_spaces_in_quoted() {
331 let stdout = "Creating workspace 'foo bar'\n";
332 assert_eq!(extract_workspace_name(stdout), None);
333 }
334
335 #[test]
336 fn extract_workspace_name_empty() {
337 assert_eq!(extract_workspace_name(""), None);
338 }
339
340 #[test]
341 fn extract_workspace_name_multiline_finds_first() {
342 let stdout = "frost-castle\nSome other output\n";
343 assert_eq!(
344 extract_workspace_name(stdout),
345 Some("frost-castle".to_string())
346 );
347 }
348
349 #[test]
352 fn empty_steps_list() {
353 let steps: Vec<String> = vec![];
354 let report = execute_steps(&steps).unwrap();
355 assert_eq!(report.results.len(), 0);
356 assert_eq!(report.remaining.len(), 0);
357 }
358
359 #[test]
362 fn render_text_empty_report() {
363 let report = ExecutionReport {
364 results: vec![],
365 remaining: vec![],
366 };
367 let text = render_text(&report);
368 assert_eq!(text, "");
369 }
370
371 #[test]
372 fn render_text_single_success() {
373 let report = ExecutionReport {
374 results: vec![StepResult {
375 command: "echo hello".to_string(),
376 success: true,
377 stdout: "hello\n".to_string(),
378 stderr: String::new(),
379 }],
380 remaining: vec![],
381 };
382 let text = render_text(&report);
383 assert!(text.contains("step 1/1"));
384 assert!(text.contains("echo hello"));
385 assert!(text.contains("ok"));
386 }
387
388 #[test]
389 fn render_text_single_failure() {
390 let report = ExecutionReport {
391 results: vec![StepResult {
392 command: "false".to_string(),
393 success: false,
394 stdout: String::new(),
395 stderr: String::new(),
396 }],
397 remaining: vec!["echo not run".to_string()],
398 };
399 let text = render_text(&report);
400 assert!(text.contains("step 1/2"));
401 assert!(text.contains("false"));
402 assert!(text.contains("FAILED"));
403 assert!(text.contains("step 2/2"));
404 assert!(text.contains("not executed"));
405 }
406
407 #[test]
408 fn render_text_workspace_creation() {
409 let report = ExecutionReport {
410 results: vec![StepResult {
411 command: "maw ws create --random".to_string(),
412 success: true,
413 stdout: "Creating workspace 'amber-reef'\n".to_string(),
414 stderr: String::new(),
415 }],
416 remaining: vec![],
417 };
418 let text = render_text(&report);
419 assert!(text.contains("ws=amber-reef"));
420 }
421
422 #[test]
423 fn render_json_valid_structure() {
424 let report = ExecutionReport {
425 results: vec![StepResult {
426 command: "echo test".to_string(),
427 success: true,
428 stdout: "test\n".to_string(),
429 stderr: String::new(),
430 }],
431 remaining: vec![],
432 };
433 let json = render_json(&report);
434 assert!(json.contains("steps_run"));
435 assert!(json.contains("steps_total"));
436 assert!(json.contains("success"));
437 assert!(json.contains("results"));
438 assert!(json.contains("remaining"));
439 }
440
441 #[test]
442 fn render_json_with_failure() {
443 let report = ExecutionReport {
444 results: vec![StepResult {
445 command: "false".to_string(),
446 success: false,
447 stdout: String::new(),
448 stderr: "error\n".to_string(),
449 }],
450 remaining: vec!["echo skipped".to_string()],
451 };
452 let json = render_json(&report);
453 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
454 assert_eq!(parsed["steps_run"].as_u64(), Some(1));
455 assert_eq!(parsed["steps_total"].as_u64(), Some(2));
456 assert_eq!(parsed["success"].as_bool(), Some(false));
457 }
458
459 #[test]
460 fn render_pretty_has_colors() {
461 let report = ExecutionReport {
462 results: vec![StepResult {
463 command: "echo hello".to_string(),
464 success: true,
465 stdout: "hello\n".to_string(),
466 stderr: String::new(),
467 }],
468 remaining: vec![],
469 };
470 let pretty = render_pretty(&report);
471 assert!(pretty.contains("\x1b[")); }
473
474 #[test]
475 fn render_pretty_success_is_green() {
476 let report = ExecutionReport {
477 results: vec![StepResult {
478 command: "true".to_string(),
479 success: true,
480 stdout: String::new(),
481 stderr: String::new(),
482 }],
483 remaining: vec![],
484 };
485 let pretty = render_pretty(&report);
486 assert!(pretty.contains("\x1b[32m")); assert!(pretty.contains("✓"));
488 }
489
490 #[test]
491 fn render_pretty_failure_is_red() {
492 let report = ExecutionReport {
493 results: vec![StepResult {
494 command: "false".to_string(),
495 success: false,
496 stdout: String::new(),
497 stderr: String::new(),
498 }],
499 remaining: vec![],
500 };
501 let pretty = render_pretty(&report);
502 assert!(pretty.contains("\x1b[31m")); assert!(pretty.contains("✗"));
504 }
505
506 #[test]
507 fn render_report_delegates_to_format() {
508 let report = ExecutionReport {
509 results: vec![],
510 remaining: vec![],
511 };
512
513 let text = render_report(&report, OutputFormat::Text);
514 assert_eq!(text, "");
515
516 let json = render_report(&report, OutputFormat::Json);
517 assert!(json.contains("steps_run"));
518
519 let pretty = render_report(&report, OutputFormat::Pretty);
520 assert_eq!(pretty, "");
522 }
523
524 #[test]
527 fn ws_substitution_mock() {
528 let steps = vec![
530 "maw ws create --random".to_string(),
531 "echo workspace is $WS".to_string(),
532 "bus claims stake 'workspace://$WS'".to_string(),
533 ];
534
535 let mock_ws_output = "Creating workspace 'frost-castle'\n";
537 let extracted_ws = extract_workspace_name(mock_ws_output);
538 assert_eq!(extracted_ws, Some("frost-castle".to_string()));
539
540 let ws_name = extracted_ws.unwrap();
542 let step2_with_sub = steps[1].replace("$WS", &ws_name);
543 let step3_with_sub = steps[2].replace("$WS", &ws_name);
544
545 assert_eq!(step2_with_sub, "echo workspace is frost-castle");
546 assert_eq!(
547 step3_with_sub,
548 "bus claims stake 'workspace://frost-castle'"
549 );
550 }
551
552 #[test]
553 fn ws_substitution_no_workspace_created() {
554 let step = "echo $WS is unknown".to_string();
556 let ws_name: Option<String> = None;
557
558 let effective_step = if let Some(ref ws) = ws_name {
559 step.replace("$WS", ws)
560 } else {
561 step.clone()
562 };
563
564 assert_eq!(effective_step, "echo $WS is unknown");
565 }
566
567 #[test]
570 #[ignore] fn execute_steps_real_subprocess() {
572 let steps = vec!["echo hello".to_string(), "echo world".to_string()];
573 let report = execute_steps(&steps).unwrap();
574 assert_eq!(report.results.len(), 2);
575 assert!(report.results[0].success);
576 assert!(report.results[1].success);
577 assert!(report.remaining.is_empty());
578 }
579
580 #[test]
581 #[ignore]
582 fn execute_steps_stops_on_failure() {
583 let steps = vec![
584 "true".to_string(),
585 "false".to_string(),
586 "echo should not run".to_string(),
587 ];
588 let report = execute_steps(&steps).unwrap();
589 assert_eq!(report.results.len(), 2);
590 assert!(report.results[0].success);
591 assert!(!report.results[1].success);
592 assert_eq!(report.remaining.len(), 1);
593 assert_eq!(report.remaining[0], "echo should not run");
594 }
595}