runex 0.1.19

Cross-shell abbreviation engine that expands short tokens into full commands
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
use crate::domain::shell::{bash_quote_string, lua_quote_string, pwsh_quote_string, Shell};

// `RUNEX_INIT_MARKER` moved to `crate::infra::integration_check` in
// Phase D D1b — both the writer (`cmd::init`) and the reader
// (`integration_check::check_rcfile_marker`) need it, and the
// reader lives in infra. Keeping the const in app::init forced
// `infra → app` imports, which created a cycle.

/// Seed config written by `runex init` when no config exists yet.
///
/// Includes a working trigger key and one runnable sample abbreviation
/// so the user can verify expansion immediately after a fresh install
/// without first having to read the docs. The codex usability review
/// flagged "installed but nothing happens" as the second-most painful
/// onboarding break, so the seed is deliberately *useful* rather than
/// minimal.
///
/// `runex init` only writes this content when the config file does not
/// already exist (`OpenOptions::create_new`). Existing configs are
/// never touched.
pub(crate) fn default_config_content() -> &'static str {
    r#"version = 1

[keybind.trigger]
default = "space"

# Sample abbreviation. After restarting your shell, type `gst<Space>`
# and it will expand to `git status `.
[[abbr]]
key    = "gst"
expand = "git status"

# Add your own below. For more recipes (per-shell commands, fallback
# chains, cursor placeholders, etc.) see:
# https://github.com/ShortArrow/runex/blob/main/docs/recipes.md
"#
}

/// The single line appended to the shell rc file. Phase G changes
/// this to source a static cache file at
/// `<XDG_CACHE_HOME>/runex/integration.<ext>` whose contents have
/// the absolute runex binary path baked in. This keeps shell startup
/// fast (no `runex` PATH lookup) and `bind -x`-style hooks fast
/// (no per-keystroke PATH resolution).
///
/// ## Drift resistance
///
/// The cache file is regenerated by `runex init <shell>` (and
/// silently by `runex add` / `runex remove` in Phase G5). Doctor
/// (G6) detects stale caches via the `runex-integration-version:`
/// and `runex-bin:` headers. Upgrading runex without re-running
/// `runex init` keeps using the old cache; doctor will warn.
///
/// **clink is the exception.** The lua file lives outside any rcfile
/// reload pathway, so users have to re-run `runex init clink` after a
/// `runex` upgrade. `runex doctor` flags this drift via the
/// `integration:clink` check (see
/// [`crate::infra::integration_check::check_clink_lua_freshness`]).
///
/// Strip any character that would break out of a nu single-quoted
/// literal or smuggle in additional nu syntax via the rcfile. Nu has
/// no escape mechanism inside single quotes, so injecting a closing
/// `'` would terminate the string mid-path and let the rest of the
/// path tail be parsed as fresh nu code. Newlines and other control
/// characters are dropped for the same reason: a literal `\n` inside
/// the single quotes would split the line and the next physical line
/// would become an unrelated nu statement.
///
/// We drop rather than escape because the cache path is machine-
/// derived (`xdg_cache_home_with` → `XDG_CACHE_HOME` / `LOCALAPPDATA`
/// / `~/.cache`). A user with a hostile `XDG_CACHE_HOME` ends up with
/// a slightly wrong path that fails the `path exists` guard at
/// runtime, which is a silent no-op — far less damaging than a parse
/// error that breaks every line after it in env.nu.
///
/// Unicode visual-deception characters (RLO, BOM, ZWSP) are filtered
/// via `is_deceptive_unicode`; bidi reversal / invisible insertion in
/// a path the user pastes into `runex doctor` output is the same
/// class of attack the `expand`/`key` validators already defend
/// against.
fn sanitize_nu_cache_path_literal(s: &str) -> String {
    s.chars()
        .filter(|&c| {
            !matches!(c, '\'' | '\n' | '\r' | '\t')
                && !(c as u32 <= 0x1F)
                && c as u32 != 0x7F
                && !crate::domain::sanitize::is_deceptive_unicode(c)
        })
        .collect()
}

