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:?}");
        }
    }
}
