modo-rs 0.8.0

Rust web framework for small monolithic apps
Documentation
use std::collections::BTreeMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};

use serde::{Deserialize, Serialize};

/// A single flash message carrying a severity level and a text body.
///
/// Serializes to/from JSON for cookie storage.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FlashEntry {
    /// Severity level such as `"success"`, `"error"`, `"warning"`, or `"info"`.
    pub level: String,
    /// Human-readable message text.
    pub message: String,
}

pub(crate) struct FlashState {
    pub(crate) incoming: Vec<FlashEntry>,
    pub(crate) outgoing: Mutex<Vec<FlashEntry>>,
    pub(crate) read: AtomicBool,
}

impl FlashState {
    pub(crate) fn new(incoming: Vec<FlashEntry>) -> Self {
        Self {
            incoming,
            outgoing: Mutex::new(Vec::new()),
            read: AtomicBool::new(false),
        }
    }

    pub(crate) fn push(&self, level: &str, message: &str) {
        let mut outgoing = self.outgoing.lock().expect("flash mutex poisoned");
        outgoing.push(FlashEntry {
            level: level.to_string(),
            message: message.to_string(),
        });
    }

    pub(crate) fn drain_outgoing(&self) -> Vec<FlashEntry> {
        let mut outgoing = self.outgoing.lock().expect("flash mutex poisoned");
        std::mem::take(&mut *outgoing)
    }

    pub(crate) fn was_read(&self) -> bool {
        self.read.load(Ordering::Acquire)
    }

    pub(crate) fn mark_read(&self) {
        self.read.store(true, Ordering::Release);
    }

    pub(crate) fn incoming_as_template_value(&self) -> Vec<BTreeMap<String, String>> {
        self.incoming
            .iter()
            .map(|entry| {
                let mut map = BTreeMap::new();
                map.insert(entry.level.clone(), entry.message.clone());
                map
            })
            .collect()
    }
}

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

    #[test]
    fn new_with_empty_incoming() {
        let state = FlashState::new(vec![]);
        assert!(state.incoming.is_empty());
        assert!(!state.was_read());
    }

    #[test]
    fn new_with_incoming_entries() {
        let entries = vec![
            FlashEntry {
                level: "success".into(),
                message: "Done".into(),
            },
            FlashEntry {
                level: "error".into(),
                message: "Oops".into(),
            },
        ];
        let state = FlashState::new(entries.clone());
        assert_eq!(state.incoming, entries);
    }

    #[test]
    fn push_adds_to_outgoing() {
        let state = FlashState::new(vec![]);
        state.push("info", "hello");
        state.push("error", "fail");
        let outgoing = state.drain_outgoing();
        assert_eq!(outgoing.len(), 2);
        assert_eq!(
            outgoing[0],
            FlashEntry {
                level: "info".into(),
                message: "hello".into()
            }
        );
        assert_eq!(
            outgoing[1],
            FlashEntry {
                level: "error".into(),
                message: "fail".into()
            }
        );
    }

    #[test]
    fn drain_outgoing_clears_vec() {
        let state = FlashState::new(vec![]);
        state.push("info", "msg");
        let first = state.drain_outgoing();
        assert_eq!(first.len(), 1);
        let second = state.drain_outgoing();
        assert!(second.is_empty());
    }

    #[test]
    fn read_flag_default_false() {
        let state = FlashState::new(vec![]);
        assert!(!state.was_read());
    }

    #[test]
    fn mark_read_sets_flag() {
        let state = FlashState::new(vec![]);
        state.mark_read();
        assert!(state.was_read());
    }

    #[test]
    fn multiple_same_level_preserved_in_order() {
        let state = FlashState::new(vec![]);
        state.push("error", "first");
        state.push("error", "second");
        state.push("info", "third");
        let outgoing = state.drain_outgoing();
        assert_eq!(outgoing.len(), 3);
        assert_eq!(outgoing[0].level, "error");
        assert_eq!(outgoing[0].message, "first");
        assert_eq!(outgoing[1].level, "error");
        assert_eq!(outgoing[1].message, "second");
        assert_eq!(outgoing[2].level, "info");
    }

    #[test]
    fn incoming_as_template_value_formats_correctly() {
        let entries = vec![
            FlashEntry {
                level: "error".into(),
                message: "bad".into(),
            },
            FlashEntry {
                level: "info".into(),
                message: "ok".into(),
            },
        ];
        let state = FlashState::new(entries);
        let result = state.incoming_as_template_value();
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].get("error").unwrap(), "bad");
        assert_eq!(result[1].get("info").unwrap(), "ok");
    }

    #[test]
    fn flash_entry_serialization_roundtrip() {
        let entry = FlashEntry {
            level: "success".into(),
            message: "Item saved".into(),
        };
        let json = serde_json::to_string(&entry).unwrap();
        let parsed: FlashEntry = serde_json::from_str(&json).unwrap();
        assert_eq!(entry, parsed);
    }

    #[test]
    fn flash_entry_vec_serialization() {
        let entries = vec![
            FlashEntry {
                level: "error".into(),
                message: "fail".into(),
            },
            FlashEntry {
                level: "success".into(),
                message: "ok".into(),
            },
        ];
        let json = serde_json::to_string(&entries).unwrap();
        let parsed: Vec<FlashEntry> = serde_json::from_str(&json).unwrap();
        assert_eq!(entries, parsed);
    }
}