/// `cache_path` is the absolute on-disk path to the integration
/// cache file (resolved by `infra::integration_cache::cache_path`).
/// For clink, callers pass the lua install path; the line is a
/// human-readable hint, not actually appended to any rcfile.
pub(crate) fn integration_line(shell: Shell, cache_path: &str) -> String {
    match shell {
        Shell::Bash => format!(
            "[ -r {p} ] && . {p}",
            p = bash_quote_string(cache_path)
        ),
        Shell::Zsh => format!(
            "[ -r {p} ] && . {p}",
            p = bash_quote_string(cache_path)
        ),
        Shell::Pwsh => format!(
            "if (Test-Path {p}) {{ . {p} }}",
            p = pwsh_quote_string(cache_path)
        ),
        Shell::Nu => {
            // nu's `source` and `path exists` want a literal string,
            // not an external-command invocation. `nu_quote_string`
            // wraps in `^"..."` which is nu's *external-command*
            // prefix — `source ^"path"` parses as "run an executable
            // called <path>, then source its output" and fails with
            // "File not found: ^<path>" (issue surfaced during pre-release
            // hand-check on nu 0.112.2).
            //
            // Use a single-quoted literal instead. Nu's single-quoted
            // strings are byte-literal: no escape sequences, no
            // interpolation, no command interpretation. The validator
            // for the `number` / cache paths already rejects control
            // characters and deceptive Unicode, and the cache path is
            // derived from `xdg_cache_home_with` so it cannot contain
            // a literal `'` in any supported configuration. If a user
            // ever lands a single quote in the cache path through a
            // hostile `XDG_CACHE_HOME`, the worst case is the nu
            // parser rejecting the line at load time (visible as a
            // doctor WARN, not silent breakage).
            let safe = sanitize_nu_cache_path_literal(cache_path);
            format!("if ('{safe}' | path exists) {{ source '{safe}' }}")
        }
        Shell::Clink => format!(
            "-- runex clink integration is auto-loaded from {}",
            lua_quote_string(cache_path)
        ),
    }
}

/// Where `runex init clink` writes the lua integration script.
///
/// Resolution order (first match wins):
///
/// 1. `RUNEX_CLINK_LUA_PATH` env — explicit override for non-standard
///    clink installations or for testing.
/// 2. `%LOCALAPPDATA%\clink\runex.lua` — clink's default state directory
///    on Windows. This is what `clink info` reports as the scripts dir.
/// 3. `~/.local/share/clink/runex.lua` — POSIX-style fallback for the
///    Linux clink fork (rare, included for completeness).
///
/// We deliberately do not shell out to `clink info` to discover the
/// scripts directory: that would invert the dependency direction
/// (Rust → shell tool) for one path lookup, and the env-var override
/// already lets users with non-standard installs cope.
/// Resolver-injectable factory for the clink lua install path.
/// Production callers pass [`SystemHomeDir`]; tests pass an
/// [`crate::infra::env::EnvHomeDir`] so a hermetic `cmd::init::handle`
/// run can verify the chosen install path without touching real env
/// vars or `dirs::home_dir()`.
pub(crate) fn clink_lua_install_path_with_resolver(
    env: &dyn crate::infra::env::HomeDirResolver,
) -> std::path::PathBuf {
    if let Some(p) = env.env_var("RUNEX_CLINK_LUA_PATH") {
        return std::path::PathBuf::from(p);
    }
    if let Some(local) = env.env_var("LOCALAPPDATA") {
        return std::path::PathBuf::from(local).join("clink").join("runex.lua");
    }
    if let Some(home) = env.home_dir() {
        return home.join(".local").join("share").join("clink").join("runex.lua");
    }
    std::path::PathBuf::from("runex.lua")
}

