use std::sync::Mutex;
use crate::self_modify::gate::{
BenchmarkSample, CheckResult, CheckRunner, RecommendedAction, StagingMetric, ValidationGate,
ValidationReport,
};
#[derive(Debug, Clone)]
pub struct ParamChange {
pub param_name: String,
pub old_value: f64,
pub new_value: f64,
}
pub trait DeploymentTarget: Send + Sync {
fn name(&self) -> &str;
fn apply(&self, changes: &[ParamChange]) -> Result<(), DeploymentError>;
}
#[derive(Debug)]
pub enum DeploymentError {
TargetFailed { target: String, reason: String },
ValidationFailed { failed_checks: Vec<String> },
NoTargets,
}
impl std::fmt::Display for DeploymentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeploymentError::TargetFailed { target, reason } => {
write!(f, "target '{}' failed: {}", target, reason)
}
DeploymentError::ValidationFailed { failed_checks } => {
write!(f, "validation failed: [{}]", failed_checks.join(", "))
}
DeploymentError::NoTargets => {
write!(f, "no deployment targets registered")
}
}
}
}
impl std::error::Error for DeploymentError {}
#[derive(Debug, Clone, PartialEq)]
pub enum DeploymentOutcome {
Deployed {
changes_applied: usize,
targets_notified: usize,
},
AwaitingReview {
change_id: String,
},
Rejected {
failed_checks: Vec<String>,
},
TargetError {
target: String,
reason: String,
},
NoTargets,
}
pub struct StagedDeploymentPipeline {
gate: ValidationGate,
targets: Vec<Box<dyn DeploymentTarget>>,
}
impl StagedDeploymentPipeline {
pub fn new(gate: ValidationGate) -> Self {
Self {
gate,
targets: Vec::new(),
}
}
pub fn add_target(&mut self, target: Box<dyn DeploymentTarget>) {
self.targets.push(target);
}
pub fn target_count(&self) -> usize {
self.targets.len()
}
pub fn deploy(
&self,
change_id: impl Into<String>,
runner: &dyn CheckRunner,
changes: &[ParamChange],
) -> DeploymentOutcome {
let change_id = change_id.into();
let report = self.gate.run(change_id.clone(), runner);
if !report.overall_passed {
let failed_checks = report
.failed_checks()
.into_iter()
.map(|c| c.name.clone())
.collect();
return DeploymentOutcome::Rejected { failed_checks };
}
if report.recommended_action == RecommendedAction::AwaitReview {
return DeploymentOutcome::AwaitingReview { change_id };
}
if self.targets.is_empty() {
return DeploymentOutcome::NoTargets;
}
for target in &self.targets {
if let Err(err) = target.apply(changes) {
let (target_name, reason) = match err {
DeploymentError::TargetFailed { target, reason } => (target, reason),
other => (target.name().to_string(), other.to_string()),
};
return DeploymentOutcome::TargetError {
target: target_name,
reason,
};
}
}
DeploymentOutcome::Deployed {
changes_applied: changes.len(),
targets_notified: self.targets.len(),
}
}
pub fn validate_only(
&self,
change_id: impl Into<String>,
runner: &dyn CheckRunner,
) -> ValidationReport {
self.gate.run(change_id, runner)
}
}
pub struct InMemoryParamTarget {
name: String,
applied: Mutex<Vec<ParamChange>>,
}
impl InMemoryParamTarget {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
applied: Mutex::new(Vec::new()),
}
}
pub fn applied_changes(&self) -> Vec<ParamChange> {
match self.applied.lock() {
Ok(guard) => guard.clone(),
Err(_) => Vec::new(),
}
}
}
impl DeploymentTarget for InMemoryParamTarget {
fn name(&self) -> &str {
&self.name
}
fn apply(&self, changes: &[ParamChange]) -> Result<(), DeploymentError> {
match self.applied.lock() {
Ok(mut guard) => {
guard.extend_from_slice(changes);
Ok(())
}
Err(_) => Err(DeploymentError::TargetFailed {
target: self.name.clone(),
reason: "internal mutex poisoned".to_string(),
}),
}
}
}
pub struct PassAllCheckRunner;
impl PassAllCheckRunner {
pub fn new() -> Self {
Self
}
}
impl Default for PassAllCheckRunner {
fn default() -> Self {
Self::new()
}
}
impl CheckRunner for PassAllCheckRunner {
fn run_tests(&self, _cmd: &str) -> CheckResult {
CheckResult::passed("tests", std::time::Duration::from_millis(1))
}
fn run_clippy(&self, _cmd: &str) -> CheckResult {
CheckResult::passed("clippy", std::time::Duration::from_millis(1))
}
fn run_benchmarks(&self) -> Vec<BenchmarkSample> {
vec![]
}
fn run_smoke(&self, _cmd: &str) -> CheckResult {
CheckResult::passed("smoke", std::time::Duration::from_millis(1))
}
fn staging_metrics(&self) -> Vec<StagingMetric> {
vec![]
}
}
pub struct FailAllCheckRunner {
reason: String,
}
impl FailAllCheckRunner {
pub fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
impl CheckRunner for FailAllCheckRunner {
fn run_tests(&self, _cmd: &str) -> CheckResult {
CheckResult::failed("tests", &self.reason, std::time::Duration::ZERO)
}
fn run_clippy(&self, _cmd: &str) -> CheckResult {
CheckResult::failed("clippy", &self.reason, std::time::Duration::ZERO)
}
fn run_benchmarks(&self) -> Vec<BenchmarkSample> {
vec![]
}
fn run_smoke(&self, _cmd: &str) -> CheckResult {
CheckResult::failed("smoke", &self.reason, std::time::Duration::ZERO)
}
fn staging_metrics(&self) -> Vec<StagingMetric> {
vec![]
}
}
pub struct CargoCheckRunner {
workspace: String,
}
impl CargoCheckRunner {
pub fn new(workspace: impl Into<String>) -> Self {
Self {
workspace: workspace.into(),
}
}
}
impl CheckRunner for CargoCheckRunner {
fn run_tests(&self, cmd: &str) -> CheckResult {
let start = std::time::Instant::now();
let parts: Vec<&str> = cmd.split_whitespace().collect();
let (program, args) = if parts.is_empty() {
("cargo", vec!["test", "--all-features"])
} else {
(parts[0], parts[1..].to_vec())
};
match std::process::Command::new(program)
.args(&args)
.current_dir(&self.workspace)
.output()
{
Ok(output) if output.status.success() => CheckResult::passed("tests", start.elapsed()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let reason = if stderr.len() > 500 {
format!("exit={}: {}...", output.status, &stderr[..500])
} else {
format!("exit={}: {}", output.status, stderr)
};
CheckResult::failed("tests", reason, start.elapsed())
}
Err(e) => CheckResult::failed("tests", format!("spawn error: {e}"), start.elapsed()),
}
}
fn run_clippy(&self, cmd: &str) -> CheckResult {
let start = std::time::Instant::now();
let parts: Vec<&str> = cmd.split_whitespace().collect();
let (program, args) = if parts.is_empty() {
(
"cargo",
vec!["clippy", "--all-features", "--", "-D", "warnings"],
)
} else {
(parts[0], parts[1..].to_vec())
};
match std::process::Command::new(program)
.args(&args)
.current_dir(&self.workspace)
.output()
{
Ok(output) if output.status.success() => CheckResult::passed("clippy", start.elapsed()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let reason = if stderr.len() > 500 {
format!("exit={}: {}...", output.status, &stderr[..500])
} else {
format!("exit={}: {}", output.status, stderr)
};
CheckResult::failed("clippy", reason, start.elapsed())
}
Err(e) => CheckResult::failed("clippy", format!("spawn error: {e}"), start.elapsed()),
}
}
fn run_benchmarks(&self) -> Vec<BenchmarkSample> {
vec![]
}
fn run_smoke(&self, cmd: &str) -> CheckResult {
let start = std::time::Instant::now();
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
return CheckResult::skipped("smoke", "no smoke command configured");
}
match std::process::Command::new(parts[0])
.args(&parts[1..])
.current_dir(&self.workspace)
.output()
{
Ok(output) if output.status.success() => CheckResult::passed("smoke", start.elapsed()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
CheckResult::failed(
"smoke",
format!("exit={}: {}", output.status, stderr),
start.elapsed(),
)
}
Err(e) => CheckResult::failed("smoke", format!("spawn error: {e}"), start.elapsed()),
}
}
fn staging_metrics(&self) -> Vec<StagingMetric> {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::self_modify::gate::{GateConfig, TrustLevel};
fn auto_deploy_gate() -> ValidationGate {
ValidationGate::new(GateConfig {
trust_level: TrustLevel::AutoDeploy,
run_benchmarks: false,
..GateConfig::default()
})
}
fn auto_merge_gate() -> ValidationGate {
ValidationGate::new(GateConfig {
trust_level: TrustLevel::AutoMerge,
run_benchmarks: false,
..GateConfig::default()
})
}
fn review_gate() -> ValidationGate {
ValidationGate::new(GateConfig {
trust_level: TrustLevel::ReviewRequired,
run_benchmarks: false,
..GateConfig::default()
})
}
fn sample_changes() -> Vec<ParamChange> {
vec![
ParamChange {
param_name: "temperature".into(),
old_value: 0.7,
new_value: 0.9,
},
ParamChange {
param_name: "top_p".into(),
old_value: 0.9,
new_value: 0.85,
},
]
}
fn empty_changes() -> Vec<ParamChange> {
vec![]
}
#[test]
fn new_pipeline_has_no_targets() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
assert_eq!(pipeline.target_count(), 0);
}
#[test]
fn add_target_increments_count() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t1")));
assert_eq!(pipeline.target_count(), 1);
pipeline.add_target(Box::new(InMemoryParamTarget::new("t2")));
assert_eq!(pipeline.target_count(), 2);
}
#[test]
fn deploy_fail_checks_returns_rejected() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let runner = FailAllCheckRunner::new("forced failure");
let outcome = pipeline.deploy("c1", &runner, &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::Rejected { .. }));
}
#[test]
fn deploy_rejected_contains_failed_check_names() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let runner = FailAllCheckRunner::new("forced failure");
let outcome = pipeline.deploy("c1", &runner, &sample_changes());
if let DeploymentOutcome::Rejected { failed_checks } = outcome {
assert!(!failed_checks.is_empty());
assert!(failed_checks.contains(&"tests".to_string()));
} else {
panic!("expected Rejected");
}
}
#[test]
fn deploy_rejected_does_not_call_targets() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let target = InMemoryParamTarget::new("sink");
let tracked = InMemoryParamTarget::new("tracked");
tracked.apply(&sample_changes()).unwrap();
let before_count = tracked.applied_changes().len();
assert_eq!(before_count, 2);
pipeline.add_target(Box::new(target));
let runner = FailAllCheckRunner::new("fail");
let _outcome = pipeline.deploy("c1", &runner, &sample_changes());
assert!(matches!(_outcome, DeploymentOutcome::Rejected { .. }));
}
#[test]
fn fail_runner_deploy_does_not_reach_targets() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let runner = FailAllCheckRunner::new("deliberate failure");
let outcome = pipeline.deploy("chg-999", &runner, &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::Rejected { .. }));
}
#[test]
fn deploy_pass_all_checks_review_required_returns_awaiting_review() {
let mut pipeline = StagedDeploymentPipeline::new(review_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let runner = PassAllCheckRunner::new();
let outcome = pipeline.deploy("rev-001", &runner, &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::AwaitingReview { .. }));
}
#[test]
fn deployment_outcome_awaiting_review_equality() {
let a = DeploymentOutcome::AwaitingReview {
change_id: "x".into(),
};
let b = DeploymentOutcome::AwaitingReview {
change_id: "x".into(),
};
assert_eq!(a, b);
let c = DeploymentOutcome::AwaitingReview {
change_id: "y".into(),
};
assert_ne!(a, c);
}
#[test]
fn awaiting_review_preserves_change_id() {
let mut pipeline = StagedDeploymentPipeline::new(review_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let outcome = pipeline.deploy(
"my-special-id",
&PassAllCheckRunner::new(),
&sample_changes(),
);
if let DeploymentOutcome::AwaitingReview { change_id } = outcome {
assert_eq!(change_id, "my-special-id");
} else {
panic!("expected AwaitingReview");
}
}
#[test]
fn deploy_no_targets_returns_no_targets() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let runner = PassAllCheckRunner::new();
let outcome = pipeline.deploy("c1", &runner, &sample_changes());
assert_eq!(outcome, DeploymentOutcome::NoTargets);
}
#[test]
fn deploy_pass_all_auto_deploy_applies_changes() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("target")));
let runner = PassAllCheckRunner::new();
let outcome = pipeline.deploy("c1", &runner, &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::Deployed { .. }));
}
#[test]
fn deploy_pass_all_auto_merge_applies_changes() {
let mut pipeline = StagedDeploymentPipeline::new(auto_merge_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("target")));
let runner = PassAllCheckRunner::new();
let outcome = pipeline.deploy("c2", &runner, &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::Deployed { .. }));
}
#[test]
fn deploy_auto_merge_target_change_count_matches() {
let mut pipeline = StagedDeploymentPipeline::new(auto_merge_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let changes = sample_changes();
let outcome = pipeline.deploy("c", &PassAllCheckRunner::new(), &changes);
if let DeploymentOutcome::Deployed {
changes_applied,
targets_notified,
} = outcome
{
assert_eq!(changes_applied, 2);
assert_eq!(targets_notified, 1);
} else {
panic!("expected Deployed");
}
}
#[test]
fn deploy_applies_to_multiple_targets() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("a")));
pipeline.add_target(Box::new(InMemoryParamTarget::new("b")));
pipeline.add_target(Box::new(InMemoryParamTarget::new("c")));
let outcome = pipeline.deploy("c1", &PassAllCheckRunner::new(), &sample_changes());
if let DeploymentOutcome::Deployed {
targets_notified, ..
} = outcome
{
assert_eq!(targets_notified, 3);
} else {
panic!("expected Deployed");
}
}
#[test]
fn two_targets_both_receive_changes() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("alpha")));
pipeline.add_target(Box::new(InMemoryParamTarget::new("beta")));
let changes = sample_changes();
let outcome = pipeline.deploy("chg", &PassAllCheckRunner::new(), &changes);
if let DeploymentOutcome::Deployed {
changes_applied,
targets_notified,
} = outcome
{
assert_eq!(targets_notified, 2);
assert_eq!(changes_applied, 2);
} else {
panic!("expected Deployed");
}
}
#[test]
fn deploy_with_zero_changes_still_notifies_targets() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let outcome = pipeline.deploy("c0", &PassAllCheckRunner::new(), &empty_changes());
if let DeploymentOutcome::Deployed {
changes_applied,
targets_notified,
} = outcome
{
assert_eq!(changes_applied, 0);
assert_eq!(targets_notified, 1);
} else {
panic!("expected Deployed");
}
}
#[test]
fn deployment_outcome_deployed_equality() {
let a = DeploymentOutcome::Deployed {
changes_applied: 3,
targets_notified: 2,
};
let b = DeploymentOutcome::Deployed {
changes_applied: 3,
targets_notified: 2,
};
assert_eq!(a, b);
let c = DeploymentOutcome::Deployed {
changes_applied: 1,
targets_notified: 2,
};
assert_ne!(a, c);
}
#[test]
fn deployment_outcome_rejected_equality() {
let a = DeploymentOutcome::Rejected {
failed_checks: vec!["tests".into()],
};
let b = DeploymentOutcome::Rejected {
failed_checks: vec!["tests".into()],
};
assert_eq!(a, b);
let c = DeploymentOutcome::Rejected {
failed_checks: vec!["clippy".into()],
};
assert_ne!(a, c);
}
#[test]
fn in_memory_target_stores_applied_changes() {
let target = InMemoryParamTarget::new("t");
let changes = sample_changes();
target.apply(&changes).unwrap();
let stored = target.applied_changes();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].param_name, "temperature");
assert_eq!(stored[1].param_name, "top_p");
}
#[test]
fn in_memory_target_name_matches() {
let target = InMemoryParamTarget::new("my-target");
assert_eq!(target.name(), "my-target");
}
#[test]
fn in_memory_target_accumulates_across_calls() {
let target = InMemoryParamTarget::new("t");
target.apply(&sample_changes()).unwrap();
target.apply(&sample_changes()).unwrap();
assert_eq!(target.applied_changes().len(), 4);
}
#[test]
fn in_memory_target_empty_on_new() {
let target = InMemoryParamTarget::new("fresh");
assert!(target.applied_changes().is_empty());
}
#[test]
fn param_change_fields_accessible() {
let c = ParamChange {
param_name: "lr".into(),
old_value: 0.001,
new_value: 0.0005,
};
assert_eq!(c.param_name, "lr");
assert!((c.old_value - 0.001).abs() < f64::EPSILON);
assert!((c.new_value - 0.0005).abs() < f64::EPSILON);
}
#[test]
fn param_change_clone_is_independent() {
let c = ParamChange {
param_name: "x".into(),
old_value: 1.0,
new_value: 2.0,
};
let mut c2 = c.clone();
c2.param_name = "y".into();
assert_eq!(c.param_name, "x");
assert_eq!(c2.param_name, "y");
}
#[test]
fn deployment_error_display_target_failed() {
let e = DeploymentError::TargetFailed {
target: "alpha".into(),
reason: "connection refused".into(),
};
let s = e.to_string();
assert!(s.contains("alpha"));
assert!(s.contains("connection refused"));
}
#[test]
fn deployment_error_display_validation_failed() {
let e = DeploymentError::ValidationFailed {
failed_checks: vec!["tests".into(), "clippy".into()],
};
let s = e.to_string();
assert!(s.contains("tests"));
assert!(s.contains("clippy"));
}
#[test]
fn deployment_error_display_no_targets() {
let e = DeploymentError::NoTargets;
let s = e.to_string();
assert!(!s.is_empty());
assert!(s.contains("no deployment targets"));
}
#[test]
fn deployment_error_is_std_error() {
let e: Box<dyn std::error::Error> = Box::new(DeploymentError::NoTargets);
assert!(!e.to_string().is_empty());
}
#[test]
fn pass_all_runner_default_construction() {
let _r = PassAllCheckRunner::default();
}
#[test]
fn pass_all_runner_tests_pass() {
let r = PassAllCheckRunner::new();
let result = r.run_tests("cargo test");
assert!(result.status.is_passed());
}
#[test]
fn pass_all_runner_clippy_passes() {
let r = PassAllCheckRunner::new();
let result = r.run_clippy("cargo clippy");
assert!(result.status.is_passed());
}
#[test]
fn pass_all_runner_benchmarks_empty() {
let r = PassAllCheckRunner::new();
assert!(r.run_benchmarks().is_empty());
}
#[test]
fn pass_all_runner_smoke_passes() {
let r = PassAllCheckRunner::new();
let result = r.run_smoke("./scripts/smoke.sh");
assert!(result.status.is_passed());
}
#[test]
fn pass_all_runner_staging_metrics_empty() {
let r = PassAllCheckRunner::new();
assert!(r.staging_metrics().is_empty());
}
#[test]
fn fail_all_runner_tests_fail() {
let r = FailAllCheckRunner::new("deliberate");
let result = r.run_tests("cargo test");
assert!(result.status.is_failed());
}
#[test]
fn fail_all_runner_reason_in_output() {
let r = FailAllCheckRunner::new("my-reason");
let result = r.run_tests("cargo test");
if let crate::self_modify::gate::CheckStatus::Failed { reason } = result.status {
assert!(reason.contains("my-reason"));
} else {
panic!("expected Failed status");
}
}
#[test]
fn fail_all_runner_clippy_fails() {
let r = FailAllCheckRunner::new("lint error");
assert!(r.run_clippy("cargo clippy").status.is_failed());
}
#[test]
fn fail_all_runner_smoke_fails() {
let r = FailAllCheckRunner::new("smoke error");
assert!(r.run_smoke("./smoke.sh").status.is_failed());
}
#[test]
fn validate_only_returns_report_not_deployed() {
let mut pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let report = pipeline.validate_only("chg", &PassAllCheckRunner::new());
assert!(report.overall_passed);
}
#[test]
fn validate_only_pass_report_overall_passed() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let report = pipeline.validate_only("c", &PassAllCheckRunner::new());
assert!(report.overall_passed);
}
#[test]
fn validate_only_fail_report_not_overall_passed() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let report = pipeline.validate_only("c", &FailAllCheckRunner::new("fail"));
assert!(!report.overall_passed);
}
#[test]
fn validate_only_check_count_matches_gate_stages() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let report = pipeline.validate_only("c", &PassAllCheckRunner::new());
assert_eq!(report.checks.len(), 5);
}
#[test]
fn validate_only_change_id_preserved() {
let pipeline = StagedDeploymentPipeline::new(auto_deploy_gate());
let report = pipeline.validate_only("unique-id-42", &PassAllCheckRunner::new());
assert_eq!(report.change_id, "unique-id-42");
}
#[test]
fn gate_config_forwarded_to_pipeline() {
let mut pipeline = StagedDeploymentPipeline::new(review_gate());
pipeline.add_target(Box::new(InMemoryParamTarget::new("t")));
let outcome = pipeline.deploy("x", &PassAllCheckRunner::new(), &sample_changes());
assert!(matches!(outcome, DeploymentOutcome::AwaitingReview { .. }));
}
}