Skip to main content

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