mail4agent-messenger-shell 0.4.30

Client shell that opens the sealed messenger store for one session.
Documentation
//! The Hatchery fields of a letter's source event, for an in-process wake adapter.
//!
//! [`WakeLetter`](crate::WakeLetter) is `gate4agent-wake`'s type and has no room for the event
//! content, so the shell carries it beside the letter: while the wake chain runs for one letter,
//! [`current`] returns the `fyi.hatchery.*` keys of that letter's Matrix event content (for
//! example `{"fyi.hatchery.interrupt": true}`). Call it from inside `WakeAdapter::wake`, on the
//! thread the chain called you on; outside a wake, or for a letter whose event has no such keys
//! (reactions, door and CLI letters, older senders), it returns `None`. The letter hook JSON
//! ([`crate::letter_hook::letter_event`]) carries the same object as its optional `content` field.

use std::cell::RefCell;

/// Key prefix of the fields carried: only `fyi.hatchery.*` keys leave the event.
pub const HATCHERY_PREFIX: &str = "fyi.hatchery.";

thread_local! {
    static CURRENT: RefCell<Option<serde_json::Value>> = const { RefCell::new(None) };
    static INTERRUPTED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Called by a wake adapter that cancelled the session's live turn for the current letter (an
/// allowed `fyi.hatchery.interrupt`), so the shell can give the sender the `interrupted` receipt.
pub fn mark_interrupted() {
    INTERRUPTED.with(|flag| flag.set(true));
}

/// Whether an adapter called [`mark_interrupted`] during the current [`scoped`] wake.
pub(crate) fn was_interrupted() -> bool {
    INTERRUPTED.with(|flag| flag.get())
}

/// The `fyi.hatchery.*` keys of `content` as an object, or `None` when there are none.
pub fn hatchery_fields(content: &serde_json::Value) -> Option<serde_json::Value> {
    let fields: serde_json::Map<String, serde_json::Value> = content
        .as_object()?
        .iter()
        .filter(|(key, _)| key.starts_with(HATCHERY_PREFIX))
        .map(|(key, value)| (key.clone(), value.clone()))
        .collect();
    (!fields.is_empty()).then_some(serde_json::Value::Object(fields))
}

/// The Hatchery fields of the letter the wake chain is handing over right now (module doc).
pub fn current() -> Option<serde_json::Value> {
    CURRENT.with(|slot| slot.borrow().clone())
}

/// `true` when the current letter asks to interrupt the session's live turn:
/// `fyi.hatchery.interrupt` is exactly `true`.
pub fn current_is_interrupt() -> bool {
    current().is_some_and(|c| c.get("fyi.hatchery.interrupt") == Some(&serde_json::Value::Bool(true)))
}

/// Runs `f` with [`current`] set to `content`, restoring the previous value afterwards (also on
/// panic), so nested or repeated wakes never see another letter's fields.
pub(crate) fn scoped<R>(content: Option<serde_json::Value>, f: impl FnOnce() -> R) -> R {
    struct Restore(Option<serde_json::Value>);
    impl Drop for Restore {
        fn drop(&mut self) {
            let previous = self.0.take();
            CURRENT.with(|slot| *slot.borrow_mut() = previous);
        }
    }
    let previous = CURRENT.with(|slot| slot.replace(content));
    INTERRUPTED.with(|flag| flag.set(false));
    let _restore = Restore(previous);
    f()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn only_hatchery_keys_are_kept() {
        let content = json!({"msgtype": "m.text", "body": "hi", "fyi.hatchery.interrupt": true, "fyi.hatchery.task": 95, "fyi.other": 1});
        assert_eq!(hatchery_fields(&content), Some(json!({"fyi.hatchery.interrupt": true, "fyi.hatchery.task": 95})));
        assert_eq!(hatchery_fields(&json!({"msgtype": "m.text", "body": "hi"})), None);
        assert_eq!(hatchery_fields(&json!("not an object")), None);
    }

    #[test]
    fn current_is_scoped_and_restored() {
        assert_eq!(current(), None);
        scoped(Some(json!({"fyi.hatchery.interrupt": true})), || {
            assert!(current_is_interrupt());
            scoped(None, || assert_eq!(current(), None));
            assert!(current_is_interrupt(), "inner scope restored the outer letter");
        });
        assert_eq!(current(), None);
        scoped(Some(json!({"fyi.hatchery.interrupt": "yes"})), || assert!(!current_is_interrupt(), "only literal true interrupts"));
    }

    #[test]
    fn the_interrupted_mark_starts_clear_for_each_letter() {
        scoped(Some(json!({"fyi.hatchery.interrupt": true})), || {
            assert!(!was_interrupted());
            mark_interrupted();
            assert!(was_interrupted());
        });
        scoped(None, || assert!(!was_interrupted(), "the next letter starts clear"));
    }
}