Skip to main content

wdotool/
lib.rs

1//! Library half of the `wdotool` binary. The thin `main.rs` wires
2//! argv → `cli::Cli` → `dispatch`, but everything testable lives here so
3//! integration tests in `tests/` can drive `dispatch` against a mock
4//! backend without spawning a subprocess.
5//!
6//! The two public entry points worth knowing:
7//!
8//! - [`dispatch`] runs a parsed [`Command`] against an arbitrary
9//!   [`Backend`], writing all human-readable output to the writers in
10//!   [`DispatchCtx`]. It returns an [`ExitCode`] — the binary translates
11//!   non-zero into `process::exit`, tests just assert on it.
12//! - [`SearchFilters`] is exposed so the existing search unit tests can
13//!   keep their friendly module-private feel without re-deriving the
14//!   filter logic.
15
16pub mod cli;
17pub mod diag;
18#[cfg(feature = "recorder")]
19pub mod record;
20#[cfg(feature = "recorder")]
21pub mod replay;
22
23use std::io::Write;
24use std::time::Duration;
25
26use regex::Regex;
27use tracing_subscriber::EnvFilter;
28
29use wdotool_core::detector::Environment;
30use wdotool_core::keysym;
31use wdotool_core::{
32    Backend, KeyDirection, MouseButton, Result, WdoError, WindowGeometry, WindowId, WindowInfo,
33};
34
35pub use cli::{Cli, Command};
36
37/// Output sinks + environment passed to [`dispatch`]. The binary fills
38/// these with `io::stdout()` / `io::stderr()`; tests fill them with
39/// `Vec<u8>` so they can assert on captured output.
40pub struct DispatchCtx<'a> {
41    pub backend: &'a dyn Backend,
42    pub env: &'a Environment,
43    pub stdout: &'a mut dyn Write,
44    pub stderr: &'a mut dyn Write,
45}
46
47/// Wraps a process exit status. `0` is success; non-zero is reserved
48/// for the xdotool-compatible "no match / unsupported on this backend"
49/// signals (search with no results, getmouselocation on a send-only
50/// backend, getwindow* with a missing field). `dispatch` returns this
51/// instead of calling `process::exit` directly so tests can run inside
52/// the same process.
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54#[must_use]
55pub struct ExitCode(pub i32);
56
57impl ExitCode {
58    pub const SUCCESS: ExitCode = ExitCode(0);
59    pub const FAILURE: ExitCode = ExitCode(1);
60
61    pub fn is_success(self) -> bool {
62        self.0 == 0
63    }
64}
65
66/// Run a parsed command against a backend. Returns a structured exit
67/// code instead of terminating the process; the binary calls
68/// `process::exit` on non-zero, tests assert directly.
69pub async fn dispatch(ctx: &mut DispatchCtx<'_>, cmd: Command) -> Result<ExitCode> {
70    match cmd {
71        Command::Capabilities => {
72            let value = wdotool_core::capabilities::report_json(ctx.env, ctx.backend);
73            let pretty = serde_json::to_string_pretty(&value)
74                .map_err(|e| WdoError::InvalidArg(format!("capabilities serialization: {e}")))?;
75            writeln!(ctx.stdout, "{pretty}").map_err(io_err)?;
76        }
77        Command::Info => {
78            let caps = ctx.backend.capabilities();
79            writeln!(ctx.stdout, "backend:  {}", ctx.backend.name()).map_err(io_err)?;
80            writeln!(ctx.stdout, "desktop:  {:?}", ctx.env.desktop).map_err(io_err)?;
81            writeln!(ctx.stdout, "session:  {:?}", ctx.env.session_type).map_err(io_err)?;
82            writeln!(ctx.stdout, "display:  {:?}", ctx.env.wayland_display).map_err(io_err)?;
83            writeln!(ctx.stdout, "hints:    {:?}", ctx.env.compositor_hints).map_err(io_err)?;
84            writeln!(ctx.stdout, "wayland:  {}", ctx.env.is_wayland()).map_err(io_err)?;
85            writeln!(ctx.stdout, "capabilities:").map_err(io_err)?;
86            writeln!(ctx.stdout, "  key_input:             {}", caps.key_input).map_err(io_err)?;
87            writeln!(ctx.stdout, "  text_input:            {}", caps.text_input).map_err(io_err)?;
88            writeln!(
89                ctx.stdout,
90                "  pointer_move_absolute: {}",
91                caps.pointer_move_absolute
92            )
93            .map_err(io_err)?;
94            writeln!(
95                ctx.stdout,
96                "  pointer_move_relative: {}",
97                caps.pointer_move_relative
98            )
99            .map_err(io_err)?;
100            writeln!(
101                ctx.stdout,
102                "  pointer_button:        {}",
103                caps.pointer_button
104            )
105            .map_err(io_err)?;
106            writeln!(ctx.stdout, "  scroll:                {}", caps.scroll).map_err(io_err)?;
107            writeln!(ctx.stdout, "  list_windows:          {}", caps.list_windows)
108                .map_err(io_err)?;
109            writeln!(
110                ctx.stdout,
111                "  active_window:         {}",
112                caps.active_window
113            )
114            .map_err(io_err)?;
115            writeln!(
116                ctx.stdout,
117                "  activate_window:       {}",
118                caps.activate_window
119            )
120            .map_err(io_err)?;
121            writeln!(ctx.stdout, "  close_window:          {}", caps.close_window)
122                .map_err(io_err)?;
123            writeln!(
124                ctx.stdout,
125                "  pointer_position:      {}",
126                caps.pointer_position
127            )
128            .map_err(io_err)?;
129            writeln!(ctx.stdout, "  list_outputs:          {}", caps.list_outputs)
130                .map_err(io_err)?;
131            writeln!(
132                ctx.stdout,
133                "  window_geometry:       {}",
134                caps.window_geometry
135            )
136            .map_err(io_err)?;
137        }
138        Command::Key {
139            clearmodifiers,
140            chain,
141        } => {
142            if clearmodifiers {
143                clear_modifiers(ctx.backend).await;
144            }
145            run_key(ctx.backend, &chain, KeyDirection::PressRelease).await?;
146        }
147        Command::Keydown {
148            clearmodifiers,
149            chain,
150        } => {
151            if clearmodifiers {
152                clear_modifiers(ctx.backend).await;
153            }
154            run_key(ctx.backend, &chain, KeyDirection::Press).await?;
155        }
156        Command::Keyup {
157            clearmodifiers,
158            chain,
159        } => {
160            if clearmodifiers {
161                clear_modifiers(ctx.backend).await;
162            }
163            run_key(ctx.backend, &chain, KeyDirection::Release).await?;
164        }
165        Command::Type {
166            delay,
167            file,
168            clearmodifiers,
169            text,
170        } => {
171            let resolved = resolve_type_input(file, text)?;
172            if clearmodifiers {
173                clear_modifiers(ctx.backend).await;
174            }
175            ctx.backend
176                .type_text(&resolved, Duration::from_millis(delay))
177                .await?;
178        }
179        Command::Mousemove {
180            relative,
181            output,
182            x,
183            y,
184        } => {
185            // clap already rejects --output combined with --relative,
186            // so the two arms here are mutually exclusive. The
187            // --output path delegates to the trait's
188            // mouse_move_to_output method, which has a default impl
189            // that translates output-local coords to global; the
190            // wlr-protocols backend overrides that default to bind a
191            // per-output virtual_pointer (fixes #22).
192            match output {
193                Some(name) => ctx.backend.mouse_move_to_output(&name, x, y).await?,
194                None => ctx.backend.mouse_move(x, y, !relative).await?,
195            }
196        }
197        Command::Click { button } => {
198            ctx.backend
199                .mouse_button(MouseButton::from_index(button), KeyDirection::PressRelease)
200                .await?;
201        }
202        Command::Mousedown { button } => {
203            ctx.backend
204                .mouse_button(MouseButton::from_index(button), KeyDirection::Press)
205                .await?;
206        }
207        Command::Mouseup { button } => {
208            ctx.backend
209                .mouse_button(MouseButton::from_index(button), KeyDirection::Release)
210                .await?;
211        }
212        Command::Scroll { dx, dy } => {
213            ctx.backend.scroll(dx, dy).await?;
214        }
215        Command::Search {
216            name,
217            class,
218            pid,
219            regex,
220            ignore_case,
221            any,
222            all: _,
223        } => {
224            let windows = ctx.backend.list_windows().await?;
225            let filters = SearchFilters::compile(SearchFlags {
226                name: name.as_deref(),
227                class: class.as_deref(),
228                pid,
229                regex,
230                ignore_case,
231                any,
232            })?;
233            let mut matched = false;
234            for w in windows.iter().filter(|w| filters.matches(w)) {
235                writeln!(ctx.stdout, "{}\t{}", w.id, w.title).map_err(io_err)?;
236                matched = true;
237            }
238            // xdotool exits 1 when nothing matched; preserve that for
239            // shell scripts that branch on `if wdotool search ...`.
240            if !matched {
241                return Ok(ExitCode::FAILURE);
242            }
243        }
244        Command::Getactivewindow => match ctx.backend.active_window().await? {
245            Some(w) => writeln!(ctx.stdout, "{}", w.id).map_err(io_err)?,
246            None => return Err(WdoError::WindowNotFound("active".into())),
247        },
248        Command::Outputs { json } => {
249            let outputs = ctx.backend.list_outputs().await?;
250            if json {
251                let value = serde_json::to_value(
252                    outputs
253                        .iter()
254                        .map(|o| {
255                            serde_json::json!({
256                                "name": o.name,
257                                "x": o.x,
258                                "y": o.y,
259                                "width": o.width,
260                                "height": o.height,
261                                "scale": o.scale,
262                            })
263                        })
264                        .collect::<Vec<_>>(),
265                )
266                .map_err(|e| WdoError::InvalidArg(format!("outputs serialization: {e}")))?;
267                let pretty = serde_json::to_string_pretty(&value)
268                    .map_err(|e| WdoError::InvalidArg(format!("outputs serialization: {e}")))?;
269                writeln!(ctx.stdout, "{pretty}").map_err(io_err)?;
270            } else {
271                writeln!(ctx.stdout, "name\tx\ty\twidth\theight\tscale").map_err(io_err)?;
272                for o in &outputs {
273                    writeln!(
274                        ctx.stdout,
275                        "{}\t{}\t{}\t{}\t{}\t{}",
276                        o.name, o.x, o.y, o.width, o.height, o.scale
277                    )
278                    .map_err(io_err)?;
279                }
280            }
281        }
282        Command::Getmouselocation => match ctx.backend.pointer_position().await? {
283            Some((x, y)) => writeln!(ctx.stdout, "x:{x} y:{y}").map_err(io_err)?,
284            None => {
285                writeln!(
286                    ctx.stderr,
287                    "wdotool: pointer position is unreadable on the {} backend (Wayland \
288                     virtual-pointer protocols are send-only). Use the kde or gnome backend, \
289                     or your compositor's IPC (hyprctl cursorpos, swaymsg get_seats).",
290                    ctx.backend.name()
291                )
292                .map_err(io_err)?;
293                return Ok(ExitCode::FAILURE);
294            }
295        },
296        Command::Windowactivate { id } => ctx.backend.activate_window(&WindowId(id)).await?,
297        Command::Windowclose { id } => ctx.backend.close_window(&WindowId(id)).await?,
298        Command::Getwindowname { id } => {
299            let w = find_window(ctx.backend, &id).await?;
300            writeln!(ctx.stdout, "{}", w.title).map_err(io_err)?;
301        }
302        Command::Getwindowpid { id } => {
303            let w = find_window(ctx.backend, &id).await?;
304            match w.pid {
305                Some(pid) => writeln!(ctx.stdout, "{pid}").map_err(io_err)?,
306                None => {
307                    writeln!(ctx.stderr, "wdotool: pid not available for window {id}")
308                        .map_err(io_err)?;
309                    return Ok(ExitCode::FAILURE);
310                }
311            }
312        }
313        Command::Getwindowclassname { id } => {
314            let w = find_window(ctx.backend, &id).await?;
315            match w.app_id {
316                Some(app_id) => writeln!(ctx.stdout, "{app_id}").map_err(io_err)?,
317                None => {
318                    writeln!(
319                        ctx.stderr,
320                        "wdotool: classname (app_id) not available for window {id}"
321                    )
322                    .map_err(io_err)?;
323                    return Ok(ExitCode::FAILURE);
324                }
325            }
326        }
327        Command::Getwindowgeometry { id } => {
328            // Trait contract:
329            //   Ok(Some(geom)) -> backend supports it, found, here it is
330            //   Err(WindowNotFound) -> backend supports it, but no
331            //                          window with that id
332            //   Ok(None) -> backend doesn't support reading geometry
333            // The error path bubbles via `?` so we only handle
334            // Ok(Some) and Ok(None) explicitly here.
335            match ctx.backend.window_geometry(&WindowId(id.clone())).await? {
336                Some(WindowGeometry {
337                    x,
338                    y,
339                    width,
340                    height,
341                }) => {
342                    // Match xdotool's default format. The "screen"
343                    // line xdotool prints doesn't translate to Wayland
344                    // (compositors don't expose a stable screen index
345                    // that's meaningful to clients), so we drop it.
346                    writeln!(ctx.stdout, "Window {id}").map_err(io_err)?;
347                    writeln!(ctx.stdout, "  Position: {x},{y}").map_err(io_err)?;
348                    writeln!(ctx.stdout, "  Geometry: {width}x{height}").map_err(io_err)?;
349                }
350                None => {
351                    writeln!(
352                        ctx.stderr,
353                        "wdotool: window geometry is unreadable on the {} backend (no Wayland \
354                         protocol exposes window geometry to other clients). Use the kde or \
355                         gnome backend.",
356                        ctx.backend.name()
357                    )
358                    .map_err(io_err)?;
359                    return Ok(ExitCode::FAILURE);
360                }
361            }
362        }
363        Command::Diag { .. } => {
364            // Handled in main() before dispatch is called so diag never
365            // bootstraps a backend.
366            unreachable!("Diag short-circuits before dispatch");
367        }
368        #[cfg(feature = "recorder")]
369        Command::Record { .. } => {
370            // Same as Diag: handled in main() before dispatch so the
371            // recorder owns its own portal session.
372            unreachable!("Record short-circuits before dispatch");
373        }
374        Command::Prime => {
375            // Same pattern: prime needs to hold the backend alive in
376            // the foreground until a signal, so main() bypasses
377            // dispatch and runs its own loop.
378            unreachable!("Prime short-circuits before dispatch");
379        }
380        #[cfg(feature = "recorder")]
381        Command::Replay { file, speed } => {
382            replay::run(ctx.backend, &file, speed).await?;
383        }
384    }
385    Ok(ExitCode::SUCCESS)
386}
387
388fn io_err(e: std::io::Error) -> WdoError {
389    // dispatch's writers are real stdout/stderr in production and an
390    // in-memory Vec<u8> in tests; the only thing that can realistically
391    // fail is a closed pipe (`wdotool foo | head`), so map to InvalidArg
392    // to keep the error type honest without inventing a new variant.
393    WdoError::InvalidArg(format!("write failed: {e}"))
394}
395
396/// Look up a window by its id string. Used by the `getwindow*` commands
397/// which all need to resolve an id to a `WindowInfo` before reading a
398/// single field. Returns `WindowNotFound` if no window in the current
399/// list has that id, which xdotool also signals via non-zero exit.
400async fn find_window(backend: &dyn Backend, id: &str) -> Result<WindowInfo> {
401    let windows = backend.list_windows().await?;
402    windows
403        .into_iter()
404        .find(|w| w.id.0 == id)
405        .ok_or_else(|| WdoError::WindowNotFound(id.to_string()))
406}
407
408/// Approximates xdotool's --clearmodifiers. Wayland doesn't let a normal
409/// client query the compositor's current modifier state, so we can't do the
410/// "save + restore" dance xdotool does. Best effort: release every standard
411/// modifier unconditionally, ignoring backend errors per-key (a modifier
412/// that isn't in the keymap is a no-op, not a user-visible failure).
413async fn clear_modifiers(backend: &dyn Backend) {
414    const STANDARD_MODIFIERS: &[&str] = &[
415        "Control_L",
416        "Control_R",
417        "Shift_L",
418        "Shift_R",
419        "Alt_L",
420        "Alt_R",
421        "Super_L",
422        "Super_R",
423        "ISO_Level3_Shift",
424    ];
425    for sym in STANDARD_MODIFIERS {
426        let _ = backend.key(sym, KeyDirection::Release).await;
427    }
428}
429
430/// Resolve the text to type: from --file (path or `-` for stdin) or the
431/// positional argument. clap enforces mutual exclusion; this function just
432/// dispatches and errors if neither source is present.
433fn resolve_type_input(file: Option<String>, text: Option<String>) -> Result<String> {
434    use std::io::Read;
435    match (file, text) {
436        (Some(path), _) => {
437            if path == "-" {
438                let mut buf = String::new();
439                std::io::stdin()
440                    .read_to_string(&mut buf)
441                    .map_err(|e| WdoError::InvalidArg(format!("failed to read stdin: {e}")))?;
442                Ok(buf)
443            } else {
444                std::fs::read_to_string(&path)
445                    .map_err(|e| WdoError::InvalidArg(format!("failed to read {path}: {e}")))
446            }
447        }
448        (None, Some(t)) => Ok(t),
449        (None, None) => Err(WdoError::InvalidArg(
450            "type requires either --file <path> or a positional text argument".into(),
451        )),
452    }
453}
454
455// Press modifiers, then the key, then release in reverse — matches xdotool
456// ordering so scripts relying on this behaviour continue to work.
457pub(crate) async fn run_key(backend: &dyn Backend, chain: &str, dir: KeyDirection) -> Result<()> {
458    let parsed = keysym::parse_chain(chain)?;
459    match dir {
460        KeyDirection::Press => {
461            for m in &parsed.modifiers {
462                backend.key(m, KeyDirection::Press).await?;
463            }
464            backend.key(&parsed.key, KeyDirection::Press).await?;
465        }
466        KeyDirection::Release => {
467            backend.key(&parsed.key, KeyDirection::Release).await?;
468            for m in parsed.modifiers.iter().rev() {
469                backend.key(m, KeyDirection::Release).await?;
470            }
471        }
472        KeyDirection::PressRelease => {
473            for m in &parsed.modifiers {
474                backend.key(m, KeyDirection::Press).await?;
475            }
476            backend.key(&parsed.key, KeyDirection::PressRelease).await?;
477            for m in parsed.modifiers.iter().rev() {
478                backend.key(m, KeyDirection::Release).await?;
479            }
480        }
481    }
482    Ok(())
483}
484
485pub fn init_tracing(verbose: bool) {
486    let default = if verbose {
487        "wdotool=debug"
488    } else {
489        "wdotool=info,warn"
490    };
491    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
492    let _ = tracing_subscriber::fmt()
493        .with_env_filter(filter)
494        .with_target(false)
495        .with_writer(std::io::stderr)
496        .try_init();
497}
498
499/// Inputs to [`SearchFilters::compile`]; mirrors the CLI flags so the
500/// filter compilation is testable without round-tripping through clap.
501pub struct SearchFlags<'a> {
502    pub name: Option<&'a str>,
503    pub class: Option<&'a str>,
504    pub pid: Option<u32>,
505    pub regex: bool,
506    pub ignore_case: bool,
507    pub any: bool,
508}
509
510/// Compiled search predicates. Built once per `wdotool search` call and
511/// then applied to each window. Holding the regex(es) here avoids
512/// recompiling per-window.
513pub struct SearchFilters {
514    name: Option<Regex>,
515    class: Option<Regex>,
516    pid: Option<u32>,
517    /// True when `--any` was passed: matching switches from AND to OR.
518    any: bool,
519}
520
521impl SearchFilters {
522    pub fn compile(flags: SearchFlags<'_>) -> Result<Self> {
523        let make_regex = |pat: &str, field: &'static str| -> Result<Regex> {
524            // Without --regex, escape so substring patterns are
525            // taken literally. With --ignore-case, prefix with the
526            // (?i) inline flag; works in both modes uniformly.
527            let body = if flags.regex {
528                pat.to_string()
529            } else {
530                regex::escape(pat)
531            };
532            let full = if flags.ignore_case {
533                format!("(?i){body}")
534            } else {
535                body
536            };
537            Regex::new(&full)
538                .map_err(|e| WdoError::InvalidArg(format!("invalid {field} pattern {pat:?}: {e}")))
539        };
540        Ok(Self {
541            name: flags.name.map(|p| make_regex(p, "--name")).transpose()?,
542            class: flags.class.map(|p| make_regex(p, "--class")).transpose()?,
543            pid: flags.pid,
544            any: flags.any,
545        })
546    }
547
548    pub fn matches(&self, w: &WindowInfo) -> bool {
549        if self.any {
550            self.matches_any(w)
551        } else {
552            self.matches_all(w)
553        }
554    }
555
556    /// AND semantics (default): every set filter must match.
557    fn matches_all(&self, w: &WindowInfo) -> bool {
558        if let Some(re) = &self.name {
559            if !re.is_match(&w.title) {
560                return false;
561            }
562        }
563        if let Some(re) = &self.class {
564            // app_id is the Wayland equivalent of WM_CLASS. Backends
565            // that don't expose it (uinput, bare libei) can't match
566            // here at all, which is correct.
567            match w.app_id.as_deref() {
568                Some(a) if re.is_match(a) => {}
569                _ => return false,
570            }
571        }
572        if let Some(p) = self.pid {
573            if w.pid != Some(p) {
574                return false;
575            }
576        }
577        true
578    }
579
580    /// OR semantics (`--any`): at least one set filter must match.
581    /// With zero set filters, falls back to "match everything" so that
582    /// `wdotool search --any` (no filters) lists all windows, same as
583    /// `wdotool search` does.
584    fn matches_any(&self, w: &WindowInfo) -> bool {
585        let any_set = self.name.is_some() || self.class.is_some() || self.pid.is_some();
586        if !any_set {
587            return true;
588        }
589        if let Some(re) = &self.name {
590            if re.is_match(&w.title) {
591                return true;
592            }
593        }
594        if let Some(re) = &self.class {
595            if let Some(a) = w.app_id.as_deref() {
596                if re.is_match(a) {
597                    return true;
598                }
599            }
600        }
601        if let Some(p) = self.pid {
602            if w.pid == Some(p) {
603                return true;
604            }
605        }
606        false
607    }
608}
609
610#[cfg(test)]
611mod search_tests {
612    use super::*;
613    use wdotool_core::WindowId;
614
615    fn win(id: &str, title: &str, app_id: Option<&str>, pid: Option<u32>) -> WindowInfo {
616        WindowInfo {
617            id: WindowId(id.into()),
618            title: title.into(),
619            app_id: app_id.map(str::to_string),
620            pid,
621        }
622    }
623
624    fn flags<'a>(
625        name: Option<&'a str>,
626        class: Option<&'a str>,
627        pid: Option<u32>,
628    ) -> SearchFlags<'a> {
629        SearchFlags {
630            name,
631            class,
632            pid,
633            regex: false,
634            ignore_case: false,
635            any: false,
636        }
637    }
638
639    #[test]
640    fn substring_name_match_is_default() {
641        let f = SearchFilters::compile(flags(Some("fox"), None, None)).unwrap();
642        assert!(f.matches(&win("1", "Firefox", None, None)));
643        assert!(!f.matches(&win("2", "Chromium", None, None)));
644    }
645
646    #[test]
647    fn dot_in_pattern_is_escaped_without_regex_flag() {
648        // With --regex off, `Fire.fox` only matches the literal string,
649        // not `Fire?fox` (which a regex would match).
650        let f = SearchFilters::compile(flags(Some("Fire.fox"), None, None)).unwrap();
651        assert!(!f.matches(&win("1", "Firefox", None, None)));
652        assert!(f.matches(&win("2", "Fire.fox Browser", None, None)));
653    }
654
655    #[test]
656    fn regex_flag_enables_pattern_semantics() {
657        let f = SearchFilters::compile(SearchFlags {
658            name: Some("Fire.*x"),
659            class: None,
660            pid: None,
661            regex: true,
662            ignore_case: false,
663            any: false,
664        })
665        .unwrap();
666        assert!(f.matches(&win("1", "Firefox", None, None)));
667        assert!(!f.matches(&win("2", "Chromium", None, None)));
668    }
669
670    #[test]
671    fn ignore_case_works_in_substring_mode() {
672        let f = SearchFilters::compile(SearchFlags {
673            name: Some("FIREFOX"),
674            class: None,
675            pid: None,
676            regex: false,
677            ignore_case: true,
678            any: false,
679        })
680        .unwrap();
681        assert!(f.matches(&win("1", "Mozilla Firefox", None, None)));
682    }
683
684    #[test]
685    fn ignore_case_works_in_regex_mode() {
686        let f = SearchFilters::compile(SearchFlags {
687            name: Some("FIRE.*X"),
688            class: None,
689            pid: None,
690            regex: true,
691            ignore_case: true,
692            any: false,
693        })
694        .unwrap();
695        assert!(f.matches(&win("1", "Mozilla Firefox", None, None)));
696    }
697
698    #[test]
699    fn class_filter_matches_app_id() {
700        let f = SearchFilters::compile(flags(None, Some("firefox"), None)).unwrap();
701        assert!(f.matches(&win("1", "Some Page", Some("org.mozilla.firefox"), None)));
702        assert!(!f.matches(&win("2", "kitty", Some("kitty"), None)));
703    }
704
705    #[test]
706    fn class_filter_skips_windows_without_app_id() {
707        let f = SearchFilters::compile(flags(None, Some("anything"), None)).unwrap();
708        // app_id None means the backend doesn't expose it (uinput,
709        // bare libei). Such windows can never satisfy a class filter.
710        assert!(!f.matches(&win("1", "Some Page", None, None)));
711    }
712
713    #[test]
714    fn pid_filter_requires_exact_match() {
715        let f = SearchFilters::compile(flags(None, None, Some(1234))).unwrap();
716        assert!(f.matches(&win("1", "Firefox", None, Some(1234))));
717        assert!(!f.matches(&win("2", "Firefox", None, Some(5678))));
718        // Backends that don't populate pid never match a pid filter.
719        assert!(!f.matches(&win("3", "Firefox", None, None)));
720    }
721
722    #[test]
723    fn filters_are_anded_together() {
724        let f =
725            SearchFilters::compile(flags(Some("Firefox"), Some("mozilla"), Some(1234))).unwrap();
726        assert!(f.matches(&win(
727            "1",
728            "Firefox - Wikipedia",
729            Some("org.mozilla.firefox"),
730            Some(1234)
731        )));
732        // Right title + class but wrong pid: rejected.
733        assert!(!f.matches(&win(
734            "2",
735            "Firefox - Wikipedia",
736            Some("org.mozilla.firefox"),
737            Some(5678)
738        )));
739    }
740
741    #[test]
742    fn no_filters_matches_everything() {
743        let f = SearchFilters::compile(flags(None, None, None)).unwrap();
744        assert!(f.matches(&win("1", "Anything", None, None)));
745        assert!(f.matches(&win("2", "Else", Some("kitty"), Some(99))));
746    }
747
748    fn flags_any<'a>(
749        name: Option<&'a str>,
750        class: Option<&'a str>,
751        pid: Option<u32>,
752    ) -> SearchFlags<'a> {
753        SearchFlags {
754            name,
755            class,
756            pid,
757            regex: false,
758            ignore_case: false,
759            any: true,
760        }
761    }
762
763    #[test]
764    fn any_matches_when_only_name_matches() {
765        let f = SearchFilters::compile(flags_any(Some("Firefox"), Some("nope"), Some(99))).unwrap();
766        // Title matches, class and pid don't. With AND this would be
767        // rejected; with --any, name alone is enough.
768        assert!(f.matches(&win(
769            "1",
770            "Firefox - Wikipedia",
771            Some("org.mozilla.firefox"),
772            Some(1234)
773        )));
774    }
775
776    #[test]
777    fn any_matches_when_only_class_matches() {
778        let f = SearchFilters::compile(flags_any(Some("nope"), Some("firefox"), Some(99))).unwrap();
779        assert!(f.matches(&win(
780            "1",
781            "Wikipedia",
782            Some("org.mozilla.firefox"),
783            Some(1234)
784        )));
785    }
786
787    #[test]
788    fn any_matches_when_only_pid_matches() {
789        let f = SearchFilters::compile(flags_any(Some("nope"), Some("nope"), Some(1234))).unwrap();
790        assert!(f.matches(&win(
791            "1",
792            "Wikipedia",
793            Some("org.mozilla.firefox"),
794            Some(1234)
795        )));
796    }
797
798    #[test]
799    fn any_rejects_when_no_filter_matches() {
800        let f = SearchFilters::compile(flags_any(Some("nope"), Some("nope"), Some(99))).unwrap();
801        assert!(!f.matches(&win(
802            "1",
803            "Wikipedia",
804            Some("org.mozilla.firefox"),
805            Some(1234)
806        )));
807    }
808
809    #[test]
810    fn any_with_no_filters_matches_everything() {
811        // Same fall-through as default: zero filters lists all windows
812        // regardless of which combinator was chosen.
813        let f = SearchFilters::compile(flags_any(None, None, None)).unwrap();
814        assert!(f.matches(&win("1", "Anything", None, None)));
815        assert!(f.matches(&win("2", "Else", Some("kitty"), Some(99))));
816    }
817
818    #[test]
819    fn any_with_class_filter_skips_window_without_app_id() {
820        // app_id None can't satisfy a class regex; with --any and only
821        // a class filter set, that means the window doesn't match.
822        let f = SearchFilters::compile(flags_any(None, Some("anything"), None)).unwrap();
823        assert!(!f.matches(&win("1", "Some Page", None, None)));
824    }
825
826    #[test]
827    fn invalid_regex_pattern_returns_invalid_arg() {
828        let result = SearchFilters::compile(SearchFlags {
829            name: Some("[unclosed"),
830            class: None,
831            pid: None,
832            regex: true,
833            ignore_case: false,
834            any: false,
835        });
836        match result {
837            Err(WdoError::InvalidArg(msg)) => assert!(msg.contains("--name")),
838            Err(other) => panic!("expected InvalidArg, got {other:?}"),
839            Ok(_) => panic!("expected InvalidArg, got Ok"),
840        }
841    }
842}