1use 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#[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 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 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 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 "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 "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 "DecreaseMainWidth" | "DecreaseMainSize" => build_decrease_main_size(rest), "IncreaseMainWidth" | "IncreaseMainSize" => build_increase_main_size(rest), "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 "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 "FloatingToTile" => Ok(Command::FloatingToTile),
175 "TileToFloating" => Ok(Command::TileToFloating),
176 "ToggleFloating" => Ok(Command::ToggleFloating),
177 "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 "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 {
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 {
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 {
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 time::sleep(time::Duration::from_millis(100)).await;
579
580 {
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}