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