use crate::core::types::{DispatchConfig, DispatchInvocation, DispatchState};
#[derive(Debug, Clone)]
pub struct PreparedDispatch {
pub command: String,
pub timeout_secs: Option<u32>,
pub name: String,
}
pub fn prepare_dispatch(
config: &DispatchConfig,
overrides: &[(String, String)],
) -> PreparedDispatch {
let mut command = config.command.clone();
for (key, value) in &config.params {
let placeholder = format!("{{{{ {key} }}}}");
command = command.replace(&placeholder, value);
}
for (key, value) in overrides {
let placeholder = format!("{{{{ {key} }}}}");
command = command.replace(&placeholder, value);
}
PreparedDispatch {
command,
timeout_secs: config.timeout_secs,
name: config.name.clone(),
}
}
pub fn record_invocation(
state: &mut DispatchState,
invocation: DispatchInvocation,
max_history: usize,
) {
state.invocations.insert(0, invocation);
state.total_invocations += 1;
if state.invocations.len() > max_history {
state.invocations.truncate(max_history);
}
}
pub fn format_dispatch_summary(name: &str, state: &DispatchState) -> String {
let mut out = format!(
"dispatch/{name}: total={} invocations\n",
state.total_invocations
);
for (i, inv) in state.invocations.iter().enumerate() {
let status = if inv.exit_code == 0 { "ok" } else { "FAIL" };
let caller = inv.caller.as_deref().unwrap_or("-");
out.push_str(&format!(
" [{:>2}] [{status:>4}] exit={} {:.1}s by={caller} at={}\n",
i + 1,
inv.exit_code,
inv.duration_ms as f64 / 1000.0,
inv.timestamp,
));
}
out
}
pub fn validate_dispatch(config: &DispatchConfig) -> Result<(), String> {
if config.name.is_empty() {
return Err("dispatch name cannot be empty".into());
}
if config.command.is_empty() {
return Err("dispatch command cannot be empty".into());
}
Ok(())
}
pub fn dispatch_script(prepared: &PreparedDispatch) -> String {
let mut script = String::from("set -euo pipefail\n");
script.push_str(&prepared.command);
if !prepared.command.ends_with('\n') {
script.push('\n');
}
script
}
pub fn success_rate(state: &DispatchState) -> f64 {
if state.invocations.is_empty() {
return 0.0;
}
let ok = state
.invocations
.iter()
.filter(|i| i.exit_code == 0)
.count();
(ok as f64 / state.invocations.len() as f64) * 100.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_dispatch_substitutes_params() {
let config = DispatchConfig {
name: "deploy".into(),
command: "deploy --env {{ env }} --region {{ region }}".into(),
params: vec![
("env".into(), "staging".into()),
("region".into(), "us-east-1".into()),
],
timeout_secs: None,
};
let prepared = prepare_dispatch(&config, &[]);
assert_eq!(prepared.command, "deploy --env staging --region us-east-1");
}
#[test]
fn prepare_dispatch_overrides_win() {
let config = DispatchConfig {
name: "build".into(),
command: "make {{ target }}".into(),
params: vec![("target".into(), "debug".into())],
timeout_secs: None,
};
let prepared = prepare_dispatch(&config, &[("target".into(), "release".into())]);
assert_eq!(prepared.command, "make debug");
}
#[test]
fn prepare_dispatch_override_only() {
let config = DispatchConfig {
name: "test".into(),
command: "test {{ suite }}".into(),
params: vec![],
timeout_secs: Some(60),
};
let prepared = prepare_dispatch(&config, &[("suite".into(), "integration".into())]);
assert_eq!(prepared.command, "test integration");
assert_eq!(prepared.timeout_secs, Some(60));
}
#[test]
fn record_invocation_adds_and_trims() {
let mut state = DispatchState::default();
for i in 0..15 {
let inv = DispatchInvocation {
timestamp: format!("t{i}"),
exit_code: 0,
duration_ms: 100,
caller: None,
};
record_invocation(&mut state, inv, 10);
}
assert_eq!(state.total_invocations, 15);
assert_eq!(state.invocations.len(), 10);
assert_eq!(state.invocations[0].timestamp, "t14");
}
#[test]
fn format_dispatch_summary_output() {
let state = DispatchState {
invocations: vec![
DispatchInvocation {
timestamp: "2026-03-06".into(),
exit_code: 0,
duration_ms: 1500,
caller: Some("admin".into()),
},
DispatchInvocation {
timestamp: "2026-03-05".into(),
exit_code: 1,
duration_ms: 300,
caller: None,
},
],
total_invocations: 42,
};
let summary = format_dispatch_summary("deploy", &state);
assert!(summary.contains("total=42"));
assert!(summary.contains("[ ok]"));
assert!(summary.contains("[FAIL]"));
assert!(summary.contains("by=admin"));
}
#[test]
fn validate_dispatch_empty_name() {
let config = DispatchConfig {
name: String::new(),
command: "echo hi".into(),
params: vec![],
timeout_secs: None,
};
assert!(validate_dispatch(&config).is_err());
}
#[test]
fn validate_dispatch_empty_command() {
let config = DispatchConfig {
name: "test".into(),
command: String::new(),
params: vec![],
timeout_secs: None,
};
assert!(validate_dispatch(&config).is_err());
}
#[test]
fn validate_dispatch_valid() {
let config = DispatchConfig {
name: "build".into(),
command: "cargo build".into(),
params: vec![],
timeout_secs: None,
};
assert!(validate_dispatch(&config).is_ok());
}
#[test]
fn dispatch_script_format() {
let prepared = PreparedDispatch {
command: "echo hello".into(),
timeout_secs: None,
name: "test".into(),
};
let script = dispatch_script(&prepared);
assert!(script.starts_with("set -euo pipefail"));
assert!(script.contains("echo hello"));
}
#[test]
fn success_rate_all_pass() {
let state = DispatchState {
invocations: vec![
DispatchInvocation {
timestamp: "t1".into(),
exit_code: 0,
duration_ms: 100,
caller: None,
},
DispatchInvocation {
timestamp: "t2".into(),
exit_code: 0,
duration_ms: 200,
caller: None,
},
],
total_invocations: 2,
};
assert!((success_rate(&state) - 100.0).abs() < 0.01);
}
#[test]
fn success_rate_empty() {
let state = DispatchState::default();
assert!((success_rate(&state) - 0.0).abs() < 0.01);
}
#[test]
fn success_rate_mixed() {
let state = DispatchState {
invocations: vec![
DispatchInvocation {
timestamp: "t1".into(),
exit_code: 0,
duration_ms: 100,
caller: None,
},
DispatchInvocation {
timestamp: "t2".into(),
exit_code: 1,
duration_ms: 200,
caller: None,
},
DispatchInvocation {
timestamp: "t3".into(),
exit_code: 0,
duration_ms: 300,
caller: None,
},
DispatchInvocation {
timestamp: "t4".into(),
exit_code: 2,
duration_ms: 400,
caller: None,
},
],
total_invocations: 4,
};
assert!((success_rate(&state) - 50.0).abs() < 0.01);
}
}