/// "What to do next" blurb shown after `runex init` finishes. The
/// integration line lives in the rcfile but the *currently-running*
/// shell hasn't sourced it yet, so the user has to either reload the
/// rcfile or open a fresh shell. Each shell has its own idiomatic
/// reload command; clink keeps no rcfile and just needs a new cmd.
///
/// `rc_path` is the file we just appended to (or `None` for clink, where
/// the integration goes into a separate lua file rather than an rcfile).
pub(crate) fn next_steps_message(shell: Shell, rc_path: Option<&std::path::Path>) -> String {
    let reload = match shell {
        Shell::Bash | Shell::Zsh => match rc_path {
            Some(p) => format!("Reload your shell: `source {}` (or `exec $SHELL`)", p.display()),
            None => "Reload your shell: `exec $SHELL`".to_string(),
        },
        Shell::Pwsh => match rc_path {
            Some(p) => format!("Reload your profile: `. $PROFILE` (resolves to {})", p.display()),
            None => "Reload your profile: `. $PROFILE`".to_string(),
        },
        Shell::Nu => "Reload nushell: open a new shell (or run `exec nu`)".to_string(),
        Shell::Clink => "Open a new cmd window — clink loads the lua at startup.".to_string(),
    };
    format!(
        "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`"
    )
}

// `rc_file_for` / `rc_file_for_with` moved to
// `crate::infra::env` in Phase D D1b. They're filesystem-shape
// resolvers (HomeDirResolver in, PathBuf out) with no app-layer
// concerns; living in `app::init` was an accident of where they
// were originally written, and cost us an `infra → app` cycle.

#[cfg(test)]
mod tests {
    use super::*;

    mod integration_line {
        use super::*;

    #[test]
    fn default_config_content_has_version() {
        assert!(default_config_content().contains("version = 1"));
    }

    /// The seed config must include a working keybind so that the very
    /// first `runex init` produces a setup that actually expands. Without
    /// this, users hit "I installed runex and nothing happens" — the
    /// codex usability review flagged this as the second-most painful
    /// onboarding break after the missing `init <shell>` surface.
    #[test]
    fn default_config_content_includes_default_trigger() {
        let s = default_config_content();
        assert!(s.contains("[keybind.trigger]"), "missing [keybind.trigger]: {s}");
        assert!(s.contains("default = \"space\""), "missing default trigger: {s}");
    }

    /// The seed config must include at least one runnable abbreviation so
    /// the user can verify expansion immediately after `runex init`.
    #[test]
    fn default_config_content_includes_sample_abbr_gst() {
        let s = default_config_content();
        assert!(s.contains("key    = \"gst\""), "missing gst sample: {s}");
        assert!(s.contains("expand = \"git status\""), "missing gst expand: {s}");
    }

    /// `next_steps_message` produces the after-init "what to do next"
    /// blurb. Each shell's blurb has to mention how to *reload* the
    /// integration (since rcfile changes don't take effect in the
    /// already-running shell), how to find more abbreviations, and how
    /// to verify with `runex doctor`.
    #[test]
    fn next_steps_for_bash_mentions_source_command() {
        let msg = next_steps_message(Shell::Bash, Some(std::path::Path::new("/home/u/.bashrc")));
        assert!(msg.contains("source /home/u/.bashrc") || msg.contains("exec"),
            "bash next_steps must explain how to reload: {msg}");
        assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
        assert!(msg.contains("recipes"), "must point at recipes: {msg}");
    }

    #[test]
    fn next_steps_for_clink_mentions_new_cmd_window() {
        let msg = next_steps_message(Shell::Clink, None);
        assert!(msg.to_lowercase().contains("cmd"),
            "clink next_steps must mention opening a new cmd window: {msg}");
        assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
    }

    #[test]
    fn next_steps_for_pwsh_mentions_dot_profile() {
        let msg = next_steps_message(
            Shell::Pwsh,
            Some(std::path::Path::new("/u/Microsoft.PowerShell_profile.ps1")),
        );
        assert!(msg.contains("$PROFILE") || msg.contains(". /"),
            "pwsh next_steps must explain reload: {msg}");
    }

    /// `clink_lua_install_path_with_resolver` decides where `runex
    /// init clink` writes the lua file. Honours
    /// `RUNEX_CLINK_LUA_PATH` first, then `LOCALAPPDATA` (Windows
    /// convention), then a POSIX-style fallback for clink forks on
    /// Linux. Tests pass an `EnvHomeDir` for hermetic resolution
    /// without racing on `std::env::set_var`.
    #[test]
    fn clink_install_path_honors_env_override() {
        use crate::infra::env::EnvHomeDir;
        use std::collections::HashMap;
        let owned: HashMap<String, String> = HashMap::from([(
            "RUNEX_CLINK_LUA_PATH".to_string(),
            "/tmp/runex_test_clink.lua".to_string(),
        )]);
        let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
        let p = clink_lua_install_path_with_resolver(&env);
        assert_eq!(p, std::path::PathBuf::from("/tmp/runex_test_clink.lua"));
    }

