optative 0.1.0

A reconciler as a memory model: declare desired state, run lifecycle hooks (enter/reconcile/exit) on the diff.
Documentation
use super::Lifecycle;

pub type ReconcileErrors<K, E> = Vec<(K, E)>;

pub trait Reconcile<T: Lifecycle> {
    fn reconcile(
        &mut self,
        desired: impl IntoIterator<Item = T>,
        ctx: &mut T::Context,
        output: &mut T::Output,
    ) -> ReconcileErrors<T::Key, T::Error>;
}

#[cfg(test)]
mod tests {
    mod fixtures {
        use crate::Lifecycle;
        use crate::reconcile::{Reconcile, ReconcileErrors};
        use std::convert::Infallible;
        use std::sync::{Arc, Mutex};

        #[derive(Clone)]
        pub struct Item {
            pub id: &'static str,
            pub value: i32,
        }

        impl std::fmt::Display for Item {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.id)
            }
        }

        pub type Ctx = Arc<Mutex<Vec<String>>>;

        impl Lifecycle for Item {
            type Key = String;
            type State = i32;
            type Context = Ctx;
            type Output = ();
            type Error = Infallible;

            fn key(&self) -> String {
                self.id.to_string()
            }

            fn enter(self, ctx: &mut Ctx, _output: &mut ()) -> Result<i32, Infallible> {
                ctx.lock().unwrap().push(format!("enter:{}", self.id));
                Ok(self.value)
            }

            fn reconcile_self(
                self,
                state: &mut i32,
                ctx: &mut Ctx,
                _output: &mut (),
            ) -> Result<(), Infallible> {
                ctx.lock()
                    .unwrap()
                    .push(format!("reconcile_self:{}", self.id));
                *state = self.value;
                Ok(())
            }

            fn exit(_state: i32, ctx: &mut Ctx, _output: &mut ()) -> Result<(), Infallible> {
                ctx.lock().unwrap().push("exit".to_string());
                Ok(())
            }
        }

        pub fn make_ctx() -> Ctx {
            Arc::new(Mutex::new(Vec::new()))
        }

        pub fn log(ctx: &Ctx) -> Vec<String> {
            ctx.lock().unwrap().clone()
        }

        pub struct RecordingReconciler {
            pub calls: Vec<Vec<String>>,
        }

        impl RecordingReconciler {
            pub fn new() -> Self {
                Self { calls: Vec::new() }
            }
        }

        impl Reconcile<Item> for RecordingReconciler {
            fn reconcile(
                &mut self,
                desired: impl IntoIterator<Item = Item>,
                _ctx: &mut Ctx,
                _output: &mut (),
            ) -> ReconcileErrors<String, Infallible> {
                self.calls
                    .push(desired.into_iter().map(|i| i.id.to_string()).collect());
                vec![]
            }
        }

        pub fn drive<R: Reconcile<Item>>(
            reconciler: &mut R,
            items: Vec<Item>,
            ctx: &mut Ctx,
        ) -> ReconcileErrors<String, Infallible> {
            reconciler.reconcile(items, ctx, &mut ())
        }
    }

    mod trait_usability {
        use super::fixtures::{Item, RecordingReconciler, drive, make_ctx};
        use crate::OptativeSet;

        #[test]
        fn accepts_managed_set() {
            let mut ctx = make_ctx();
            let mut ms: OptativeSet<Item> = OptativeSet::new();
            assert!(drive(&mut ms, vec![Item { id: "a", value: 1 }], &mut ctx).is_empty());
        }

        #[test]
        fn accepts_mock_reconciler() {
            let mut ctx = make_ctx();
            let mut mock = RecordingReconciler::new();
            drive(&mut mock, vec![Item { id: "b", value: 2 }], &mut ctx);
            assert_eq!(mock.calls, vec![vec!["b"]]);
        }
    }

    mod managed_set_via_trait {
        use super::fixtures::{Item, log, make_ctx};
        use crate::{OptativeSet, Reconcile};

        fn check<R: Reconcile<Item>>(
            reconciler: &mut R,
            setup: Vec<Item>,
            action: Vec<Item>,
            expected_log_entry: &str,
        ) {
            let mut ctx = make_ctx();
            reconciler.reconcile(setup, &mut ctx, &mut ());
            reconciler.reconcile(action, &mut ctx, &mut ());
            assert!(
                log(&ctx).iter().any(|e| e == expected_log_entry),
                "expected {:?} in log, got {:?}",
                expected_log_entry,
                log(&ctx)
            );
        }

        #[test]
        fn calls_enter_for_new_item() {
            check(
                &mut OptativeSet::new(),
                vec![],
                vec![Item { id: "a", value: 1 }],
                "enter:a",
            );
        }

        #[test]
        fn calls_reconcile_self_for_existing_item() {
            check(
                &mut OptativeSet::new(),
                vec![Item { id: "b", value: 1 }],
                vec![Item { id: "b", value: 2 }],
                "reconcile_self:b",
            );
        }

        #[test]
        fn calls_exit_for_removed_item() {
            check(
                &mut OptativeSet::new(),
                vec![Item { id: "c", value: 5 }],
                vec![],
                "exit",
            );
        }
    }

    mod mock_reconciler {
        use super::fixtures::{Item, RecordingReconciler, drive, log, make_ctx};

        #[test]
        fn records_calls_without_managing_state() {
            let mut ctx = make_ctx();
            let mut mock = RecordingReconciler::new();

            drive(&mut mock, vec![Item { id: "x", value: 99 }], &mut ctx);
            drive(
                &mut mock,
                vec![Item { id: "y", value: 1 }, Item { id: "z", value: 2 }],
                &mut ctx,
            );

            assert_eq!(mock.calls.len(), 2);
            assert_eq!(mock.calls[0], vec!["x"]);
            assert!(mock.calls[1].iter().any(|s| s == "y"));
            assert!(mock.calls[1].iter().any(|s| s == "z"));
            assert!(log(&ctx).is_empty());
        }
    }
}