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    pub(crate) fn incoming_as_template_value(&self) -> Vec<BTreeMap<String, String>> {
55        self.incoming
56            .iter()
57            .map(|entry| {
58                let mut map = BTreeMap::new();
59                map.insert(entry.level.clone(), entry.message.clone());
60                map
61            })
62            .collect()
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn new_with_empty_incoming() {
72        let state = FlashState::new(vec![]);
73        assert!(state.incoming.is_empty());
74        assert!(!state.was_read());
75    }
76
77    #[test]
78    fn new_with_incoming_entries() {
79        let entries = vec![
80            FlashEntry {
81                level: "success".into(),
82                message: "Done".into(),
83            },
84            FlashEntry {
85                level: "error".into(),
86                message: "Oops".into(),
87            },
88        ];
89        let state = FlashState::new(entries.clone());
90        assert_eq!(state.incoming, entries);
91    }
92
93    #[test]
94    fn push_adds_to_outgoing() {
95        let state = FlashState::new(vec![]);
96        state.push("info", "hello");
97        state.push("error", "fail");
98        let outgoing = state.drain_outgoing();
99        assert_eq!(outgoing.len(), 2);
100        assert_eq!(
101            outgoing[0],
102            FlashEntry {
103                level: "info".into(),
104                message: "hello".into()
105            }
106        );
107        assert_eq!(
108            outgoing[1],
109            FlashEntry {
110                level: "error".into(),
111                message: "fail".into()
112            }
113        );
114    }
115
116    #[test]
117    fn drain_outgoing_clears_vec() {
118        let state = FlashState::new(vec![]);
119        state.push("info", "msg");
120        let first = state.drain_outgoing();
121        assert_eq!(first.len(), 1);
122        let second = state.drain_outgoing();
123        assert!(second.is_empty());
124    }
125
126    #[test]
127    fn read_flag_default_false() {
128        let state = FlashState::new(vec![]);
129        assert!(!state.was_read());
130    }
131
132    #[test]
133    fn mark_read_sets_flag() {
134        let state = FlashState::new(vec![]);
135        state.mark_read();
136        assert!(state.was_read());
137    }
138
139    #[test]
140    fn multiple_same_level_preserved_in_order() {
141        let state = FlashState::new(vec![]);
142        state.push("error", "first");
143        state.push("error", "second");
144        state.push("info", "third");
145        let outgoing = state.drain_outgoing();
146        assert_eq!(outgoing.len(), 3);
147        assert_eq!(outgoing[0].level, "error");
148        assert_eq!(outgoing[0].message, "first");
149        assert_eq!(outgoing[1].level, "error");
150        assert_eq!(outgoing[1].message, "second");
151        assert_eq!(outgoing[2].level, "info");
152    }
153
154    #[test]
155    fn incoming_as_template_value_formats_correctly() {
156        let entries = vec![
157            FlashEntry {
158                level: "error".into(),
159                message: "bad".into(),
160            },
161            FlashEntry {
162                level: "info".into(),
163                message: "ok".into(),
164            },
165        ];
166        let state = FlashState::new(entries);
167        let result = state.incoming_as_template_value();
168        assert_eq!(result.len(), 2);
169        assert_eq!(result[0].get("error").unwrap(), "bad");
170        assert_eq!(result[1].get("info").unwrap(), "ok");
171    }
172
173    #[test]
174    fn flash_entry_serialization_roundtrip() {
175        let entry = FlashEntry {
176            level: "success".into(),
177            message: "Item saved".into(),
178        };
179        let json = serde_json::to_string(&entry).unwrap();
180        let parsed: FlashEntry = serde_json::from_str(&json).unwrap();
181        assert_eq!(entry, parsed);
182    }
183
184    #[test]
185    fn flash_entry_vec_serialization() {
186        let entries = vec![
187            FlashEntry {
188                level: "error".into(),
189                message: "fail".into(),
190            },
191            FlashEntry {
192                level: "success".into(),
193                message: "ok".into(),
194            },
195        ];
196        let json = serde_json::to_string(&entries).unwrap();
197        let parsed: Vec<FlashEntry> = serde_json::from_str(&json).unwrap();
198        assert_eq!(entries, parsed);
199    }
200}