Skip to main content

runex_core/
init.rs

1use std::path::PathBuf;
2
3use crate::config::xdg_config_home;
4use crate::sanitize::{double_quote_escape, is_nu_drop_char};
5use crate::shell::{bash_quote_string, lua_quote_string, nu_quote_string, pwsh_quote_string, Shell};
6
7/// Quote a filesystem path for embedding in a Nu shell string literal.
8///
9/// Uses Nu double-quoted string syntax (no `^` prefix — this is a string value,
10/// not a command invocation).  Escapes `\`, `"`, and `$` (to suppress variable
11/// interpolation of `$env.FOO` etc.).  NUL, DEL, ASCII control characters, Unicode
12/// line/paragraph separators, and visual-deception characters are all dropped.
13fn nu_quote_path(path: &str) -> String {
14    let mut out = String::from("\"");
15    for ch in path.chars() {
16        if let Some(esc) = double_quote_escape(ch) {
17            out.push_str(esc);
18        } else if ch == '$' {
19            out.push_str("\\$");
20        } else if is_nu_drop_char(ch) {
21        } else {
22            out.push(ch);
23        }
24    }
25    out.push('"');
26    out
27}
28
29/// Marker comment written into rc files to enable idempotent init.
30pub const RUNEX_INIT_MARKER: &str = "# runex-init";
31
32/// Seed config written by `runex init` when no config exists yet.
33///
34/// Includes a working trigger key and one runnable sample abbreviation
35/// so the user can verify expansion immediately after a fresh install
36/// without first having to read the docs. The codex usability review
37/// flagged "installed but nothing happens" as the second-most painful
38/// onboarding break, so the seed is deliberately *useful* rather than
39/// minimal.
40///
41/// `runex init` only writes this content when the config file does not
42/// already exist (`OpenOptions::create_new`). Existing configs are
43/// never touched.
44pub fn default_config_content() -> &'static str {
45    r#"version = 1
46
47[keybind.trigger]
48default = "space"
49
50# Sample abbreviation. After restarting your shell, type `gst<Space>`
51# and it will expand to `git status `.
52[[abbr]]
53key    = "gst"
54expand = "git status"
55
56# Add your own below. For more recipes (per-shell commands, fallback
57# chains, cursor placeholders, etc.) see:
58# https://github.com/ShortArrow/runex/blob/main/docs/recipes.md
59"#
60}
61
62/// The single line appended to the shell rc file.
63///
64/// ## Drift resistance
65///
66/// For bash/zsh/pwsh/nu the line either *re-evaluates* the export at
67/// every shell start (`eval "$(runex export ...)"`,
68/// `Invoke-Expression (& runex export pwsh ...)`) or re-writes the
69/// shell-side script every start (nu's `save --force`). That makes the
70/// integration **drift-proof**: upgrading runex automatically picks up
71/// the latest template the next time the shell starts.
72///
73/// **clink is the exception.** The lua file lives outside any rcfile
74/// reload pathway, so users have to re-run `runex init clink` after a
75/// `runex` upgrade. `runex doctor` flags this drift via the
76/// `integration:clink` check (see
77/// [`crate::integration_check::check_clink_lua_freshness`]).
78pub fn integration_line(shell: Shell, bin: &str) -> String {
79    match shell {
80        Shell::Bash => format!("eval \"$({} export bash)\"", bash_quote_string(bin)),
81        Shell::Zsh => format!("eval \"$({} export zsh)\"", bash_quote_string(bin)),
82        Shell::Pwsh => format!(
83            "Invoke-Expression (& {} export pwsh | Out-String)",
84            pwsh_quote_string(bin)
85        ),
86        Shell::Nu => {
87            let cfg_dir = xdg_config_home()
88                .map(|p| p.display().to_string())
89                .unwrap_or_else(|| "~/.config".to_string());
90            let nu_bin = nu_quote_string(bin);
91            let nu_path = nu_quote_path(&format!("{cfg_dir}/runex/runex.nu"));
92            format!(
93                "{nu_bin} export nu | save --force {nu_path}\nsource {nu_path}"
94            )
95        }
96        Shell::Clink => format!(
97            "-- add {} export clink output to your clink scripts directory",
98            lua_quote_string(bin)
99        ),
100    }
101}
102
103/// Where `runex init clink` writes the lua integration script.
104///
105/// Resolution order (first match wins):
106///
107/// 1. `RUNEX_CLINK_LUA_PATH` env — explicit override for non-standard
108///    clink installations or for testing.
109/// 2. `%LOCALAPPDATA%\clink\runex.lua` — clink's default state directory
110///    on Windows. This is what `clink info` reports as the scripts dir.
111/// 3. `~/.local/share/clink/runex.lua` — POSIX-style fallback for the
112///    Linux clink fork (rare, included for completeness).
113///
114/// We deliberately do not shell out to `clink info` to discover the
115/// scripts directory: that would invert the dependency direction
116/// (Rust → shell tool) for one path lookup, and the env-var override
117/// already lets users with non-standard installs cope.
118pub fn default_clink_lua_install_path() -> std::path::PathBuf {
119    clink_lua_install_path_with(|k| std::env::var(k).ok(), dirs::home_dir)
120}
121
122/// Pure variant of [`default_clink_lua_install_path`] for testing —
123/// env access and the home-dir lookup are injected so tests can pin
124/// the result without racing other threads on `std::env::set_var`.
125pub(crate) fn clink_lua_install_path_with<E, H>(env_get: E, home_dir: H) -> std::path::PathBuf
126where
127    E: Fn(&str) -> Option<String>,
128    H: Fn() -> Option<std::path::PathBuf>,
129{
130    if let Some(p) = env_get("RUNEX_CLINK_LUA_PATH") {
131        if !p.is_empty() {
132            return std::path::PathBuf::from(p);
133        }
134    }
135    if let Some(local) = env_get("LOCALAPPDATA") {
136        if !local.is_empty() {
137            return std::path::PathBuf::from(local).join("clink").join("runex.lua");
138        }
139    }
140    if let Some(home) = home_dir() {
141        return home.join(".local").join("share").join("clink").join("runex.lua");
142    }
143    std::path::PathBuf::from("runex.lua")
144}
145
146/// "What to do next" blurb shown after `runex init` finishes. The
147/// integration line lives in the rcfile but the *currently-running*
148/// shell hasn't sourced it yet, so the user has to either reload the
149/// rcfile or open a fresh shell. Each shell has its own idiomatic
150/// reload command; clink keeps no rcfile and just needs a new cmd.
151///
152/// `rc_path` is the file we just appended to (or `None` for clink, where
153/// the integration goes into a separate lua file rather than an rcfile).
154pub fn next_steps_message(shell: Shell, rc_path: Option<&std::path::Path>) -> String {
155    let reload = match shell {
156        Shell::Bash | Shell::Zsh => match rc_path {
157            Some(p) => format!("Reload your shell: `source {}` (or `exec $SHELL`)", p.display()),
158            None => "Reload your shell: `exec $SHELL`".to_string(),
159        },
160        Shell::Pwsh => match rc_path {
161            Some(p) => format!("Reload your profile: `. $PROFILE` (resolves to {})", p.display()),
162            None => "Reload your profile: `. $PROFILE`".to_string(),
163        },
164        Shell::Nu => "Reload nushell: open a new shell (or run `exec nu`)".to_string(),
165        Shell::Clink => "Open a new cmd window — clink loads the lua at startup.".to_string(),
166    };
167    format!(
168        "Next steps:\n  1. {reload}\n  2. Try `gst<Space>` — it should expand to `git status `.\n  3. Add your own abbreviations: see https://github.com/ShortArrow/runex/blob/main/docs/recipes.md\n  4. Verify any time with: `runex doctor`"
169    )
170}
171
172/// The rc file path for a given shell (best-effort; may not exist yet).
173///
174/// For PowerShell, `$PROFILE` is a runtime variable and cannot be resolved statically,
175/// so the conventional filesystem path is used instead.
176pub fn rc_file_for(shell: Shell) -> Option<PathBuf> {
177    let home = dirs::home_dir()?;
178    match shell {
179        Shell::Bash => Some(home.join(".bashrc")),
180        Shell::Zsh => Some(home.join(".zshrc")),
181        Shell::Pwsh => {
182            let base = if cfg!(windows) {
183                home.join("Documents").join("PowerShell")
184            } else {
185                home.join(".config").join("powershell")
186            };
187            Some(base.join("Microsoft.PowerShell_profile.ps1"))
188        }
189        Shell::Nu => {
190            let cfg = xdg_config_home().unwrap_or_else(|| home.join(".config"));
191            Some(cfg.join("nushell").join("env.nu"))
192        }
193        Shell::Clink => None,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    mod integration_line {
202        use super::*;
203
204    #[test]
205    fn default_config_content_has_version() {
206        assert!(default_config_content().contains("version = 1"));
207    }
208
209    /// The seed config must include a working keybind so that the very
210    /// first `runex init` produces a setup that actually expands. Without
211    /// this, users hit "I installed runex and nothing happens" — the
212    /// codex usability review flagged this as the second-most painful
213    /// onboarding break after the missing `init <shell>` surface.
214    #[test]
215    fn default_config_content_includes_default_trigger() {
216        let s = default_config_content();
217        assert!(s.contains("[keybind.trigger]"), "missing [keybind.trigger]: {s}");
218        assert!(s.contains("default = \"space\""), "missing default trigger: {s}");
219    }
220
221    /// The seed config must include at least one runnable abbreviation so
222    /// the user can verify expansion immediately after `runex init`.
223    #[test]
224    fn default_config_content_includes_sample_abbr_gst() {
225        let s = default_config_content();
226        assert!(s.contains("key    = \"gst\""), "missing gst sample: {s}");
227        assert!(s.contains("expand = \"git status\""), "missing gst expand: {s}");
228    }
229
230    /// `next_steps_message` produces the after-init "what to do next"
231    /// blurb. Each shell's blurb has to mention how to *reload* the
232    /// integration (since rcfile changes don't take effect in the
233    /// already-running shell), how to find more abbreviations, and how
234    /// to verify with `runex doctor`.
235    #[test]
236    fn next_steps_for_bash_mentions_source_command() {
237        let msg = next_steps_message(Shell::Bash, Some(std::path::Path::new("/home/u/.bashrc")));
238        assert!(msg.contains("source /home/u/.bashrc") || msg.contains("exec"),
239            "bash next_steps must explain how to reload: {msg}");
240        assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
241        assert!(msg.contains("recipes"), "must point at recipes: {msg}");
242    }
243
244    #[test]
245    fn next_steps_for_clink_mentions_new_cmd_window() {
246        let msg = next_steps_message(Shell::Clink, None);
247        assert!(msg.to_lowercase().contains("cmd"),
248            "clink next_steps must mention opening a new cmd window: {msg}");
249        assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
250    }
251
252    #[test]
253    fn next_steps_for_pwsh_mentions_dot_profile() {
254        let msg = next_steps_message(
255            Shell::Pwsh,
256            Some(std::path::Path::new("/u/Microsoft.PowerShell_profile.ps1")),
257        );
258        assert!(msg.contains("$PROFILE") || msg.contains(". /"),
259            "pwsh next_steps must explain reload: {msg}");
260    }
261
262    /// `clink_lua_install_path_with` decides where `runex init clink`
263    /// writes the lua file. Honours `RUNEX_CLINK_LUA_PATH` first, then
264    /// `LOCALAPPDATA` (Windows convention), then a POSIX-style fallback
265    /// for clink forks on Linux. Tests use the closure-injected variant
266    /// to avoid racing on the global env from parallel test threads.
267    #[test]
268    fn clink_install_path_honors_env_override() {
269        let p = clink_lua_install_path_with(
270            |k| match k {
271                "RUNEX_CLINK_LUA_PATH" => Some("/tmp/runex_test_clink.lua".into()),
272                _ => None,
273            },
274            || None,
275        );
276        assert_eq!(p, std::path::PathBuf::from("/tmp/runex_test_clink.lua"));
277    }
278
279    #[test]
280    fn clink_install_path_uses_localappdata_when_set() {
281        let p = clink_lua_install_path_with(
282            |k| match k {
283                "LOCALAPPDATA" => Some("/tmp/local_appdata_test".into()),
284                _ => None,
285            },
286            || None,
287        );
288        assert_eq!(
289            p,
290            std::path::PathBuf::from("/tmp/local_appdata_test/clink/runex.lua")
291        );
292    }
293
294    #[test]
295    fn clink_install_path_falls_back_to_home() {
296        let p = clink_lua_install_path_with(
297            |_| None,
298            || Some(std::path::PathBuf::from("/home/user")),
299        );
300        assert_eq!(
301            p,
302            std::path::PathBuf::from("/home/user/.local/share/clink/runex.lua")
303        );
304    }
305
306    /// An empty env var must be ignored (treated as if unset). Otherwise
307    /// `RUNEX_CLINK_LUA_PATH=` would silently anchor writes to "" which
308    /// either fails outright or hits an unintended cwd.
309    #[test]
310    fn clink_install_path_treats_empty_env_as_unset() {
311        let p = clink_lua_install_path_with(
312            |k| match k {
313                "RUNEX_CLINK_LUA_PATH" | "LOCALAPPDATA" => Some(String::new()),
314                _ => None,
315            },
316            || Some(std::path::PathBuf::from("/home/u")),
317        );
318        assert!(p.starts_with("/home/u"), "expected home fallback, got {p:?}");
319    }
320
321    #[test]
322    fn integration_line_bash() {
323        assert_eq!(
324            integration_line(Shell::Bash, "runex"),
325            r#"eval "$('runex' export bash)""#
326        );
327    }
328
329    #[test]
330    fn integration_line_pwsh() {
331        let line = integration_line(Shell::Pwsh, "runex");
332        assert!(line.contains("Invoke-Expression"));
333        assert!(line.contains("'runex' export pwsh"));
334    }
335
336    /// `bin = "run'ex"` must not break out of the eval context.
337    #[test]
338    fn integration_line_bash_escapes_single_quote_in_bin() {
339        let line = integration_line(Shell::Bash, "run'ex");
340        assert!(!line.contains("run'ex"), "unescaped quote in bash line: {line}");
341        assert!(line.contains(r"run'\''ex"), "expected bash-escaped form: {line}");
342    }
343
344    #[test]
345    fn integration_line_zsh_escapes_single_quote_in_bin() {
346        let line = integration_line(Shell::Zsh, "run'ex");
347        assert!(!line.contains("run'ex"), "unescaped quote in zsh line: {line}");
348        assert!(line.contains(r"run'\''ex"), "expected zsh-escaped form: {line}");
349    }
350
351    /// PowerShell doubles single quotes inside single-quoted strings.
352    #[test]
353    fn integration_line_pwsh_escapes_single_quote_in_bin() {
354        let line = integration_line(Shell::Pwsh, "run'ex");
355        assert!(!line.contains("run'ex"), "unescaped quote in pwsh line: {line}");
356        assert!(line.contains("run''ex"), "expected pwsh-escaped form: {line}");
357    }
358
359    /// `bin = "app; echo PWNED"` — semicolon must be enclosed in single quotes,
360    /// neutralising `;` to prevent command injection.
361    #[test]
362    fn integration_line_bash_semicolon_does_not_inject() {
363        let line = integration_line(Shell::Bash, "app; echo PWNED");
364        assert!(
365            line.contains("'app; echo PWNED'"),
366            "bin must be single-quoted in bash line: {line}"
367        );
368    }
369
370    /// `bin = "app; Write-Host PWNED"` — semicolon must be enclosed in single quotes.
371    #[test]
372    fn integration_line_pwsh_semicolon_does_not_inject() {
373        let line = integration_line(Shell::Pwsh, "app; Write-Host PWNED");
374        assert!(
375            line.contains("'app; Write-Host PWNED'"),
376            "bin must be single-quoted in pwsh line: {line}"
377        );
378    }
379
380    /// Nu: quoting without `^` makes a string, not a command — must use `^"runex"`.
381    #[test]
382    fn integration_line_nu_uses_caret_external_command_syntax() {
383        let line = integration_line(Shell::Nu, "runex");
384        assert!(
385            line.contains("^\"runex\""),
386            "nu integration line must use ^\"...\" syntax: {line}"
387        );
388    }
389
390    #[test]
391    fn integration_line_nu_escapes_special_chars_in_bin() {
392        let line = integration_line(Shell::Nu, "my\"app");
393        assert!(line.contains("^\"my\\\"app\""), "nu: special chars must be escaped: {line}");
394    }
395
396    /// `nu_quote_path` must wrap the path in double quotes so Nu doesn't tokenize on spaces.
397    #[test]
398    fn integration_line_nu_quotes_cfg_dir_with_spaces() {
399        let quoted = nu_quote_path("/home/my user/.config");
400        assert_eq!(quoted, "\"/home/my user/.config\"");
401        assert!(!quoted.starts_with('/'), "path must be quoted, not raw");
402    }
403
404    /// Windows paths: backslashes must be escaped inside Nu double-quoted strings.
405    #[test]
406    fn integration_line_nu_quotes_cfg_dir_with_backslash() {
407        let quoted = nu_quote_path(r"C:\Users\my user\AppData");
408        assert_eq!(quoted, r#""C:\\Users\\my user\\AppData""#);
409    }
410
411    /// The generated Nu integration line must quote the runex.nu path.
412    /// Both `save` and `source` must use quoted paths (starting with `"`).
413    #[test]
414    fn integration_line_nu_save_path_is_quoted() {
415        let line = integration_line(Shell::Nu, "runex");
416        for fragment in ["save --force \"", "source \""] {
417            assert!(
418                line.contains(fragment),
419                "nu line must contain `{fragment}`: {line}"
420            );
421        }
422    }
423
424    /// A single quote in bin must be passed through `lua_quote_string`.
425    /// `lua_quote_string("run'ex")` = `"run'ex"` (single quotes need no escaping in Lua double-quoted strings).
426    #[test]
427    fn integration_line_clink_single_quote_in_bin_is_lua_quoted() {
428        let line = integration_line(Shell::Clink, "run'ex");
429        assert!(
430            line.contains("\"run'ex\""),
431            "bin must be lua-quoted in clink line: {line}"
432        );
433    }
434
435    /// `lua_quote_string` escapes `\n` to `\\n`, preventing the Lua comment from being broken.
436    #[test]
437    fn integration_line_clink_newline_in_bin_does_not_inject() {
438        let line = integration_line(Shell::Clink, "runex\nos.execute('evil')");
439        assert!(
440            !line.contains('\n'),
441            "literal newline must be escaped in clink line: {line:?}"
442        );
443        assert!(
444            line.contains("\\n"),
445            "expected \\n escape sequence in clink line: {line:?}"
446        );
447    }
448
449    } // mod integration_line
450
451    /// Control-character and NUL handling in `nu_quote_path`.
452    ///
453    /// `nu_quote_path` embeds paths into Nu double-quoted string literals.
454    /// Newlines, carriage returns, tabs, NUL, DEL, and Unicode line separators
455    /// must be escaped or dropped so they cannot break out of the string context.
456    mod nu_quote_path_escaping {
457        use super::*;
458
459    #[test]
460    fn nu_quote_path_escapes_newline() {
461        let quoted = nu_quote_path("/home/user/.config\nevil");
462        assert!(!quoted.contains('\n'), "nu_quote_path must escape newline: {quoted}");
463        assert!(quoted.contains("\\n"), "expected \\n escape: {quoted}");
464    }
465
466    #[test]
467    fn nu_quote_path_escapes_carriage_return() {
468        let quoted = nu_quote_path("/path\r/evil");
469        assert!(!quoted.contains('\r'), "nu_quote_path must escape CR: {quoted}");
470        assert!(quoted.contains("\\r"), "expected \\r escape: {quoted}");
471    }
472
473    /// If XDG_CONFIG_HOME contains a newline, it must not inject Nu statements into env.nu.
474    #[test]
475    fn integration_line_nu_newline_in_xdg_does_not_inject() {
476        let quoted = nu_quote_path("/home/user/.config\nsource /tmp/evil.nu\n#");
477        assert!(!quoted.contains('\n'), "newline injection must be escaped in nu path: {quoted}");
478    }
479
480    #[test]
481    fn nu_quote_path_escapes_nul() {
482        let quoted = nu_quote_path("path\x00evil");
483        assert!(!quoted.contains('\0'), "nu_quote_path must not produce literal NUL: {quoted:?}");
484        assert!(quoted.contains("path"), "path prefix must be preserved: {quoted:?}");
485    }
486
487    #[test]
488    fn nu_quote_path_escapes_tab() {
489        let quoted = nu_quote_path("path\t/evil");
490        assert!(!quoted.contains('\t'), "nu_quote_path must escape tab: {quoted:?}");
491        assert!(quoted.contains("\\t"), "expected \\t escape: {quoted:?}");
492    }
493
494    #[test]
495    fn nu_quote_path_drops_del() {
496        let quoted = nu_quote_path("path\x7fend");
497        assert!(!quoted.contains('\x7f'), "nu_quote_path must drop DEL: {quoted:?}");
498    }
499
500    #[test]
501    fn nu_quote_path_drops_unicode_line_separators() {
502        for ch in ['\u{0085}', '\u{2028}', '\u{2029}'] {
503            let input = format!("path{ch}end");
504            let quoted = nu_quote_path(&input);
505            assert!(!quoted.contains(ch), "nu_quote_path must drop U+{:04X}: {quoted:?}", ch as u32);
506        }
507    }
508
509    #[test]
510    fn rc_file_for_bash_ends_with_bashrc() {
511        if let Some(path) = rc_file_for(Shell::Bash) {
512            assert!(path.to_str().unwrap().ends_with(".bashrc"));
513        }
514    }
515
516    /// C0 control chars other than `\n`, `\r`, `\t`, `\0`, `\x7f` must be dropped.
517    #[test]
518    fn nu_quote_path_drops_remaining_c0_control_chars() {
519        let dangerous_c0: &[char] = &[
520            '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
521            '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
522            '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
523            '\x18', '\x19', '\x1a', '\x1b',
524            '\x1c', '\x1d', '\x1e', '\x1f',
525        ];
526        for &ch in dangerous_c0 {
527            let input = format!("path{}end", ch);
528            let quoted = nu_quote_path(&input);
529            assert!(
530                !quoted.contains(ch),
531                "nu_quote_path must drop C0 control U+{:04X}: {quoted:?}",
532                ch as u32
533            );
534        }
535    }
536
537    } // mod nu_quote_path_escaping
538
539    /// `nu_quote_path` embeds the XDG_CONFIG_HOME path into the `source "..."` line
540    /// written to env.nu. If XDG_CONFIG_HOME contains Unicode visual-deception
541    /// characters (RLO, BOM, ZWSP, etc.), the displayed `source` path would appear
542    /// different from its actual content, potentially deceiving the user into
543    /// thinking a safe path is being sourced. These characters must be dropped.
544    mod nu_quote_path_deceptive {
545        use super::*;
546
547    /// U+202E (Right-to-Left Override) reverses display order in the terminal.
548    #[test]
549    fn nu_quote_path_drops_rlo() {
550        let quoted = nu_quote_path("/home/user\u{202E}/.config");
551        assert!(
552            !quoted.contains('\u{202E}'),
553            "nu_quote_path must drop U+202E (RLO): {quoted:?}"
554        );
555    }
556
557    /// U+FEFF (BOM / zero-width no-break space) is invisible.
558    #[test]
559    fn nu_quote_path_drops_bom() {
560        let quoted = nu_quote_path("/home/user\u{FEFF}/.config");
561        assert!(
562            !quoted.contains('\u{FEFF}'),
563            "nu_quote_path must drop U+FEFF (BOM): {quoted:?}"
564        );
565    }
566
567    /// U+200B (Zero-Width Space) is invisible.
568    #[test]
569    fn nu_quote_path_drops_zwsp() {
570        let quoted = nu_quote_path("/home/user\u{200B}/.config");
571        assert!(
572            !quoted.contains('\u{200B}'),
573            "nu_quote_path must drop U+200B (ZWSP): {quoted:?}"
574        );
575    }
576
577    /// Non-deceptive Unicode (e.g. Japanese path components) must pass through.
578    #[test]
579    fn nu_quote_path_preserves_non_deceptive_unicode() {
580        let quoted = nu_quote_path("/home/ユーザー/.config");
581        assert!(
582            quoted.contains("ユーザー"),
583            "nu_quote_path must preserve non-deceptive Unicode: {quoted:?}"
584        );
585    }
586
587    /// A `$` in XDG_CONFIG_HOME would allow Nu variable interpolation inside
588    /// the double-quoted `source "..."` path. Must be escaped as `\$`.
589    #[test]
590    fn nu_quote_path_escapes_dollar_sign() {
591        let quoted = nu_quote_path("/home/$USER/.config");
592        let bytes = quoted.as_bytes();
593        for i in 0..bytes.len() {
594            if bytes[i] == b'$' {
595                let mut preceding = 0usize;
596                let mut j = i;
597                while j > 0 && bytes[j - 1] == b'\\' {
598                    preceding += 1;
599                    j -= 1;
600                }
601                assert!(
602                    preceding % 2 == 1,
603                    "nu_quote_path: '$' at byte {i} has {preceding} preceding backslashes \
604                     (even = Nu interpolation not suppressed). Full output: {quoted:?}"
605                );
606            }
607        }
608        assert!(quoted.contains("\\$"), "expected \\$ in: {quoted:?}");
609    }
610
611    } // mod nu_quote_path_deceptive
612
613}