    #[test]
    fn clink_install_path_uses_localappdata_when_set() {
        use crate::infra::env::EnvHomeDir;
        use std::collections::HashMap;
        let owned: HashMap<String, String> = HashMap::from([(
            "LOCALAPPDATA".to_string(),
            "/tmp/local_appdata_test".to_string(),
        )]);
        let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
        let p = clink_lua_install_path_with_resolver(&env);
        assert_eq!(
            p,
            std::path::PathBuf::from("/tmp/local_appdata_test/clink/runex.lua")
        );
    }

    #[test]
    fn clink_install_path_falls_back_to_home() {
        use crate::infra::env::EnvHomeDir;
        use std::collections::HashMap;
        let owned: HashMap<String, String> =
            HashMap::from([("HOME".to_string(), "/home/user".to_string())]);
        let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
        let p = clink_lua_install_path_with_resolver(&env);
        assert_eq!(
            p,
            std::path::PathBuf::from("/home/user/.local/share/clink/runex.lua")
        );
    }

    /// An empty env var must be treated as unset; otherwise
    /// `RUNEX_CLINK_LUA_PATH=` would anchor writes to "" which either
    /// fails outright or hits an unintended cwd.
    /// `EnvHomeDir::env_var` already returns `None` for empty strings,
    /// so this test pins both the closure semantics and the
    /// resolver's empty-string handling.
    #[test]
    fn clink_install_path_treats_empty_env_as_unset() {
        use crate::infra::env::EnvHomeDir;
        use std::collections::HashMap;
        let owned: HashMap<String, String> = HashMap::from([
            ("RUNEX_CLINK_LUA_PATH".to_string(), String::new()),
            ("LOCALAPPDATA".to_string(), String::new()),
            ("HOME".to_string(), "/home/u".to_string()),
        ]);
        let env = EnvHomeDir::new(move |n| owned.get(n).cloned());
        let p = clink_lua_install_path_with_resolver(&env);
        assert!(p.starts_with("/home/u"), "expected home fallback, got {p:?}");
    }

    /// Phase G changed `integration_line(shell, bin)` to
    /// `integration_line(shell, cache_path)`. The string the function
    /// returns is now what gets appended to the user's rcfile/profile
    /// to source the static cache file. The legacy `eval "$(runex
    /// export bash)"` form is gone (the `runex export <shell>`
    /// subcommand still exists for ad-hoc use, but the init flow no
    /// longer writes that form).
    #[test]
    fn integration_line_bash_sources_cache_path() {
        let line = integration_line(Shell::Bash, "/home/u/.cache/runex/integration.bash");
        assert_eq!(
            line,
            r"[ -r '/home/u/.cache/runex/integration.bash' ] && . '/home/u/.cache/runex/integration.bash'"
        );
    }

    #[test]
    fn integration_line_zsh_sources_cache_path() {
        let line = integration_line(Shell::Zsh, "/home/u/.cache/runex/integration.zsh");
        assert!(
            line.starts_with("[ -r ") && line.contains("integration.zsh"),
            "zsh line must guard with `[ -r ...]` and source the cache: {line}"
        );
    }

    #[test]
    fn integration_line_pwsh_uses_test_path() {
        let line = integration_line(
            Shell::Pwsh,
            r"C:\Users\u\.cache\runex\integration.ps1",
        );
        assert!(line.contains("Test-Path"), "pwsh line must guard with Test-Path: {line}");
        assert!(line.contains("integration.ps1"), "pwsh line must reference cache file: {line}");
    }

    #[test]
    fn integration_line_nu_uses_path_exists_guard() {
        let line = integration_line(
            Shell::Nu,
            "/home/u/.cache/runex/integration.nu",
        );
        assert!(
            line.contains("path exists") && line.contains("source"),
            "nu line must check path exists and source the cache: {line}"
        );
    }

