1use 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#[derive(Debug)]
23pub enum Cmd {
24 None,
26 FetchPatchsets(ListParams),
28 FetchLists,
30 FetchStats,
32 FetchPatchsetDetail(PatchId),
34 OpenEditor {
36 content: String,
38 editor: String,
40 },
41 PersistBookmarks {
43 bookmarks: BookmarkStore,
45 path: PathBuf,
47 },
48 FetchMessages(ListParams),
50 FetchMessageDetail(PatchId),
52 ClearCacheAndBatch(Vec<Cmd>),
54 Batch(Vec<Cmd>),
56}
57
58fn 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
86pub 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(¶ms).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(¶ms).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
200fn 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 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 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 #[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 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 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}