Skip to main content

remendo/
cmd.rs

1//! TEA command/effect system.
2//!
3//! `Cmd` values describe async side-effects that `update()` requests.
4//! The `execute()` function spawns tokio tasks to perform them,
5//! sending results back as `Message` variants through a channel.
6
7use crate::bookmarks::BookmarkStore;
8use crate::client::SashikoApi;
9use crate::client::error::ApiError;
10use crate::client::types::ListParams;
11use crate::models::PatchId;
12use crate::update::Message;
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17
18/// A command describing an async side-effect.
19///
20/// Returned by `update()` to request work from the runtime.
21/// The runtime calls `execute()` to perform it.
22#[derive(Debug)]
23pub enum Cmd {
24    /// No effect — the update had nothing to request.
25    None,
26    /// Fetch the patchset list for the active remote.
27    FetchPatchsets(ListParams),
28    /// Fetch mailing lists for the active remote.
29    FetchLists,
30    /// Fetch server stats for the active remote.
31    FetchStats,
32    /// Fetch full detail for a specific patchset.
33    FetchPatchsetDetail(PatchId),
34    /// Open a file in the configured external editor.
35    OpenEditor {
36        /// Content to write to a temp file and open.
37        content: String,
38        /// The editor command to use.
39        editor: String,
40    },
41    /// Persist bookmarks to disk.
42    PersistBookmarks {
43        /// The bookmark store to save.
44        bookmarks: BookmarkStore,
45        /// Path to the bookmarks file.
46        path: PathBuf,
47    },
48    /// Fetch the message list for the active remote.
49    FetchMessages(ListParams),
50    /// Fetch full detail for a specific message.
51    FetchMessageDetail(PatchId),
52    /// Clear the API response cache, then execute a batch.
53    ClearCacheAndBatch(Vec<Cmd>),
54    /// Execute multiple commands concurrently.
55    Batch(Vec<Cmd>),
56}
57
58/// Spawn an async fetch task if the client exists, or send an error message.
59///
60/// This helper eliminates the repeated `if let Some(client) = clients.get(...)
61/// { clone + spawn } else { send error }` pattern across all fetch commands.
62fn spawn_fetch<S, F, Fut, T>(
63    clients: &HashMap<String, Arc<dyn SashikoApi>, S>,
64    active_remote: &str,
65    msg_tx: &mpsc::UnboundedSender<Message>,
66    fetch: F,
67    on_missing: fn(&str) -> Message,
68) where
69    S: ::std::hash::BuildHasher,
70    F: FnOnce(Arc<dyn SashikoApi>) -> Fut + Send + 'static,
71    Fut: std::future::Future<Output = T> + Send,
72    T: Into<Message> + Send + 'static,
73{
74    if let Some(client) = clients.get(active_remote) {
75        let client = Arc::clone(client);
76        let tx = msg_tx.clone();
77        tokio::spawn(async move {
78            let result = fetch(client).await;
79            let _ = tx.send(result.into());
80        });
81    } else {
82        let _ = msg_tx.send(on_missing(active_remote));
83    }
84}
85
86/// Execute a command by spawning async tasks that send results
87/// back through `msg_tx`.
88///
89/// The `active_remote` key is used to look up the correct client
90/// from `clients`. If the remote is not found, an error message
91/// is sent instead.
92pub fn execute<S: ::std::hash::BuildHasher>(
93    cmd: Cmd,
94    clients: &HashMap<String, Arc<dyn SashikoApi>, S>,
95    active_remote: &str,
96    msg_tx: &mpsc::UnboundedSender<Message>,
97) {
98    match cmd {
99        Cmd::None => {}
100        Cmd::FetchPatchsets(params) => {
101            tracing::debug!(
102                remote = active_remote,
103                page = params.page,
104                search = ?params.search,
105                mailing_list = ?params.mailing_list,
106                "cmd: fetch patchsets"
107            );
108            spawn_fetch(
109                clients,
110                active_remote,
111                msg_tx,
112                |c| async move { Message::PatchsetsLoaded(c.patchsets(&params).await) },
113                |r| Message::PatchsetsLoaded(Err(no_remote_error(r))),
114            );
115        }
116        Cmd::FetchLists => {
117            tracing::debug!(remote = active_remote, "cmd: fetch lists");
118            spawn_fetch(
119                clients,
120                active_remote,
121                msg_tx,
122                |c| async move { Message::ListsLoaded(c.lists().await) },
123                |r| Message::ListsLoaded(Err(no_remote_error(r))),
124            );
125        }
126        Cmd::FetchStats => {
127            tracing::debug!(remote = active_remote, "cmd: fetch stats");
128            spawn_fetch(
129                clients,
130                active_remote,
131                msg_tx,
132                |c| async move { Message::StatsLoaded(c.stats().await) },
133                |r| Message::StatsLoaded(Err(no_remote_error(r))),
134            );
135        }
136        Cmd::FetchPatchsetDetail(id) => {
137            tracing::debug!(remote = active_remote, id = %id, "cmd: fetch patchset detail");
138            spawn_fetch(
139                clients,
140                active_remote,
141                msg_tx,
142                |c| async move {
143                    Message::PatchsetDetailLoaded(Box::new(c.patchset_summary(&id).await))
144                },
145                |r| Message::PatchsetDetailLoaded(Box::new(Err(no_remote_error(r)))),
146            );
147        }
148        Cmd::FetchMessages(params) => {
149            tracing::debug!(
150                remote = active_remote,
151                page = params.page,
152                "cmd: fetch messages"
153            );
154            spawn_fetch(
155                clients,
156                active_remote,
157                msg_tx,
158                |c| async move { Message::MessagesLoaded(c.messages(&params).await) },
159                |r| Message::MessagesLoaded(Err(no_remote_error(r))),
160            );
161        }
162        Cmd::FetchMessageDetail(id) => {
163            tracing::debug!(remote = active_remote, id = %id, "cmd: fetch message detail");
164            spawn_fetch(
165                clients,
166                active_remote,
167                msg_tx,
168                |c| async move { Message::MessageDetailLoaded(Box::new(c.message_detail(&id).await)) },
169                |r| Message::MessageDetailLoaded(Box::new(Err(no_remote_error(r)))),
170            );
171        }
172        Cmd::OpenEditor { .. } => {
173            tracing::error!("Cmd::OpenEditor reached execute() — should be handled in main loop");
174        }
175        Cmd::PersistBookmarks { bookmarks, path } => {
176            tracing::debug!("cmd: persist bookmarks");
177            let tx = msg_tx.clone();
178            tokio::task::spawn_blocking(move || {
179                let result = bookmarks.save(&path);
180                let _ = tx.send(Message::BookmarksPersisted(result));
181            });
182        }
183        Cmd::ClearCacheAndBatch(cmds) => {
184            if let Some(client) = clients.get(active_remote) {
185                client.clear_cache();
186                tracing::debug!(remote = active_remote, "cmd: cache cleared");
187            }
188            for c in cmds {
189                execute(c, clients, active_remote, msg_tx);
190            }
191        }
192        Cmd::Batch(cmds) => {
193            for c in cmds {
194                execute(c, clients, active_remote, msg_tx);
195            }
196        }
197    }
198}
199
200/// Create an `ApiError` for when no client exists for a remote name.
201fn no_remote_error(remote: &str) -> ApiError {
202    ApiError::Configuration(format!("no client configured for remote '{remote}'"))
203}
204
205#[cfg(test)]
206#[allow(clippy::expect_used)]
207mod tests {
208    use super::*;
209    use crate::client::MockClient;
210    use crate::models::{
211        EmailMessage, MailingList, Paginated, Patchset, PatchsetDetail, ServerStats,
212    };
213
214    #[test]
215    fn cmd_none_is_default() {
216        // Cmd::None should be constructible
217        let cmd = Cmd::None;
218        assert!(matches!(cmd, Cmd::None));
219    }
220
221    #[test]
222    fn cmd_batch_composes() {
223        let cmd = Cmd::Batch(vec![
224            Cmd::FetchLists,
225            Cmd::FetchPatchsets(ListParams::default()),
226            Cmd::FetchStats,
227        ]);
228        assert!(matches!(cmd, Cmd::Batch(cmds) if cmds.len() == 3));
229    }
230
231    /// Build a single-remote client map with the given `MockClient`.
232    fn mock_clients(mock: Arc<MockClient>) -> HashMap<String, Arc<dyn SashikoApi>> {
233        let mut clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
234        clients.insert("test".to_string(), mock as Arc<dyn SashikoApi>);
235        clients
236    }
237
238    // -- execute() tests --
239
240    #[tokio::test]
241    async fn execute_none_sends_nothing() {
242        let (tx, mut rx) = mpsc::unbounded_channel();
243        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
244        execute(Cmd::None, &clients, "test", &tx);
245        // Give a moment for any spurious sends
246        tokio::task::yield_now().await;
247        assert!(rx.try_recv().is_err());
248    }
249
250    #[tokio::test]
251    async fn execute_fetch_patchsets_ok() {
252        let (tx, mut rx) = mpsc::unbounded_channel();
253        let mock = Arc::new(MockClient::new());
254        mock.set_patchsets(Ok(Paginated {
255            items: vec![Patchset::fixture()],
256            total: 1,
257            page: 1,
258            per_page: 50,
259        }));
260        let clients = mock_clients(mock);
261        execute(
262            Cmd::FetchPatchsets(ListParams::default()),
263            &clients,
264            "test",
265            &tx,
266        );
267        let msg = rx.recv().await.expect("should receive message");
268        assert!(matches!(msg, Message::PatchsetsLoaded(Ok(p)) if p.items.len() == 1));
269    }
270
271    #[tokio::test]
272    async fn execute_fetch_patchsets_missing_remote() {
273        let (tx, mut rx) = mpsc::unbounded_channel();
274        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
275        execute(
276            Cmd::FetchPatchsets(ListParams::default()),
277            &clients,
278            "missing",
279            &tx,
280        );
281        let msg = rx.recv().await.expect("should receive error");
282        assert!(matches!(msg, Message::PatchsetsLoaded(Err(_))));
283    }
284
285    #[tokio::test]
286    async fn execute_fetch_lists_ok() {
287        let (tx, mut rx) = mpsc::unbounded_channel();
288        let mock = Arc::new(MockClient::new());
289        mock.set_lists(Ok(vec![MailingList::fixture()]));
290        let clients = mock_clients(mock);
291        execute(Cmd::FetchLists, &clients, "test", &tx);
292        let msg = rx.recv().await.expect("should receive message");
293        assert!(matches!(msg, Message::ListsLoaded(Ok(lists)) if lists.len() == 1));
294    }
295
296    #[tokio::test]
297    async fn execute_fetch_lists_missing_remote() {
298        let (tx, mut rx) = mpsc::unbounded_channel();
299        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
300        execute(Cmd::FetchLists, &clients, "missing", &tx);
301        let msg = rx.recv().await.expect("should receive error");
302        assert!(matches!(msg, Message::ListsLoaded(Err(_))));
303    }
304
305    #[tokio::test]
306    async fn execute_fetch_stats_ok() {
307        let (tx, mut rx) = mpsc::unbounded_channel();
308        let mock = Arc::new(MockClient::new());
309        mock.set_stats(Ok(ServerStats::fixture()));
310        let clients = mock_clients(mock);
311        execute(Cmd::FetchStats, &clients, "test", &tx);
312        let msg = rx.recv().await.expect("should receive message");
313        assert!(matches!(msg, Message::StatsLoaded(Ok(_))));
314    }
315
316    #[tokio::test]
317    async fn execute_fetch_stats_missing_remote() {
318        let (tx, mut rx) = mpsc::unbounded_channel();
319        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
320        execute(Cmd::FetchStats, &clients, "missing", &tx);
321        let msg = rx.recv().await.expect("should receive error");
322        assert!(matches!(msg, Message::StatsLoaded(Err(_))));
323    }
324
325    #[tokio::test]
326    async fn execute_fetch_patchset_detail_ok() {
327        let (tx, mut rx) = mpsc::unbounded_channel();
328        let mock = Arc::new(MockClient::new());
329        mock.set_patch_detail(Ok(PatchsetDetail::fixture()));
330        let clients = mock_clients(mock);
331        execute(
332            Cmd::FetchPatchsetDetail(PatchId::Numeric(1)),
333            &clients,
334            "test",
335            &tx,
336        );
337        let msg = rx.recv().await.expect("should receive message");
338        assert!(matches!(msg, Message::PatchsetDetailLoaded(ref r) if r.is_ok()));
339    }
340
341    #[tokio::test]
342    async fn execute_fetch_patchset_detail_missing_remote() {
343        let (tx, mut rx) = mpsc::unbounded_channel();
344        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
345        execute(
346            Cmd::FetchPatchsetDetail(PatchId::Numeric(1)),
347            &clients,
348            "missing",
349            &tx,
350        );
351        let msg = rx.recv().await.expect("should receive error");
352        assert!(matches!(msg, Message::PatchsetDetailLoaded(ref r) if r.is_err()));
353    }
354
355    #[tokio::test]
356    async fn execute_fetch_messages_ok() {
357        let (tx, mut rx) = mpsc::unbounded_channel();
358        let mock = Arc::new(MockClient::new());
359        mock.set_messages(Ok(Paginated {
360            items: vec![EmailMessage::fixture()],
361            total: 1,
362            page: 1,
363            per_page: 50,
364        }));
365        let clients = mock_clients(mock);
366        execute(
367            Cmd::FetchMessages(ListParams::default()),
368            &clients,
369            "test",
370            &tx,
371        );
372        let msg = rx.recv().await.expect("should receive message");
373        assert!(matches!(msg, Message::MessagesLoaded(Ok(p)) if p.items.len() == 1));
374    }
375
376    #[tokio::test]
377    async fn execute_fetch_messages_missing_remote() {
378        let (tx, mut rx) = mpsc::unbounded_channel();
379        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
380        execute(
381            Cmd::FetchMessages(ListParams::default()),
382            &clients,
383            "missing",
384            &tx,
385        );
386        let msg = rx.recv().await.expect("should receive error");
387        assert!(matches!(msg, Message::MessagesLoaded(Err(_))));
388    }
389
390    #[tokio::test]
391    async fn execute_fetch_message_detail_ok() {
392        let (tx, mut rx) = mpsc::unbounded_channel();
393        let mock = Arc::new(MockClient::new());
394        mock.set_message_detail(Ok(EmailMessage::fixture()));
395        let clients = mock_clients(mock);
396        execute(
397            Cmd::FetchMessageDetail(PatchId::Numeric(1)),
398            &clients,
399            "test",
400            &tx,
401        );
402        let msg = rx.recv().await.expect("should receive message");
403        assert!(matches!(msg, Message::MessageDetailLoaded(ref r) if r.is_ok()));
404    }
405
406    #[tokio::test]
407    async fn execute_fetch_message_detail_missing_remote() {
408        let (tx, mut rx) = mpsc::unbounded_channel();
409        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
410        execute(
411            Cmd::FetchMessageDetail(PatchId::Numeric(1)),
412            &clients,
413            "missing",
414            &tx,
415        );
416        let msg = rx.recv().await.expect("should receive error");
417        assert!(matches!(msg, Message::MessageDetailLoaded(ref r) if r.is_err()));
418    }
419
420    #[tokio::test]
421    async fn execute_open_editor_is_noop() {
422        let (tx, mut rx) = mpsc::unbounded_channel();
423        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
424        execute(
425            Cmd::OpenEditor {
426                content: "test".to_string(),
427                editor: "vim".to_string(),
428            },
429            &clients,
430            "test",
431            &tx,
432        );
433        tokio::task::yield_now().await;
434        assert!(rx.try_recv().is_err());
435    }
436
437    #[tokio::test]
438    async fn execute_persist_bookmarks() {
439        let (tx, mut rx) = mpsc::unbounded_channel();
440        let clients: HashMap<String, Arc<dyn SashikoApi>> = HashMap::new();
441        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
442        execute(
443            Cmd::PersistBookmarks {
444                bookmarks: BookmarkStore::new(),
445                path: tmp.path().to_path_buf(),
446            },
447            &clients,
448            "test",
449            &tx,
450        );
451        let msg = rx.recv().await.expect("should receive message");
452        assert!(matches!(msg, Message::BookmarksPersisted(Ok(()))));
453    }
454
455    #[tokio::test]
456    async fn execute_batch_runs_all() {
457        let (tx, mut rx) = mpsc::unbounded_channel();
458        let mock = Arc::new(MockClient::new());
459        mock.set_stats(Ok(ServerStats::fixture()));
460        mock.set_lists(Ok(vec![MailingList::fixture()]));
461        let clients = mock_clients(mock);
462        execute(
463            Cmd::Batch(vec![Cmd::FetchStats, Cmd::FetchLists]),
464            &clients,
465            "test",
466            &tx,
467        );
468        let msg1 = rx.recv().await.expect("first message");
469        let msg2 = rx.recv().await.expect("second message");
470        // Both should succeed (order may vary since async)
471        let mut got_stats = false;
472        let mut got_lists = false;
473        for msg in [msg1, msg2] {
474            match msg {
475                Message::StatsLoaded(Ok(_)) => got_stats = true,
476                Message::ListsLoaded(Ok(_)) => got_lists = true,
477                _ => {}
478            }
479        }
480        assert!(got_stats, "should have received StatsLoaded");
481        assert!(got_lists, "should have received ListsLoaded");
482    }
483
484    #[tokio::test]
485    async fn execute_clear_cache_and_batch() {
486        let (tx, mut rx) = mpsc::unbounded_channel();
487        let mock = Arc::new(MockClient::new());
488        mock.set_stats(Ok(ServerStats::fixture()));
489        let clients = mock_clients(mock);
490        execute(
491            Cmd::ClearCacheAndBatch(vec![Cmd::FetchStats]),
492            &clients,
493            "test",
494            &tx,
495        );
496        let msg = rx.recv().await.expect("should receive message");
497        assert!(matches!(msg, Message::StatsLoaded(Ok(_))));
498    }
499}