rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;

/// A deferred callback registered for a named load hook.
pub type LoadHook = Arc<dyn Fn() + Send + Sync + 'static>;

#[derive(Default)]
struct HookState {
    callbacks: Vec<LoadHook>,
    ran: bool,
}

static LOAD_HOOKS: Lazy<Mutex<HashMap<String, HookState>>> =
    Lazy::new(|| Mutex::new(HashMap::new()));

/// Registers a callback to be run when `name` is loaded.
pub fn on_load<F>(name: &str, callback: F)
where
    F: Fn() + Send + Sync + 'static,
{
    let callback: LoadHook = Arc::new(callback);
    let should_run_immediately = {
        let mut hooks = LOAD_HOOKS.lock();
        let state = hooks.entry(name.to_owned()).or_default();
        if state.ran {
            true
        } else {
            state.callbacks.push(Arc::clone(&callback));
            false
        }
    };

    if should_run_immediately {
        callback();
    }
}

/// Runs all callbacks registered for `name` exactly once.
pub fn run_load_hooks(name: &str) {
    let callbacks = {
        let mut hooks = LOAD_HOOKS.lock();
        let state = hooks.entry(name.to_owned()).or_default();
        if state.ran {
            return;
        }
        state.ran = true;
        std::mem::take(&mut state.callbacks)
    };

    for callback in callbacks {
        callback();
    }
}

#[cfg(test)]
pub(crate) fn reset_hooks() {
    LOAD_HOOKS.lock().clear();
}

#[cfg(test)]
mod tests {
    use super::{on_load, reset_hooks, run_load_hooks};
    use parking_lot::Mutex;
    use std::sync::{Arc, LazyLock, Mutex as StdMutex};

    static TEST_LOCK: LazyLock<StdMutex<()>> = LazyLock::new(|| StdMutex::new(()));

    #[test]
    fn registered_callbacks_run_when_hook_is_loaded() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        let calls = Arc::new(Mutex::new(Vec::new()));
        let observed = Arc::clone(&calls);
        on_load("active_record", move || observed.lock().push("ran"));

        run_load_hooks("active_record");

        assert_eq!(&*calls.lock(), &["ran"]);
    }

    #[test]
    fn multiple_callbacks_run_in_registration_order() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        let calls = Arc::new(Mutex::new(Vec::new()));
        let first = Arc::clone(&calls);
        let second = Arc::clone(&calls);
        on_load("action_controller", move || first.lock().push("first"));
        on_load("action_controller", move || second.lock().push("second"));

        run_load_hooks("action_controller");

        assert_eq!(&*calls.lock(), &["first", "second"]);
    }

    #[test]
    fn running_the_same_hook_twice_is_a_no_op() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        let calls = Arc::new(Mutex::new(0usize));
        let observed = Arc::clone(&calls);
        on_load("action_mailer", move || *observed.lock() += 1);

        run_load_hooks("action_mailer");
        run_load_hooks("action_mailer");

        assert_eq!(*calls.lock(), 1);
    }

    #[test]
    fn registering_after_a_hook_has_run_executes_immediately() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        let calls = Arc::new(Mutex::new(Vec::new()));
        run_load_hooks("active_job");
        let observed = Arc::clone(&calls);
        on_load("active_job", move || observed.lock().push("immediate"));

        assert_eq!(&*calls.lock(), &["immediate"]);
    }

    #[test]
    fn different_hooks_do_not_interfere_with_each_other() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        let calls = Arc::new(Mutex::new(Vec::new()));
        let record = Arc::clone(&calls);
        let view = Arc::clone(&calls);
        on_load("active_record", move || record.lock().push("record"));
        on_load("action_view", move || view.lock().push("view"));

        run_load_hooks("action_view");

        assert_eq!(&*calls.lock(), &["view"]);
    }

    #[test]
    fn running_an_unknown_hook_is_a_no_op() {
        let _guard = TEST_LOCK.lock().expect("test lock should not be poisoned");
        reset_hooks();
        run_load_hooks("unknown");
        run_load_hooks("unknown");
    }
}