Skip to main content

edict/commands/protocol/
executor.rs

1//! Step executor for protocol commands with --execute mode.
2//!
3//! Executes shell commands sequentially, captures output, handles failures,
4//! and performs $WS placeholder substitution for workspace names.
5
6use serde::{Deserialize, Serialize};
7use std::process::{Command, Stdio};
8use thiserror::Error;
9
10use crate::commands::doctor::OutputFormat;
11
12/// Result of executing a single step.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct StepResult {
15    /// The command that was run
16    pub command: String,
17    /// Whether the command succeeded (exit code 0)
18    pub success: bool,
19    /// Standard output from the command
20    pub stdout: String,
21    /// Standard error from the command
22    pub stderr: String,
23}
24
25/// Complete execution report with results and remaining steps.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExecutionReport {
28    /// Steps that were executed (in order)
29    pub results: Vec<StepResult>,
30    /// Steps that were not executed due to earlier failure
31    pub remaining: Vec<String>,
32}
33
34/// Errors that can occur during step execution.
35#[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
43/// Execute a list of shell commands sequentially.
44///
45/// Commands are run via `sh -c`, with output captured per step.
46/// Execution stops on the first failure, and remaining steps are returned.
47///
48/// ### $WS Placeholder Substitution
49///
50/// When a step contains `maw ws create`, the executor parses the workspace name
51/// from stdout and substitutes `$WS` in all subsequent steps.
52///
53/// Example:
54/// - Step 1: `maw ws create --random` outputs "Creating workspace 'frost-castle'"
55/// - Step 2: `bus claims stake --agent $AGENT "workspace://project/$WS"` becomes
56///   `bus claims stake --agent $AGENT "workspace://project/frost-castle"`
57pub 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        // Apply $WS substitution if workspace name is known
63        let effective_step = if let Some(ref ws) = workspace_name {
64            step.replace("$WS", ws)
65        } else {
66            step.clone()
67        };
68
69        // Execute the command via sh -c
70        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        // Check if this step creates a workspace
83        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        // Stop on first failure
95        if !success {
96            let remaining = steps[idx + 1..].iter().map(|s| s.clone()).collect();
97            return Ok(ExecutionReport { results, remaining });
98        }
99    }
100
101    // All steps succeeded
102    Ok(ExecutionReport {
103        results,
104        remaining: Vec::new(),
105    })
106}
107
108/// Extract workspace name from `maw ws create` output.
109///
110/// Looks for patterns like:
111/// - "Creating workspace 'frost-castle'"
112/// - Just the workspace name on its own line
113///
114/// Returns the first alphanumeric-hyphen sequence found.
115fn extract_workspace_name(stdout: &str) -> Option<String> {
116    // Try to find quoted workspace name first
117    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            // Validate: must be non-empty, alphanumeric+hyphens, start with alphanumeric
122            // This prevents command injection if maw output were ever malformed
123            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    // Fallback: find the first valid workspace name (alphanumeric + hyphens)
135    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
150/// Render an execution report in the specified format.
151///
152/// - Text: concise step-by-step output for agents
153/// - JSON: structured output with all details
154/// - Pretty: colored output with symbols for humans
155pub 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
163/// Render execution report as text (agent-friendly format).
164///
165/// Format:
166/// ```text
167/// step 1/5  bus claims stake --agent $AGENT 'bone://edict/bd-abc'  ok
168/// step 2/5  maw ws create --random  ok  ws=frost-castle
169/// step 3/5  bus claims stake --agent $AGENT 'workspace://edict/$WS'  FAILED
170/// step 4/5  (not executed)
171/// step 5/5  (not executed)
172/// ```
173fn 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 this was a workspace creation, show the workspace name
187        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
204/// Render execution report as JSON.
205///
206/// Includes all step results, remaining steps, and summary statistics.
207fn 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
237/// Render execution report with color codes (TTY/human format).
238///
239/// Uses green checkmarks for success, red X for failures.
240fn 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 this was a workspace creation, show the workspace name
263        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    // --- Workspace name extraction tests ---
288
289    #[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        // Defense-in-depth: quoted path 1 must validate alphanumeric+hyphens
325        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    // --- Step execution tests (mock, no actual subprocess calls) ---
350
351    #[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    // --- Rendering tests ---
360
361    #[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[")); // ANSI color codes
472    }
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")); // green
487        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")); // red
503        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        // Pretty output for empty report is also empty
521        assert_eq!(pretty, "");
522    }
523
524    // --- Integration tests: simulate $WS substitution logic ---
525
526    #[test]
527    fn ws_substitution_mock() {
528        // Simulate what execute_steps does for $WS substitution
529        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        // Mock workspace name extraction
536        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        // Mock substitution
541        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        // If no workspace is created, $WS should remain as-is
555        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    // --- Real subprocess test (optional, can be slow) ---
568
569    #[test]
570    #[ignore] // Run with `cargo test -- --ignored` to include subprocess tests
571    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}