    /// nu's `source` and `path exists` want a literal string, not an
    /// external-command invocation. `^"path"` (which `nu_quote_string`
    /// returns) is the *external-command* prefix in nu — `source ^"..."`
    /// parses as "run an executable called <path>, then source its
    /// output" and fails with "File not found: ^<path>". Issue #N.
    #[test]
    fn integration_line_nu_does_not_use_external_command_prefix() {
        let line = integration_line(
            Shell::Nu,
            "/home/u/.cache/runex/integration.nu",
        );
        assert!(
            !line.contains("^\""),
            "nu line must NOT prefix the path with ^ (external command marker): {line}",
        );
    }

    /// The path inside the nu line should be a single-quoted literal so
    /// nu's parser treats it as a bare string, never as a command name.
    /// Single quotes are the only literal that doesn't allow nested
    /// escapes (the config validator already rejects `'` in cache paths
    /// by way of the deceptive-Unicode / control-char checks plus the
    /// generated cache paths being machine-derived from XDG_CACHE_HOME).
    #[test]
    fn integration_line_nu_uses_single_quoted_literal() {
        let line = integration_line(
            Shell::Nu,
            "/home/u/.cache/runex/integration.nu",
        );
        let expected_quoted = "'/home/u/.cache/runex/integration.nu'";
        assert!(
            line.contains(expected_quoted),
            "nu line must contain single-quoted literal {expected_quoted}: {line}"
        );
    }

    /// Quoting safety: a cache path containing a single quote must
    /// not break out of the bash-quoted string. (Implausible for an
    /// XDG_CACHE_HOME the user controls, but `bash_quote_string`
    /// must still neutralise it.)
    #[test]
    fn integration_line_bash_escapes_single_quote_in_path() {
        let line = integration_line(Shell::Bash, "/odd/path'with-quote/integration.bash");
        assert!(
            !line.contains("/odd/path'with-quote/"),
            "raw single quote leaked into bash line: {line}"
        );
        assert!(
            line.contains(r"path'\''with-quote"),
            "expected bash-escaped form: {line}"
        );
    }

    #[test]
    fn integration_line_zsh_escapes_single_quote_in_path() {
        let line = integration_line(Shell::Zsh, "/odd/path'with-quote/integration.zsh");
        assert!(
            line.contains(r"path'\''with-quote"),
            "expected zsh-escaped form: {line}"
        );
    }

    /// PowerShell doubles single quotes inside single-quoted strings.
    #[test]
    fn integration_line_pwsh_escapes_single_quote_in_path() {
        let line = integration_line(Shell::Pwsh, "C:\\odd\\path'with-quote\\integration.ps1");
        assert!(
            !line.contains("path'with-quote"),
            "raw single quote leaked into pwsh line: {line}"
        );
        assert!(
            line.contains("path''with-quote"),
            "expected pwsh-doubled-quote form: {line}"
        );
    }

    /// Nu single-quoted literals don't interpret `"` so a path
    /// containing a double quote needs no special escaping — the
    /// `"` ends up verbatim inside the single-quoted string.
    #[test]
    fn integration_line_nu_passes_double_quote_through_literal() {
        let line = integration_line(Shell::Nu, "/odd/path\"with-quote/integration.nu");
        assert!(
            line.contains("'/odd/path\"with-quote/integration.nu'"),
            "nu single-quoted literal must contain the path verbatim with the double quote intact: {line}"
        );
    }

    /// Nu single-quoted literals don't interpret `\\`, so a Windows
    /// path with backslashes is passed through verbatim — unlike the
    /// double-quoted form which would have required every `\\` to be
    /// doubled. (Windows nu users do exist; runex generates the cache
    /// path via XDG → LOCALAPPDATA → home, all of which can produce
    /// backslashes.)
    #[test]
    fn integration_line_nu_passes_backslashes_through_literal() {
        let line = integration_line(Shell::Nu, "C:\\Users\\u\\AppData\\Local\\runex\\integration.nu");
        assert!(
            line.contains("'C:\\Users\\u\\AppData\\Local\\runex\\integration.nu'"),
            "nu single-quoted literal must contain backslashes verbatim: {line}"
        );
    }

