Skip to main content

modo/flash/
state.rs

1use std::collections::BTreeMap;
2use std::sync::Mutex;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use serde::{Deserialize, Serialize};
6
7/// A single flash message carrying a severity level and a text body.
8///
9/// Serializes to/from JSON for cookie storage.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct FlashEntry {
12    /// Severity level such as `"success"`, `"error"`, `"warning"`, or `"info"`.
13    pub level: String,
14    /// Human-readable message text.
15    pub message: String,
16}
17
18pub(crate) struct FlashState {
19    pub(crate) incoming: Vec<FlashEntry>,
20    pub(crate) outgoing: Mutex<Vec<FlashEntry>>,
21    pub(crate) read: AtomicBool,
22}
23
24impl FlashState {
25    pub(crate) fn new(incoming: Vec<FlashEntry>) -> Self {
26        Self {
27            incoming,
28            outgoing: Mutex::new(Vec::new()),
29            read: AtomicBool::new(false),
30        }
31    }
32
33    pub(crate) fn push(&self, level: &str, message: &str) {
34        let mut outgoing = self.outgoing.lock().expect("flash mutex poisoned");
35        outgoing.push(FlashEntry {
36            level: level.to_string(),
37            message: message.to_string(),
38        });
39    }
40
41    pub(crate) fn drain_outgoing(&self) -> Vec<FlashEntry> {
42        let mut outgoing = self.outgoing.lock().expect("flash mutex poisoned");
43        std::mem::take(&mut *outgoing)
44    }
45
46    pub(crate) fn was_read(&self) -> bool {
47        self.read.load(Ordering::Acquire)
48    }
49
50    pub(crate) fn mark_read(&self) {
51        self.read.store(true, Ordering::Release);
52    }
53
54    #[cfg_attr(not(feature = "templates"), allow(dead_code))]
55    pub(crate) fn incoming_as_template_value(&self) -> Vec<BTreeMap<String, String>> {
56        self.incoming
57            .iter()
58            .map(|entry| {
59                let mut map = BTreeMap::new();
60                map.insert(entry.level.clone(), entry.message.clone());
61                map
62            })
63            .collect()
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn new_with_empty_incoming() {
73        let state = FlashState::new(vec![]);
74        assert!(state.incoming.is_empty());
75        assert!(!state.was_read());
76    }
77
78    #[test]
79    fn new_with_incoming_entries() {
80        let entries = vec![
81            FlashEntry {
82                level: "success".into(),
83                message: "Done".into(),
84            },
85            FlashEntry {
86                level: "error".into(),
87                message: "Oops".into(),
88            },
89        ];
90        let state = FlashState::new(entries.clone());
91        assert_eq!(state.incoming, entries);
92    }
93
94    #[test]
95    fn push_adds_to_outgoing() {
96        let state = FlashState::new(vec![]);
97        state.push("info", "hello");
98        state.push("error", "fail");
99        let outgoing = state.drain_outgoing();
100        assert_eq!(outgoing.len(), 2);
101        assert_eq!(
102            outgoing[0],
103            FlashEntry {
104                level: "info".into(),
105                message: "hello".into()
106            }
107        );
108        assert_eq!(
109            outgoing[1],
110            FlashEntry {
111                level: "error".into(),
112                message: "fail".into()
113            }
114        );
115    }
116
117    #[test]
118    fn drain_outgoing_clears_vec() {
119        let state = FlashState::new(vec![]);
120        state.push("info", "msg");
121        let first = state.drain_outgoing();
122        assert_eq!(first.len(), 1);
123        let second = state.drain_outgoing();
124        assert!(second.is_empty());
125    }
126
127    #[test]
128    fn read_flag_default_false() {
129        let state = FlashState::new(vec![]);
130        assert!(!state.was_read());
131    }
132
133    #[test]
134    fn mark_read_sets_flag() {
135        let state = FlashState::new(vec![]);
136        state.mark_read();
137        assert!(state.was_read());
138    }
139
140    #[test]
141    fn multiple_same_level_preserved_in_order() {
142        let state = FlashState::new(vec![]);
143        state.push("error", "first");
144        state.push("error", "second");
145        state.push("info", "third");
146        let outgoing = state.drain_outgoing();
147        assert_eq!(outgoing.len(), 3);
148        assert_eq!(outgoing[0].level, "error");
149        assert_eq!(outgoing[0].message, "first");
150        assert_eq!(outgoing[1].level, "error");
151        assert_eq!(outgoing[1].message, "second");
152        assert_eq!(outgoing[2].level, "info");
153    }
154
155    #[test]
156    fn incoming_as_template_value_formats_correctly() {
157        let entries = vec![
158            FlashEntry {
159                level: "error".into(),
160                message: "bad".into(),
161            },
162            FlashEntry {
163                level: "info".into(),
164                message: "ok".into(),
165            },
166        ];
167        let state = FlashState::new(entries);
168        let result = state.incoming_as_template_value();
169        assert_eq!(result.len(), 2);
170        assert_eq!(result[0].get("error").unwrap(), "bad");
171        assert_eq!(result[1].get("info").unwrap(), "ok");
172    }
173
174    #[test]
175    fn flash_entry_serialization_roundtrip() {
176        let entry = FlashEntry {
177            level: "success".into(),
178            message: "Item saved".into(),
179        };
180        let json = serde_json::to_string(&entry).unwrap();
181        let parsed: FlashEntry = serde_json::from_str(&json).unwrap();
182        assert_eq!(entry, parsed);
183    }
184
185    #[test]
186    fn flash_entry_vec_serialization() {
187        let entries = vec![
188            FlashEntry {
189                level: "error".into(),
190                message: "fail".into(),
191            },
192            FlashEntry {
193                level: "success".into(),
194                message: "ok".into(),
195            },
196        ];
197        let json = serde_json::to_string(&entries).unwrap();
198        let parsed: Vec<FlashEntry> = serde_json::from_str(&json).unwrap();
199        assert_eq!(entries, parsed);
200    }
201}