rskit-chain 0.2.0-alpha.3

Sequential chain execution pattern for rskit
Documentation
use super::*;
use parking_lot::Mutex;
use rskit_errors::{AppError, AppResult, ErrorCode};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio_util::sync::CancellationToken;

#[tokio::test]
async fn typed_chain_composes_heterogeneous_steps() {
    let chain = ChainBuilder::new()
        .step(Step::from_fn(
            "parse",
            |input: String, context| async move {
                context.progress(50, Some("parsed input".into()));
                input
                    .parse::<u32>()
                    .map_err(|err| AppError::invalid_input("input", err.to_string()))
            },
        ))
        .step(Step::from_fn("format", |value: u32, _context| async move {
            Ok::<_, AppError>(format!("value={}", value + 1))
        }))
        .build();

    let output = chain
        .execute("41".to_string(), None, CancellationToken::new())
        .await
        .unwrap();

    assert_eq!(output, "value=42");
    assert_eq!(chain.len(), 2);
}

#[tokio::test]
async fn failure_short_circuits_and_runs_cleanup() {
    let cleaned = Arc::new(AtomicBool::new(false));
    let cleanup_flag = cleaned.clone();

    let chain = ChainBuilder::new()
        .step(
            Step::from_fn("reserve", |value: u32, _context| async move {
                Ok::<_, AppError>(value + 1)
            })
            .with_cleanup(move || {
                let cleanup_flag = cleanup_flag.clone();
                async move {
                    cleanup_flag.store(true, Ordering::SeqCst);
                    Ok(())
                }
            }),
        )
        .step(Step::from_fn("fail", |_value: u32, _context| async move {
            Err::<u32, AppError>(AppError::new(ErrorCode::Internal, "intentional failure"))
        }))
        .step(Step::from_fn(
            "unreached",
            |value: u32, _context| async move { Ok::<_, AppError>(value + 1) },
        ))
        .build();

    let error = chain
        .execute(0u32, None, CancellationToken::new())
        .await
        .unwrap_err();

    assert_eq!(error.code(), ErrorCode::Internal);
    assert!(error.message().contains("fail"));
    assert!(cleaned.load(Ordering::SeqCst));
}

#[tokio::test]
async fn cleanup_failure_does_not_hide_step_failure() {
    let chain = ChainBuilder::new()
        .step(
            Step::from_fn("reserve", |value: u32, _context| async move {
                Ok::<_, AppError>(value + 1)
            })
            .with_cleanup(|| async {
                Err::<(), AppError>(AppError::new(ErrorCode::Internal, "cleanup failure"))
            }),
        )
        .step(Step::from_fn("fail", |_value: u32, _context| async move {
            Err::<u32, AppError>(AppError::invalid_input("input", "primary failure"))
        }))
        .build();

    let error = chain
        .execute(0u32, None, CancellationToken::new())
        .await
        .unwrap_err();

    assert_eq!(error.code(), ErrorCode::InvalidInput);
    assert!(error.message().contains("chain step `fail` failed"));
    assert!(error.message().contains("primary failure"));
    assert!(error.message().contains("cleanup failure"));
}

#[tokio::test]
async fn cancellation_before_next_step_fails_chain() {
    let cancel = CancellationToken::new();
    let cancel_for_step = cancel.clone();

    let chain = ChainBuilder::new()
        .step(Step::from_fn("cancel", move |value: u32, _context| {
            let cancel = cancel_for_step.clone();
            async move {
                cancel.cancel();
                Ok::<_, AppError>(value)
            }
        }))
        .step(Step::from_fn(
            "unreached",
            |value: u32, _context| async move { Ok::<_, AppError>(value + 1) },
        ))
        .build();

    let error = chain.execute(1u32, None, cancel).await.unwrap_err();
    assert_eq!(error.code(), ErrorCode::Cancelled);
}

#[tokio::test]
async fn progress_callback_receives_step_events() {
    let events = Arc::new(Mutex::new(Vec::<StepProgress>::new()));
    let captured = events.clone();
    let progress: ChainProgressFn = Arc::new(move |event| {
        captured.lock().push(event);
    });

    let chain = ChainBuilder::new()
        .step(Step::from_fn("one", |value: u32, context| async move {
            context.progress(25, Some("working".into()));
            Ok::<_, AppError>(value + 1)
        }))
        .build();

    let output = chain
        .execute(1u32, Some(progress), CancellationToken::new())
        .await
        .unwrap();

    assert_eq!(output, 2);
    let events = events.lock();
    assert_eq!(events[0].status, StepStatus::Running);
    assert_eq!(events[1].progress_percent, 25);
    assert_eq!(events[2].status, StepStatus::Completed);
}

#[tokio::test]
async fn default_empty_chain_returns_input_and_reports_empty() {
    let chain = ChainBuilder::<u32, u32>::default().build();

    let output = chain
        .execute(7, None, CancellationToken::new())
        .await
        .expect("empty chain should pass input through");

    assert_eq!(output, 7);
    assert!(chain.is_empty());
    assert_eq!(chain.len(), 0);
    _assert_result_type(Ok(output));
}

#[tokio::test]
async fn cancellation_before_step_runs_existing_cleanup_failure() {
    let cancel = CancellationToken::new();
    let cancel_for_step = cancel.clone();

    let chain = ChainBuilder::new()
        .step(
            Step::from_fn("cancel", move |value: u32, _context| {
                let cancel = cancel_for_step.clone();
                async move {
                    cancel.cancel();
                    Ok::<_, AppError>(value + 1)
                }
            })
            .with_cleanup(|| async {
                Err::<(), AppError>(AppError::new(ErrorCode::Internal, "cleanup failed"))
            }),
        )
        .step(Step::from_fn(
            "unreached",
            |value: u32, _context| async move { Ok::<_, AppError>(value + 1) },
        ))
        .build();

    let error = chain.execute(1, None, cancel).await.unwrap_err();
    assert_eq!(error.code(), ErrorCode::Cancelled);
    assert!(error.message().contains("chain cancelled"));
    assert!(error.message().contains("cleanup failed"));
}

fn _assert_result_type(_: AppResult<u32>) {}