cordis-core 0.0.4

A typed, scope-based plugin runtime inspired by Cordis
Documentation
use std::sync::{
    Arc, Mutex,
    atomic::{AtomicUsize, Ordering},
};

use cordis_core::{App, Error, Plugin, PluginContext, Result, ServiceKey};

trait Marker: Send + Sync {}
struct MarkerKey;
impl ServiceKey for MarkerKey {
    type Value = dyn Marker;
    const NAME: &'static str = "marker";
}
struct MarkerValue;
impl Marker for MarkerValue {}

struct RollbackPlugin;
impl Plugin for RollbackPlugin {
    type Config = Arc<AtomicUsize>;

    fn name(&self) -> &'static str {
        "rollback-plugin"
    }

    async fn apply(&self, ctx: PluginContext, calls: Arc<Self::Config>) -> Result<()> {
        let marker: Arc<dyn Marker> = Arc::new(MarkerValue);
        ctx.provide::<MarkerKey>(marker)?;
        ctx.on::<RollbackEvent, _, _>(move |_, _| {
            let calls = calls.clone();
            async move {
                calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            }
        })?;
        Err(Error::cleanup("apply failed"))
    }
}

struct RollbackEvent;

#[tokio::test]
async fn failed_apply_rolls_back_all_registered_effects() -> Result<()> {
    let app = App::new();
    let calls = Arc::new(AtomicUsize::new(0));

    let plugin = app.install(RollbackPlugin, calls.clone()).await?;
    assert!(matches!(
        plugin.status(),
        cordis_core::PluginStatus::Failed {
            phase: cordis_core::FailurePhase::Apply,
            message,
            ..
        } if message.contains("apply failed")
    ));
    assert!(app.context().try_get::<MarkerKey>().is_none());

    app.context().emit(RollbackEvent).await?;
    assert_eq!(calls.load(Ordering::SeqCst), 0);
    plugin.dispose().await
}

struct CleanupOrderPlugin;
impl Plugin for CleanupOrderPlugin {
    type Config = Arc<Mutex<Vec<u8>>>;

    fn name(&self) -> &'static str {
        "cleanup-order"
    }

    async fn apply(&self, ctx: PluginContext, log: Arc<Self::Config>) -> Result<()> {
        for value in [1, 2, 3] {
            let log = log.clone();
            ctx.defer(move || async move {
                log.lock().unwrap().push(value);
                Ok(())
            })?;
        }
        Ok(())
    }
}

#[tokio::test]
async fn cleanup_runs_in_reverse_registration_order() -> Result<()> {
    let app = App::new();
    let log = Arc::new(Mutex::new(Vec::new()));
    let scope = app.install(CleanupOrderPlugin, log.clone()).await?;

    scope.dispose().await?;
    assert_eq!(*log.lock().unwrap(), vec![3, 2, 1]);
    Ok(())
}

struct FailingCleanupPlugin;
impl Plugin for FailingCleanupPlugin {
    type Config = Arc<Mutex<Vec<u8>>>;

    fn name(&self) -> &'static str {
        "failing-cleanup"
    }

    async fn apply(&self, ctx: PluginContext, log: Arc<Self::Config>) -> Result<()> {
        let first = log.clone();
        ctx.defer(move || async move {
            first.lock().unwrap().push(1);
            Err(Error::cleanup("first registered failure"))
        })?;

        let second = log.clone();
        ctx.defer(move || async move {
            second.lock().unwrap().push(2);
            Err(Error::cleanup("first executed failure"))
        })?;

        ctx.defer(move || async move {
            log.lock().unwrap().push(3);
            Ok(())
        })?;
        Ok(())
    }
}

#[tokio::test]
async fn cleanup_continues_after_errors_and_returns_first_execution_error() -> Result<()> {
    let app = App::new();
    let log = Arc::new(Mutex::new(Vec::new()));
    let scope = app.install(FailingCleanupPlugin, log.clone()).await?;

    let error = scope.dispose().await.unwrap_err();
    assert!(matches!(error, Error::Cleanup(message) if message == "first executed failure"));
    assert_eq!(*log.lock().unwrap(), vec![3, 2, 1]);
    Ok(())
}

struct CountCleanup;
impl Plugin for CountCleanup {
    type Config = Arc<AtomicUsize>;

    fn name(&self) -> &'static str {
        "count-cleanup"
    }

    async fn apply(&self, ctx: PluginContext, count: Arc<Self::Config>) -> Result<()> {
        ctx.defer(move || async move {
            count.fetch_add(1, Ordering::SeqCst);
            Ok(())
        })
    }
}

#[tokio::test]
async fn explicit_dispose_then_app_shutdown_does_not_cleanup_twice() -> Result<()> {
    let app = App::new();
    let count = Arc::new(AtomicUsize::new(0));
    let scope = app.install(CountCleanup, count.clone()).await?;

    scope.dispose().await?;
    app.shutdown().await?;
    assert_eq!(count.load(Ordering::SeqCst), 1);
    Ok(())
}

#[tokio::test]
async fn plugin_scope_exposes_stable_name_and_nonzero_id() -> Result<()> {
    let app = App::new();
    let first = app
        .install(CountCleanup, Arc::new(AtomicUsize::new(0)))
        .await?;
    let second = app
        .install(CountCleanup, Arc::new(AtomicUsize::new(0)))
        .await?;

    assert_eq!(first.name(), "count-cleanup");
    assert_ne!(first.id(), second.id());
    second.dispose().await?;
    first.dispose().await
}