use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum StepError {
#[error("Step execution failed: {0}")]
ExecutionFailed(String),
#[error("Rollback failed: {0}")]
RollbackFailed(String),
#[error("Step not ready: {0}")]
NotReady(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
SerializationError(String),
#[error("Checkpoint error: {0}")]
CheckpointError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_name: String,
pub step_id: String,
pub success: bool,
pub duration_ms: u64,
pub message: Option<String>,
pub output: serde_json::Value,
pub retryable: bool,
}
impl StepResult {
#[must_use]
pub fn success(step_id: String, step_name: String, duration_ms: u64) -> Self {
Self {
step_name,
step_id,
success: true,
duration_ms,
message: None,
output: serde_json::Value::Object(serde_json::Map::default()),
retryable: false,
}
}
#[must_use]
pub fn failed(step_id: String, step_name: String, message: String) -> Self {
Self {
step_name,
step_id,
success: false,
duration_ms: 0,
message: Some(message),
output: serde_json::Value::Object(serde_json::Map::default()),
retryable: false,
}
}
#[must_use]
pub fn with_output(mut self, output: serde_json::Value) -> Self {
self.output = output;
self
}
#[must_use]
pub const fn with_retry(mut self) -> Self {
self.retryable = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowContext {
pub workspace_path: String,
pub target_version: Option<String>,
pub dry_run: bool,
pub state: HashMap<String, serde_json::Value>,
pub metrics: WorkflowMetrics,
pub ci_environment: Option<CiEnvironment>,
}
impl WorkflowContext {
#[must_use]
pub fn new(workspace_path: String) -> Self {
Self {
workspace_path,
target_version: None,
dry_run: false,
state: HashMap::new(),
metrics: WorkflowMetrics::default(),
ci_environment: CiEnvironment::detect(),
}
}
#[must_use]
pub fn with_target_version(mut self, version: String) -> Self {
self.target_version = Some(version);
self
}
#[must_use]
pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.state.get(key)
}
pub fn set(&mut self, key: String, value: serde_json::Value) {
self.state.insert(key, value);
}
#[must_use]
pub const fn is_ci(&self) -> bool {
self.ci_environment.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkflowMetrics {
pub total_duration_ms: u64,
pub git_operations: u64,
pub api_calls: u64,
pub memory_usage_mb: u64,
pub crates_processed: u64,
pub crates_published: u64,
pub crates_failed: u64,
}
impl WorkflowMetrics {
pub const fn add_git_op(&mut self) {
self.git_operations += 1;
}
pub const fn add_api_call(&mut self) {
self.api_calls += 1;
}
pub const fn increment_crates_processed(&mut self) {
self.crates_processed += 1;
}
pub const fn increment_crates_published(&mut self) {
self.crates_published += 1;
}
pub const fn increment_crates_failed(&mut self) {
self.crates_failed += 1;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiEnvironment {
pub platform: CiPlatform,
pub is_pull_request: bool,
pub branch: Option<String>,
pub build_number: Option<String>,
pub extra: HashMap<String, String>,
}
impl CiEnvironment {
#[must_use]
pub fn detect() -> Option<Self> {
let platform = if std::env::var("GITHUB_ACTIONS").is_ok() {
CiPlatform::GitHubActions
} else if std::env::var("GITLAB_CI").is_ok() {
CiPlatform::GitLabCi
} else if std::env::var("CI").is_ok() {
CiPlatform::Generic
} else {
return None;
};
Some(Self {
platform,
is_pull_request: Self::is_pr(),
branch: std::env::var("GITHUB_REF_NAME")
.or_else(|_| std::env::var("CI_COMMIT_REF_NAME"))
.ok(),
build_number: std::env::var("GITHUB_RUN_ID")
.or_else(|_| std::env::var("CI_PIPELINE_ID"))
.ok(),
extra: Self::collect_extra_env(platform),
})
}
fn is_pr() -> bool {
std::env::var("GITHUB_EVENT_NAME")
.map(|e| e == "pull_request" || e == "pull_request_target")
.unwrap_or(false)
|| std::env::var("CI_MERGE_REQUEST_ID").is_ok()
}
fn collect_extra_env(platform: CiPlatform) -> HashMap<String, String> {
let mut extra = HashMap::new();
match platform {
CiPlatform::GitHubActions => {
if let Ok(val) = std::env::var("GITHUB_REPOSITORY") {
extra.insert("repository".to_string(), val);
}
if let Ok(val) = std::env::var("GITHUB_REF") {
extra.insert("ref".to_string(), val);
}
if let Ok(val) = std::env::var("GITHUB_SHA") {
extra.insert("sha".to_string(), val);
}
}
CiPlatform::GitLabCi => {
if let Ok(val) = std::env::var("CI_PROJECT_URL") {
extra.insert("project_url".to_string(), val);
}
if let Ok(val) = std::env::var("CI_COMMIT_SHA") {
extra.insert("sha".to_string(), val);
}
}
CiPlatform::Generic => {}
}
extra
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CiPlatform {
GitHubActions,
GitLabCi,
Generic,
}
#[async_trait]
pub trait WorkflowStep: Send + Sync {
fn name(&self) -> &str;
fn id(&self) -> &str;
async fn execute(&self, ctx: &mut WorkflowContext) -> Result<StepResult, StepError>;
async fn rollback(&self, ctx: &WorkflowContext) -> Result<(), StepError>;
async fn can_skip(&self, ctx: &WorkflowContext) -> bool {
ctx.dry_run && !matches!(self.id(), "analyze" | "plan" | "check" | "simulate")
}
fn is_idempotent(&self) -> bool {
false
}
fn serialize_state(&self) -> Result<serde_json::Value, StepError> {
Ok(serde_json::json!({
"id": self.id(),
"name": self.name(),
}))
}
fn dependencies(&self) -> Vec<String> {
Vec::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ErrorHandlingStrategy {
#[default]
Stop,
SkipAndContinue,
Retry {
max_attempts: usize,
delay_secs: u64,
},
Rollback,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepBatch {
pub batch_number: usize,
pub steps: Vec<String>,
pub can_parallelize: bool,
}
impl StepBatch {
#[must_use]
pub const fn new(batch_number: usize, steps: Vec<String>, can_parallelize: bool) -> Self {
Self {
batch_number,
steps,
can_parallelize,
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.steps.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.steps.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ci_detection() {
let env = CiEnvironment {
platform: CiPlatform::GitHubActions,
is_pull_request: true,
branch: Some("main".to_string()),
build_number: Some("42".to_string()),
extra: HashMap::new(),
};
assert_eq!(env.platform, CiPlatform::GitHubActions);
assert!(env.is_pull_request);
}
#[test]
fn test_workflow_context() {
let mut ctx = WorkflowContext::new("/test".to_string())
.with_target_version("1.0.0".to_string())
.with_dry_run(true);
assert_eq!(ctx.target_version, Some("1.0.0".to_string()));
assert!(ctx.dry_run);
ctx.set("key".to_string(), serde_json::json!("value"));
assert_eq!(ctx.get("key"), Some(&serde_json::json!("value")));
}
#[test]
fn test_workflow_metrics() {
let mut metrics = WorkflowMetrics::default();
metrics.add_git_op();
metrics.add_api_call();
metrics.increment_crates_processed();
metrics.increment_crates_published();
assert_eq!(metrics.git_operations, 1);
assert_eq!(metrics.api_calls, 1);
assert_eq!(metrics.crates_processed, 1);
assert_eq!(metrics.crates_published, 1);
assert_eq!(metrics.crates_failed, 0);
}
#[test]
fn test_step_result() {
let result = StepResult::success("step-1".to_string(), "Step 1".to_string(), 100);
assert!(result.success);
assert_eq!(result.step_id, "step-1");
assert_eq!(result.duration_ms, 100);
let failed = StepResult::failed(
"step-2".to_string(),
"Step 2".to_string(),
"Error".to_string(),
);
assert!(!failed.success);
assert_eq!(failed.message, Some("Error".to_string()));
}
#[test]
fn test_step_batch() {
let batch = StepBatch::new(1, vec!["step-1".to_string(), "step-2".to_string()], false);
assert_eq!(batch.batch_number, 1);
assert_eq!(batch.len(), 2);
assert!(!batch.can_parallelize);
}
}