Skip to main content

bubbletea/
command.rs

1//! Commands for side effects.
2//!
3//! Commands represent IO operations that produce messages. They are the only
4//! way to perform side effects in the Elm Architecture.
5//!
6//! # Sync vs Async Commands
7//!
8//! The crate supports both synchronous and asynchronous commands:
9//!
10//! - `Cmd` - Synchronous commands that run on a blocking thread pool
11//! - `AsyncCmd` - Asynchronous commands that run on the tokio runtime (requires `async` feature)
12//!
13//! Both types are automatically handled by the program's command executor.
14
15use std::time::{Duration, Instant, SystemTime};
16
17use crate::message::{
18    BatchMsg, Message, PrintLineMsg, QuitMsg, RequestWindowSizeMsg, SequenceMsg, SetWindowTitleMsg,
19};
20
21#[cfg(feature = "async")]
22use std::future::Future;
23#[cfg(feature = "async")]
24use std::pin::Pin;
25
26/// A command that produces a message when executed.
27///
28/// Commands are lazy - they don't execute until the program runs them.
29/// This allows for pure update functions that return commands without
30/// side effects.
31///
32/// # Example
33///
34/// ```rust
35/// use bubbletea::{Cmd, Message};
36/// use std::time::Duration;
37///
38/// // A command that produces a message after a delay
39/// fn delayed_message() -> Cmd {
40///     Cmd::new(|| {
41///         std::thread::sleep(Duration::from_secs(1));
42///         Message::new("done")
43///     })
44/// }
45/// ```
46pub struct Cmd(Box<dyn FnOnce() -> Option<Message> + Send + 'static>);
47
48impl Cmd {
49    /// Create a new command from a closure.
50    pub fn new<F>(f: F) -> Self
51    where
52        F: FnOnce() -> Message + Send + 'static,
53    {
54        Self(Box::new(move || Some(f())))
55    }
56
57    /// Create a command that may not produce a message.
58    pub fn new_optional<F>(f: F) -> Self
59    where
60        F: FnOnce() -> Option<Message> + Send + 'static,
61    {
62        Self(Box::new(f))
63    }
64
65    /// Create an empty command that does nothing.
66    pub fn none() -> Option<Self> {
67        None
68    }
69
70    /// Execute the command and return the resulting message.
71    pub fn execute(self) -> Option<Message> {
72        (self.0)()
73    }
74
75    /// Create a command that performs blocking I/O.
76    ///
77    /// This is semantically equivalent to `Cmd::new()` but makes the blocking
78    /// intent explicit. When the `async` feature is enabled, blocking commands
79    /// are automatically run on tokio's blocking thread pool via `spawn_blocking`.
80    ///
81    /// Use this for operations like:
82    /// - File I/O (`std::fs::read`, `std::fs::write`)
83    /// - Network operations with blocking APIs
84    /// - CPU-intensive computations
85    /// - Thread sleep operations
86    ///
87    /// # Example
88    ///
89    /// ```rust
90    /// use bubbletea::{Cmd, Message};
91    ///
92    /// fn read_config() -> Cmd {
93    ///     Cmd::blocking(|| {
94    ///         let content = std::fs::read_to_string("config.toml").unwrap();
95    ///         Message::new(content)
96    ///     })
97    /// }
98    /// ```
99    pub fn blocking<F>(f: F) -> Self
100    where
101        F: FnOnce() -> Message + Send + 'static,
102    {
103        // Blocking commands are handled the same as regular commands.
104        // When the async feature is enabled, CommandKind::execute() runs
105        // sync commands via tokio::task::spawn_blocking automatically.
106        Self::new(f)
107    }
108
109    /// Create a command that performs a blocking operation returning a Result.
110    ///
111    /// Converts `Result<T, E>` into a message, wrapping both success and error
112    /// cases. This is convenient for I/O operations that can fail.
113    ///
114    /// # Example
115    ///
116    /// ```rust
117    /// use bubbletea::{Cmd, Message};
118    /// use std::io;
119    ///
120    /// struct FileContent(String);
121    /// struct FileError(io::Error);
122    ///
123    /// fn read_file(path: &'static str) -> Cmd {
124    ///     Cmd::blocking_result(
125    ///         move || std::fs::read_to_string(path),
126    ///         |content| Message::new(FileContent(content)),
127    ///         |err| Message::new(FileError(err)),
128    ///     )
129    /// }
130    /// ```
131    pub fn blocking_result<F, T, E, S, Err>(f: F, on_success: S, on_error: Err) -> Self
132    where
133        F: FnOnce() -> Result<T, E> + Send + 'static,
134        S: FnOnce(T) -> Message + Send + 'static,
135        Err: FnOnce(E) -> Message + Send + 'static,
136    {
137        Self::new(move || match f() {
138            Ok(value) => on_success(value),
139            Err(err) => on_error(err),
140        })
141    }
142}
143
144// =============================================================================
145// Async Commands (requires "async" feature)
146// =============================================================================
147
148/// An asynchronous command that produces a message when executed.
149///
150/// Unlike `Cmd`, async commands can await I/O operations without blocking
151/// a thread. They run on the tokio runtime's async task pool.
152///
153/// # Example
154///
155/// ```rust,ignore
156/// use bubbletea::{AsyncCmd, Message};
157///
158/// fn fetch_data() -> AsyncCmd {
159///     AsyncCmd::new(|| async {
160///         let data = reqwest::get("https://api.example.com/data")
161///             .await
162///             .unwrap()
163///             .text()
164///             .await
165///             .unwrap();
166///         Message::new(data)
167///     })
168/// }
169/// ```
170#[cfg(feature = "async")]
171#[allow(clippy::type_complexity)]
172pub struct AsyncCmd(
173    Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = Option<Message>> + Send>> + Send + 'static>,
174);
175
176#[cfg(feature = "async")]
177impl AsyncCmd {
178    /// Create a new async command from an async closure.
179    pub fn new<F, Fut>(f: F) -> Self
180    where
181        F: FnOnce() -> Fut + Send + 'static,
182        Fut: Future<Output = Message> + Send + 'static,
183    {
184        Self(Box::new(move || Box::pin(async move { Some(f().await) })))
185    }
186
187    /// Create an async command that may not produce a message.
188    pub fn new_optional<F, Fut>(f: F) -> Self
189    where
190        F: FnOnce() -> Fut + Send + 'static,
191        Fut: Future<Output = Option<Message>> + Send + 'static,
192    {
193        Self(Box::new(move || Box::pin(f())))
194    }
195
196    /// Create an empty async command that does nothing.
197    pub fn none() -> Option<Self> {
198        None
199    }
200
201    /// Execute the async command and return the resulting message.
202    pub async fn execute(self) -> Option<Message> {
203        (self.0)().await
204    }
205}
206
207/// Internal enum for handling both sync and async commands.
208#[cfg(feature = "async")]
209pub(crate) enum CommandKind {
210    /// Synchronous command (runs on blocking thread pool)
211    Sync(Cmd),
212    /// Asynchronous command (runs on async task pool)
213    Async(AsyncCmd),
214}
215
216#[cfg(feature = "async")]
217impl CommandKind {
218    /// Execute the command, handling both sync and async variants.
219    pub async fn execute(self) -> Option<Message> {
220        match self {
221            CommandKind::Sync(cmd) => {
222                // Run blocking code on tokio's blocking thread pool
223                tokio::task::spawn_blocking(move || cmd.execute())
224                    .await
225                    .ok()
226                    .flatten()
227            }
228            CommandKind::Async(cmd) => cmd.execute().await,
229        }
230    }
231}
232
233#[cfg(feature = "async")]
234impl From<Cmd> for CommandKind {
235    fn from(cmd: Cmd) -> Self {
236        CommandKind::Sync(cmd)
237    }
238}
239
240#[cfg(feature = "async")]
241impl From<AsyncCmd> for CommandKind {
242    fn from(cmd: AsyncCmd) -> Self {
243        CommandKind::Async(cmd)
244    }
245}
246
247/// Batch multiple commands to run concurrently.
248///
249/// Commands in a batch run in parallel with no ordering guarantees.
250/// Use this to return multiple commands from an update function.
251///
252/// # Example
253///
254/// ```rust
255/// use bubbletea::{Cmd, Message, batch};
256///
257/// let cmd = batch(vec![
258///     Some(Cmd::new(|| Message::new("first"))),
259///     Some(Cmd::new(|| Message::new("second"))),
260/// ]);
261/// ```
262pub fn batch(cmds: Vec<Option<Cmd>>) -> Option<Cmd> {
263    let valid_cmds: Vec<Cmd> = cmds.into_iter().flatten().collect();
264
265    match valid_cmds.len() {
266        0 => None,
267        1 => valid_cmds.into_iter().next(),
268        _ => Some(Cmd::new_optional(move || {
269            Some(Message::new(BatchMsg(valid_cmds)))
270        })),
271    }
272}
273
274/// Sequence commands to run one at a time, in order.
275///
276/// Unlike batch, sequenced commands run one after another.
277/// Use this when the order of execution matters.
278///
279/// # Example
280///
281/// ```rust
282/// use bubbletea::{Cmd, Message, sequence};
283///
284/// let cmd = sequence(vec![
285///     Some(Cmd::new(|| Message::new("first"))),
286///     Some(Cmd::new(|| Message::new("second"))),
287/// ]);
288/// ```
289pub fn sequence(cmds: Vec<Option<Cmd>>) -> Option<Cmd> {
290    let valid_cmds: Vec<Cmd> = cmds.into_iter().flatten().collect();
291
292    match valid_cmds.len() {
293        0 => None,
294        1 => valid_cmds.into_iter().next(),
295        _ => Some(Cmd::new_optional(move || {
296            Some(Message::new(SequenceMsg(valid_cmds)))
297        })),
298    }
299}
300
301/// Command that signals the program to quit.
302pub fn quit() -> Cmd {
303    Cmd::new(|| Message::new(QuitMsg))
304}
305
306/// Command that ticks after a duration.
307///
308/// The tick runs for the full duration from when it's invoked.
309/// To create periodic ticks, return another tick command from
310/// your update function when handling the tick message.
311///
312/// # Example
313///
314/// ```rust,ignore
315/// use bubbletea::{Cmd, tick, Message};
316/// use std::time::{Duration, Instant};
317///
318/// struct TickMsg(Instant);
319///
320/// fn do_tick() -> Cmd {
321///     tick(Duration::from_secs(1), |t| Message::new(TickMsg(t)))
322/// }
323/// ```
324pub fn tick<F>(duration: Duration, f: F) -> Cmd
325where
326    F: FnOnce(Instant) -> Message + Send + 'static,
327{
328    Cmd::new(move || {
329        std::thread::sleep(duration);
330        f(Instant::now())
331    })
332}
333
334/// Command that ticks in sync with the system clock.
335///
336/// Unlike `tick`, this aligns with the system clock. For example,
337/// if you tick every second and the clock is at 12:34:20.5, the
338/// next tick will happen at 12:34:21.0 (in 0.5 seconds).
339///
340/// # Example
341///
342/// ```rust,ignore
343/// use bubbletea::{Cmd, every, Message};
344/// use std::time::{Duration, Instant};
345///
346/// struct TickMsg(Instant);
347///
348/// fn tick_every_second() -> Cmd {
349///     every(Duration::from_secs(1), |t| Message::new(TickMsg(t)))
350/// }
351/// ```
352pub fn every<F>(duration: Duration, f: F) -> Cmd
353where
354    F: FnOnce(Instant) -> Message + Send + 'static,
355{
356    Cmd::new(move || {
357        let duration_nanos = duration.as_nanos() as u64;
358        if duration_nanos == 0 {
359            // Zero duration means tick immediately
360            return f(Instant::now());
361        }
362
363        // Get current wall clock time as nanos since Unix epoch
364        let now_nanos = SystemTime::now()
365            .duration_since(SystemTime::UNIX_EPOCH)
366            .map(|d| d.as_nanos() as u64)
367            .unwrap_or(0);
368        // Calculate time until next tick aligned with system clock
369        let next_tick_nanos = ((now_nanos / duration_nanos) + 1) * duration_nanos;
370        let sleep_nanos = next_tick_nanos - now_nanos;
371        std::thread::sleep(Duration::from_nanos(sleep_nanos));
372        f(Instant::now())
373    })
374}
375
376// =============================================================================
377// Async Tick Commands (requires "async" feature)
378// =============================================================================
379
380/// Async command that ticks after a duration using tokio::time.
381///
382/// Unlike the sync `tick`, this doesn't block a thread while waiting.
383/// Use this when running on an async runtime.
384///
385/// # Example
386///
387/// ```rust,ignore
388/// use bubbletea::{tick_async, AsyncCmd, Message};
389/// use std::time::{Duration, Instant};
390///
391/// struct TickMsg(Instant);
392///
393/// fn do_tick() -> AsyncCmd {
394///     tick_async(Duration::from_secs(1), |t| Message::new(TickMsg(t)))
395/// }
396/// ```
397#[cfg(feature = "async")]
398pub fn tick_async<F>(duration: Duration, f: F) -> AsyncCmd
399where
400    F: FnOnce(Instant) -> Message + Send + 'static,
401{
402    AsyncCmd::new(move || async move {
403        tokio::time::sleep(duration).await;
404        f(Instant::now())
405    })
406}
407
408/// Async command that ticks in sync with the system clock using tokio::time.
409///
410/// Unlike the sync `every`, this doesn't block a thread while waiting.
411/// Use this when running on an async runtime.
412///
413/// # Example
414///
415/// ```rust,ignore
416/// use bubbletea::{every_async, AsyncCmd, Message};
417/// use std::time::{Duration, Instant};
418///
419/// struct TickMsg(Instant);
420///
421/// fn tick_every_second() -> AsyncCmd {
422///     every_async(Duration::from_secs(1), |t| Message::new(TickMsg(t)))
423/// }
424/// ```
425#[cfg(feature = "async")]
426pub fn every_async<F>(duration: Duration, f: F) -> AsyncCmd
427where
428    F: FnOnce(Instant) -> Message + Send + 'static,
429{
430    AsyncCmd::new(move || async move {
431        let duration_nanos = duration.as_nanos() as u64;
432        if duration_nanos == 0 {
433            // Zero duration means tick immediately
434            return f(Instant::now());
435        }
436
437        // Get current wall clock time as nanos since Unix epoch
438        let now_nanos = SystemTime::now()
439            .duration_since(SystemTime::UNIX_EPOCH)
440            .map(|d| d.as_nanos() as u64)
441            .unwrap_or(0);
442        // Calculate time until next tick aligned with system clock
443        let next_tick_nanos = ((now_nanos / duration_nanos) + 1) * duration_nanos;
444        let sleep_nanos = next_tick_nanos - now_nanos;
445        tokio::time::sleep(Duration::from_nanos(sleep_nanos)).await;
446        f(Instant::now())
447    })
448}
449
450/// Command to set the terminal window title.
451pub fn set_window_title(title: impl Into<String>) -> Cmd {
452    let title = title.into();
453    Cmd::new(move || Message::new(SetWindowTitleMsg(title)))
454}
455
456/// Command to query the current window size.
457///
458/// The result is delivered as a `WindowSizeMsg`.
459pub fn window_size() -> Cmd {
460    Cmd::new(|| Message::new(RequestWindowSizeMsg))
461}
462
463/// Print a line above the program's TUI output.
464///
465/// This output is unmanaged by the program and will persist across renders.
466/// Unlike `std::println!`, the message is printed on its own line (similar to `log::info!`).
467///
468/// **Note:** If the alternate screen is active, no output will be printed.
469/// This is because alternate screen mode uses a separate buffer that doesn't
470/// support this kind of unmanaged output.
471///
472/// # Example
473///
474/// ```rust,ignore
475/// use bubbletea::{Model, Message, Cmd, println};
476///
477/// impl Model for MyModel {
478///     fn update(&mut self, msg: Message) -> Option<Cmd> {
479///         if msg.is::<DownloadComplete>() {
480///             return Some(println("Download finished!"));
481///         }
482///         None
483///     }
484/// }
485/// ```
486pub fn println(msg: impl Into<String>) -> Cmd {
487    let msg = msg.into();
488    Cmd::new(move || Message::new(PrintLineMsg(msg)))
489}
490
491/// Print a formatted line above the program's TUI output.
492///
493/// This works like [`std::format!`] but prints above the TUI.
494/// Output is unmanaged by the program and will persist across renders.
495/// Unlike `std::print!`, the message is printed on its own line (similar to `log::info!`).
496///
497/// **Note:** If the alternate screen is active, no output will be printed.
498/// This is because alternate screen mode uses a separate buffer that doesn't
499/// support this kind of unmanaged output.
500///
501/// # Example
502///
503/// ```rust,ignore
504/// use bubbletea::{Model, Message, Cmd, printf};
505///
506/// impl Model for MyModel {
507///     fn update(&mut self, msg: Message) -> Option<Cmd> {
508///         if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
509///             return Some(printf(format!("Window size: {}x{}", size.width, size.height)));
510///         }
511///         None
512///     }
513/// }
514/// ```
515///
516/// For more complex formatting, use [`std::format!`] with [`println`]:
517///
518/// ```rust,ignore
519/// println(format!("Processing {} of {} items", current, total))
520/// ```
521pub fn printf(msg: impl Into<String>) -> Cmd {
522    println(msg)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_cmd_new() {
531        let cmd = Cmd::new(|| Message::new(42i32));
532        let msg = cmd.execute().unwrap();
533        assert_eq!(msg.downcast::<i32>().unwrap(), 42);
534    }
535
536    #[test]
537    fn test_cmd_none() {
538        assert!(Cmd::none().is_none());
539    }
540
541    #[test]
542    fn test_batch_empty() {
543        let cmd = batch(vec![]);
544        assert!(cmd.is_none());
545    }
546
547    #[test]
548    fn test_batch_single() {
549        let cmd = batch(vec![Some(Cmd::new(|| Message::new(42i32)))]);
550        assert!(cmd.is_some());
551    }
552
553    #[test]
554    fn test_sequence_empty() {
555        let cmd = sequence(vec![]);
556        assert!(cmd.is_none());
557    }
558
559    // =========================================================================
560    // Batch and Sequence Comprehensive Tests (bd-1u1s)
561    // =========================================================================
562
563    #[test]
564    fn test_batch_multiple_commands() {
565        // Batch with multiple commands should return Some
566        let cmd = batch(vec![
567            Some(Cmd::new(|| Message::new(1i32))),
568            Some(Cmd::new(|| Message::new(2i32))),
569            Some(Cmd::new(|| Message::new(3i32))),
570        ]);
571        assert!(cmd.is_some());
572
573        // Execute returns a BatchMsg containing all commands
574        let msg = cmd.unwrap().execute().unwrap();
575        assert!(msg.is::<BatchMsg>());
576    }
577
578    #[test]
579    fn test_batch_filters_none_values() {
580        // Batch should filter out None values and still work
581        let cmd = batch(vec![
582            Some(Cmd::new(|| Message::new(1i32))),
583            None, // This should be filtered
584            Some(Cmd::new(|| Message::new(2i32))),
585            None, // This should be filtered
586        ]);
587        assert!(cmd.is_some());
588
589        // Verify it produces a BatchMsg
590        let msg = cmd.unwrap().execute().unwrap();
591        let batch_msg = msg.downcast::<BatchMsg>().unwrap();
592        // Should have 2 commands (the two Some values)
593        assert_eq!(batch_msg.0.len(), 2);
594    }
595
596    #[test]
597    fn test_batch_all_none_returns_none() {
598        // Batch with all None should return None
599        let cmd = batch(vec![None, None, None]);
600        assert!(cmd.is_none());
601    }
602
603    #[test]
604    fn test_batch_mixed_with_single_some() {
605        // Batch with only one Some among Nones
606        let cmd = batch(vec![None, Some(Cmd::new(|| Message::new(42i32))), None]);
607        assert!(cmd.is_some());
608    }
609
610    #[test]
611    fn test_sequence_single() {
612        // Sequence with a single command returns the command directly (optimization)
613        let cmd = sequence(vec![Some(Cmd::new(|| Message::new(42i32)))]);
614        assert!(cmd.is_some());
615
616        // Single command executes directly, not wrapped in SequenceMsg
617        let msg = cmd.unwrap().execute().unwrap();
618        assert!(msg.is::<i32>());
619        assert_eq!(msg.downcast::<i32>().unwrap(), 42);
620    }
621
622    #[test]
623    fn test_sequence_multiple_commands() {
624        // Sequence with multiple commands
625        let cmd = sequence(vec![
626            Some(Cmd::new(|| Message::new(1i32))),
627            Some(Cmd::new(|| Message::new(2i32))),
628            Some(Cmd::new(|| Message::new(3i32))),
629        ]);
630        assert!(cmd.is_some());
631
632        // Verify it produces a SequenceMsg
633        let msg = cmd.unwrap().execute().unwrap();
634        assert!(msg.is::<SequenceMsg>());
635    }
636
637    #[test]
638    fn test_sequence_filters_none_values() {
639        // Sequence should filter out None values
640        let cmd = sequence(vec![
641            Some(Cmd::new(|| Message::new(1i32))),
642            None,
643            Some(Cmd::new(|| Message::new(2i32))),
644        ]);
645        assert!(cmd.is_some());
646
647        // Verify the SequenceMsg has correct number of commands
648        let msg = cmd.unwrap().execute().unwrap();
649        let seq_msg = msg.downcast::<SequenceMsg>().unwrap();
650        assert_eq!(seq_msg.0.len(), 2);
651    }
652
653    #[test]
654    fn test_sequence_all_none_returns_none() {
655        // Sequence with all None should return None
656        let cmd = sequence(vec![None, None]);
657        assert!(cmd.is_none());
658    }
659
660    #[test]
661    fn test_cmd_new_with_closure() {
662        // Test Cmd::new with various closure types
663        let cmd = Cmd::new(|| Message::new("hello"));
664        let msg = cmd.execute().unwrap();
665        assert!(msg.is::<&str>());
666        assert_eq!(msg.downcast::<&str>().unwrap(), "hello");
667    }
668
669    #[test]
670    fn test_cmd_new_with_captured_value() {
671        // Test Cmd::new with closure capturing values
672        let value = 42i32;
673        let cmd = Cmd::new(move || Message::new(value));
674        let msg = cmd.execute().unwrap();
675        assert_eq!(msg.downcast::<i32>().unwrap(), 42);
676    }
677
678    #[test]
679    fn test_cmd_new_optional_some() {
680        // Test Cmd::new_optional returning Some
681        let cmd = Cmd::new_optional(|| Some(Message::new(42i32)));
682        let msg = cmd.execute();
683        assert!(msg.is_some());
684        assert_eq!(msg.unwrap().downcast::<i32>().unwrap(), 42);
685    }
686
687    #[test]
688    fn test_cmd_new_optional_none() {
689        // Test Cmd::new_optional returning None
690        let cmd = Cmd::new_optional(|| None);
691        let msg = cmd.execute();
692        assert!(msg.is_none());
693    }
694
695    #[test]
696    fn test_blocking_executes() {
697        // Test Cmd::blocking actually executes
698        use std::sync::Arc;
699        use std::sync::atomic::{AtomicBool, Ordering};
700
701        let executed = Arc::new(AtomicBool::new(false));
702        let executed_clone = Arc::clone(&executed);
703
704        let cmd = Cmd::blocking(move || {
705            executed_clone.store(true, Ordering::SeqCst);
706            Message::new(())
707        });
708
709        let msg = cmd.execute();
710        assert!(msg.is_some());
711        assert!(executed.load(Ordering::SeqCst));
712    }
713
714    #[test]
715    fn test_quit() {
716        let cmd = quit();
717        let msg = cmd.execute().unwrap();
718        assert!(msg.is::<QuitMsg>());
719    }
720
721    #[test]
722    fn test_set_window_title() {
723        let cmd = set_window_title("My App");
724        let msg = cmd.execute().unwrap();
725        assert!(msg.is::<SetWindowTitleMsg>());
726    }
727
728    #[test]
729    fn test_println() {
730        let cmd = println("Hello, World!");
731        let msg = cmd.execute().unwrap();
732        assert!(msg.is::<PrintLineMsg>());
733        let print_msg = msg.downcast::<PrintLineMsg>().unwrap();
734        assert_eq!(print_msg.0, "Hello, World!");
735    }
736
737    #[test]
738    fn test_println_from_string() {
739        let cmd = println(String::from("From String"));
740        let msg = cmd.execute().unwrap();
741        let print_msg = msg.downcast::<PrintLineMsg>().unwrap();
742        assert_eq!(print_msg.0, "From String");
743    }
744
745    #[test]
746    fn test_printf() {
747        let cmd = printf(format!("Count: {}", 42));
748        let msg = cmd.execute().unwrap();
749        assert!(msg.is::<PrintLineMsg>());
750        let print_msg = msg.downcast::<PrintLineMsg>().unwrap();
751        assert_eq!(print_msg.0, "Count: 42");
752    }
753
754    #[test]
755    fn test_println_multiline() {
756        let cmd = println("Line 1\nLine 2\nLine 3");
757        let msg = cmd.execute().unwrap();
758        let print_msg = msg.downcast::<PrintLineMsg>().unwrap();
759        assert_eq!(print_msg.0, "Line 1\nLine 2\nLine 3");
760    }
761
762    #[test]
763    fn test_blocking() {
764        let cmd = Cmd::blocking(|| Message::new("blocked"));
765        let msg = cmd.execute().unwrap();
766        assert_eq!(msg.downcast::<&str>().unwrap(), "blocked");
767    }
768
769    #[test]
770    fn test_blocking_result_success() {
771        struct FileContent(String);
772
773        let cmd = Cmd::blocking_result(
774            || Ok::<_, std::io::Error>("file content".to_string()),
775            |content| Message::new(FileContent(content)),
776            |_err| Message::new("error"),
777        );
778        let msg = cmd.execute().unwrap();
779        assert!(msg.is::<FileContent>());
780        let content = msg.downcast::<FileContent>().unwrap();
781        assert_eq!(content.0, "file content");
782    }
783
784    #[test]
785    fn test_blocking_result_error() {
786        #[allow(dead_code)] // Field unused; we only check is::<FileError>()
787        struct FileError(std::io::Error);
788
789        let cmd = Cmd::blocking_result(
790            || {
791                Err::<String, _>(std::io::Error::new(
792                    std::io::ErrorKind::NotFound,
793                    "not found",
794                ))
795            },
796            |_content| Message::new("success"),
797            |err| Message::new(FileError(err)),
798        );
799        let msg = cmd.execute().unwrap();
800        assert!(msg.is::<FileError>());
801    }
802
803    // =============================================================================
804    // Async Command Tests (requires "async" feature)
805    // =============================================================================
806
807    #[cfg(feature = "async")]
808    mod async_tests {
809        use super::*;
810
811        #[tokio::test]
812        async fn test_async_cmd_new() {
813            let cmd = AsyncCmd::new(|| async { Message::new(42i32) });
814            let msg = cmd.execute().await.unwrap();
815            assert_eq!(msg.downcast::<i32>().unwrap(), 42);
816        }
817
818        #[tokio::test]
819        async fn test_async_cmd_new_optional_some() {
820            let cmd = AsyncCmd::new_optional(|| async { Some(Message::new("hello")) });
821            let msg = cmd.execute().await.unwrap();
822            assert_eq!(msg.downcast::<&str>().unwrap(), "hello");
823        }
824
825        #[tokio::test]
826        async fn test_async_cmd_new_optional_none() {
827            let cmd = AsyncCmd::new_optional(|| async { None });
828            assert!(cmd.execute().await.is_none());
829        }
830
831        #[tokio::test]
832        async fn test_async_cmd_none() {
833            assert!(AsyncCmd::none().is_none());
834        }
835
836        #[tokio::test]
837        async fn test_command_kind_sync() {
838            let cmd = Cmd::new(|| Message::new(100i32));
839            let kind: CommandKind = cmd.into();
840            let msg = kind.execute().await.unwrap();
841            assert_eq!(msg.downcast::<i32>().unwrap(), 100);
842        }
843
844        #[tokio::test]
845        async fn test_command_kind_async() {
846            let cmd = AsyncCmd::new(|| async { Message::new(200i32) });
847            let kind: CommandKind = cmd.into();
848            let msg = kind.execute().await.unwrap();
849            assert_eq!(msg.downcast::<i32>().unwrap(), 200);
850        }
851
852        #[tokio::test]
853        async fn test_tick_async_produces_message() {
854            struct TickMsg(#[allow(dead_code)] Instant);
855
856            let cmd = tick_async(Duration::from_millis(1), |t| Message::new(TickMsg(t)));
857            let msg = cmd.execute().await.unwrap();
858            assert!(msg.is::<TickMsg>());
859        }
860
861        #[tokio::test]
862        async fn test_blocking_via_spawn_blocking() {
863            // Verify that Cmd::blocking runs via spawn_blocking in async context
864            let cmd = Cmd::blocking(|| {
865                // Simulate a blocking operation
866                std::thread::sleep(Duration::from_millis(1));
867                Message::new("blocked_async")
868            });
869            let kind: CommandKind = cmd.into();
870            let msg = kind.execute().await.unwrap();
871            assert_eq!(msg.downcast::<&str>().unwrap(), "blocked_async");
872        }
873
874        #[tokio::test]
875        async fn test_blocking_result_via_spawn_blocking() {
876            #[allow(dead_code)]
877            struct FileContent(String);
878
879            let cmd = Cmd::blocking_result(
880                || {
881                    // Simulate blocking I/O
882                    std::thread::sleep(Duration::from_millis(1));
883                    Ok::<_, std::io::Error>("async file content".to_string())
884                },
885                |content| Message::new(FileContent(content)),
886                |_err| Message::new("error"),
887            );
888            let kind: CommandKind = cmd.into();
889            let msg = kind.execute().await.unwrap();
890            assert!(msg.is::<FileContent>());
891        }
892
893        // =========================================================================
894        // Error Handling Tests
895        // =========================================================================
896
897        #[tokio::test]
898        async fn test_blocking_result_error_in_async_context() {
899            #[allow(dead_code)]
900            struct ErrorResult(String);
901
902            let cmd = Cmd::blocking_result(
903                || {
904                    Err::<String, _>(std::io::Error::new(
905                        std::io::ErrorKind::NotFound,
906                        "not found",
907                    ))
908                },
909                |_content| Message::new("success"),
910                |err| Message::new(ErrorResult(err.to_string())),
911            );
912            let kind: CommandKind = cmd.into();
913            let msg = kind.execute().await.unwrap();
914            assert!(msg.is::<ErrorResult>());
915        }
916
917        #[tokio::test]
918        async fn test_async_cmd_with_io_error() {
919            #[allow(dead_code)]
920            struct IoError(String);
921
922            let cmd = AsyncCmd::new(|| async {
923                // Simulate an async operation that fails
924                let result: Result<String, std::io::Error> = Err(std::io::Error::new(
925                    std::io::ErrorKind::NotFound,
926                    "file not found",
927                ));
928                match result {
929                    Ok(data) => Message::new(data),
930                    Err(e) => Message::new(IoError(e.to_string())),
931                }
932            });
933            let msg = cmd.execute().await.unwrap();
934            assert!(msg.is::<IoError>());
935        }
936
937        #[tokio::test]
938        async fn test_async_cmd_optional_returns_none_on_error() {
939            let cmd = AsyncCmd::new_optional(|| async {
940                // Simulate operation that fails silently
941                let result: Result<i32, &str> = Err("failed");
942                result.ok().map(Message::new)
943            });
944            assert!(cmd.execute().await.is_none());
945        }
946
947        // =========================================================================
948        // Timeout Tests
949        // =========================================================================
950
951        #[tokio::test]
952        async fn test_tick_async_respects_duration() {
953            struct TimerFired;
954
955            let start = std::time::Instant::now();
956            let cmd = tick_async(Duration::from_millis(50), |_| Message::new(TimerFired));
957            let msg = cmd.execute().await.unwrap();
958            let elapsed = start.elapsed();
959
960            assert!(msg.is::<TimerFired>());
961            assert!(elapsed >= Duration::from_millis(50));
962            assert!(elapsed < Duration::from_millis(150)); // Allow some slack
963        }
964
965        #[tokio::test]
966        async fn test_async_cmd_with_timeout() {
967            use tokio::time::timeout;
968
969            struct SlowResult;
970
971            let cmd = AsyncCmd::new(|| async {
972                tokio::time::sleep(Duration::from_millis(10)).await;
973                Message::new(SlowResult)
974            });
975
976            // Should complete within timeout
977            let result = timeout(Duration::from_millis(100), cmd.execute()).await;
978            assert!(result.is_ok());
979            assert!(result.unwrap().unwrap().is::<SlowResult>());
980        }
981
982        #[tokio::test]
983        async fn test_async_cmd_timeout_expires() {
984            use tokio::time::timeout;
985
986            let cmd = AsyncCmd::new(|| async {
987                tokio::time::sleep(Duration::from_secs(10)).await;
988                Message::new("never")
989            });
990
991            // Should timeout
992            let result = timeout(Duration::from_millis(10), cmd.execute()).await;
993            assert!(result.is_err()); // Timeout elapsed
994        }
995
996        // =========================================================================
997        // Concurrency Tests
998        // =========================================================================
999
1000        #[tokio::test]
1001        async fn test_concurrent_async_commands() {
1002            use std::sync::Arc;
1003            use std::sync::atomic::{AtomicUsize, Ordering};
1004
1005            #[allow(dead_code)]
1006            struct CounterResult(usize);
1007
1008            let counter = Arc::new(AtomicUsize::new(0));
1009            let mut handles = vec![];
1010
1011            // Spawn 10 concurrent async commands
1012            for i in 0..10 {
1013                let counter = Arc::clone(&counter);
1014                let cmd = AsyncCmd::new(move || async move {
1015                    counter.fetch_add(1, Ordering::SeqCst);
1016                    tokio::time::sleep(Duration::from_millis(1)).await;
1017                    Message::new(CounterResult(i))
1018                });
1019                handles.push(tokio::spawn(async move { cmd.execute().await }));
1020            }
1021
1022            // Wait for all
1023            for handle in handles {
1024                let msg = handle.await.unwrap().unwrap();
1025                assert!(msg.is::<CounterResult>());
1026            }
1027
1028            // All 10 should have run
1029            assert_eq!(counter.load(Ordering::SeqCst), 10);
1030        }
1031
1032        #[tokio::test]
1033        async fn test_concurrent_command_kind_mixed() {
1034            use std::sync::Arc;
1035            use std::sync::atomic::{AtomicUsize, Ordering};
1036
1037            let counter = Arc::new(AtomicUsize::new(0));
1038            let mut handles = vec![];
1039
1040            // Mix of sync and async commands
1041            for i in 0..6 {
1042                let counter = Arc::clone(&counter);
1043                let kind: CommandKind = if i % 2 == 0 {
1044                    // Sync command (runs via spawn_blocking)
1045                    let counter = Arc::clone(&counter);
1046                    Cmd::new(move || {
1047                        counter.fetch_add(1, Ordering::SeqCst);
1048                        Message::new(i)
1049                    })
1050                    .into()
1051                } else {
1052                    // Async command
1053                    let counter = Arc::clone(&counter);
1054                    AsyncCmd::new(move || async move {
1055                        counter.fetch_add(1, Ordering::SeqCst);
1056                        Message::new(i)
1057                    })
1058                    .into()
1059                };
1060                handles.push(tokio::spawn(async move { kind.execute().await }));
1061            }
1062
1063            // Wait for all
1064            for handle in handles {
1065                assert!(handle.await.unwrap().is_some());
1066            }
1067
1068            // All 6 should have run
1069            assert_eq!(counter.load(Ordering::SeqCst), 6);
1070        }
1071
1072        #[tokio::test]
1073        async fn test_command_kind_ordering_within_single_task() {
1074            use std::sync::Arc;
1075            use std::sync::atomic::{AtomicUsize, Ordering};
1076
1077            #[derive(Debug, PartialEq)]
1078            struct OrderedResult {
1079                index: usize,
1080                order: usize,
1081            }
1082
1083            let order = Arc::new(AtomicUsize::new(0));
1084            let mut results = vec![];
1085
1086            // Execute commands sequentially within single task
1087            for i in 0..3usize {
1088                let order = Arc::clone(&order);
1089                let cmd = AsyncCmd::new(move || async move {
1090                    let n = order.fetch_add(1, Ordering::SeqCst);
1091                    Message::new(OrderedResult { index: i, order: n })
1092                });
1093                let msg = cmd.execute().await.unwrap();
1094                results.push(msg.downcast::<OrderedResult>().unwrap());
1095            }
1096
1097            // Should execute in order
1098            assert_eq!(results[0], OrderedResult { index: 0, order: 0 });
1099            assert_eq!(results[1], OrderedResult { index: 1, order: 1 });
1100            assert_eq!(results[2], OrderedResult { index: 2, order: 2 });
1101        }
1102
1103        // =========================================================================
1104        // Edge Cases
1105        // =========================================================================
1106
1107        #[tokio::test]
1108        async fn test_async_cmd_with_large_message() {
1109            let large_data = vec![42u8; 1024 * 1024]; // 1MB
1110            let cmd = AsyncCmd::new(move || async move { Message::new(large_data) });
1111            let msg = cmd.execute().await.unwrap();
1112            let data = msg.downcast::<Vec<u8>>().unwrap();
1113            assert_eq!(data.len(), 1024 * 1024);
1114            assert!(data.iter().all(|&b| b == 42));
1115        }
1116
1117        #[tokio::test]
1118        async fn test_every_async_produces_message() {
1119            struct EveryTick;
1120
1121            let cmd = every_async(Duration::from_millis(1), |_| Message::new(EveryTick));
1122            let msg = cmd.execute().await.unwrap();
1123            assert!(msg.is::<EveryTick>());
1124        }
1125
1126        #[tokio::test]
1127        async fn test_command_kind_from_conversions() {
1128            // Test From<Cmd> for CommandKind
1129            let sync_cmd = Cmd::new(|| Message::new(1i32));
1130            let kind: CommandKind = sync_cmd.into();
1131            assert!(matches!(kind, CommandKind::Sync(_)));
1132
1133            // Test From<AsyncCmd> for CommandKind
1134            let async_cmd = AsyncCmd::new(|| async { Message::new(2i32) });
1135            let kind: CommandKind = async_cmd.into();
1136            assert!(matches!(kind, CommandKind::Async(_)));
1137        }
1138
1139        #[tokio::test]
1140        async fn test_spawn_blocking_does_not_block_runtime() {
1141            use std::time::Instant;
1142
1143            let start = Instant::now();
1144
1145            // Start two blocking commands concurrently
1146            let cmd1: CommandKind = Cmd::blocking(|| {
1147                std::thread::sleep(Duration::from_millis(50));
1148                Message::new(1)
1149            })
1150            .into();
1151
1152            let cmd2: CommandKind = Cmd::blocking(|| {
1153                std::thread::sleep(Duration::from_millis(50));
1154                Message::new(2)
1155            })
1156            .into();
1157
1158            let (r1, r2) = tokio::join!(cmd1.execute(), cmd2.execute());
1159
1160            let elapsed = start.elapsed();
1161
1162            assert!(r1.is_some());
1163            assert!(r2.is_some());
1164
1165            // Should run concurrently, so total time should be ~50ms, not ~100ms
1166            assert!(elapsed < Duration::from_millis(100));
1167        }
1168    }
1169}