leftwm_core/utils/
command_pipe.rs

1//! Creates a pipe to listen for external commands.
2use crate::models::{Handle, TagId};
3use crate::utils::return_pipe::ReturnPipe;
4use crate::{command, Command, ReleaseScratchPadOption};
5use leftwm_layouts::geometry::Direction as FocusDirection;
6use std::error::Error;
7use std::fs::OpenOptions;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11use std::{env, fmt};
12use tokio::fs;
13use tokio::io::{AsyncBufReadExt, BufReader};
14use tokio::sync::mpsc;
15use xdg::BaseDirectories;
16
17/// Holds pipe file location and a receiver.
18#[derive(Debug)]
19pub struct CommandPipe<H: Handle> {
20    pipe_file: PathBuf,
21    rx: mpsc::UnboundedReceiver<Command<H>>,
22}
23
24impl<H: Handle> Drop for CommandPipe<H> {
25    fn drop(&mut self) {
26        use std::os::unix::fs::OpenOptionsExt;
27        self.rx.close();
28
29        // Open fifo for write to unblock pending open for read operation that prevents tokio runtime
30        // from shutting down.
31        if let Err(err) = std::fs::OpenOptions::new()
32            .write(true)
33            .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits())
34            .open(&self.pipe_file)
35        {
36            eprintln!(
37                "Failed to open {} when dropping CommandPipe: {err}",
38                self.pipe_file.display()
39            );
40        }
41    }
42}
43
44impl<H: Handle> CommandPipe<H> {
45    /// Create and listen to the named pipe.
46    /// # Errors
47    ///
48    /// Will error if unable to `mkfifo`, likely a filesystem issue
49    /// such as inadequate permissions.
50    pub async fn new(pipe_file: PathBuf) -> Result<Self, std::io::Error> {
51        fs::remove_file(pipe_file.as_path()).await.ok();
52        if let Err(e) = nix::unistd::mkfifo(&pipe_file, nix::sys::stat::Mode::S_IRWXU) {
53            tracing::error!("Failed to create new fifo {:?}", e);
54        }
55
56        let path = pipe_file.clone();
57        let (tx, rx) = mpsc::unbounded_channel();
58        tokio::spawn(async move {
59            while !tx.is_closed() {
60                read_from_pipe(&path, &tx).await;
61            }
62            fs::remove_file(path).await.ok();
63        });
64
65        Ok(Self { pipe_file, rx })
66    }
67
68    pub async fn read_command(&mut self) -> Option<Command<H>> {
69        self.rx.recv().await
70    }
71}
72
73pub fn pipe_name() -> PathBuf {
74    let display = env::var("DISPLAY")
75        .ok()
76        .and_then(|d| d.rsplit_once(':').map(|(_, r)| r.to_owned()))
77        .unwrap_or_else(|| "0".to_string());
78
79    PathBuf::from(format!("command-{display}.pipe"))
80}
81
82async fn read_from_pipe<H: Handle>(
83    pipe_file: &Path,
84    tx: &mpsc::UnboundedSender<Command<H>>,
85) -> Option<()> {
86    let file = fs::File::open(pipe_file).await.ok()?;
87    let mut lines = BufReader::new(file).lines();
88
89    while let Some(line) = lines.next_line().await.ok()? {
90        let cmd = match parse_command(&line) {
91            Ok(cmd) => {
92                if let Command::Other(_) = cmd {
93                    cmd
94                } else {
95                    let file_name = ReturnPipe::pipe_name();
96                    if let Ok(file_path) = BaseDirectories::with_prefix("leftwm") {
97                        if let Some(file_path) = file_path.find_runtime_file(&file_name) {
98                            if let Ok(mut file) = OpenOptions::new().append(true).open(file_path) {
99                                if let Err(e) = writeln!(file, "OK: command executed successfully")
100                                {
101                                    tracing::error!("Unable to write to return pipe: {e}");
102                                }
103                            }
104                        }
105                    }
106                    cmd
107                }
108            }
109            Err(err) => {
110                tracing::error!("An error occurred while parsing the command: {}", err);
111                // return to stdout
112                let file_name = ReturnPipe::pipe_name();
113                if let Ok(file_path) = BaseDirectories::with_prefix("leftwm") {
114                    if let Some(file_path) = file_path.find_runtime_file(file_name) {
115                        if let Ok(mut file) = OpenOptions::new().append(true).open(file_path) {
116                            if let Err(e) = writeln!(file, "ERROR: Error parsing command: {err}") {
117                                tracing::error!("Unable to write error to return pipe: {e}");
118                            }
119                        }
120                    }
121                }
122
123                return None;
124            }
125        };
126        tx.send(cmd).ok()?;
127    }
128
129    Some(())
130}
131
132fn parse_command<H: Handle>(s: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
133    let (head, rest) = s.split_once(' ').unwrap_or((s, ""));
134    match head {
135        // Move Window
136        "MoveWindowDown" => Ok(Command::MoveWindowDown),
137        "MoveWindowTop" => build_move_window_top(rest),
138        "SwapWindowTop" => build_swap_window_top(rest),
139        "MoveWindowUp" => Ok(Command::MoveWindowUp),
140        "MoveWindowToNextTag" => build_move_window_to_next_tag(rest),
141        "MoveWindowToPreviousTag" => build_move_window_to_previous_tag(rest),
142        "MoveWindowToLastWorkspace" => Ok(Command::MoveWindowToLastWorkspace),
143        "MoveWindowToNextWorkspace" => Ok(Command::MoveWindowToNextWorkspace),
144        "MoveWindowToPreviousWorkspace" => Ok(Command::MoveWindowToPreviousWorkspace),
145        "MoveWindowAt" => build_move_window_dir(rest),
146        "SendWindowToTag" => build_send_window_to_tag(rest),
147        // Focus Navigation
148        "FocusWindowDown" => Ok(Command::FocusWindowDown),
149        "FocusWindowTop" => build_focus_window_top(rest),
150        "FocusWindowUp" => Ok(Command::FocusWindowUp),
151        "FocusWindowAt" => build_focus_window_dir(rest),
152        "FocusNextTag" => build_focus_next_tag(rest),
153        "FocusPreviousTag" => build_focus_previous_tag(rest),
154        "FocusWorkspaceNext" => Ok(Command::FocusWorkspaceNext),
155        "FocusWorkspacePrevious" => Ok(Command::FocusWorkspacePrevious),
156        "FocusWindow" => build_focus_window(rest),
157        // Layout
158        "DecreaseMainWidth" | "DecreaseMainSize" => build_decrease_main_size(rest), // 'DecreaseMainWidth' deprecated
159        "IncreaseMainWidth" | "IncreaseMainSize" => build_increase_main_size(rest), // 'IncreaseMainWidth' deprecated
160        "DecreaseMainCount" => Ok(Command::DecreaseMainCount()),
161        "IncreaseMainCount" => Ok(Command::IncreaseMainCount()),
162        "NextLayout" => Ok(Command::NextLayout),
163        "PreviousLayout" => Ok(Command::PreviousLayout),
164        "RotateTag" => Ok(Command::RotateTag),
165        "SetLayout" => build_set_layout(rest),
166        "SetMarginMultiplier" => build_set_margin_multiplier(rest),
167        // Scratchpad
168        "ToggleScratchPad" => build_toggle_scratchpad(rest),
169        "AttachScratchPad" => build_attach_scratchpad(rest),
170        "ReleaseScratchPad" => Ok(build_release_scratchpad(rest)),
171        "NextScratchPadWindow" => Ok(Command::NextScratchPadWindow {
172            scratchpad: rest.to_owned().into(),
173        }),
174        "PrevScratchPadWindow" => Ok(Command::PrevScratchPadWindow {
175            scratchpad: rest.to_owned().into(),
176        }),
177        // Floating
178        "FloatingToTile" => Ok(Command::FloatingToTile),
179        "TileToFloating" => Ok(Command::TileToFloating),
180        "ToggleFloating" => Ok(Command::ToggleFloating),
181        // Workspace/Tag
182        "GoToTag" => build_go_to_tag(rest),
183        "ReturnToLastTag" => Ok(Command::ReturnToLastTag),
184        "SendWorkspaceToTag" => build_send_workspace_to_tag(rest),
185        "SwapScreens" => Ok(Command::SwapScreens),
186        "ToggleFullScreen" => Ok(Command::ToggleFullScreen),
187        "ToggleMaximized" => Ok(Command::ToggleMaximized),
188        "ToggleSticky" => Ok(Command::ToggleSticky),
189        "ToggleAbove" => Ok(Command::ToggleAbove),
190        // General
191        "CloseWindow" => Ok(Command::CloseWindow),
192        "CloseAllOtherWindows" => Ok(Command::CloseAllOtherWindows),
193        "SoftReload" => Ok(Command::SoftReload),
194        _ => Ok(Command::Other(s.into())),
195    }
196}
197
198fn build_attach_scratchpad<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
199    let name = if raw.is_empty() {
200        return Err("missing argument scratchpad's name".into());
201    } else {
202        raw
203    };
204    Ok(Command::AttachScratchPad {
205        scratchpad: name.into(),
206        window: None,
207    })
208}
209
210fn build_release_scratchpad<H: Handle>(raw: &str) -> Command<H> {
211    if raw.is_empty() {
212        Command::ReleaseScratchPad {
213            window: ReleaseScratchPadOption::None,
214            tag: None,
215        }
216    } else if let Ok(tag_id) = usize::from_str(raw) {
217        Command::ReleaseScratchPad {
218            window: ReleaseScratchPadOption::None,
219            tag: Some(tag_id),
220        }
221    } else {
222        Command::ReleaseScratchPad {
223            window: ReleaseScratchPadOption::ScratchpadName(raw.into()),
224            tag: None,
225        }
226    }
227}
228
229fn build_toggle_scratchpad<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
230    let name = if raw.is_empty() {
231        return Err("missing argument scratchpad's name".into());
232    } else {
233        raw
234    };
235    Ok(Command::ToggleScratchPad(name.into()))
236}
237
238fn build_go_to_tag<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
239    let headless = without_head(raw, "GoToTag ");
240    let mut parts = headless.split(' ');
241    let tag: TagId = parts
242        .next()
243        .ok_or("missing argument tag_id")?
244        .parse()
245        .or(Err("argument tag_id was missing or not a valid tag number"))?;
246    let swap: bool = match parts.next().ok_or("missing argument swap")?.parse() {
247        Ok(b) => b,
248        Err(_) => Err("argument swap was not true or false")?,
249    };
250    Ok(Command::GoToTag { tag, swap })
251}
252
253fn build_send_window_to_tag<H: Handle>(
254    raw: &str,
255) -> Result<Command<H>, Box<dyn std::error::Error>> {
256    let tag_id = if raw.is_empty() {
257        return Err("missing argument tag_id".into());
258    } else {
259        match TagId::from_str(raw) {
260            Ok(tag) => tag,
261            Err(_) => Err("argument tag_id was not a valid tag number")?,
262        }
263    };
264    Ok(Command::SendWindowToTag {
265        window: None,
266        tag: tag_id,
267    })
268}
269
270fn build_send_workspace_to_tag<H: Handle>(
271    raw: &str,
272) -> Result<Command<H>, Box<dyn std::error::Error>> {
273    if raw.is_empty() {
274        return Err("missing argument workspace index".into());
275    }
276    let mut parts: std::str::Split<'_, char> = raw.split(' ');
277    let ws_index: usize = match parts
278        .next()
279        .expect("split() always returns an array of at least 1 element")
280        .parse()
281    {
282        Ok(ws) => ws,
283        Err(_) => Err("argument workspace index was not a valid workspace number")?,
284    };
285    let tag_index: usize = match parts.next().ok_or("missing argument tag index")?.parse() {
286        Ok(tag) => tag,
287        Err(_) => Err("argument tag index was not a valid tag number")?,
288    };
289    Ok(Command::SendWorkspaceToTag(ws_index, tag_index))
290}
291
292fn build_set_layout<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
293    let layout_name = if raw.is_empty() {
294        return Err("missing layout name".into());
295    } else {
296        raw
297    };
298    Ok(Command::SetLayout(String::from(layout_name)))
299}
300
301fn build_set_margin_multiplier<H: Handle>(
302    raw: &str,
303) -> Result<Command<H>, Box<dyn std::error::Error>> {
304    let margin_multiplier = if raw.is_empty() {
305        return Err("missing argument multiplier".into());
306    } else {
307        f32::from_str(raw)?
308    };
309    Ok(Command::SetMarginMultiplier(margin_multiplier))
310}
311
312fn build_focus_window_top<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
313    let swap = if raw.is_empty() {
314        false
315    } else {
316        match bool::from_str(raw) {
317            Ok(bl) => bl,
318            Err(_) => Err("Argument swap was not true or false")?,
319        }
320    };
321    Ok(Command::FocusWindowTop { swap })
322}
323
324fn build_focus_window_dir<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
325    let dir = if raw.is_empty() {
326        FocusDirection::North
327    } else {
328        match FocusDirection::from_str(raw) {
329            Ok(d) => d,
330            Err(()) => Err("Argument direction was missing or invalid")?,
331        }
332    };
333    Ok(Command::FocusWindowAt(dir))
334}
335
336fn build_move_window_dir<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
337    let dir = if raw.is_empty() {
338        FocusDirection::North
339    } else {
340        match FocusDirection::from_str(raw) {
341            Ok(d) => d,
342            Err(()) => Err("Argument direction was missing or invalid")?,
343        }
344    };
345    Ok(Command::MoveWindowAt(dir))
346}
347
348fn build_move_window_top<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
349    let swap = if raw.is_empty() {
350        true
351    } else {
352        match bool::from_str(raw) {
353            Ok(bl) => bl,
354            Err(_) => Err("Argument swap was not true or false")?,
355        }
356    };
357    Ok(Command::MoveWindowTop { swap })
358}
359
360fn build_swap_window_top<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
361    let swap = if raw.is_empty() {
362        true
363    } else {
364        match bool::from_str(raw) {
365            Ok(bl) => bl,
366            Err(_) => Err("Argument swap was not true or false")?,
367        }
368    };
369    Ok(Command::SwapWindowTop { swap })
370}
371
372fn build_move_window_to_next_tag<H: Handle>(
373    raw: &str,
374) -> Result<Command<H>, Box<dyn std::error::Error>> {
375    let follow = if raw.is_empty() {
376        true
377    } else {
378        match bool::from_str(raw) {
379            Ok(bl) => bl,
380            Err(_) => Err("Argument follow was not true or false")?,
381        }
382    };
383    Ok(Command::MoveWindowToNextTag { follow })
384}
385
386fn build_move_window_to_previous_tag<H: Handle>(
387    raw: &str,
388) -> Result<Command<H>, Box<dyn std::error::Error>> {
389    let follow = if raw.is_empty() {
390        true
391    } else {
392        match bool::from_str(raw) {
393            Ok(bl) => bl,
394            Err(_) => Err("Argument follow was not true or false")?,
395        }
396    };
397    Ok(Command::MoveWindowToPreviousTag { follow })
398}
399
400fn build_increase_main_size<H: Handle>(
401    raw: &str,
402) -> Result<Command<H>, Box<dyn std::error::Error>> {
403    let mut parts = raw.split(' ');
404    let change: i32 = match parts.next().ok_or("missing argument change")?.parse() {
405        Ok(num) => num,
406        Err(_) => Err("argument change was missing or invalid")?,
407    };
408    Ok(Command::IncreaseMainSize(change))
409}
410
411fn build_decrease_main_size<H: Handle>(
412    raw: &str,
413) -> Result<Command<H>, Box<dyn std::error::Error>> {
414    let mut parts = raw.split(' ');
415    let change: i32 = match parts.next().ok_or("missing argument change")?.parse() {
416        Ok(num) => num,
417        Err(_) => Err("argument change was missing or invalid")?,
418    };
419    Ok(Command::DecreaseMainSize(change))
420}
421
422fn build_focus_next_tag<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
423    match raw {
424        "ignore_empty" | "goto_used" => Ok(Command::FocusNextTag {
425            behavior: command::FocusDeltaBehavior::IgnoreEmpty,
426        }),
427        "ignore_used" | "goto_empty" => Ok(Command::FocusNextTag {
428            behavior: command::FocusDeltaBehavior::IgnoreUsed,
429        }),
430        "default" | "" => Ok(Command::FocusNextTag {
431            behavior: command::FocusDeltaBehavior::Default,
432        }),
433        _ => Err(Box::new(InvalidFocusDeltaBehaviorError {
434            attempted_value: raw.to_owned(),
435            command: Command::<H>::FocusNextTag {
436                behavior: command::FocusDeltaBehavior::Default,
437            },
438        })),
439    }
440}
441
442fn build_focus_previous_tag<H: Handle>(
443    raw: &str,
444) -> Result<Command<H>, Box<dyn std::error::Error>> {
445    match raw {
446        "ignore_empty" | "goto_used" => Ok(Command::FocusPreviousTag {
447            behavior: command::FocusDeltaBehavior::IgnoreEmpty,
448        }),
449        "ignore_used" | "goto_empty" => Ok(Command::FocusPreviousTag {
450            behavior: command::FocusDeltaBehavior::IgnoreUsed,
451        }),
452
453        "default" | "" => Ok(Command::FocusPreviousTag {
454            behavior: command::FocusDeltaBehavior::Default,
455        }),
456        _ => Err(Box::new(InvalidFocusDeltaBehaviorError {
457            attempted_value: raw.to_owned(),
458            command: Command::<H>::FocusPreviousTag {
459                behavior: command::FocusDeltaBehavior::Default,
460            },
461        })),
462    }
463}
464
465fn build_focus_window<H: Handle>(raw: &str) -> Result<Command<H>, Box<dyn std::error::Error>> {
466    if raw.is_empty() {
467        Err("argument window class was missing")?;
468    }
469
470    Ok(Command::FocusWindow(String::from(raw)))
471}
472
473fn without_head<'a>(s: &'a str, head: &'a str) -> &'a str {
474    if !s.starts_with(head) {
475        return s;
476    }
477    &s[head.len()..]
478}
479
480#[derive(Debug)]
481struct InvalidFocusDeltaBehaviorError<H: Handle> {
482    attempted_value: String,
483    command: Command<H>,
484}
485
486impl<H: Handle> Error for InvalidFocusDeltaBehaviorError<H> {}
487
488impl<H: Handle> fmt::Display for InvalidFocusDeltaBehaviorError<H> {
489    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
490        match &self.command {
491            Command::FocusNextTag { .. } => write!(
492                f,
493                "Invalid behavior for FocusNextTag: {}",
494                &self.attempted_value
495            ),
496            Command::FocusPreviousTag { .. } => write!(
497                f,
498                "Invalid behavior for FocusPreviousTag: {}",
499                &self.attempted_value
500            ),
501            _ => write!(f, "Invalid behavior: {}", &self.attempted_value),
502        }
503    }
504}
505
506#[cfg(test)]
507mod test {
508    use super::*;
509    use crate::models::MockHandle;
510    use crate::utils::helpers::test::temp_path;
511    use tokio::io::AsyncWriteExt;
512    use tokio::time;
513
514    #[tokio::test]
515    async fn read_good_command() {
516        let pipe_file = temp_path().await.unwrap();
517        let mut command_pipe = CommandPipe::<MockHandle>::new(pipe_file.clone())
518            .await
519            .unwrap();
520
521        // Write some meaningful command to the pipe and close it.
522        {
523            let mut pipe = fs::OpenOptions::new()
524                .write(true)
525                .open(&pipe_file)
526                .await
527                .unwrap();
528            pipe.write_all(b"SoftReload\n").await.unwrap();
529            pipe.flush().await.unwrap();
530
531            assert_eq!(
532                Command::SoftReload,
533                command_pipe.read_command().await.unwrap()
534            );
535        }
536    }
537
538    #[tokio::test]
539    async fn read_bad_command() {
540        let pipe_file = temp_path().await.unwrap();
541        let mut command_pipe = CommandPipe::<MockHandle>::new(pipe_file.clone())
542            .await
543            .unwrap();
544
545        // Write some custom command and close it.
546        {
547            let mut pipe = fs::OpenOptions::new()
548                .write(true)
549                .open(&pipe_file)
550                .await
551                .unwrap();
552            pipe.write_all(b"Hello World\n").await.unwrap();
553            pipe.flush().await.unwrap();
554
555            assert_eq!(
556                Command::Other("Hello World".to_string()),
557                command_pipe.read_command().await.unwrap()
558            );
559        }
560    }
561
562    #[tokio::test]
563    async fn pipe_cleanup() {
564        let pipe_file = temp_path().await.unwrap();
565        fs::remove_file(pipe_file.as_path()).await.unwrap();
566
567        // Write to pipe.
568        {
569            let _command_pipe = CommandPipe::<MockHandle>::new(pipe_file.clone())
570                .await
571                .unwrap();
572            let mut pipe = fs::OpenOptions::new()
573                .write(true)
574                .open(&pipe_file)
575                .await
576                .unwrap();
577            pipe.write_all(b"ToggleFullScreen\n").await.unwrap();
578            pipe.flush().await.unwrap();
579        }
580
581        // Let the OS close the write end of the pipe before shutting down the listener.
582        time::sleep(time::Duration::from_millis(100)).await;
583
584        // NOTE: clippy is drunk
585        {
586            assert!(!pipe_file.exists());
587        }
588    }
589
590    #[test]
591    fn build_toggle_scratchpad_without_parameter() {
592        assert!(build_toggle_scratchpad::<MockHandle>("").is_err());
593    }
594
595    #[test]
596    fn build_send_window_to_tag_without_parameter() {
597        assert!(build_toggle_scratchpad::<MockHandle>("").is_err());
598    }
599
600    #[test]
601    fn build_send_workspace_to_tag_without_parameter() {
602        assert!(build_send_workspace_to_tag::<MockHandle>("").is_err());
603    }
604
605    #[test]
606    fn build_set_layout_without_parameter() {
607        assert!(build_set_layout::<MockHandle>("").is_err());
608    }
609
610    #[test]
611    fn build_set_margin_multiplier_without_parameter() {
612        assert!(build_set_margin_multiplier::<MockHandle>("").is_err());
613    }
614
615    #[test]
616    fn build_move_window_top_without_parameter() {
617        assert_eq!(
618            build_move_window_top::<MockHandle>("").unwrap(),
619            Command::MoveWindowTop { swap: true }
620        );
621    }
622
623    #[test]
624    fn build_focus_window_top_without_parameter() {
625        assert_eq!(
626            build_focus_window_top::<MockHandle>("").unwrap(),
627            Command::FocusWindowTop { swap: false }
628        );
629    }
630
631    #[test]
632    fn build_focus_window_dir_without_parameter() {
633        assert_eq!(
634            build_focus_window_dir::<MockHandle>("").unwrap(),
635            Command::FocusWindowAt(FocusDirection::North)
636        );
637    }
638
639    #[test]
640    fn build_move_window_dir_without_parameter() {
641        assert_eq!(
642            build_move_window_dir::<MockHandle>("").unwrap(),
643            Command::MoveWindowAt(FocusDirection::North)
644        );
645    }
646
647    #[test]
648    fn build_move_window_to_next_tag_without_parameter() {
649        assert_eq!(
650            build_move_window_to_next_tag::<MockHandle>("").unwrap(),
651            Command::MoveWindowToNextTag { follow: true }
652        );
653    }
654
655    #[test]
656    fn build_move_window_to_previous_tag_without_parameter() {
657        assert_eq!(
658            build_move_window_to_previous_tag::<MockHandle>("").unwrap(),
659            Command::MoveWindowToPreviousTag { follow: true }
660        );
661    }
662
663    #[test]
664    fn build_focus_next_tag_without_parameter() {
665        assert_eq!(
666            build_focus_next_tag::<MockHandle>("").unwrap(),
667            Command::FocusNextTag {
668                behavior: command::FocusDeltaBehavior::Default
669            }
670        );
671    }
672
673    #[test]
674    fn build_focus_previous_tag_without_parameter() {
675        assert_eq!(
676            build_focus_previous_tag::<MockHandle>("").unwrap(),
677            Command::FocusPreviousTag {
678                behavior: command::FocusDeltaBehavior::Default
679            }
680        );
681    }
682
683    #[test]
684    fn build_focus_next_tag_with_invalid() {
685        assert_eq!(
686            build_focus_next_tag::<MockHandle>("gurke")
687                .unwrap_err()
688                .to_string(),
689            (InvalidFocusDeltaBehaviorError {
690                attempted_value: String::from("gurke"),
691                command: Command::<MockHandle>::FocusNextTag {
692                    behavior: command::FocusDeltaBehavior::Default,
693                }
694            })
695            .to_string()
696        );
697    }
698
699    #[test]
700    fn build_focus_previous_tag_with_invalid() {
701        assert_eq!(
702            build_focus_previous_tag::<MockHandle>("gurke")
703                .unwrap_err()
704                .to_string(),
705            (InvalidFocusDeltaBehaviorError {
706                attempted_value: String::from("gurke"),
707                command: Command::<MockHandle>::FocusPreviousTag {
708                    behavior: command::FocusDeltaBehavior::Default,
709                }
710            })
711            .to_string()
712        );
713    }
714}