frp-signal 0.1.0

Lifecycle and telemetry signal contracts for frp persistence flows.
Documentation
use std::sync::{
    Arc, RwLock,
    atomic::{AtomicBool, Ordering},
};

use crate::signal::Signal;

// ---------------------------------------------------------------------------
// DirtySink — object-safe helper for cross-type tracking
// ---------------------------------------------------------------------------

/// Object-safe trait for anything that can be notified of dirtiness via an
/// `Arc<AtomicBool>` flag. Implemented by [`Computed<T>`] for all `T`.
pub trait DirtySink: Send + Sync {
    /// Return the dirty flag so a [`Signal`] can register it as a subscriber.
    fn dirty_flag(&self) -> Arc<AtomicBool>;
}

// ---------------------------------------------------------------------------
// Computed<T>
// ---------------------------------------------------------------------------

/// A lazily-evaluated derived value backed by a user-supplied function.
///
/// The result is cached after the first evaluation and only recomputed when
/// the internal dirty flag is set to `true` — typically by a [`Signal`] whose
/// subscriber this `Computed` has registered via [`Computed::track`] or
/// [`Computed::track_any`].
///
/// `Computed<T>` is cheaply cloneable — all clones share the same cache,
/// dirty flag, and function.
pub struct Computed<T: Clone + Send + Sync + 'static> {
    func: Arc<dyn Fn() -> T + Send + Sync>,
    cached: Arc<RwLock<Option<T>>>,
    dirty: Arc<AtomicBool>,
}

impl<T: Clone + Send + Sync + 'static> Computed<T> {
    /// Create a new `Computed` from a closure. Starts dirty so the first
    /// `get()` always evaluates the function.
    pub fn new(f: impl Fn() -> T + Send + Sync + 'static) -> Self {
        Self {
            func: Arc::new(f),
            cached: Arc::new(RwLock::new(None)),
            // Start dirty so the very first get() runs the function.
            dirty: Arc::new(AtomicBool::new(true)),
        }
    }

    /// Get the current value.
    ///
    /// - If the dirty flag is set, the backing function is re-run, the cache
    ///   is updated, and the dirty flag is cleared.
    /// - Otherwise the cached value is returned immediately without calling
    ///   the function.
    pub fn get(&self) -> T {
        // Fast path: not dirty — return cached value.
        if !self.dirty.load(Ordering::Acquire) {
            if let Some(cached) = self.cached.read().expect("Computed cache poisoned").as_ref() {
                return cached.clone();
            }
        }

        // Slow path: dirty or cache is empty — recompute.
        let new_val = (self.func)();
        let mut guard = self.cached.write().expect("Computed cache poisoned");
        *guard = Some(new_val.clone());
        // Clear dirty *after* storing the new value to avoid a TOCTOU window.
        self.dirty.store(false, Ordering::Release);
        new_val
    }

    /// Return the `Arc<AtomicBool>` dirty flag. Pass this to
    /// [`Signal::subscribe`](crate::signal::Signal::subscribe) — actually,
    /// prefer [`Computed::track`] or [`Computed::track_any`] for ergonomics.
    pub fn dirty_flag(&self) -> Arc<AtomicBool> {
        Arc::clone(&self.dirty)
    }

    /// Wire this `Computed` as a dependent of `signal`. After this call, any
    /// `signal.set(…)` will mark this `Computed` dirty, so the next `get()`
    /// will re-evaluate the function.
    ///
    /// Requires `T` to match the signal's type. For cross-type tracking use
    /// [`Computed::track_any`].
    pub fn track(&self, signal: &Signal<T>) {
        signal.subscribe_with_flag(Arc::clone(&self.dirty));
    }

    /// Wire this `Computed` as a dependent of any [`DirtySink`]-compatible
    /// signal. Use this for cross-type dependencies (e.g. `Signal<i32>`
    /// driving `Computed<String>`).
    pub fn track_any<S: SignalSubscribable>(&self, signal: &S) {
        signal.subscribe_with_flag(Arc::clone(&self.dirty));
    }
}

impl<T: Clone + Send + Sync + 'static> DirtySink for Computed<T> {
    fn dirty_flag(&self) -> Arc<AtomicBool> {
        Arc::clone(&self.dirty)
    }
}

impl<T: Clone + Send + Sync + 'static> Clone for Computed<T> {
    fn clone(&self) -> Self {
        Self {
            func: Arc::clone(&self.func),
            cached: Arc::clone(&self.cached),
            dirty: Arc::clone(&self.dirty),
        }
    }
}

impl<T: Clone + Send + Sync + std::fmt::Debug + 'static> std::fmt::Debug for Computed<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let dirty = self.dirty.load(Ordering::Relaxed);
        match self.cached.try_read() {
            Ok(guard) => write!(f, "Computed(dirty={}, cached={:?})", dirty, *guard),
            Err(_) => write!(f, "Computed(dirty={}, cached=<locked>)", dirty),
        }
    }
}

// ---------------------------------------------------------------------------
// SignalSubscribable — object-safe cross-type subscription
// ---------------------------------------------------------------------------

/// Implemented by [`Signal<T>`] for all `T` so that a [`Computed`] of a
/// *different* type can register its dirty flag.
pub trait SignalSubscribable: Send + Sync {
    fn subscribe_with_flag(&self, flag: Arc<AtomicBool>);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::AtomicU32;

    #[test]
    fn computed_evaluates_on_first_get() {
        let c = Computed::new(|| 42i32);
        assert_eq!(c.get(), 42);
    }

    #[test]
    fn computed_caches_after_first_get() {
        let call_count = Arc::new(AtomicU32::new(0));
        let cc = Arc::clone(&call_count);
        let c = Computed::new(move || {
            cc.fetch_add(1, Ordering::SeqCst);
            99i32
        });
        assert_eq!(c.get(), 99);
        assert_eq!(c.get(), 99); // second call must NOT recompute
        assert_eq!(call_count.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn dirty_flag_triggers_recompute() {
        let call_count = Arc::new(AtomicU32::new(0));
        let cc = Arc::clone(&call_count);
        let c = Computed::new(move || {
            cc.fetch_add(1, Ordering::SeqCst);
            7i32
        });
        let _ = c.get(); // first eval
        c.dirty_flag().store(true, Ordering::Release); // manually dirty
        let _ = c.get(); // must recompute
        assert_eq!(call_count.load(Ordering::SeqCst), 2);
    }

    #[test]
    fn clone_shares_cache_and_dirty() {
        let c1 = Computed::new(|| 1i32);
        let c2 = c1.clone();
        let _ = c1.get(); // prime the cache
        c2.dirty_flag().store(true, Ordering::Release);
        // c1 should also see dirty since they share the same Arc
        assert!(c1.dirty_flag().load(Ordering::Acquire));
    }

    #[test]
    fn track_wires_signal_to_computed() {
        let signal = Signal::new(10i32);
        let s = signal.clone();
        let c = Computed::new(move || s.get() * 2);
        c.track(&signal);

        assert_eq!(c.get(), 20); // initial eval
        assert_eq!(c.get(), 20); // cached

        signal.set(5);
        // Computed is now dirty — next get() must recompute
        assert_eq!(c.get(), 10);
        // And cache again
        assert_eq!(c.get(), 10);
    }

    #[test]
    fn track_any_cross_type() {
        let signal: Signal<i32> = Signal::new(3);
        let s = signal.clone();
        let c: Computed<String> = Computed::new(move || format!("val={}", s.get()));
        c.track_any(&signal);

        assert_eq!(c.get(), "val=3");
        signal.set(7);
        assert_eq!(c.get(), "val=7");
    }
}