Skip to main content

runex_core/
shell.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::model::{Config, TriggerKey};
5use crate::sanitize::{double_quote_escape, is_nu_drop_char, is_unicode_line_separator, is_unsafe_for_display};
6
7// Shell is defined in model to avoid circular dependency; re-export it here
8// so callers that do `use runex_core::shell::Shell` still work.
9pub use crate::model::Shell;
10
11impl FromStr for Shell {
12    type Err = ShellParseError;
13
14    fn from_str(s: &str) -> Result<Self, Self::Err> {
15        match s.to_ascii_lowercase().as_str() {
16            "bash" => Ok(Shell::Bash),
17            "zsh" => Ok(Shell::Zsh),
18            "pwsh" => Ok(Shell::Pwsh),
19            "clink" => Ok(Shell::Clink),
20            "nu" => Ok(Shell::Nu),
21            _ => Err(ShellParseError(s.to_string())),
22        }
23    }
24}
25
26/// Error returned when a shell name string cannot be parsed into a [`Shell`] variant.
27///
28/// The `Display` impl sanitizes the raw shell name before embedding it in the message:
29/// ASCII control characters and Unicode visual-deception characters (directional overrides,
30/// BOM, zero-width chars) are stripped to prevent terminal injection via crafted error output.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ShellParseError(pub String);
33
34impl fmt::Display for ShellParseError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        let safe: String = self
37            .0
38            .chars()
39            .filter(|&c| !is_unsafe_for_display(c))
40            .collect();
41        write!(
42            f,
43            "unknown shell '{}' (expected: bash, zsh, pwsh, clink, nu)",
44            safe
45        )
46    }
47}
48
49impl std::error::Error for ShellParseError {}
50
51fn trigger_for(shell: Shell, config: Option<&Config>) -> Option<TriggerKey> {
52    let keybind = match config {
53        Some(config) => &config.keybind,
54        None => return None,
55    };
56
57    match shell {
58        Shell::Bash => keybind.trigger.bash.or(keybind.trigger.default),
59        Shell::Zsh => keybind.trigger.zsh.or(keybind.trigger.default),
60        Shell::Pwsh => keybind.trigger.pwsh.or(keybind.trigger.default),
61        Shell::Nu => keybind.trigger.nu.or(keybind.trigger.default),
62        Shell::Clink => keybind.trigger.default,
63    }
64}
65
66fn self_insert_for(shell: Shell, config: Option<&Config>) -> Option<TriggerKey> {
67    let keybind = match config {
68        Some(config) => &config.keybind,
69        None => return None,
70    };
71
72    match shell {
73        Shell::Bash => keybind.self_insert.bash.or(keybind.self_insert.default),
74        Shell::Zsh => keybind.self_insert.zsh.or(keybind.self_insert.default),
75        Shell::Pwsh => keybind.self_insert.pwsh.or(keybind.self_insert.default),
76        Shell::Nu => keybind.self_insert.nu.or(keybind.self_insert.default),
77        Shell::Clink => None,
78    }
79}
80
81fn bash_chord(trigger: TriggerKey) -> &'static str {
82    match trigger {
83        TriggerKey::Space => "\\x20",
84        TriggerKey::Tab => "\\C-i",
85        TriggerKey::AltSpace => "\\e ",
86        TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in bash"),
87    }
88}
89
90fn zsh_chord(trigger: TriggerKey) -> &'static str {
91    match trigger {
92        TriggerKey::Space => " ",
93        TriggerKey::Tab => "^I",
94        TriggerKey::AltSpace => "^[ ",
95        TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in zsh"),
96    }
97}
98
99/// Quote `token` for use as a Bash `case` pattern.
100///
101/// Uses the same escaping as [`bash_quote_string`]: single-quoted with `'\''`
102/// for embedded single quotes.  ASCII control characters and Unicode
103/// line/paragraph separators are dropped.
104/// Quote `value` as a Bash single-quoted string.
105///
106/// Single quotes are escaped as `'\''` (close, escaped quote, reopen).
107/// ASCII control characters and Unicode line/paragraph separators are dropped:
108/// valid executable paths never contain them, and embedding `$'\n'` inside
109/// `eval "$(...)"` would cause command-splitting injection.
110pub(crate) fn bash_quote_string(value: &str) -> String {
111    let mut out = String::from("'");
112    for ch in value.chars() {
113        match ch {
114            '\'' => out.push_str(r"'\''"),
115            c if c.is_ascii_control() || is_unicode_line_separator(c) => {}
116            _ => out.push(ch),
117        }
118    }
119    out.push('\'');
120    out
121}
122
123// The legacy `posix_known_cases` / `bash_known_cases` / `zsh_known_cases`
124// helpers built a `case` block listing every abbreviation key, which was
125// spliced into the bash/zsh bootstraps. The hook-based bootstraps don't
126// need that — `runex hook` consults the config at keypress time — so
127// those helpers were removed together with the shell-side case blocks.
128
129fn pwsh_chord(trigger: TriggerKey) -> &'static str {
130    match trigger {
131        TriggerKey::Space => " ",
132        TriggerKey::Tab => "Tab",
133        TriggerKey::AltSpace => "Alt+Spacebar",
134        TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in pwsh"),
135    }
136}
137
138/// Quote `token` as a PowerShell single-quoted string.
139///
140/// Single quotes are escaped as `''`.  ASCII control characters and Unicode
141/// line/paragraph separators are dropped: valid executable paths never contain them,
142/// and backtick concatenation (`'a'`n'b'`) risks token-splitting in some PS contexts.
143pub(crate) fn pwsh_quote_string(token: &str) -> String {
144    let mut out = String::from("'");
145    for ch in token.chars() {
146        match ch {
147            '\'' => out.push_str("''"),
148            c if c.is_ascii_control() || is_unicode_line_separator(c) => {}
149            _ => out.push(ch),
150        }
151    }
152    out.push('\'');
153    out
154}
155
156/// Quote `value` for use as an external Nu shell command invocation (`^"..."`).
157///
158/// The `^` prefix forces Nu to execute the string as an external command rather
159/// than treating it as a string literal.  Inside the double-quoted form:
160/// - `\` → `\\`, `"` → `\"`, `$` → `\$` (suppresses variable interpolation)
161/// - `\n`, `\r`, `\t` are escaped as their two-character sequences
162/// - NUL, DEL, remaining ASCII control characters, and Unicode line/paragraph
163///   separators are dropped
164pub(crate) fn nu_quote_string(value: &str) -> String {
165    let mut out = String::from("^\"");
166    for ch in value.chars() {
167        if let Some(esc) = double_quote_escape(ch) {
168            out.push_str(esc);
169        } else if ch == '$' {
170            out.push_str("\\$");
171        } else if is_nu_drop_char(ch) {
172        } else {
173            out.push(ch);
174        }
175    }
176    out.push('"');
177    out
178}
179
180/// Like [`nu_quote_string`], but safe for embedding inside an outer Nu double-quoted
181/// string (e.g. `cmd: "..."`).
182///
183/// Each `\` and `"` in the standalone form must be escaped one more level so the outer
184/// Nu parser sees them as literals.  The two-character sequence `\$` (produced by
185/// [`nu_quote_string`] to suppress variable interpolation) is kept atomic — converting
186/// `\` to `\\` here would yield `\\$`, which the outer parser reads as a literal `\`
187/// followed by variable interpolation (unsafe).
188///
189/// Standalone: `^"runex"`  →  Embedded: `^\"runex\"`
190fn nu_quote_string_embedded(value: &str) -> String {
191    let standalone = nu_quote_string(value);
192    let mut out = String::with_capacity(standalone.len() + 8);
193    let mut chars = standalone.chars().peekable();
194    while let Some(ch) = chars.next() {
195        match ch {
196            '\\' => {
197                if chars.peek() == Some(&'$') {
198                    out.push('\\');
199                    out.push('$');
200                    chars.next();
201                } else {
202                    out.push_str("\\\\");
203                }
204            }
205            '"' => out.push_str("\\\""),
206            c => out.push(c),
207        }
208    }
209    out
210}
211
212/// Quote `value` as a Lua double-quoted string.
213///
214/// - `\`, `"` → `\\`, `\"`
215/// - `\n`, `\r`, `\t` → two-character escape sequences
216/// - NUL is dropped (Lua uses C strings; NUL truncates them)
217/// - Unicode line/paragraph separators are dropped
218/// - Remaining ASCII control characters use three-digit decimal `\DDD` escapes.
219///   Zero-padding is required: without it `\1` followed by `0` would be read as
220///   `\10` (LF) rather than SOH followed by `"0"`.
221pub(crate) fn lua_quote_string(value: &str) -> String {
222    let mut out = String::from("\"");
223    for ch in value.chars() {
224        if let Some(esc) = double_quote_escape(ch) {
225            out.push_str(esc);
226        } else if ch == '\0' || is_unicode_line_separator(ch) {
227        } else if ch.is_ascii_control() {
228            out.push_str(&format!("\\{:03}", ch as u8));
229        } else {
230            out.push(ch);
231        }
232    }
233    out.push('"');
234    out
235}
236
237fn nu_modifier(trigger: TriggerKey) -> &'static str {
238    match trigger {
239        TriggerKey::AltSpace => "alt",
240        TriggerKey::ShiftSpace => "shift",
241        TriggerKey::Space | TriggerKey::Tab => "none",
242    }
243}
244
245fn nu_keycode(trigger: TriggerKey) -> &'static str {
246    match trigger {
247        TriggerKey::Space | TriggerKey::AltSpace | TriggerKey::ShiftSpace => "space",
248        TriggerKey::Tab => "tab",
249    }
250}
251
252fn clink_key_sequence(trigger: TriggerKey) -> &'static str {
253    match trigger {
254        TriggerKey::Space => r#"" ""#,
255        TriggerKey::Tab => r#""\t""#,
256        TriggerKey::AltSpace => r#""\e ""#,
257        TriggerKey::ShiftSpace => unreachable!("ShiftSpace cannot be used as a trigger in clink"),
258    }
259}
260
261/// Generate the `bind` lines for bash, removing the old binding before adding the new one.
262/// Only the configured trigger key is touched; other keys are left as-is.
263fn bash_bind_lines(trigger: Option<TriggerKey>) -> String {
264    let mut lines = Vec::new();
265    if let Some(trigger) = trigger {
266        lines.push(format!(
267            r#"bind -r "{}" 2>/dev/null || true"#,
268            bash_chord(trigger)
269        ));
270        lines.push(format!("bind -x '\"{}\": __runex_expand'", bash_chord(trigger)));
271    }
272    lines.join("\n")
273}
274
275/// Generate the `bindkey` lines for zsh, removing the old binding before adding the new one.
276/// Only the configured trigger key is touched; other keys are left as-is.
277fn zsh_bind_lines(trigger: Option<TriggerKey>) -> String {
278    let mut lines = Vec::new();
279    if let Some(trigger) = trigger {
280        lines.push(format!(
281            r#"bindkey -r "{}" 2>/dev/null"#,
282            zsh_chord(trigger)
283        ));
284        lines.push(format!(r#"bindkey "{}" __runex_expand"#, zsh_chord(trigger)));
285    }
286    lines.join("\n")
287}
288
289fn bash_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
290    match self_insert {
291        Some(TriggerKey::AltSpace) => [
292            r#"bind -r "\e " 2>/dev/null || true"#,
293            r#"bind '"\e ": self-insert'"#,
294        ]
295        .join("\n"),
296        _ => String::new(),
297    }
298}
299
300fn zsh_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
301    match self_insert {
302        Some(TriggerKey::AltSpace) => [
303            r#"bindkey -r "^[ " 2>/dev/null"#,
304            r#"bindkey "^[ " self-insert"#,
305        ]
306        .join("\n"),
307        _ => String::new(),
308    }
309}
310
311fn pwsh_register_lines(trigger: Option<TriggerKey>) -> String {
312    let mut lines = Vec::new();
313    if let Some(trigger) = trigger {
314        lines.push(format!(
315            "    __runex_register_expand_handler '{}'",
316            pwsh_chord(trigger)
317        ));
318    }
319    let mut vi_lines = Vec::new();
320    if let Some(trigger) = trigger {
321        vi_lines.push(format!(
322            "        __runex_register_expand_handler '{}' Insert",
323            pwsh_chord(trigger)
324        ));
325    }
326    if !vi_lines.is_empty() {
327        lines.push("    if ((Get-PSReadLineOption).EditMode -eq 'Vi') {".to_string());
328        lines.extend(vi_lines);
329        lines.push("    }".to_string());
330    }
331    lines.join("\n")
332}
333
334fn pwsh_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
335    match self_insert {
336        Some(TriggerKey::ShiftSpace) => {
337            "    Set-PSReadLineKeyHandler -Chord 'Shift+Spacebar' -Function SelfInsert"
338                .to_string()
339        }
340        Some(TriggerKey::AltSpace) => {
341            "    Set-PSReadLineKeyHandler -Chord 'Alt+Spacebar' -Function SelfInsert".to_string()
342        }
343        _ => String::new(),
344    }
345}
346
347fn nu_bindings(trigger: Option<TriggerKey>, bin: &str) -> String {
348    let mut blocks = Vec::new();
349    if let Some(trigger) = trigger {
350        blocks.push(
351            include_str!("templates/nu_expand_binding.nu")
352                .replace("{NU_BIN}", &nu_quote_string_embedded(bin))
353                .replace("{NU_MODIFIER}", nu_modifier(trigger))
354                .replace("{NU_KEYCODE}", nu_keycode(trigger)),
355        );
356    }
357    blocks.join(" | append ")
358}
359
360fn nu_self_insert_lines(self_insert: Option<TriggerKey>) -> String {
361    let key = match self_insert {
362        Some(TriggerKey::ShiftSpace) => Some(("shift", "space")),
363        Some(TriggerKey::AltSpace) => Some(("alt", "space")),
364        _ => None,
365    };
366    let Some((modifier, keycode)) = key else {
367        return String::new();
368    };
369    include_str!("templates/nu_self_insert_binding.nu")
370        .replace("{NU_SI_MODIFIER}", modifier)
371        .replace("{NU_SI_KEYCODE}", keycode)
372}
373
374fn clink_binding(trigger: Option<TriggerKey>) -> String {
375    let Some(trigger) = trigger else {
376        return String::new();
377    };
378
379    let key = clink_key_sequence(trigger);
380    [
381        format!(
382            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "emacs")"#,
383            key = key
384        ),
385        format!(
386            r#"pcall(rl.setbinding, [[{key}]], [["luafunc:runex_expand"]], "vi-insert")"#,
387            key = key
388        ),
389    ]
390    .join("\n")
391}
392
393/// Generate a shell integration script.
394///
395/// `{BIN}` placeholders in the template are replaced with `bin`.
396pub fn export_script(shell: Shell, bin: &str, config: Option<&Config>) -> String {
397    let template = match shell {
398        Shell::Bash => include_str!("templates/bash.sh"),
399        Shell::Zsh => include_str!("templates/zsh.zsh"),
400        Shell::Pwsh => include_str!("templates/pwsh.ps1"),
401        Shell::Clink => include_str!("templates/clink.lua"),
402        Shell::Nu => include_str!("templates/nu.nu"),
403    };
404    let trigger = trigger_for(shell, config);
405    let self_insert = self_insert_for(shell, config);
406    template
407        .replace("\r\n", "\n")
408        .replace("{BASH_BIN}", &bash_quote_string(bin))
409        .replace("{BASH_BIND_LINES}", &bash_bind_lines(trigger))
410        .replace("{BASH_SELF_INSERT_LINES}", &bash_self_insert_lines(self_insert))
411        .replace("{ZSH_BIN}", &bash_quote_string(bin))
412        .replace("{ZSH_BIND_LINES}", &zsh_bind_lines(trigger))
413        .replace("{ZSH_SELF_INSERT_LINES}", &zsh_self_insert_lines(self_insert))
414        .replace("{CLINK_BIN}", &lua_quote_string(bin))
415        .replace("{CLINK_BINDING}", &clink_binding(trigger))
416        .replace("{PWSH_BIN}", &pwsh_quote_string(bin))
417        .replace("{PWSH_REGISTER_LINES}", &pwsh_register_lines(trigger))
418        .replace("{PWSH_SELF_INSERT_LINES}", &pwsh_self_insert_lines(self_insert))
419        .replace("{NU_BIN}", &nu_quote_string(bin))
420        .replace("{NU_BINDINGS}", &nu_bindings(trigger, bin))
421        .replace("{NU_SELF_INSERT_BINDINGS}", &nu_self_insert_lines(self_insert))
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    mod shell_parse {
429        use super::*;
430
431    #[test]
432    fn parse_bash() {
433        assert_eq!(Shell::from_str("bash").unwrap(), Shell::Bash);
434    }
435
436    #[test]
437    fn parse_case_insensitive() {
438        assert_eq!(Shell::from_str("PWSH").unwrap(), Shell::Pwsh);
439        assert_eq!(Shell::from_str("Clink").unwrap(), Shell::Clink);
440        assert_eq!(Shell::from_str("Nu").unwrap(), Shell::Nu);
441        assert_eq!(Shell::from_str("Zsh").unwrap(), Shell::Zsh);
442    }
443
444    /// `Shell::from_str` is called with user-supplied input. Embedding raw ANSI sequences
445    /// (e.g. `"bash\x1b[2J"`) in an error message printed to stderr causes terminal injection.
446    /// The `Display` impl must sanitize the shell name before embedding it.
447    #[test]
448    fn shell_parse_error_display_strips_esc_sequences() {
449        let err = Shell::from_str("bash\x1b[2Jevil").unwrap_err();
450        let msg = err.to_string();
451        assert!(
452            !msg.contains('\x1b'),
453            "ShellParseError Display must not contain raw ESC: {msg:?}"
454        );
455    }
456
457    #[test]
458    fn shell_parse_error_display_strips_bel() {
459        let err = Shell::from_str("bash\x07evil").unwrap_err();
460        let msg = err.to_string();
461        assert!(
462            !msg.contains('\x07'),
463            "ShellParseError Display must not contain raw BEL: {msg:?}"
464        );
465    }
466
467    #[test]
468    fn shell_parse_error_display_strips_del() {
469        let err = Shell::from_str("bash\x7fevil").unwrap_err();
470        let msg = err.to_string();
471        assert!(
472            !msg.contains('\x7f'),
473            "ShellParseError Display must not contain DEL: {msg:?}"
474        );
475    }
476
477    #[test]
478    fn shell_parse_error_display_strips_rlo() {
479        let err = Shell::from_str("bash\u{202E}lve").unwrap_err();
480        let msg = err.to_string();
481        assert!(
482            !msg.contains('\u{202E}'),
483            "ShellParseError Display must not contain RLO U+202E: {msg:?}"
484        );
485    }
486
487    #[test]
488    fn shell_parse_error_display_strips_bom() {
489        let err = Shell::from_str("bash\u{FEFF}evil").unwrap_err();
490        let msg = err.to_string();
491        assert!(
492            !msg.contains('\u{FEFF}'),
493            "ShellParseError Display must not contain BOM U+FEFF: {msg:?}"
494        );
495    }
496
497    #[test]
498    fn shell_parse_error_display_strips_zwsp() {
499        let err = Shell::from_str("ba\u{200B}sh").unwrap_err();
500        let msg = err.to_string();
501        assert!(
502            !msg.contains('\u{200B}'),
503            "ShellParseError Display must not contain ZWSP U+200B: {msg:?}"
504        );
505    }
506
507    #[test]
508    fn parse_unknown_errors() {
509        let err = Shell::from_str("fish").unwrap_err();
510        assert_eq!(err.0, "fish");
511    }
512
513    } // mod shell_parse
514
515    mod script_generation {
516        use super::*;
517
518    #[test]
519    fn export_script_contains_bin() {
520        let config = Config {
521            version: 1,
522            keybind: crate::model::KeybindConfig {
523                trigger: crate::model::PerShellKey {
524                    default: Some(TriggerKey::Space),
525                    ..Default::default()
526                },
527                ..crate::model::KeybindConfig::default()
528            },
529            precache: crate::model::PrecacheConfig::default(),
530            abbr: vec![],
531        };
532        for shell in [Shell::Bash, Shell::Zsh, Shell::Pwsh, Shell::Clink, Shell::Nu] {
533            let script = export_script(shell, "my-runex", Some(&config));
534            assert!(
535                script.contains("my-runex"),
536                "{shell:?} script must contain the bin name"
537            );
538        }
539    }
540
541    #[test]
542    fn bash_script_has_bind() {
543        let s = export_script(
544            Shell::Bash,
545            "runex",
546            Some(&Config {
547                version: 1,
548                keybind: crate::model::KeybindConfig {
549                    trigger: crate::model::PerShellKey {
550                        default: Some(TriggerKey::Space),
551                        ..Default::default()
552                    },
553                    ..crate::model::KeybindConfig::default()
554                },
555                precache: crate::model::PrecacheConfig::default(),
556                abbr: vec![],
557            }),
558        );
559        // New design: the bootstrap is a thin wrapper that calls
560        // `runex hook --shell bash` at keypress time. It should still bind the
561        // trigger key via `bind -x`, but the expansion logic itself now lives
562        // in the Rust binary — so there must be no inline `expand` call or
563        // READLINE inspection in the template (the hook output handles both).
564        assert!(s.contains("bind -x"), "bash bootstrap must use bind -x");
565        assert!(
566            s.contains("hook --shell bash"),
567            "bash bootstrap must invoke `runex hook --shell bash`"
568        );
569        assert!(
570            s.contains("'runex' hook --shell bash"),
571            "bash bootstrap must quote the executable name"
572        );
573        assert!(!s.contains("{BASH_BIND_LINES}"), "bash script must resolve bind lines");
574    }
575
576    #[test]
577    fn pwsh_script_has_psreadline() {
578        let s = export_script(
579            Shell::Pwsh,
580            "runex",
581            Some(&Config {
582                version: 1,
583                keybind: crate::model::KeybindConfig {
584                    trigger: crate::model::PerShellKey {
585                        default: Some(TriggerKey::Space),
586                        ..Default::default()
587                    },
588                    ..crate::model::KeybindConfig::default()
589                },
590                precache: crate::model::PrecacheConfig::default(),
591                abbr: vec![],
592            }),
593        );
594        assert!(s.contains("Set-PSReadLineKeyHandler"), "pwsh script must use PSReadLine");
595        assert!(
596            !s.contains("Set-PSReadLineKeyHandler -Chord 'Tab' -Function Complete"),
597            "pwsh script must not clobber the user's Tab binding"
598        );
599        assert!(
600            s.contains("'runex' @hookArgs") || s.contains("'runex' hook"),
601            "pwsh bootstrap must invoke runex with hook args"
602        );
603        assert!(
604            s.contains("hook"),
605            "pwsh bootstrap must invoke `runex hook`"
606        );
607        assert!(!s.contains("{PWSH_REGISTER_LINES}"), "pwsh script must resolve register lines");
608    }
609
610    #[test]
611    fn pwsh_script_has_paste_guard() {
612        // The paste-detection reflection is the one piece of logic that has
613        // to stay in the bootstrap — PSReadLine's `_queuedKeys` can only be
614        // inspected from inside the PSReadLine process. Guard against it
615        // being accidentally removed when the template is further trimmed.
616        let s = export_script(
617            Shell::Pwsh,
618            "runex",
619            Some(&Config {
620                version: 1,
621                keybind: crate::model::KeybindConfig {
622                    trigger: crate::model::PerShellKey {
623                        default: Some(TriggerKey::Space),
624                        ..Default::default()
625                    },
626                    ..crate::model::KeybindConfig::default()
627                },
628                precache: crate::model::PrecacheConfig::default(),
629                abbr: vec![],
630            }),
631        );
632        assert!(s.contains("__runex_queued_key_count"), "pwsh must retain paste guard helper");
633        assert!(s.contains("_queuedKeys"), "pwsh must probe PSReadLine's _queuedKeys field");
634        assert!(s.contains("--paste-pending"), "pwsh must forward paste state to `runex hook`");
635    }
636
637    #[test]
638    fn zsh_script_has_zle_widget() {
639        let s = export_script(
640            Shell::Zsh,
641            "runex",
642            Some(&Config {
643                version: 1,
644                keybind: crate::model::KeybindConfig {
645                    trigger: crate::model::PerShellKey {
646                        default: Some(TriggerKey::Space),
647                        ..Default::default()
648                    },
649                    ..crate::model::KeybindConfig::default()
650                },
651                precache: crate::model::PrecacheConfig::default(),
652                abbr: vec![],
653            }),
654        );
655        assert!(s.contains("zle -N __runex_expand"), "zsh script must register a zle widget");
656        assert!(s.contains(r#"bindkey " " __runex_expand"#), "zsh script must bind the trigger key");
657        assert!(s.contains("LBUFFER"), "zsh script must inspect the text before the cursor");
658        assert!(s.contains("RBUFFER"), "zsh script must inspect the text after the cursor");
659        assert!(
660            s.contains("'runex' hook --shell zsh"),
661            "zsh bootstrap must invoke `runex hook --shell zsh`"
662        );
663    }
664
665    #[test]
666    fn clink_script_has_clink() {
667        let s = export_script(
668            Shell::Clink,
669            "runex",
670            Some(&Config {
671                version: 1,
672                keybind: crate::model::KeybindConfig {
673                    trigger: crate::model::PerShellKey {
674                        default: Some(TriggerKey::Space),
675                        ..Default::default()
676                    },
677                    ..crate::model::KeybindConfig::default()
678                },
679                precache: crate::model::PrecacheConfig::default(),
680                abbr: vec![],
681            }),
682        );
683        assert!(s.contains("clink"), "clink script must reference clink");
684        assert!(s.contains("local RUNEX_BIN = \"runex\""), "clink script must quote the executable");
685        assert!(
686            s.contains("hook --shell clink"),
687            "clink bootstrap must invoke `runex hook --shell clink`"
688        );
689        assert!(
690            !s.contains("local RUNEX_KNOWN"),
691            "clink bootstrap must not embed token lookup table (moved to `runex hook`)"
692        );
693        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "emacs")"#), "clink script must bind the trigger key in emacs mode");
694        assert!(s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]], "vi-insert")"#), "clink script must bind the trigger key in vi insert mode");
695        assert!(s.contains("rl_buffer:getcursor()"), "clink script must inspect the cursor");
696        assert!(!s.contains("clink.onfilterinput"), "clink script must not use onfilterinput for realtime expansion");
697    }
698
699    #[test]
700    fn clink_script_uses_alt_space_sequence() {
701        let config = Config {
702            version: 1,
703            keybind: crate::model::KeybindConfig {
704                trigger: crate::model::PerShellKey {
705                    default: Some(TriggerKey::AltSpace),
706                    ..Default::default()
707                },
708                ..crate::model::KeybindConfig::default()
709            },
710            precache: crate::model::PrecacheConfig::default(),
711            abbr: vec![],
712        };
713        let s = export_script(Shell::Clink, "runex", Some(&config));
714        assert!(
715            s.contains(r#"pcall(rl.setbinding, [["\e "]], [["luafunc:runex_expand"]], "emacs")"#),
716            "clink script must use the alt-space sequence"
717        );
718    }
719
720    #[test]
721    fn nu_script_has_keybindings() {
722        let s = export_script(
723            Shell::Nu,
724            "runex",
725            Some(&Config {
726                version: 1,
727                keybind: crate::model::KeybindConfig {
728                    trigger: crate::model::PerShellKey {
729                        default: Some(TriggerKey::Space),
730                        ..Default::default()
731                    },
732                    ..crate::model::KeybindConfig::default()
733                },
734                precache: crate::model::PrecacheConfig::default(),
735                abbr: vec![],
736            }),
737        );
738        assert!(s.contains("keybindings"), "nu script must reference keybindings");
739        assert!(s.contains("commandline get-cursor"), "nu script must inspect the cursor");
740    }
741
742    #[test]
743    fn bash_script_uses_keybind_override() {
744        let config = Config {
745            version: 1,
746            keybind: crate::model::KeybindConfig {
747                trigger: crate::model::PerShellKey {
748                    bash: Some(TriggerKey::AltSpace),
749                    ..Default::default()
750                },
751                ..Default::default()
752            },
753            precache: crate::model::PrecacheConfig::default(),
754            abbr: vec![],
755        };
756        let s = export_script(Shell::Bash, "runex", Some(&config));
757        assert!(s.contains("\\e "), "bash script must use the configured key chord");
758    }
759
760    /// A bin value that is itself a template placeholder (e.g. `{BASH_BIN}`) must not cause
761    /// a second substitution pass. Quoting wraps it in single quotes, so `.replace()` never
762    /// matches it as a placeholder.
763    #[test]
764    fn export_script_placeholder_bin_does_not_cause_second_order_substitution() {
765        use crate::model::{Config, KeybindConfig, TriggerKey};
766        let config = Config {
767            version: 1,
768            keybind: KeybindConfig {
769                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
770                ..Default::default()
771            },
772            precache: crate::model::PrecacheConfig::default(),
773            abbr: vec![],
774        };
775
776        let cases: &[(&str, Shell, &str)] = &[
777            ("{BASH_BIN}", Shell::Bash, "'{BASH_BIN}'"),
778            ("{ZSH_BIN}", Shell::Zsh, "'{ZSH_BIN}'"),
779            ("{PWSH_BIN}", Shell::Pwsh, "'{PWSH_BIN}'"),
780        ];
781        for (placeholder, shell, expected_quoted) in cases {
782            let s = export_script(*shell, placeholder, Some(&config));
783            assert!(
784                s.contains(expected_quoted),
785                "bin={placeholder:?} for {shell:?} must appear as quoted literal {expected_quoted:?} in script"
786            );
787        }
788    }
789
790    /// `eval "$runex_debug_trap"` allows arbitrary code execution if bash-preexec or
791    /// another framework installed a DEBUG trap with an attacker-controlled string.
792    /// The script must NOT use eval to restore the trap.
793    #[test]
794    fn bash_script_does_not_eval_debug_trap() {
795        let config = Config {
796            version: 1,
797            keybind: crate::model::KeybindConfig {
798                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
799                ..Default::default()
800            },
801            precache: crate::model::PrecacheConfig::default(),
802            abbr: vec![],
803        };
804        let s = export_script(Shell::Bash, "runex", Some(&config));
805        assert!(
806            !s.contains("eval \"$runex_debug_trap\"") && !s.contains("eval '$runex_debug_trap'"),
807            "bash script must not eval the captured debug trap: {s}"
808        );
809    }
810
811    #[test]
812    fn bash_script_does_not_embed_known_tokens() {
813        // New design: the abbreviation list is consulted at keypress time by
814        // `runex hook`, not baked into the bootstrap as a `case` block. This
815        // keeps the emitted script independent of user-supplied key strings —
816        // which, besides being simpler, avoids a whole class of injection
817        // concerns (quoting gcm's key into a `case` arm).
818        let config = Config {
819            version: 1,
820            keybind: crate::model::KeybindConfig::default(),
821            precache: crate::model::PrecacheConfig::default(),
822            abbr: vec![crate::model::Abbr {
823                key: "gcm".into(),
824                expand: crate::model::PerShellString::All("git commit -m".into()),
825                when_command_exists: None,
826            }],
827        };
828        let s = export_script(Shell::Bash, "runex", Some(&config));
829        assert!(!s.contains("'gcm'"), "bash bootstrap must not embed tokens anymore");
830        assert!(!s.contains("__runex_is_known_token"), "legacy helper removed");
831    }
832
833    #[test]
834    fn pwsh_script_uses_global_keybind() {
835        let config = Config {
836            version: 1,
837            keybind: crate::model::KeybindConfig {
838                trigger: crate::model::PerShellKey {
839                    default: Some(TriggerKey::Tab),
840                    ..Default::default()
841                },
842                ..Default::default()
843            },
844            precache: crate::model::PrecacheConfig::default(),
845            abbr: vec![],
846        };
847        let s = export_script(Shell::Pwsh, "runex", Some(&config));
848        assert!(
849            s.contains("__runex_register_expand_handler 'Tab'"),
850            "pwsh script must use the configured chord"
851        );
852    }
853
854    #[test]
855    fn pwsh_script_uses_spacebar_name_for_alt_space() {
856        let config = Config {
857            version: 1,
858            keybind: crate::model::KeybindConfig {
859                trigger: crate::model::PerShellKey {
860                    pwsh: Some(TriggerKey::AltSpace),
861                    ..Default::default()
862                },
863                ..Default::default()
864            },
865            precache: crate::model::PrecacheConfig::default(),
866            abbr: vec![],
867        };
868        let s = export_script(Shell::Pwsh, "runex", Some(&config));
869        assert!(
870            s.contains("__runex_register_expand_handler 'Alt+Spacebar'"),
871            "pwsh script must register Alt+Space using Spacebar"
872        );
873    }
874
875    #[test]
876    fn pwsh_script_does_not_embed_known_tokens() {
877        // Same rationale as bash_script_does_not_embed_known_tokens: the
878        // hook-based bootstrap consults the config at keypress time.
879        let config = Config {
880            version: 1,
881            keybind: crate::model::KeybindConfig::default(),
882            precache: crate::model::PrecacheConfig::default(),
883            abbr: vec![crate::model::Abbr {
884                key: "gcm".into(),
885                expand: crate::model::PerShellString::All("git commit -m".into()),
886                when_command_exists: None,
887            }],
888        };
889        let s = export_script(Shell::Pwsh, "runex", Some(&config));
890        assert!(!s.contains("'gcm' { return $true }"), "pwsh must not embed tokens");
891        assert!(!s.contains("__runex_is_known_token"), "legacy helper removed");
892    }
893
894    #[test]
895    fn no_keybinds_means_no_handlers() {
896        let s = export_script(Shell::Bash, "runex", Some(&Config {
897            version: 1,
898            keybind: crate::model::KeybindConfig::default(),
899            precache: crate::model::PrecacheConfig::default(),
900            abbr: vec![],
901        }));
902        assert!(!s.contains("bind -x"), "bash script should not bind keys by default");
903        assert!(!s.contains(r#"bind -r"#), "bash script should not remove keybinds when no trigger is configured");
904
905        let s = export_script(Shell::Pwsh, "runex", Some(&Config {
906            version: 1,
907            keybind: crate::model::KeybindConfig::default(),
908            precache: crate::model::PrecacheConfig::default(),
909            abbr: vec![],
910        }));
911        assert!(
912            !s.contains("__runex_register_expand_handler '"),
913            "pwsh script should not register expand handlers by default"
914        );
915        assert!(
916            !s.contains("Set-PSReadLineKeyHandler -Chord ' ' -Function SelfInsert"),
917            "pwsh script should not clobber default key handlers when no trigger is configured"
918        );
919
920        let s = export_script(Shell::Clink, "runex", Some(&Config {
921            version: 1,
922            keybind: crate::model::KeybindConfig::default(),
923            precache: crate::model::PrecacheConfig::default(),
924            abbr: vec![],
925        }));
926        assert!(
927            !s.contains("rl.setbinding("),
928            "clink script should not register handlers by default"
929        );
930    }
931
932    /// These tests verify that a bin value containing shell metacharacters does
933    /// not break out of the quoted context it is embedded in.
934    /// The dangerous case is a quote character that closes the surrounding literal
935    /// and allows arbitrary code to follow on the same line.
936    #[test]
937    fn bin_single_quote_is_escaped_in_bash() {
938        let s = export_script(Shell::Bash, "run'ex", None);
939        assert!(s.contains(r"'run'\''ex'"), "bash: single quote must be escaped as '\\''");
940    }
941
942    #[test]
943    fn bin_single_quote_is_escaped_in_zsh() {
944        let s = export_script(Shell::Zsh, "run'ex", None);
945        assert!(s.contains(r"'run'\''ex'"), "zsh: single quote must be escaped as '\\''");
946    }
947
948    #[test]
949    fn bin_single_quote_is_escaped_in_pwsh() {
950        let s = export_script(Shell::Pwsh, "run'ex", None);
951        assert!(s.contains("'run''ex'"), "pwsh: single quote must be doubled");
952    }
953
954    #[test]
955    fn bin_double_quote_is_escaped_in_clink() {
956        let s = export_script(Shell::Clink, r#"run"ex"#, None);
957        assert!(s.contains(r#""run\"ex""#), "clink: double quote must be escaped");
958    }
959
960    #[test]
961    fn bin_with_special_chars_is_safe_in_nu() {
962        let s = export_script(Shell::Nu, "runex; echo INJECTED", None);
963        // The bin value must appear only inside double quotes, never as a
964        // naked command. `^"..."` runs the quoted external command literally.
965        assert!(
966            !s.contains("; echo INJECTED") || s.contains(r#"^"runex; echo INJECTED""#),
967            "nu: bin value must be quoted; got:\n{s}"
968        );
969        // Paranoia: ensure no unquoted `echo INJECTED` appears at start of a line.
970        for line in s.lines() {
971            let trimmed = line.trim_start();
972            assert!(
973                !trimmed.starts_with("echo INJECTED"),
974                "nu: unquoted injection detected: {line}"
975            );
976        }
977    }
978
979    /// In Nu, quoting a command name as `"runex"` makes it a string, not a command.
980    /// The correct external-command syntax is `^"runex"` — the `^` forces external execution.
981    /// Inside the `cmd: "..."` heredoc string, the quotes must be escaped: `^\"runex\"`.
982    /// The `{NU_BIN}` placeholder is only emitted when a trigger keybind is configured.
983    #[test]
984    fn nu_bin_uses_caret_external_command_syntax() {
985        use crate::model::{Config, KeybindConfig, TriggerKey};
986        let config = Config {
987            version: 1,
988            keybind: KeybindConfig {
989                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
990                ..Default::default()
991            },
992            precache: crate::model::PrecacheConfig::default(),
993            abbr: vec![],
994        };
995        let s = export_script(Shell::Nu, "runex", Some(&config));
996        assert!(
997            s.contains("^\\\"runex\\\""),
998            "nu: bin inside cmd string must use ^\\\"...\\\" syntax, got snippet: {:?}",
999            s.lines().find(|l| l.contains("runex")).unwrap_or("<not found>")
1000        );
1001    }
1002
1003    #[test]
1004    fn nu_bin_with_special_chars_uses_caret_syntax() {
1005        use crate::model::{Config, KeybindConfig, TriggerKey};
1006        let config = Config {
1007            version: 1,
1008            keybind: KeybindConfig {
1009                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1010                ..Default::default()
1011            },
1012            precache: crate::model::PrecacheConfig::default(),
1013            abbr: vec![],
1014        };
1015        let s = export_script(Shell::Nu, "my\"app", Some(&config));
1016        assert!(s.contains("^\\\"my\\\\\\\"app\\\""), "nu: special chars must be escaped in embedded context: {s}");
1017    }
1018
1019    /// REGRESSION: `{NU_BIN}` is substituted inside a `cmd: "..."` double-quoted Nu string.
1020    /// If the substitution produces `^"runex"`, the `"` terminates the outer string → syntax error.
1021    /// The substitution must use `\"` (escaped) inside the cmd context: `^\"runex\"`.
1022    #[test]
1023    fn nu_bin_in_cmd_string_does_not_break_outer_quotes() {
1024        use crate::model::{Config, KeybindConfig, TriggerKey};
1025        let config = Config {
1026            version: 1,
1027            keybind: KeybindConfig {
1028                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1029                ..Default::default()
1030            },
1031            precache: crate::model::PrecacheConfig::default(),
1032            abbr: vec![],
1033        };
1034        let s = export_script(Shell::Nu, "runex", Some(&config));
1035        let cmd_start = s.find("cmd: \"").expect("cmd: block not found");
1036        let cmd_block = &s[cmd_start..];
1037        assert!(
1038            cmd_block.contains("^\\\"runex\\\""),
1039            "nu: bin inside cmd string must use ^\\\"...\\\" syntax (escaped quotes), got:\n{}",
1040            cmd_block.lines().find(|l| l.contains("runex")).unwrap_or("<not found>")
1041        );
1042    }
1043
1044    } // mod script_generation
1045
1046    mod quote_functions {
1047        use super::*;
1048
1049    #[test]
1050    fn nu_quote_string_escapes_newline() {
1051        let s = nu_quote_string("run\nex");
1052        assert!(!s.contains('\n'), "nu_quote_string must escape newline: {s}");
1053        assert!(s.contains("\\n"), "expected \\n escape: {s}");
1054    }
1055
1056    #[test]
1057    fn nu_quote_string_escapes_carriage_return() {
1058        let s = nu_quote_string("run\rex");
1059        assert!(!s.contains('\r'), "nu_quote_string must escape CR: {s}");
1060        assert!(s.contains("\\r"), "expected \\r escape: {s}");
1061    }
1062
1063    /// `expand --token $token` (space-separated) is vulnerable to argument injection:
1064    /// if `$token` is `"--dry-run"`, Clap receives `["expand", "--token", "--dry-run"]` and
1065    /// may treat `"--dry-run"` as a flag rather than the value for `--token`.
1066    /// The safe form `expand --token=($token)` passes the value as part of the same argument.
1067    /// Note: `($token)` is Nu's parenthesized expression, not string interpolation —
1068    /// The nu bootstrap passes the buffer as a positional `--line $line`
1069    /// argument directly. Nu evaluates `$line` in its own variable scope and
1070    /// passes each argument as an opaque string — there is no shell-style
1071    /// word splitting — so argument injection via user-typed buffer content
1072    /// is not possible. This test pins that property.
1073    #[test]
1074    fn nu_hook_invocation_uses_separate_line_and_cursor_args() {
1075        use crate::model::{Config, KeybindConfig, TriggerKey};
1076        let config = Config {
1077            version: 1,
1078            keybind: KeybindConfig {
1079                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1080                ..Default::default()
1081            },
1082            precache: crate::model::PrecacheConfig::default(),
1083            abbr: vec![],
1084        };
1085        let s = export_script(Shell::Nu, "runex", Some(&config));
1086        assert!(
1087            s.contains("hook --shell nu --line $line --cursor $cursor"),
1088            "Nu bootstrap must pass buffer state as separate --line/--cursor args: {s}"
1089        );
1090        // The hook returns a JSON object which the bootstrap parses with
1091        // `from json`. Keep this as a structural assertion so the eval path
1092        // stays parseable rather than shell-executed.
1093        assert!(s.contains("from json"), "Nu bootstrap must parse hook output via `from json`: {s}");
1094    }
1095
1096    #[test]
1097    fn nu_bin_newline_does_not_inject_into_cmd_block() {
1098        use crate::model::{Config, KeybindConfig, TriggerKey};
1099        let config = Config {
1100            version: 1,
1101            keybind: KeybindConfig {
1102                trigger: crate::model::PerShellKey { default: Some(TriggerKey::Space), ..Default::default() },
1103                ..Default::default()
1104            },
1105            precache: crate::model::PrecacheConfig::default(),
1106            abbr: vec![],
1107        };
1108        let s = export_script(Shell::Nu, "runex\nsource /tmp/evil.nu\n", Some(&config));
1109        let lines: Vec<&str> = s.lines().collect();
1110        assert!(
1111            !lines.iter().any(|l| l.trim() == "source /tmp/evil.nu"),
1112            "newline must not create an injected source line: {s}"
1113        );
1114    }
1115
1116    #[test]
1117    fn bash_quote_string_drops_newline() {
1118        let s = bash_quote_string("run\nex");
1119        assert!(!s.contains('\n'), "bash_quote_string must drop newline: {s:?}");
1120        assert!(!s.contains("$'"), "dollar-quote ANSI-C form must not be used: {s:?}");
1121        assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1122    }
1123
1124    #[test]
1125    fn bash_quote_string_drops_carriage_return() {
1126        let s = bash_quote_string("run\rex");
1127        assert!(!s.contains('\r'), "bash_quote_string must drop CR: {s:?}");
1128        assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1129    }
1130
1131    #[test]
1132    fn bash_quote_string_escapes_nul() {
1133        let s = bash_quote_string("run\x00ex");
1134        assert!(!s.contains('\0'), "bash_quote_string must drop NUL: {s:?}");
1135    }
1136
1137    #[test]
1138    fn pwsh_quote_string_drops_newline() {
1139        let s = pwsh_quote_string("run\nex");
1140        assert!(!s.contains('\n'), "pwsh_quote_string must drop newline: {s:?}");
1141        assert!(!s.contains("'`"), "backtick-concat form must not be used: {s:?}");
1142        assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1143    }
1144
1145    #[test]
1146    fn pwsh_quote_string_drops_carriage_return() {
1147        let s = pwsh_quote_string("run\rex");
1148        assert!(!s.contains('\r'), "pwsh_quote_string must drop CR: {s:?}");
1149        assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1150    }
1151
1152    #[test]
1153    fn pwsh_quote_string_escapes_nul() {
1154        let s = pwsh_quote_string("run\x00ex");
1155        assert!(!s.contains('\0'), "pwsh_quote_string must drop NUL: {s:?}");
1156    }
1157
1158    #[test]
1159    fn nu_quote_string_escapes_nul() {
1160        let s = nu_quote_string("run\x00ex");
1161        assert!(!s.contains('\0'), "nu_quote_string must drop NUL: {s:?}");
1162    }
1163
1164
1165    #[test]
1166    fn bash_quote_string_newline_safe_in_eval_context() {
1167        let line = bash_quote_string("runex\necho INJECTED");
1168        assert!(!line.contains('\n'), "literal newline must not appear: {line:?}");
1169        assert!(!line.contains("$'"), "dollar-quote ANSI-C form must not be used (eval injection risk): {line:?}");
1170    }
1171
1172    #[test]
1173    fn bash_quote_string_cr_safe_in_eval_context() {
1174        let line = bash_quote_string("runex\recho INJECTED");
1175        assert!(!line.contains('\r'), "literal CR must not appear: {line:?}");
1176        assert!(!line.contains("$'"), "dollar-quote ANSI-C form must not be used: {line:?}");
1177    }
1178
1179    // bash_quote_pattern tests dropped — the helper and its callers (the
1180    // case-arm builder) are gone now that abbreviations aren't embedded in
1181    // shell code.
1182
1183    #[test]
1184    fn lua_quote_string_escapes_nul() {
1185        let s = lua_quote_string("run\x00ex");
1186        assert!(!s.contains('\0'), "lua_quote_string must not produce literal NUL: {s:?}");
1187    }
1188
1189    #[test]
1190    fn lua_quote_string_escapes_tab() {
1191        let s = lua_quote_string("run\tex");
1192        assert!(!s.contains('\t'), "lua_quote_string must escape tab: {s:?}");
1193    }
1194
1195    #[test]
1196    fn nu_quote_string_nul_is_dropped_not_embedded() {
1197        let s = nu_quote_string("run\x00ex");
1198        assert!(!s.contains("\\u{0000}"), "NUL must be dropped, not embedded as \\u{{0000}}: {s:?}");
1199        assert!(!s.contains('\0'), "literal NUL must not appear: {s:?}");
1200        assert!(s.contains("runex"), "remaining chars must be preserved: {s:?}");
1201    }
1202
1203    /// Guards against the `bytes[i] as char` antipattern: processing byte-by-byte splits
1204    /// multi-byte UTF-8 sequences (e.g. U+00E9 = [0xC3, 0xA9]), producing corrupted output.
1205    #[test]
1206    fn nu_quote_string_embedded_preserves_non_ascii_unicode() {
1207        let input = "caf\u{00E9}";
1208        let embedded = nu_quote_string_embedded(input);
1209        assert!(
1210            std::str::from_utf8(embedded.as_bytes()).is_ok(),
1211            "nu_quote_string_embedded must produce valid UTF-8: {embedded:?}"
1212        );
1213        assert!(
1214            embedded.contains('\u{00E9}'),
1215            "nu_quote_string_embedded must preserve non-ASCII char U+00E9: {embedded:?}"
1216        );
1217    }
1218
1219    #[test]
1220    fn pwsh_quote_string_newline_not_using_backtick_concat() {
1221        let s = pwsh_quote_string("run\nex");
1222        assert!(!s.contains('\n'), "literal newline must not appear: {s:?}");
1223        assert!(!s.contains("'`"), "backtick-concat form must not be used (token split risk): {s:?}");
1224    }
1225
1226    } // mod quote_functions
1227
1228    mod regression_issues {
1229        use super::*;
1230
1231    /// A `"` in bin must not terminate the shell double-quoted string inside `io.popen`.
1232    /// The fix is single-quote wrapping (with `'\''` for embedded single quotes).
1233    #[test]
1234    fn clink_script_double_quote_in_bin_does_not_inject_into_popen() {
1235        let s = export_script(Shell::Clink, "run\"ex", Some(&Config {
1236            version: 1,
1237            keybind: crate::model::KeybindConfig::default(),
1238            precache: crate::model::PrecacheConfig::default(),
1239            abbr: vec![],
1240        }));
1241        assert!(
1242            !s.contains(r#"'"' .. RUNEX_BIN .. '"'"#),
1243            "io.popen must not wrap RUNEX_BIN in shell double-quotes: {s}"
1244        );
1245    }
1246
1247    #[test]
1248    fn clink_script_bin_with_double_quote_uses_single_quote_shell_wrapping() {
1249        let s = export_script(Shell::Clink, "run\"ex", Some(&Config {
1250            version: 1,
1251            keybind: crate::model::KeybindConfig::default(),
1252            precache: crate::model::PrecacheConfig::default(),
1253            abbr: vec![],
1254        }));
1255        assert!(
1256            s.contains("runex_shell_quote"),
1257            "clink script must use a shell-quoting helper for RUNEX_BIN in io.popen: {s}"
1258        );
1259    }
1260
1261
1262    /// Regression: cmd.exe (which Lua's `io.popen` invokes via `cmd /c
1263    /// <string>` on Windows) heuristically strips the *outermost* pair of
1264    /// double quotes when the string starts AND ends with `"`. The clink
1265    /// template therefore must wrap the entire io.popen command in an
1266    /// extra pair of `"` so the inner quoting around argv0 (which can
1267    /// contain spaces, e.g. `C:\Program Files\...`) and `--line "..."`
1268    /// survives. Without this, cmd reports `'... is not recognized as an
1269    /// internal or external command'` and the hook never executes.
1270    #[test]
1271    fn clink_io_popen_command_is_wrapped_in_extra_pair_of_quotes() {
1272        let s = export_script(Shell::Clink, "runex", None);
1273        assert!(
1274            s.contains("local cmd = '\"' .. runex_shell_quote(RUNEX_BIN)"),
1275            "clink script must prepend a literal '\"' before runex_shell_quote(RUNEX_BIN): {s}"
1276        );
1277        assert!(
1278            s.contains("' 2>&1\"'"),
1279            "clink script must append a literal '\"' after `2>&1`: {s}"
1280        );
1281    }
1282
1283    #[test]
1284    fn nu_quote_string_escapes_tab() {
1285        let s = nu_quote_string("run\tex");
1286        assert!(!s.contains('\t'), "nu_quote_string must escape tab: {s:?}");
1287        assert!(s.contains("\\t"), "expected \\t escape: {s:?}");
1288    }
1289
1290
1291    #[test]
1292    fn bash_quote_string_drops_unicode_line_separator() {
1293        let s = bash_quote_string("run\u{2028}ex");
1294        assert!(!s.contains('\u{2028}'), "bash_quote_string must drop U+2028: {s:?}");
1295    }
1296
1297    #[test]
1298    fn pwsh_quote_string_drops_unicode_line_separator() {
1299        let s = pwsh_quote_string("run\u{2028}ex");
1300        assert!(!s.contains('\u{2028}'), "pwsh_quote_string must drop U+2028: {s:?}");
1301    }
1302
1303    #[test]
1304    fn nu_quote_string_drops_unicode_line_separator() {
1305        let s = nu_quote_string("run\u{2028}ex");
1306        assert!(!s.contains('\u{2028}'), "nu_quote_string must drop U+2028: {s:?}");
1307    }
1308
1309
1310    #[test]
1311    fn nu_quote_string_drops_del() {
1312        let s = nu_quote_string("run\x7fex");
1313        assert!(!s.contains('\x7f'), "nu_quote_string must drop DEL (\\x7f): {s:?}");
1314    }
1315
1316    #[test]
1317    fn nu_quote_string_escapes_dollar_sign() {
1318        let s = nu_quote_string("run$exenv");
1319        let raw_dollar = s
1320            .char_indices()
1321            .filter(|(_, c)| *c == '$')
1322            .any(|(i, _)| i == 0 || s.as_bytes()[i - 1] != b'\\');
1323        assert!(
1324            !raw_dollar,
1325            "nu_quote_string must escape '$' to prevent Nu variable interpolation: {s:?}"
1326        );
1327        assert!(s.contains("\\$"), "expected \\$ escape sequence in: {s:?}");
1328    }
1329
1330    /// In the outer `cmd: "..."` Nu string, `\\$` means literal `\` + variable interpolation
1331    /// (unsafe). `nu_quote_string` emits `\$` for a literal `$`; when embedded, `\$` must
1332    /// become `\\\$` so the outer parser still sees `\$` (suppressed interpolation), not `\\$`.
1333    /// Verified by asserting every `$` byte is preceded by an odd number of backslashes.
1334    #[test]
1335    fn nu_quote_string_embedded_escapes_dollar_sign() {
1336        let s = nu_quote_string_embedded("run$exenv");
1337        let bytes = s.as_bytes();
1338        for i in 0..bytes.len() {
1339            if bytes[i] == b'$' {
1340                let mut preceding_backslashes = 0usize;
1341                let mut j = i;
1342                while j > 0 && bytes[j - 1] == b'\\' {
1343                    preceding_backslashes += 1;
1344                    j -= 1;
1345                }
1346                assert!(
1347                    preceding_backslashes % 2 == 1,
1348                    "nu_quote_string_embedded: '$' at byte {i} has {preceding_backslashes} preceding backslashes \
1349                     (even = Nu interpolation NOT suppressed). Full output: {s:?}"
1350                );
1351            }
1352        }
1353    }
1354
1355    /// `\n`, `\r`, `\t` are escaped as two-character sequences; all other C0 control chars
1356    /// (`\x01`–`\x08`, `\x0b`, `\x0c`, `\x0e`–`\x1f`) are dropped entirely.
1357    #[test]
1358    fn nu_quote_string_drops_remaining_c0_control_chars() {
1359        let dangerous_c0: &[char] = &[
1360            '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
1361            '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
1362            '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
1363            '\x18', '\x19', '\x1a', '\x1b',
1364            '\x1c', '\x1d', '\x1e', '\x1f',
1365        ];
1366        for &ch in dangerous_c0 {
1367            let input = format!("run{}ex", ch);
1368            let s = nu_quote_string(&input);
1369            assert!(
1370                !s.contains(ch),
1371                "nu_quote_string must drop C0 control U+{:04X}: {s:?}",
1372                ch as u32
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn pwsh_self_insert_shift_space_when_configured() {
1379        let config = Config {
1380            version: 1,
1381            keybind: crate::model::KeybindConfig {
1382                self_insert: crate::model::PerShellKey {
1383                    pwsh: Some(TriggerKey::ShiftSpace),
1384                    ..Default::default()
1385                },
1386                ..crate::model::KeybindConfig::default()
1387            },
1388            precache: crate::model::PrecacheConfig::default(),
1389            abbr: vec![],
1390        };
1391        let s = export_script(Shell::Pwsh, "runex", Some(&config));
1392        assert!(
1393            s.contains("Set-PSReadLineKeyHandler -Chord 'Shift+Spacebar' -Function SelfInsert"),
1394            "pwsh script must bind Shift+Spacebar to SelfInsert when self_insert = shift-space: {s}"
1395        );
1396    }
1397
1398    #[test]
1399    fn pwsh_self_insert_alt_space_when_configured() {
1400        let config = Config {
1401            version: 1,
1402            keybind: crate::model::KeybindConfig {
1403                self_insert: crate::model::PerShellKey {
1404                    pwsh: Some(TriggerKey::AltSpace),
1405                    ..Default::default()
1406                },
1407                ..crate::model::KeybindConfig::default()
1408            },
1409            precache: crate::model::PrecacheConfig::default(),
1410            abbr: vec![],
1411        };
1412        let s = export_script(Shell::Pwsh, "runex", Some(&config));
1413        assert!(
1414            s.contains("Set-PSReadLineKeyHandler -Chord 'Alt+Spacebar' -Function SelfInsert"),
1415            "pwsh script must bind Alt+Spacebar to SelfInsert when self_insert = alt-space: {s}"
1416        );
1417    }
1418
1419    #[test]
1420    fn pwsh_no_self_insert_when_not_configured() {
1421        let config = Config {
1422            version: 1,
1423            keybind: crate::model::KeybindConfig {
1424                trigger: crate::model::PerShellKey {
1425                    default: Some(TriggerKey::Space),
1426                    ..Default::default()
1427                },
1428                ..crate::model::KeybindConfig::default()
1429            },
1430            precache: crate::model::PrecacheConfig::default(),
1431            abbr: vec![],
1432        };
1433        let s = export_script(Shell::Pwsh, "runex", Some(&config));
1434        assert!(
1435            !s.contains("SelfInsert"),
1436            "pwsh script must not bind SelfInsert when self_insert is not configured (even if trigger is Space): {s}"
1437        );
1438    }
1439
1440    #[test]
1441    fn nu_self_insert_shift_space_when_configured() {
1442        let config = Config {
1443            version: 1,
1444            keybind: crate::model::KeybindConfig {
1445                self_insert: crate::model::PerShellKey {
1446                    nu: Some(TriggerKey::ShiftSpace),
1447                    ..Default::default()
1448                },
1449                ..crate::model::KeybindConfig::default()
1450            },
1451            precache: crate::model::PrecacheConfig::default(),
1452            abbr: vec![],
1453        };
1454        let s = export_script(Shell::Nu, "runex", Some(&config));
1455        assert!(
1456            s.contains("runex_self_insert") && s.contains("modifier: shift") && s.contains("keycode: space"),
1457            "nu script must include shift+space self-insert binding when self_insert = shift-space: {s}"
1458        );
1459    }
1460
1461    #[test]
1462    fn nu_self_insert_alt_space_when_configured() {
1463        let config = Config {
1464            version: 1,
1465            keybind: crate::model::KeybindConfig {
1466                self_insert: crate::model::PerShellKey {
1467                    nu: Some(TriggerKey::AltSpace),
1468                    ..Default::default()
1469                },
1470                ..crate::model::KeybindConfig::default()
1471            },
1472            precache: crate::model::PrecacheConfig::default(),
1473            abbr: vec![],
1474        };
1475        let s = export_script(Shell::Nu, "runex", Some(&config));
1476        assert!(
1477            s.contains("runex_self_insert") && s.contains("modifier: alt") && s.contains("keycode: space"),
1478            "nu script must include alt+space self-insert binding when self_insert = alt-space: {s}"
1479        );
1480    }
1481
1482    #[test]
1483    fn nu_no_self_insert_when_not_configured() {
1484        let config = Config {
1485            version: 1,
1486            keybind: crate::model::KeybindConfig {
1487                trigger: crate::model::PerShellKey {
1488                    default: Some(TriggerKey::Space),
1489                    ..Default::default()
1490                },
1491                ..crate::model::KeybindConfig::default()
1492            },
1493            precache: crate::model::PrecacheConfig::default(),
1494            abbr: vec![],
1495        };
1496        let s = export_script(Shell::Nu, "runex", Some(&config));
1497        assert!(
1498            !s.contains("insertchar"),
1499            "nu script must not contain insertchar append block when self_insert is not configured: {s}"
1500        );
1501    }
1502
1503    #[test]
1504    fn bash_self_insert_alt_space_when_configured() {
1505        let config = Config {
1506            version: 1,
1507            keybind: crate::model::KeybindConfig {
1508                self_insert: crate::model::PerShellKey {
1509                    bash: Some(TriggerKey::AltSpace),
1510                    ..Default::default()
1511                },
1512                ..crate::model::KeybindConfig::default()
1513            },
1514            precache: crate::model::PrecacheConfig::default(),
1515            abbr: vec![],
1516        };
1517        let s = export_script(Shell::Bash, "runex", Some(&config));
1518        assert!(
1519            s.contains(r#"bind '"\e ": self-insert'"#),
1520            "bash script must bind Alt+Space to self-insert when self_insert = alt-space: {s}"
1521        );
1522    }
1523
1524    #[test]
1525    fn bash_no_self_insert_when_not_configured() {
1526        let config = Config {
1527            version: 1,
1528            keybind: crate::model::KeybindConfig {
1529                trigger: crate::model::PerShellKey {
1530                    default: Some(TriggerKey::Space),
1531                    ..Default::default()
1532                },
1533                ..crate::model::KeybindConfig::default()
1534            },
1535            precache: crate::model::PrecacheConfig::default(),
1536            abbr: vec![],
1537        };
1538        let s = export_script(Shell::Bash, "runex", Some(&config));
1539        assert!(
1540            !s.contains("self-insert"),
1541            "bash script must not contain self-insert when self_insert is not configured: {s}"
1542        );
1543    }
1544
1545    #[test]
1546    fn zsh_self_insert_alt_space_when_configured() {
1547        let config = Config {
1548            version: 1,
1549            keybind: crate::model::KeybindConfig {
1550                self_insert: crate::model::PerShellKey {
1551                    zsh: Some(TriggerKey::AltSpace),
1552                    ..Default::default()
1553                },
1554                ..crate::model::KeybindConfig::default()
1555            },
1556            precache: crate::model::PrecacheConfig::default(),
1557            abbr: vec![],
1558        };
1559        let s = export_script(Shell::Zsh, "runex", Some(&config));
1560        assert!(
1561            s.contains(r#"bindkey "^[ " self-insert"#),
1562            "zsh script must bind Alt+Space to self-insert when self_insert = alt-space: {s}"
1563        );
1564    }
1565
1566    #[test]
1567    fn zsh_no_self_insert_when_not_configured() {
1568        let config = Config {
1569            version: 1,
1570            keybind: crate::model::KeybindConfig {
1571                trigger: crate::model::PerShellKey {
1572                    default: Some(TriggerKey::Space),
1573                    ..Default::default()
1574                },
1575                ..crate::model::KeybindConfig::default()
1576            },
1577            precache: crate::model::PrecacheConfig::default(),
1578            abbr: vec![],
1579        };
1580        let s = export_script(Shell::Zsh, "runex", Some(&config));
1581        assert!(
1582            !s.contains("self-insert"),
1583            "zsh script must not contain self-insert when self_insert is not configured: {s}"
1584        );
1585    }
1586
1587    #[test]
1588    fn trigger_for_shell_override_takes_precedence_over_default() {
1589        let config = Config {
1590            version: 1,
1591            keybind: crate::model::KeybindConfig {
1592                trigger: crate::model::PerShellKey {
1593                    default: Some(TriggerKey::Space),
1594                    bash: Some(TriggerKey::AltSpace),
1595                    ..Default::default()
1596                },
1597                ..Default::default()
1598            },
1599            precache: crate::model::PrecacheConfig::default(),
1600            abbr: vec![],
1601        };
1602        // bash-specific override (AltSpace) takes precedence over default (Space)
1603        let bash_s = export_script(Shell::Bash, "runex", Some(&config));
1604        assert!(bash_s.contains("\\e "), "bash must use AltSpace override, not default Space");
1605        // zsh falls back to default (Space)
1606        let zsh_s = export_script(Shell::Zsh, "runex", Some(&config));
1607        assert!(zsh_s.contains(r#"bindkey " " __runex_expand"#), "zsh must fall back to default Space");
1608    }
1609
1610    #[test]
1611    fn trigger_for_falls_back_to_default() {
1612        let config = Config {
1613            version: 1,
1614            keybind: crate::model::KeybindConfig {
1615                trigger: crate::model::PerShellKey {
1616                    default: Some(TriggerKey::Tab),
1617                    ..Default::default()
1618                },
1619                ..Default::default()
1620            },
1621            precache: crate::model::PrecacheConfig::default(),
1622            abbr: vec![],
1623        };
1624        // nu has no shell-specific override, must use default (Tab)
1625        let nu_s = export_script(Shell::Nu, "runex", Some(&config));
1626        assert!(nu_s.contains("tab"), "nu must fall back to default Tab trigger");
1627    }
1628
1629    #[test]
1630    fn clink_ignores_shell_specific_trigger_fields() {
1631        let config = Config {
1632            version: 1,
1633            keybind: crate::model::KeybindConfig {
1634                trigger: crate::model::PerShellKey {
1635                    default: Some(TriggerKey::Space),
1636                    bash: Some(TriggerKey::AltSpace),
1637                    ..Default::default()
1638                },
1639                ..Default::default()
1640            },
1641            precache: crate::model::PrecacheConfig::default(),
1642            abbr: vec![],
1643        };
1644        // Clink only uses trigger.default, not bash/zsh/pwsh/nu
1645        let s = export_script(Shell::Clink, "runex", Some(&config));
1646        assert!(
1647            s.contains(r#"pcall(rl.setbinding, [[" "]], [["luafunc:runex_expand"]]"#),
1648            "clink must use trigger.default (Space), not the bash-specific AltSpace: {s}"
1649        );
1650    }
1651
1652    } // mod regression_issues
1653
1654    mod unicode_edge_cases {
1655        use super::*;
1656
1657    #[test]
1658    fn lua_quote_string_drops_del() {
1659        let s = lua_quote_string("run\x7fex");
1660        assert!(!s.contains('\x7f'), "lua_quote_string must drop DEL: {s:?}");
1661    }
1662
1663    #[test]
1664    fn lua_quote_string_drops_unicode_line_separators() {
1665        for ch in ['\u{0085}', '\u{2028}', '\u{2029}'] {
1666            let input = format!("run{ch}ex");
1667            let s = lua_quote_string(&input);
1668            assert!(!s.contains(ch), "lua_quote_string must drop U+{:04X}: {s:?}", ch as u32);
1669        }
1670    }
1671
1672    /// Naive `format!("\\{}", 1)` produces `"\1"` which Lua reads as `"\10"` (LF) when
1673    /// followed by `"0"`. Three-digit zero-padded `"\001"` avoids the ambiguity.
1674    #[test]
1675    fn lua_quote_string_decimal_escape_not_ambiguous_with_following_digit() {
1676        let s = lua_quote_string("\x010");
1677        assert!(
1678            !s.contains("\\10"),
1679            "lua_quote_string: \\x01 + '0' must not produce ambiguous \\10: {s:?}"
1680        );
1681        assert!(
1682            s.contains("\\001"),
1683            "lua_quote_string: \\x01 must be escaped as \\001: {s:?}"
1684        );
1685    }
1686
1687    } // mod unicode_edge_cases
1688
1689    /// In bash/zsh `case` patterns, characters enclosed in single quotes are
1690    /// treated as literals, not glob wildcards. `'*'` matches only a literal
1691    /// asterisk, not every string. `bash_quote_pattern` wraps keys in single
1692    /// quotes, so `*`, `?`, `[...]` are all safe in case patterns.
1693    mod case_pattern_globs {
1694        use super::*;
1695
1696    // The bash case-pattern injection tests were specific to the legacy
1697    // design where abbreviation keys were embedded as `case` arms inside the
1698    // bash bootstrap. With the new hook-based bootstrap, keys are never
1699    // emitted into shell code, so glob-like keys (`*`, `?`, `[...]`) pose no
1700    // shell-expansion risk at export time. The equivalent safety is now
1701    // enforced by Rust-side key validation in `config::validate_abbr_key`
1702    // (see runex-core/src/config.rs).
1703
1704    // Zsh case-pattern injection tests removed for the same reason as bash:
1705    // the new hook-based bootstrap does not embed keys in shell code. See the
1706    // comment block above for the bash equivalent.
1707
1708    // The "pwsh switch must have exactly one `default {` clause" regression
1709    // test was specific to the legacy token-embedding design; with the new
1710    // bootstrap there is no switch statement at all.
1711
1712    } // mod case_pattern_globs
1713}