Skip to main content

zsh/extensions/
intercepts.rs

1//! Command intercept / advice machinery — extension; no zsh C counterpart.
2#[allow(unused_imports)]
3use crate::ported::vm_helper::ShellExecutor;
4#[allow(unused_imports)]
5use std::collections::HashMap;
6
7/// AOP advice type — before, after, or around.
8#[derive(Debug, Clone)]
9/// Aspect-oriented advice classification.
10/// zshrs-original — no C zsh counterpart. C zsh's closest
11/// analog is the function-wrapper hook in Src/module.c
12/// (`addwrapper()`, used by `zsh/zprof`), but per-function
13/// before/after/around AOP intercepts are unique to zshrs.
14pub enum AdviceKind {
15    /// Run code before the command executes.
16    Before,
17    /// Run code after the command executes. $? and INTERCEPT_MS available.
18    After,
19    /// Wrap the command. Code must call `intercept_proceed` to run original.
20    Around,
21}
22
23/// An intercept registration.
24#[derive(Debug, Clone)]
25/// One AOP intercept registered against a function pattern.
26/// zshrs-original — no C counterpart.
27pub struct Intercept {
28    /// Pattern to match command names. Supports glob: "git *", "_*", "*".
29    pub pattern: String,
30    /// What kind of advice.
31    pub kind: AdviceKind,
32    /// Shell code to execute as advice.
33    pub code: String,
34    /// Unique ID for removal.
35    pub id: u32,
36}
37
38// ===========================================================
39// Block-body syntax — `intercept <kind> <pat> { code }`
40//
41// zshrs-only; no C counterpart. `}` cannot be a bare argument in
42// zsh (`echo }` is "parse error near `}'"), so the documented brace
43// form is not reachable through ordinary word lexing. Worse, the
44// body's own operators would be eaten by the OUTER command: in
45// `intercept before git { echo hi >> ~/git.log }` the `>>` lexes as
46// a redirection of `intercept` itself and never reaches argv, so no
47// amount of re-joining the words downstream can rebuild the body.
48//
49// The lexer therefore captures the span between the braces as RAW
50// SOURCE and hands it over as a single STRING token, which is what
51// `builtin_intercept` wants anyway — it stores advice as text and
52// runs it through `execute_advice`. The parser and the grammar are
53// untouched: `intercept before git { … }` becomes an ordinary
54// four-word simple command.
55// ===========================================================
56
57thread_local! {
58    /// Raised when the command word just lexed was `intercept`, so the
59    /// next `{` in ARGUMENT position opens an advice body rather than
60    /// starting a brace expansion. Cleared by the next command word, and
61    /// by the capture itself.
62    static LEX_ININTERCEPT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
63}
64
65/// Note a token the lexer produced at command position, arming or
66/// disarming the block-body capture.
67///
68/// Assignment, not a one-way set: every command word re-decides, so an
69/// `intercept` that never reached a `{` cannot leave the capture armed
70/// for an unrelated later command.
71///
72/// `quoted` is true when the original token carried Snull/Dnull/Bnull
73/// quote markers — `'intercept' before git { … }` is a quoted word and
74/// gets no keyword treatment, the same rule the reserved-word table
75/// follows for `"if"` / `"}"` (lex.rs:3953).
76pub(crate) fn note_command_word(word: &str, quoted: bool) {
77    LEX_ININTERCEPT.set(word == "intercept" && !quoted);
78}
79
80/// True when the `{` the lexer is looking at opens an advice body.
81///
82/// False under `zshrs --zsh`: that mode promises identical behaviour to
83/// `/bin/zsh`, which rejects the construct outright, so the capture must
84/// stand down and let the normal path produce zsh's own diagnostic.
85///
86/// False whenever the lexer runs with any `lexflags` set. Only the
87/// execution parser lexes with `lexflags == 0`; every non-zero mode is a
88/// word splitter that must see the line as zsh's lexer does — `{`, then
89/// each body word, then `}`:
90///   * `LEXFLAGS_ZLE` — get_comp_string (c:Src/Zle/zle_tricky.c:1170) and
91///     the highlighter. Capturing there swallowed an unclosed body to end
92///     of input and returned LEXERR, so `intercept before ls { ech<TAB>`
93///     lost every word from `{` on; `_intercept` steps past the brace with
94///     `compset -N '{'` instead.
95///   * `LEXFLAGS_ACTIVE` plus the comment/newline modes — bufferwords for
96///     the `(z)` / `(Z:…:)` flags (c:Src/hist.c:3406). Capturing there
97///     returned the whole body as one quoted word.
98pub(crate) fn wants_block() -> bool {
99    use crate::ported::lex::LEX_LEXFLAGS;
100    LEX_ININTERCEPT.get()
101        && LEX_LEXFLAGS.get() == 0
102        && !crate::dash_mode::zsh_dropin()
103}
104
105/// Clear the armed state. Called once a body is captured, and whenever
106/// the lexer resets its context.
107pub(crate) fn disarm() {
108    LEX_ININTERCEPT.set(false);
109}
110
111/// Consume the raw source of an advice body, starting just past its
112/// opening `{`, and return the text between the braces.
113///
114/// `getc` is the lexer's own character source, so the body is taken off
115/// the real input stream — a body may span lines, and on the interactive
116/// REPL the continuation prompt appears exactly as it does inside `{ }`
117/// anywhere else.
118///
119/// Brace counting alone would be wrong: a `}` inside quotes, a comment, a
120/// command substitution, or a here-document is not a terminator. Each of
121/// those is tracked so the scan ends on the brace a reader would pick.
122///
123/// Returns `None` at end of input with the body still open. The consumed
124/// characters are NOT pushed back — there is nothing left to push them
125/// back in front of — and the caller falls through to the ordinary path,
126/// where the unterminated construct raises the usual parse error.
127pub(crate) fn scan_block_body<G>(mut getc: G) -> Option<String>
128where
129    G: FnMut() -> Option<char>,
130{
131    let mut body = String::new();
132    let mut depth: u32 = 1;
133    // Delimiters of here-documents opened on the line being scanned. A
134    // `<<EOF` body is arbitrary text — its braces, quotes and `#` are all
135    // literal — so it is skipped wholesale at the next newline.
136    let mut pending_heredocs: Vec<(String, bool)> = Vec::new();
137    // True while the scanner is between words, where `#` starts a comment.
138    let mut at_word_start = true;
139
140    loop {
141        let c = getc()?;
142
143        match c {
144            '\\' => {
145                // A backslash quotes the next character anywhere outside
146                // single quotes, including a brace and a newline.
147                body.push(c);
148                body.push(getc()?);
149                at_word_start = false;
150                continue;
151            }
152            '\'' => {
153                // Single quotes take everything literally — no escapes.
154                body.push(c);
155                loop {
156                    let q = getc()?;
157                    body.push(q);
158                    if q == '\'' {
159                        break;
160                    }
161                }
162                at_word_start = false;
163                continue;
164            }
165            '"' => {
166                body.push(c);
167                scan_double_quoted(&mut getc, &mut body)?;
168                at_word_start = false;
169                continue;
170            }
171            '`' => {
172                body.push(c);
173                loop {
174                    let q = getc()?;
175                    body.push(q);
176                    match q {
177                        '\\' => body.push(getc()?),
178                        '`' => break,
179                        _ => {}
180                    }
181                }
182                at_word_start = false;
183                continue;
184            }
185            '#' if at_word_start => {
186                // Comment to end of line. The newline itself is left for
187                // the heredoc check below.
188                body.push(c);
189                loop {
190                    match getc() {
191                        Some('\n') => {
192                            body.push('\n');
193                            break;
194                        }
195                        Some(ch) => body.push(ch),
196                        None => return None,
197                    }
198                }
199                at_word_start = true;
200                if !pending_heredocs.is_empty() {
201                    drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
202                }
203                continue;
204            }
205            '<' => {
206                body.push(c);
207                let (delim, stopped_at) = scan_heredoc_intro(&mut getc, &mut body)?;
208                if let Some(d) = delim {
209                    pending_heredocs.push(d);
210                }
211                at_word_start = false;
212                // Reading the delimiter word necessarily consumed the
213                // character that ended it, and for `cat <<EOF` that
214                // character IS the newline the here-document body starts
215                // after. The main loop will never see it, so the drain has
216                // to happen here or the body's text gets scanned as shell.
217                if stopped_at == Some('\n') {
218                    at_word_start = true;
219                    if !pending_heredocs.is_empty() {
220                        drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
221                    }
222                }
223                continue;
224            }
225            '\n' => {
226                body.push(c);
227                at_word_start = true;
228                if !pending_heredocs.is_empty() {
229                    drain_heredocs(&mut getc, &mut body, &mut pending_heredocs)?;
230                }
231                continue;
232            }
233            '{' => {
234                depth += 1;
235                body.push(c);
236            }
237            '}' => {
238                depth -= 1;
239                if depth == 0 {
240                    return Some(body);
241                }
242                body.push(c);
243            }
244            _ => body.push(c),
245        }
246        at_word_start = c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')');
247    }
248}
249
250/// Copy a double-quoted string, stopping after its closing quote.
251///
252/// Backslash still escapes inside double quotes, and `$( … )` may contain
253/// arbitrary shell — including more quotes and braces — so the nested
254/// command substitution is copied through by paren depth rather than
255/// scanned for a terminator.
256fn scan_double_quoted<G>(getc: &mut G, body: &mut String) -> Option<()>
257where
258    G: FnMut() -> Option<char>,
259{
260    loop {
261        let c = getc()?;
262        body.push(c);
263        match c {
264            '\\' => body.push(getc()?),
265            '"' => return Some(()),
266            '$' => {
267                let n = getc()?;
268                body.push(n);
269                if n == '(' {
270                    let mut depth = 1;
271                    while depth > 0 {
272                        let q = getc()?;
273                        body.push(q);
274                        match q {
275                            '\\' => body.push(getc()?),
276                            '(' => depth += 1,
277                            ')' => depth -= 1,
278                            _ => {}
279                        }
280                    }
281                }
282            }
283            _ => {}
284        }
285    }
286}
287
288/// After a `<`, decide whether this is a here-document and if so return
289/// its delimiter plus whether `<<-` tab-stripping is in effect.
290///
291/// `<<<` is a here-STRING: an ordinary word follows, with no body to skip.
292#[allow(clippy::type_complexity)]
293fn scan_heredoc_intro<G>(
294    getc: &mut G,
295    body: &mut String,
296) -> Option<(Option<(String, bool)>, Option<char>)>
297where
298    G: FnMut() -> Option<char>,
299{
300    let second = getc()?;
301    body.push(second);
302    if second != '<' {
303        return Some((None, Some(second)));
304    }
305    let mut third = getc()?;
306    body.push(third);
307    if third == '<' {
308        // here-string
309        return Some((None, Some(third)));
310    }
311    let strip_tabs = third == '-';
312    if strip_tabs {
313        third = getc()?;
314        body.push(third);
315    }
316    // Skip blanks between the operator and the delimiter word.
317    let mut c = third;
318    while c == ' ' || c == '\t' {
319        c = getc()?;
320        body.push(c);
321    }
322    // The delimiter may be quoted; the quotes are not part of it.
323    let mut delim = String::new();
324    // The character the delimiter word ended on, handed back so the caller
325    // knows whether the here-document body starts immediately.
326    let mut stopped_at: Option<char> = None;
327    loop {
328        match c {
329            '\'' | '"' => {
330                let close = c;
331                loop {
332                    let q = getc()?;
333                    body.push(q);
334                    if q == close {
335                        break;
336                    }
337                    delim.push(q);
338                }
339            }
340            '\\' => {
341                let q = getc()?;
342                body.push(q);
343                delim.push(q);
344            }
345            _ if c.is_whitespace() || c == ';' || c == '&' || c == '|' || c == ')' => {
346                stopped_at = Some(c);
347                break;
348            }
349            _ => delim.push(c),
350        }
351        match getc() {
352            Some(n) => {
353                c = n;
354                body.push(n);
355            }
356            None => break,
357        }
358    }
359    if delim.is_empty() {
360        Some((None, stopped_at))
361    } else {
362        Some((Some((delim, strip_tabs)), stopped_at))
363    }
364}
365
366/// Copy here-document bodies verbatim, one per pending delimiter.
367///
368/// Everything up to the terminator line is literal text: braces, quotes
369/// and `#` inside it must not steer the scan.
370fn drain_heredocs<G>(
371    getc: &mut G,
372    body: &mut String,
373    pending: &mut Vec<(String, bool)>,
374) -> Option<()>
375where
376    G: FnMut() -> Option<char>,
377{
378    for (delim, strip_tabs) in pending.drain(..) {
379        loop {
380            let mut line = String::new();
381            let mut hit_eof = true;
382            while let Some(c) = getc() {
383                if c == '\n' {
384                    hit_eof = false;
385                    break;
386                }
387                line.push(c);
388            }
389            body.push_str(&line);
390            if !hit_eof {
391                body.push('\n');
392            }
393            let candidate = if strip_tabs {
394                line.trim_start_matches('\t')
395            } else {
396                line.as_str()
397            };
398            if candidate == delim {
399                break;
400            }
401            if hit_eof {
402                // zsh warns and treats EOF as the terminator; the body ends
403                // here either way.
404                return Some(());
405            }
406        }
407    }
408    Some(())
409}
410
411/// Match an intercept pattern against a command name or full command string.
412/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
413pub(crate) fn intercept_matches(pattern: &str, cmd_name: &str, full_cmd: &str) -> bool {
414    if pattern == "*" || pattern == "all" {
415        return true;
416    }
417    if pattern == cmd_name {
418        return true;
419    }
420    if pattern.contains('*') || pattern.contains('?') {
421        if let Ok(pat) = glob::Pattern::new(pattern) {
422            return pat.matches(cmd_name) || pat.matches(full_cmd);
423        }
424    }
425    false
426}
427
428// ===========================================================
429// Methods moved verbatim from src/ported/vm_helper because their
430// C counterpart's source file maps 1:1 to this Rust module.
431// Phase: drift
432// ===========================================================
433
434thread_local! {
435    /// True while advice code runs. Intercepts are not consulted then, so advice
436    /// that calls the command it advises (`intercept before git { git rev-parse … }`)
437    /// does not re-trigger itself forever.
438    static IN_ADVICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
439}
440
441/// Run `f` with the in-advice flag set to `value`, restoring the previous value.
442fn with_advice_flag<T>(value: bool, f: impl FnOnce() -> T) -> T {
443    let prev = IN_ADVICE.with(|c| c.replace(value));
444    let out = f();
445    IN_ADVICE.with(|c| c.set(prev));
446    out
447}
448
449/// Run `f` as the ORIGINAL command of an around advice: commands it runs are
450/// ordinary again and see their own intercepts.
451pub(crate) fn outside_advice<T>(f: impl FnOnce() -> T) -> T {
452    with_advice_flag(false, f)
453}
454
455/// The alias a command word came out of, with the word count of its body, for
456/// `intercept … ALIAS`. `hasalias` is `input_hasalias()` at the command word.
457/// The body is counted by blank-separated spans (no lexer re-entry from the
458/// parser), so a quoted space in an alias body miscounts the user's arguments.
459thread_local! {
460    /// The real command an alias-level advice stands in front of. While it is
461    /// set, `run_original_command` runs this instead of the (alias) name it is
462    /// handed, so `intercept_proceed` and `after` advice run the expanded command.
463    static ORIGINAL_OVERRIDE: std::cell::RefCell<Option<(String, Vec<String>)>> =
464        const { std::cell::RefCell::new(None) };
465}
466
467/// Installs [`ORIGINAL_OVERRIDE`] for one advice call and restores the previous
468/// value after it, so a command run from inside the advice never sees the outer
469/// alias's override.
470struct OriginalOverrideGuard(Option<(String, Vec<String>)>);
471
472impl OriginalOverrideGuard {
473    fn set(original: Option<(String, Vec<String>)>) -> Self {
474        Self(ORIGINAL_OVERRIDE.with(|slot| slot.replace(original)))
475    }
476}
477
478impl Drop for OriginalOverrideGuard {
479    fn drop(&mut self) {
480        ORIGINAL_OVERRIDE.with(|slot| *slot.borrow_mut() = self.0.take());
481    }
482}
483
484pub(crate) fn alias_origin(hasalias: Option<String>) -> Option<(String, usize)> {
485    let name = hasalias?;
486    let text = crate::ported::hashtable::aliastab_lock()
487        .read()
488        .ok()?
489        .get(name.as_str())?
490        .text
491        .clone();
492    let body_words = crate::ported::hist::histsplitwords(&text, false).len();
493    Some((name, body_words))
494}
495
496// BEGIN moved-from-exec-rs
497impl crate::ported::vm_helper::ShellExecutor {
498    /// Unset every `$INTERCEPT_*` parameter the advice machinery bound, so none
499    /// outlives the command it was bound for.
500    fn clear_advice_vars(&mut self) {
501        for name in [
502            "INTERCEPT_NAME",
503            "INTERCEPT_ARGS",
504            "INTERCEPT_CMD",
505            "INTERCEPT_MS",
506            "INTERCEPT_US",
507            "INTERCEPT_STATUS",
508            "__intercept_proceed",
509        ] {
510            self.unset_scalar(name);
511        }
512    }
513
514    /// Check intercepts for a command. Returns Some(result) if an around
515    /// advice fully handled the command, None to proceed normally.
516    pub(crate) fn run_intercepts(
517        &mut self,
518        cmd_name: &str,
519        full_cmd: &str,
520        args: &[String],
521    ) -> Option<Result<i32, String>> {
522        self.run_intercepts_with(cmd_name, full_cmd, args, None)
523    }
524
525    /// [`run_intercepts`](Self::run_intercepts) for a command word that was an
526    /// ALIAS: advice matches the alias `cmd_name` and sees only the arguments the
527    /// user typed, while `original` (the expanded command) is what runs when the
528    /// advice proceeds or an `after` advice needs the command run.
529    pub(crate) fn run_intercepts_with(
530        &mut self,
531        cmd_name: &str,
532        full_cmd: &str,
533        args: &[String],
534        original: Option<(String, Vec<String>)>,
535    ) -> Option<Result<i32, String>> {
536        let _override_guard = OriginalOverrideGuard::set(original);
537        if IN_ADVICE.with(|c| c.get()) {
538            return None;
539        }
540        // Collect matching intercepts (clone to avoid borrow issues)
541        let matching: Vec<Intercept> = self
542            .intercepts
543            .iter()
544            .filter(|i| intercept_matches(&i.pattern, cmd_name, full_cmd))
545            .cloned()
546            .collect();
547
548        if matching.is_empty() {
549            return None;
550        }
551
552        // Set INTERCEPT_NAME and INTERCEPT_ARGS for advice code
553        self.set_scalar("INTERCEPT_NAME".to_string(), cmd_name.to_string());
554        self.set_scalar("INTERCEPT_ARGS".to_string(), args.join(" "));
555        self.set_scalar("INTERCEPT_CMD".to_string(), full_cmd.to_string());
556
557        // Run before advice
558        for advice in matching
559            .iter()
560            .filter(|i| matches!(i.kind, AdviceKind::Before))
561        {
562            let _ = self.execute_advice(&advice.code);
563        }
564
565        // Check for around advice — first match wins
566        let around = matching
567            .iter()
568            .find(|i| matches!(i.kind, AdviceKind::Around));
569
570        let t0 = std::time::Instant::now();
571
572        let result = if let Some(advice) = around {
573            // Around advice: set INTERCEPT_PROCEED flag, run advice code.
574            // If advice calls `intercept_proceed`, the original command runs.
575            self.set_scalar("__intercept_proceed".to_string(), "0".to_string());
576            let advice_result = self.execute_advice(&advice.code);
577
578            // Check if intercept_proceed was called
579            let proceeded = self
580                .scalar("__intercept_proceed")
581                .map(|v| v == "1")
582                .unwrap_or(false);
583
584            if proceeded {
585                // The original command was already executed inside the advice
586                advice_result
587            } else {
588                // Advice didn't call proceed — command was suppressed
589                advice_result
590            }
591        } else {
592            // No around advice — run the original command.
593            // We return None to let the normal dispatch continue.
594            // But we still need after advice to fire, so we can't return None here
595            // if there are after advices. Run the command ourselves.
596            let has_after = matching.iter().any(|i| matches!(i.kind, AdviceKind::After));
597            if !has_after {
598                // Only before advice, no after — let normal dispatch continue
599                self.clear_advice_vars();
600                return None;
601            }
602
603            // Has after advice — we must run the command and then run after advice
604            self.run_original_command(cmd_name, args)
605        };
606
607        let elapsed = t0.elapsed();
608
609        // Set timing variable for after advice
610        let ms = elapsed.as_secs_f64() * 1000.0;
611        self.set_scalar("INTERCEPT_MS".to_string(), format!("{:.3}", ms));
612        self.set_scalar("INTERCEPT_US".to_string(), format!("{:.0}", ms * 1000.0));
613        // The intercepted command's exit status, for `after` advice that
614        // wants to branch on success. `$?` alone is not enough: the advice
615        // body's own first command overwrites it, and a `before` advice that
616        // ran earlier has already moved it. An advice that failed to run at
617        // all reports 1, matching what the shell would have left behind.
618        self.set_scalar(
619            "INTERCEPT_STATUS".to_string(),
620            match &result {
621                Ok(st) => st.to_string(),
622                Err(_) => "1".to_string(),
623            },
624        );
625
626        // Run after advice
627        for advice in matching
628            .iter()
629            .filter(|i| matches!(i.kind, AdviceKind::After))
630        {
631            let _ = self.execute_advice(&advice.code);
632        }
633
634        // Clean up
635        self.clear_advice_vars();
636
637        Some(result)
638    }
639    /// Execute the original command (used by around/after intercept dispatch).
640    /// Execute advice code — dispatches @ prefix to stryke (fat binary),
641    /// everything else to the shell parser. No fork. Machine code speed.
642    pub(crate) fn execute_advice(&mut self, code: &str) -> Result<i32, String> {
643        let code = code.trim();
644        if code.starts_with('@') {
645            let stryke_code = code.trim_start_matches('@').trim();
646            if let Some(status) = crate::try_stryke_dispatch(stryke_code) {
647                self.set_last_status(status);
648                return Ok(status);
649            }
650            // No stryke handler (thin binary) — fall through to shell
651        }
652        with_advice_flag(true, || self.execute_script(code))
653    }
654    pub(crate) fn run_original_command(
655        &mut self,
656        cmd_name: &str,
657        args: &[String],
658    ) -> Result<i32, String> {
659        let (cmd_name, args) = match ORIGINAL_OVERRIDE.with(|o| o.borrow().clone()) {
660            Some((name, real_args)) => (name, real_args),
661            None => (cmd_name.to_string(), args.to_vec()),
662        };
663        let (cmd_name, args) = (cmd_name.as_str(), args.as_slice());
664        // Function dispatch via the compiled pipeline (functions_compiled
665        // first, falls back to legacy AST recompile if needed).
666        if let Some(status) = self.dispatch_function_call(cmd_name, args) {
667            return Ok(status);
668        }
669        // External command
670        self.execute_external(cmd_name, args, &[])
671    }
672}
673// END moved-from-exec-rs
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn star_matches_anything() {
681        assert!(intercept_matches("*", "anything", "anything --here"));
682        assert!(intercept_matches("*", "", ""));
683    }
684
685    #[test]
686    fn all_matches_anything() {
687        assert!(intercept_matches("all", "ls", "ls -la"));
688        assert!(intercept_matches("all", "git", "git status"));
689    }
690
691    #[test]
692    fn exact_match_on_cmd_name() {
693        assert!(intercept_matches("git", "git", "git push"));
694        assert!(intercept_matches("ls", "ls", "ls -la"));
695    }
696
697    #[test]
698    fn exact_pattern_does_not_match_different_name() {
699        assert!(!intercept_matches("git", "svn", "svn diff"));
700        assert!(!intercept_matches("ls", "lsof", "lsof -p 1"));
701    }
702
703    #[test]
704    fn glob_star_matches_prefix() {
705        // "git *" should match the full command line like "git push origin".
706        assert!(intercept_matches("git *", "git", "git push origin"));
707    }
708
709    #[test]
710    fn glob_star_underscore_prefix_matches_completion_funcs() {
711        // "_*" is the canonical zsh pattern for completion functions.
712        assert!(intercept_matches("_*", "_files", "_files"));
713        assert!(intercept_matches("_*", "_describe", "_describe"));
714    }
715
716    #[test]
717    fn glob_star_does_not_match_non_prefix() {
718        assert!(!intercept_matches("_*", "files", "files"));
719    }
720
721    #[test]
722    fn question_mark_glob_matches_single_char() {
723        assert!(intercept_matches("l?", "ls", "ls"));
724        assert!(!intercept_matches("l?", "lsof", "lsof"));
725    }
726
727    #[test]
728    fn unmatched_pattern_without_glob_chars_returns_false() {
729        assert!(!intercept_matches("nope", "git", "git push"));
730    }
731
732    #[test]
733    fn invalid_glob_pattern_returns_false() {
734        // `[` with no closing bracket is invalid; should not panic and not match.
735        // Pattern with `[` triggers neither the `*` shortcut nor exact match,
736        // but it also contains no `*` or `?`, so we never reach glob parsing.
737        assert!(!intercept_matches("[invalid", "git", "git push"));
738    }
739
740    #[test]
741    fn empty_pattern_does_not_match_non_empty_cmd() {
742        assert!(!intercept_matches("", "ls", "ls -la"));
743    }
744
745    #[test]
746    fn empty_pattern_matches_empty_cmd_exactly() {
747        // Falls through to the `pattern == cmd_name` check.
748        assert!(intercept_matches("", "", ""));
749    }
750
751    #[test]
752    fn advice_kind_variants_round_trip_clone() {
753        let b = AdviceKind::Before;
754        let a = AdviceKind::After;
755        let r = AdviceKind::Around;
756        assert!(matches!(b.clone(), AdviceKind::Before));
757        assert!(matches!(a.clone(), AdviceKind::After));
758        assert!(matches!(r.clone(), AdviceKind::Around));
759    }
760
761    #[test]
762    fn intercept_struct_clone_preserves_fields() {
763        let i = Intercept {
764            pattern: "git *".into(),
765            kind: AdviceKind::Before,
766            code: "echo before".into(),
767            id: 42,
768        };
769        let c = i.clone();
770        assert_eq!(c.pattern, "git *");
771        assert!(matches!(c.kind, AdviceKind::Before));
772        assert_eq!(c.code, "echo before");
773        assert_eq!(c.id, 42);
774    }
775}