Skip to main content

batuta/playbook/
executor_command.rs

1//! Command execution and public validation/status commands for the playbook executor.
2
3use crate::playbook::cache;
4use crate::playbook::dag;
5use crate::playbook::parser;
6use crate::playbook::types::*;
7use anyhow::{Context, Result};
8use std::path::Path;
9
10/// Command execution error with exit code and stderr
11#[derive(Debug, thiserror::Error)]
12#[error("command failed (exit code: {exit_code:?}): {stderr}")]
13pub(super) struct CommandError {
14    pub(super) exit_code: Option<i32>,
15    pub(super) stderr: String,
16}
17
18/// Execute a shell command via `sh -c`
19pub(super) async fn execute_command(cmd: &str) -> Result<()> {
20    let output = tokio::process::Command::new("sh")
21        .arg("-c")
22        .arg(cmd)
23        .output()
24        .await
25        .context("failed to spawn command")?;
26
27    if !output.status.success() {
28        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
29        return Err(CommandError {
30            exit_code: output.status.code(),
31            stderr: if stderr.is_empty() {
32                format!("exit code {}", output.status.code().unwrap_or(-1))
33            } else {
34                stderr
35            },
36        }
37        .into());
38    }
39
40    Ok(())
41}
42
43/// Validate a playbook without executing (for `batuta playbook validate`)
44pub fn validate_only(playbook_path: &Path) -> Result<(Playbook, Vec<ValidationWarning>)> {
45    let playbook = parser::parse_playbook_file(playbook_path)?;
46    let warnings = parser::validate_playbook(&playbook)?;
47    let _ = dag::build_dag(&playbook)?; // Validates DAG (no cycles, etc.)
48    Ok((playbook, warnings))
49}
50
51/// Show playbook status (for `batuta playbook status`)
52pub fn show_status(playbook_path: &Path) -> Result<()> {
53    let playbook = parser::parse_playbook_file(playbook_path)?;
54    let lock = cache::load_lock_file(playbook_path)?;
55
56    println!("Playbook: {} ({})", playbook.name, playbook_path.display());
57    println!("Version: {}", playbook.version);
58    println!("Stages: {}", playbook.stages.len());
59
60    if let Some(ref lock) = lock {
61        println!("\nLock file: {} ({})", lock.generator, lock.generated_at);
62        println!("{}", "-".repeat(60));
63        for (name, _stage) in &playbook.stages {
64            if let Some(stage_lock) = lock.stages.get(name) {
65                let status_str = match stage_lock.status {
66                    StageStatus::Completed => "COMPLETED",
67                    StageStatus::Failed => "FAILED",
68                    StageStatus::Cached => "CACHED",
69                    StageStatus::Running => "RUNNING",
70                    StageStatus::Pending => "PENDING",
71                    StageStatus::Hashing => "HASHING",
72                    StageStatus::Validating => "VALIDATING",
73                };
74                let duration = stage_lock
75                    .duration_seconds
76                    .map(|d| format!("{:.1}s", d))
77                    .unwrap_or_else(|| "-".to_string());
78                println!("  {:20} {:12} {}", name, status_str, duration);
79            } else {
80                println!("  {:20} {:12}", name, "NOT RUN");
81            }
82        }
83    } else {
84        println!("\nNo lock file found (pipeline has not been run)");
85    }
86
87    Ok(())
88}