use anyhow::{Context, Result};
use bmux_cli_schema::{RecordingExportFormat, RecordingRenderMode};
use std::time::Duration;
use super::{
discover_bundled_plugin_ids, recording, run_recording_export, sandbox_cli::run_sandbox_cleanup,
};
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
clippy::fn_params_excessive_bools,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
pub(super) async fn run_playbook_run(
source: &str,
json: bool,
interactive: bool,
target_server: bool,
record: bool,
export_gif: Option<&str>,
viewport: Option<&str>,
timeout: Option<u64>,
shell: Option<&str>,
cli_vars: &[String],
verbose: bool,
) -> Result<u8> {
let mut playbook = if source == "-" {
crate::playbook::parse_stdin().context("failed parsing playbook from stdin")?
} else {
crate::playbook::parse_file(std::path::Path::new(source))
.with_context(|| format!("failed parsing playbook from {source}"))?
};
if record || export_gif.is_some() {
playbook.config.record = true;
}
if let Some(vp) = viewport {
let (cols, rows) = parse_viewport_string(vp)?;
playbook.config.viewport.cols = cols;
playbook.config.viewport.rows = rows;
}
if let Some(secs) = timeout {
playbook.config.timeout = Duration::from_secs(secs);
}
if let Some(sh) = shell {
playbook.config.shell = Some(sh.to_string());
}
for var_str in cli_vars {
if let Some(eq_pos) = var_str.find('=') {
let key = var_str[..eq_pos].to_string();
let value = var_str[eq_pos + 1..].to_string();
playbook.config.vars.insert(key, value);
} else {
anyhow::bail!("invalid --var format: expected KEY=VALUE, got '{var_str}'");
}
}
playbook.config.bundled_plugin_ids = discover_bundled_plugin_ids();
playbook.config.verbose = verbose;
let result = if interactive {
crate::playbook::run_with_options(
playbook,
target_server,
crate::playbook::RunOptions { interactive: true },
)
.await?
} else {
crate::playbook::run(playbook, target_server).await?
};
if let Some(gif_path) = export_gif {
if let Some(ref rec_id) = result.recording_id {
let recording_id_str = rec_id.to_string();
match run_recording_export(
&recording_id_str,
RecordingExportFormat::Gif,
gif_path,
None, 1.0, None, None, None, RecordingRenderMode::Bitmap, None, None, None, None, None, None, &[], None, None, None, &[], None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, true, )
.await
{
Ok(_) => {
if !json {
println!("exported GIF: {gif_path}");
}
}
Err(e) => {
eprintln!("GIF export failed: {e:#}");
}
}
} else if !json {
eprintln!("GIF export skipped: no recording was produced");
}
}
if json {
let json_str =
serde_json::to_string_pretty(&result).context("failed serializing playbook result")?;
println!("{json_str}");
} else {
print!("{}", crate::playbook::format_result(&result));
}
Ok(u8::from(!result.pass))
}
pub(super) fn run_playbook_validate(source: &str, json: bool) -> Result<u8> {
let playbook = if source == "-" {
crate::playbook::parse_stdin().context("failed parsing playbook from stdin")?
} else {
crate::playbook::parse_file(std::path::Path::new(source))
.with_context(|| format!("failed parsing playbook from {source}"))?
};
let errors = crate::playbook::validate(&playbook, false);
if json {
let report = serde_json::json!({
"valid": errors.is_empty(),
"errors": errors,
});
println!("{}", serde_json::to_string_pretty(&report)?);
} else if errors.is_empty() {
println!("playbook is valid");
} else {
println!("playbook validation errors:");
for error in &errors {
println!(" - {error}");
}
}
Ok(u8::from(!errors.is_empty()))
}
pub(super) fn run_playbook_dry_run(source: &str, json: bool) -> Result<u8> {
let playbook = if source == "-" {
crate::playbook::parse_stdin().context("failed parsing playbook from stdin")?
} else {
crate::playbook::parse_file(std::path::Path::new(source))
.with_context(|| format!("failed parsing playbook from {source}"))?
};
let errors = crate::playbook::validate(&playbook, false);
let valid = errors.is_empty();
if json {
let config = &playbook.config;
let env_mode_str = match config.env_mode {
Some(crate::playbook::types::SandboxEnvMode::Clean) => "clean",
Some(crate::playbook::types::SandboxEnvMode::Inherit) => "inherit",
None => "default",
};
let steps: Vec<serde_json::Value> = playbook
.steps
.iter()
.map(|s| {
serde_json::json!({
"index": s.index,
"action": s.action.name(),
"dsl": s.to_dsl(),
})
})
.collect();
let report = serde_json::json!({
"valid": valid,
"config": {
"name": config.name,
"viewport": format!("{}x{}", config.viewport.cols, config.viewport.rows),
"shell": config.shell,
"timeout_ms": u64::try_from(config.timeout.as_millis()).unwrap_or(u64::MAX),
"env_mode": env_mode_str,
"record": config.record,
},
"steps": steps,
"step_count": playbook.steps.len(),
"errors": errors,
});
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let name = playbook.config.name.as_deref().unwrap_or("<unnamed>");
println!("playbook: {name} (dry run)");
println!(
" config: viewport={}x{} shell={} timeout={}ms env_mode={}",
playbook.config.viewport.cols,
playbook.config.viewport.rows,
playbook.config.shell.as_deref().unwrap_or("default"),
playbook.config.timeout.as_millis(),
match playbook.config.env_mode {
Some(crate::playbook::types::SandboxEnvMode::Clean) => "clean",
Some(crate::playbook::types::SandboxEnvMode::Inherit) => "inherit",
None => "default",
},
);
println!(" steps:");
for step in &playbook.steps {
println!(" {}. {}", step.index, step.to_dsl());
}
if valid {
println!(" validation: ok");
} else {
println!(" validation: ERRORS");
for error in &errors {
println!(" - {error}");
}
}
}
Ok(u8::from(!valid))
}
#[allow(clippy::cast_precision_loss)] pub(super) fn run_playbook_diff(
left_path: &str,
right_path: &str,
json: bool,
timing_threshold: u64,
) -> Result<u8> {
let left_data = std::fs::read_to_string(left_path)
.with_context(|| format!("failed reading {left_path}"))?;
let right_data = std::fs::read_to_string(right_path)
.with_context(|| format!("failed reading {right_path}"))?;
let left: crate::playbook::types::PlaybookResult = serde_json::from_str(&left_data)
.with_context(|| format!("failed parsing {left_path} as PlaybookResult JSON"))?;
let right: crate::playbook::types::PlaybookResult = serde_json::from_str(&right_data)
.with_context(|| format!("failed parsing {right_path} as PlaybookResult JSON"))?;
let report = crate::playbook::diff::diff_results(&left, &right, timing_threshold as f64);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let left_name = std::path::Path::new(left_path)
.file_name()
.map_or(left_path, |n| n.to_str().unwrap_or(left_path));
let right_name = std::path::Path::new(right_path)
.file_name()
.map_or(right_path, |n| n.to_str().unwrap_or(right_path));
print!(
"{}",
crate::playbook::diff::format_diff_report(&report, left_name, right_name)
);
}
let has_changes = report.summary.outcome_changed
|| report.summary.steps_changed > 0
|| report.summary.snapshots_changed > 0
|| !report.timing_regressions.is_empty();
Ok(u8::from(has_changes))
}
pub(super) fn run_playbook_cleanup(dry_run: bool, json: bool) -> Result<u8> {
run_sandbox_cleanup(dry_run, false, None, Some("playbook"), json)
}
pub(super) async fn run_playbook_interactive(
socket: Option<&str>,
record: bool,
viewport: &str,
shell: Option<&str>,
timeout: Option<u64>,
) -> Result<u8> {
let (cols, rows) = parse_viewport_string(viewport)?;
let timeout_duration = timeout.map(Duration::from_secs);
crate::playbook::interactive::run_interactive(
socket,
record,
cols,
rows,
shell,
timeout_duration,
)
.await
}
pub(super) fn parse_viewport_string(viewport: &str) -> Result<(u16, u16)> {
let parts: Vec<&str> = viewport.split('x').collect();
if parts.len() != 2 {
anyhow::bail!("invalid viewport format: expected COLSxROWS (e.g. 80x24), got '{viewport}'");
}
let cols: u16 = parts[0]
.parse()
.with_context(|| format!("invalid viewport cols: '{}'", parts[0]))?;
let rows: u16 = parts[1]
.parse()
.with_context(|| format!("invalid viewport rows: '{}'", parts[1]))?;
if cols < 10 || rows < 5 {
anyhow::bail!("viewport too small (minimum 10x5): {cols}x{rows}");
}
Ok((cols, rows))
}
pub(super) fn run_playbook_from_recording(recording_id: &str, output: Option<&str>) -> Result<u8> {
let recordings = recording::list_recordings_from_dir(&recording::recordings_root_dir())?;
let resolved_id = recording::resolve_recording_id_prefix(recording_id, &recordings)?;
let events = recording::load_recording_events(&resolved_id.to_string())?;
let playbook_dsl = crate::playbook::from_recording::events_to_playbook(&events);
if let Some(path) = output {
std::fs::write(path, &playbook_dsl)
.with_context(|| format!("failed writing playbook to {path}"))?;
println!("wrote playbook to {path}");
} else {
print!("{playbook_dsl}");
}
Ok(0)
}