    /// Cache path containing a single quote: nu single-quoted literals
    /// cannot contain `'` (no escape mechanism). The sanitizer drops
    /// the offending character so the line still parses, falling back
    /// to a "path not found" at runtime instead of a hard parse error
    /// that would break every line after it in env.nu.
    #[test]
    fn integration_line_nu_drops_single_quote_in_path() {
        let line = integration_line(Shell::Nu, "/odd/path'with-quote/integration.nu");
        assert!(
            !line.contains("path'with"),
            "single quote inside the literal would break out and corrupt env.nu: {line}"
        );
        // The sanitised form is `/odd/pathwith-quote/integration.nu`.
        assert!(
            line.contains("'/odd/pathwith-quote/integration.nu'"),
            "expected single-quoted literal with the ' stripped: {line}"
        );
    }

    /// Clink is the exception: clink lua autoloads from a fixed
    /// install path, no rcfile to append to. `integration_line`
    /// returns a comment string for documentation purposes only;
    /// the comment must still be lua-safe so it can't be passed
    /// through some downstream tool that interprets it.
    #[test]
    fn integration_line_clink_comment_is_lua_quoted() {
        let line = integration_line(Shell::Clink, "/path/to/runex.lua");
        assert!(line.starts_with("--"), "clink line must start with `--` comment marker: {line}");
        assert!(
            line.contains("\"/path/to/runex.lua\""),
            "clink line must lua-quote the path: {line}"
        );
    }

    /// `lua_quote_string` escapes `\\n` to `\\\\n`, preventing the
    /// Lua comment from being broken across lines.
    #[test]
    fn integration_line_clink_newline_in_path_does_not_inject() {
        let line = integration_line(Shell::Clink, "/path/runex.lua\nos.execute('evil')");
        assert!(
            !line.contains('\n'),
            "literal newline leaked into clink line: {line:?}"
        );
        assert!(
            line.contains("\\n"),
            "expected `\\n` escape: {line:?}"
        );
    }

    } // mod integration_line

    /// Phase G replaced the inline `nu_quote_path` helper with
    /// `domain::shell::nu_quote_string`. The function-level
    /// security test mods (`nu_quote_path_escaping`,
    /// `nu_quote_path_deceptive`) that pinned RLO / BOM / ZWSP /
    /// control-char / `$` injection-safety against the old helper
    /// are reproduced here against the **new API surface**
    /// (`integration_line(Shell::Nu, ...)`), so a regression in
    /// `nu_quote_string`'s drop-char policy or the integration_line
    /// formatter shows up immediately.
    ///
    /// Lower-level tests for `is_nu_drop_char` / `nu_quote_string`
    /// itself live in `domain::sanitize::tests` and
    /// `app::shell_export::tests::regression_issues`.
    mod integration_line_nu_security {
        use super::*;

        /// U+202E (Right-to-Left Override) reverses display order.
        #[test]
        fn drops_rlo_in_cache_path() {
            let line = integration_line(Shell::Nu, "/home/user\u{202E}/.cache/runex/integration.nu");
            assert!(
                !line.contains('\u{202E}'),
                "integration_line nu must drop U+202E (RLO): {line:?}"
            );
        }

        /// U+FEFF (BOM / zero-width no-break space) is invisible.
        #[test]
        fn drops_bom_in_cache_path() {
            let line = integration_line(Shell::Nu, "/home/user\u{FEFF}/.cache/runex/integration.nu");
            assert!(
                !line.contains('\u{FEFF}'),
                "integration_line nu must drop U+FEFF (BOM): {line:?}"
            );
        }

        /// U+200B (Zero-Width Space) is invisible.
        #[test]
        fn drops_zwsp_in_cache_path() {
            let line = integration_line(Shell::Nu, "/home/user\u{200B}/.cache/runex/integration.nu");
            assert!(
                !line.contains('\u{200B}'),
                "integration_line nu must drop U+200B (ZWSP): {line:?}"
            );
        }

        /// Non-deceptive Unicode (Japanese path components) must
        /// pass through untouched so users with localised home
        /// directories still get a working integration.
        #[test]
        fn preserves_non_deceptive_unicode_in_cache_path() {
            let line = integration_line(Shell::Nu, "/home/ユーザー/.cache/runex/integration.nu");
            assert!(
                line.contains("ユーザー"),
                "integration_line nu must preserve non-deceptive Unicode: {line:?}"
            );
        }

        /// `$` in the cache path is harmless inside nu single-quoted
        /// literals — nu single quotes do not interpolate. The path
        /// passes through verbatim and `path exists` resolves it as a
        /// filesystem path with a literal `$` in it.
        #[test]
        fn dollar_sign_passes_through_literal() {
            let line = integration_line(Shell::Nu, "/home/$USER/.cache/runex/integration.nu");
            assert!(
                line.contains("'/home/$USER/.cache/runex/integration.nu'"),
                "single-quoted literal must contain $ verbatim: {line:?}"
            );
        }

        /// All C0 control characters except `\n`, `\r`, `\t` must
        /// be dropped from the cache path. `\n`, `\r`, `\t`
        /// themselves get explicit `\n`, `\r`, `\t` escapes; any
        /// other control char is silently removed (= `is_nu_drop_char`
        /// policy).
        #[test]
        fn drops_remaining_c0_controls_in_cache_path() {
            let dangerous_c0: &[char] = &[
                '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
                '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
                '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
                '\x18', '\x19', '\x1a', '\x1b',
                '\x1c', '\x1d', '\x1e', '\x1f',
            ];
            for &ch in dangerous_c0 {
                let path = format!("/home/user{}{}rest", ch, ch);
                let line = integration_line(Shell::Nu, &path);
                assert!(
                    !line.contains(ch),
                    "integration_line nu must drop C0 control U+{:04X}: {line:?}",
                    ch as u32
                );
            }
        }

        /// DEL (U+007F) must also be dropped.
        #[test]
        fn drops_del_in_cache_path() {
            let line = integration_line(Shell::Nu, "/home/user\x7Fevil/runex.nu");
            assert!(
                !line.contains('\x7F'),
                "integration_line nu must drop DEL: {line:?}"
            );
        }

        /// Newline injection: a path containing `\n` must not produce
        /// a nu line that breaks across `source ...` statements. Nu
        /// single-quoted literals don't have an escape mechanism, so
        /// the sanitiser drops the `\n` rather than escapes it; the
        /// resulting `path exists` lookup fails harmlessly.
        #[test]
        fn newline_in_cache_path_does_not_inject() {
            let line = integration_line(Shell::Nu, "/home/user/.cache\nsource /tmp/evil.nu");
            assert!(
                !line.contains('\n'),
                "newline in cache_path must not appear raw in the rcfile line: {line:?}"
            );
            // The "source /tmp/evil.nu" fragment must not survive as a
            // standalone statement; it gets absorbed into the path
            // literal (and then fails the path-exists guard at run time).
            assert!(
                !line.split('\n').any(|chunk| chunk.trim_start().starts_with("source ")),
                "evil `source ...` line must not appear as a standalone statement: {line:?}"
            );
        }
    }

    /// Mirror of `integration_line_nu_security` for clink, where
    /// the path is wrapped via `lua_quote_string` instead of
    /// `nu_quote_string`. The original `nu_quote_path_deceptive`
    /// suite covered nu only; clink's lua-string escaping is
    /// validated separately in
    /// `app::shell_export::tests::regression_issues` for the
    /// `lua_quote_string` function itself, but pinning it through
    /// the public `integration_line` entry point catches drift in
    /// the formatter.
    mod integration_line_clink_security {
        use super::*;

        #[test]
        fn drops_rlo_in_path() {
            let line = integration_line(Shell::Clink, "/path\u{202E}/runex.lua");
            assert!(!line.contains('\u{202E}'), "clink line must drop U+202E: {line:?}");
        }

        #[test]
        fn drops_bom_in_path() {
            let line = integration_line(Shell::Clink, "/path\u{FEFF}/runex.lua");
            assert!(!line.contains('\u{FEFF}'), "clink line must drop U+FEFF: {line:?}");
        }
    }
}