Skip to main content

rtk/discover/
registry.rs

1//! Matches shell commands against known RTK rewrite rules to decide how to handle them.
2
3use lazy_static::lazy_static;
4use regex::{Regex, RegexSet};
5
6use super::lexer::{split_on_operators, tokenize, TokenKind};
7use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, RULES};
8
9/// Result of classifying a command.
10#[derive(Debug, PartialEq)]
11pub enum Classification {
12    Supported {
13        rtk_equivalent: &'static str,
14        category: &'static str,
15        estimated_savings_pct: f64,
16        status: super::report::RtkStatus,
17    },
18    Unsupported {
19        base_command: String,
20    },
21    Ignored,
22}
23
24/// Average token counts per category for estimation when no output_len available.
25pub fn category_avg_tokens(category: &str, subcmd: &str) -> usize {
26    match category {
27        "Git" => match subcmd {
28            "log" | "diff" | "show" => 200,
29            _ => 40,
30        },
31        "Cargo" => match subcmd {
32            "test" => 500,
33            _ => 150,
34        },
35        "Tests" => 800,
36        "Files" => 100,
37        "Build" => 300,
38        "Infra" => 120,
39        "Network" => 150,
40        "GitHub" => 200,
41        "GitLab" => 200,
42        "PackageManager" => 150,
43        _ => 150,
44    }
45}
46
47lazy_static! {
48    static ref REGEX_SET: RegexSet =
49        RegexSet::new(RULES.iter().map(|r| r.pattern)).expect("invalid regex patterns");
50    static ref COMPILED: Vec<Regex> = RULES
51        .iter()
52        .map(|r| Regex::new(r.pattern).expect("invalid regex"))
53        .collect();
54    static ref ENV_PREFIX: Regex = {
55        let double_quoted = r#""(?:[^"\\]|\\.)*""#;
56        let single_quoted = r#"'(?:[^'\\]|\\.)*'"#;
57        let unquoted = r#"[^\s]*"#;
58        let env_value = format!("(?:{}|{}|{})", double_quoted, single_quoted, unquoted);
59        let env_assign = format!(r#"[A-Z_][A-Z0-9_]*={}"#, env_value);
60        Regex::new(&format!(r#"^(?:sudo\s+|env\s+|{}\s+)+"#, env_assign)).unwrap()
61    };
62    // Git global options that appear before the subcommand: -C <path>, -c <key=val>,
63    // --git-dir <dir>, --work-tree <dir>, and flag-only options (#163)
64    static ref GIT_GLOBAL_OPT: Regex =
65        Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap();
66    // Issue #1362: each capture expects a SINGLE file argument (`\S+$`). Multi-file
67    // invocations like `head -3 a b c` fail to match so the segment is passed through
68    // to the native `head`/`tail` binary — which already handles multi-file with
69    // `==> name <==` banners that `rtk read --max-lines` cannot reproduce.
70    static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(\S+)$").unwrap();
71    static ref HEAD_LINES: Regex = Regex::new(r"^head\s+--lines=(\d+)\s+(\S+)$").unwrap();
72    static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(\S+)$").unwrap();
73    static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(\S+)$").unwrap();
74    static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(\S+)$").unwrap();
75    static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(\S+)$").unwrap();
76}
77
78const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[
79    "-c",
80    "--color",
81    "--config",
82    "--cpu-profile-path",
83    "--mem-profile-path",
84    "--trace-path",
85];
86
87#[derive(Debug, Clone, Copy)]
88struct GolangciRunParts<'a> {
89    global_segment: &'a str,
90    run_segment: &'a str,
91}
92
93/// Classify a single (already-split) command.
94pub fn classify_command(cmd: &str) -> Classification {
95    let trimmed = cmd.trim();
96    if trimmed.is_empty() {
97        return Classification::Ignored;
98    }
99
100    // Check ignored
101    for exact in IGNORED_EXACT {
102        if trimmed == *exact {
103            return Classification::Ignored;
104        }
105    }
106    for prefix in IGNORED_PREFIXES {
107        if trimmed.starts_with(prefix) {
108            return Classification::Ignored;
109        }
110    }
111
112    // Strip env prefixes (sudo, env VAR=val, VAR=val)
113    let stripped = ENV_PREFIX.replace(trimmed, "");
114    let cmd_clean = stripped.trim();
115    if cmd_clean.is_empty() {
116        return Classification::Ignored;
117    }
118
119    // Normalize absolute binary paths: /usr/bin/grep → grep (#485)
120    let cmd_normalized = strip_absolute_path(cmd_clean);
121    // Strip git global options: git -C /tmp status → git status (#163)
122    let cmd_normalized = strip_git_global_opts(&cmd_normalized);
123    // Strip golangci-lint global options before `run` so classify/rewrite stays
124    // aligned with the runtime wrapper behavior.
125    let cmd_normalized = strip_golangci_global_opts(&cmd_normalized);
126    let cmd_clean = cmd_normalized.as_str();
127
128    // Exclude cat/head/tail with redirect operators — these are writes, not reads (#315)
129    if cmd_clean.starts_with("cat ")
130        || cmd_clean.starts_with("head ")
131        || cmd_clean.starts_with("tail ")
132    {
133        let has_redirect = cmd_clean
134            .split_whitespace()
135            .skip(1)
136            .any(|t| t.starts_with('>') || t == "<" || t.starts_with(">>"));
137        if has_redirect {
138            return Classification::Unsupported {
139                base_command: cmd_clean
140                    .split_whitespace()
141                    .next()
142                    .unwrap_or("cat")
143                    .to_string(),
144            };
145        }
146    }
147
148    // Fast check with RegexSet — take the last (most specific) match
149    let matches: Vec<usize> = REGEX_SET.matches(cmd_clean).into_iter().collect();
150    if let Some(&idx) = matches.last() {
151        let rule = &RULES[idx];
152
153        // Extract subcommand for savings override and status detection
154        let (savings, status) = if let Some(caps) = COMPILED[idx].captures(cmd_clean) {
155            if let Some(sub) = caps.get(1) {
156                let subcmd = sub.as_str();
157                // Check if this subcommand has a special status
158                let status = rule
159                    .subcmd_status
160                    .iter()
161                    .find(|(s, _)| *s == subcmd)
162                    .map(|(_, st)| *st)
163                    .unwrap_or(super::report::RtkStatus::Existing);
164
165                // Check if this subcommand has custom savings
166                let savings = rule
167                    .subcmd_savings
168                    .iter()
169                    .find(|(s, _)| *s == subcmd)
170                    .map(|(_, pct)| *pct)
171                    .unwrap_or(rule.savings_pct);
172
173                (savings, status)
174            } else {
175                (rule.savings_pct, super::report::RtkStatus::Existing)
176            }
177        } else {
178            (rule.savings_pct, super::report::RtkStatus::Existing)
179        };
180
181        Classification::Supported {
182            rtk_equivalent: rule.rtk_cmd,
183            category: rule.category,
184            estimated_savings_pct: savings,
185            status,
186        }
187    } else {
188        // Extract base command for unsupported
189        let base = extract_base_command(cmd_clean);
190        if base.is_empty() {
191            Classification::Ignored
192        } else {
193            Classification::Unsupported {
194                base_command: base.to_string(),
195            }
196        }
197    }
198}
199
200/// Extract the base command (first word, or first two if it looks like a subcommand pattern).
201fn extract_base_command(cmd: &str) -> &str {
202    let parts: Vec<&str> = cmd.splitn(3, char::is_whitespace).collect();
203    match parts.len() {
204        0 => "",
205        1 => parts[0],
206        _ => {
207            let second = parts[1];
208            // If the second token looks like a subcommand (no leading -)
209            if !second.starts_with('-') && !second.contains('/') && !second.contains('.') {
210                // Return "cmd subcmd"
211                let end = cmd
212                    .find(char::is_whitespace)
213                    .and_then(|i| {
214                        let rest = &cmd[i..];
215                        let trimmed = rest.trim_start();
216                        trimmed
217                            .find(char::is_whitespace)
218                            .map(|j| i + (rest.len() - trimmed.len()) + j)
219                    })
220                    .unwrap_or(cmd.len());
221                &cmd[..end]
222            } else {
223                parts[0]
224            }
225        }
226    }
227}
228
229/// Quote-aware heredoc detection — `<<` inside quotes is not a heredoc.
230pub fn has_heredoc(cmd: &str) -> bool {
231    tokenize(cmd)
232        .iter()
233        .any(|t| t.kind == TokenKind::Redirect && t.value.starts_with("<<"))
234}
235
236pub fn split_command_chain(cmd: &str) -> Vec<&str> {
237    let trimmed = cmd.trim();
238    if trimmed.is_empty() {
239        return vec![];
240    }
241
242    // Lexer-based for `<<`; string-based for `$((` (lexer splits it across tokens).
243    if has_heredoc(trimmed) || trimmed.contains("$((") {
244        return vec![trimmed];
245    }
246
247    split_on_operators(trimmed, true)
248}
249
250/// Strip git global options before the subcommand (#163).
251/// `git -C /tmp status` → `git status`, preserving the rest.
252/// Returns the original string unchanged if not a git command.
253fn strip_git_global_opts(cmd: &str) -> String {
254    // Only applies to commands starting with "git "
255    if !cmd.starts_with("git ") {
256        return cmd.to_string();
257    }
258    let after_git = &cmd[4..]; // skip "git "
259    let stripped = GIT_GLOBAL_OPT.replace(after_git, "");
260    format!("git {}", stripped.trim())
261}
262
263/// Strip golangci-lint global options before the `run` subcommand.
264/// `golangci-lint --color never run ./...` → `golangci-lint run ./...`
265/// Returns the original string unchanged if this is not a supported compact `run` invocation.
266fn strip_golangci_global_opts(cmd: &str) -> String {
267    match parse_golangci_run_parts(cmd) {
268        Some(parts) => format!("golangci-lint {}", parts.run_segment),
269        None => cmd.to_string(),
270    }
271}
272
273/// Parse supported golangci-lint invocations with optional global flags before `run`.
274fn parse_golangci_run_parts(cmd: &str) -> Option<GolangciRunParts<'_>> {
275    let tokens = split_token_spans(cmd);
276    let first = tokens.first()?;
277    if first.0 != "golangci-lint" && first.0 != "golangci" {
278        return None;
279    }
280
281    let mut i = 1;
282    while i < tokens.len() {
283        let token = tokens[i].0;
284
285        if token == "--" {
286            return None;
287        }
288
289        if !token.starts_with('-') {
290            if token == "run" {
291                let global_segment = if i > 1 {
292                    cmd[tokens[1].1..tokens[i].1].trim()
293                } else {
294                    ""
295                };
296                let run_segment = cmd[tokens[i].1..].trim();
297                return Some(GolangciRunParts {
298                    global_segment,
299                    run_segment,
300                });
301            }
302            return None;
303        }
304
305        if let Some(flag) = split_golangci_flag_name(token) {
306            if golangci_flag_takes_separate_value(token, flag) {
307                i += 1;
308            }
309        }
310
311        i += 1;
312    }
313
314    None
315}
316
317fn split_golangci_flag_name(arg: &str) -> Option<&str> {
318    if arg.starts_with("--") {
319        return Some(arg.split_once('=').map(|(flag, _)| flag).unwrap_or(arg));
320    }
321
322    if arg.starts_with('-') {
323        return Some(arg);
324    }
325
326    None
327}
328
329fn golangci_flag_takes_separate_value(arg: &str, flag: &str) -> bool {
330    if !GOLANGCI_GLOBAL_OPT_WITH_VALUE.contains(&flag) {
331        return false;
332    }
333
334    if arg.starts_with("--") && arg.contains('=') {
335        return false;
336    }
337
338    true
339}
340
341fn split_token_spans(cmd: &str) -> Vec<(&str, usize, usize)> {
342    let mut tokens = Vec::new();
343    let mut start = None;
344
345    for (idx, ch) in cmd.char_indices() {
346        if ch.is_whitespace() {
347            if let Some(token_start) = start.take() {
348                tokens.push((&cmd[token_start..idx], token_start, idx));
349            }
350        } else if start.is_none() {
351            start = Some(idx);
352        }
353    }
354
355    if let Some(token_start) = start {
356        tokens.push((&cmd[token_start..], token_start, cmd.len()));
357    }
358
359    tokens
360}
361
362/// Normalize absolute binary paths: `/usr/bin/grep -rn foo` → `grep -rn foo` (#485)
363/// Only strips if the first word contains a `/` (Unix path).
364fn strip_absolute_path(cmd: &str) -> String {
365    let first_space = cmd.find(' ');
366    let first_word = match first_space {
367        Some(pos) => &cmd[..pos],
368        None => cmd,
369    };
370    if first_word.contains('/') {
371        // Extract basename
372        let basename = first_word.rsplit('/').next().unwrap_or(first_word);
373        if basename.is_empty() {
374            return cmd.to_string();
375        }
376        match first_space {
377            Some(pos) => format!("{}{}", basename, &cmd[pos..]),
378            None => basename.to_string(),
379        }
380    } else {
381        cmd.to_string()
382    }
383}
384
385pub fn prefix_contains_rtk_disabled(prefix_part: &str) -> bool {
386    prefix_part.contains("RTK_DISABLED=")
387}
388
389/// Check if a command has RTK_DISABLED= prefix in its env prefix portion.
390pub fn cmd_has_rtk_disabled_prefix(cmd: &str) -> bool {
391    let (prefix_part, _) = strip_disabled_prefix(cmd);
392    prefix_contains_rtk_disabled(prefix_part)
393}
394
395/// Strip RTK_DISABLED=X and other env prefixes, returns `(env_prefix, actual_command)`.
396pub fn strip_disabled_prefix(cmd: &str) -> (&str, &str) {
397    let trimmed = cmd.trim();
398    let stripped = ENV_PREFIX.replace(trimmed, "");
399    // stripped is a Cow<str> that borrows from trimmed when no replacement happens.
400    // We need to return a &str into the original, so compute the offset.
401    let prefix_len = trimmed.len() - stripped.len();
402    let prefix_part = &trimmed[..prefix_len];
403    let rest = trimmed[prefix_len..].trim();
404    (prefix_part, rest)
405}
406
407fn strip_trailing_redirects(cmd: &str) -> (&str, &str) {
408    let tokens = tokenize(cmd);
409    if tokens.is_empty() {
410        return (cmd, "");
411    }
412
413    let mut redir_boundary = tokens.len();
414    let mut i = tokens.len();
415    while i > 0 {
416        i -= 1;
417        match tokens[i].kind {
418            TokenKind::Redirect => {
419                redir_boundary = i;
420            }
421            TokenKind::Arg => {
422                if i > 0 && tokens[i - 1].kind == TokenKind::Redirect {
423                    redir_boundary = i - 1;
424                    i -= 1;
425                } else {
426                    break;
427                }
428            }
429            _ => break,
430        }
431    }
432
433    if redir_boundary >= tokens.len() {
434        return (cmd, "");
435    }
436
437    let cut = tokens[redir_boundary].offset;
438    let cmd_part = cmd[..cut].trim_end();
439    let redir_part = &cmd[cmd_part.len()..];
440    (cmd_part, redir_part)
441}
442
443lazy_static! {
444    /// Matches a bash line-continuation: a backslash immediately followed by
445    /// `\n` or `\r\n`, *plus* any horizontal whitespace on the line before AND
446    /// after the break. This is what bash already collapses to a single space
447    /// before executing the command — rtk's hook matcher needs to do the same
448    /// so commands authored across multiple lines still hit the rewrite rules.
449    /// Consuming the trailing whitespace prevents double spaces in cases like
450    /// `git diff \<NL>HEAD~1`.
451    static ref LINE_CONTINUATION_RE: Regex =
452        Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap();
453}
454
455/// Replace every bash line continuation with a single space, mirroring what
456/// bash does before dispatching the command. Returns a borrowed `&str` when the
457/// input contains no continuations, so the common fast path allocates nothing.
458fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> {
459    LINE_CONTINUATION_RE.replace_all(s, " ")
460}
461
462/// Returns `None` if the command is unsupported or ignored (hook should pass through).
463///
464/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
465/// For pipes (`|`), only rewrites the left-hand command (pipe targets stay raw),
466/// but continues rewriting segments after subsequent `&&`/`||`/`;` operators.
467/// Also strips user-configured transparent wrapper prefixes
468/// (`[hooks].transparent_prefixes` in `config.toml`) before routing.
469///
470/// A transparent prefix is a wrapper command that doesn't change *what* is
471/// being run, only *how* it's run — e.g. `docker exec mycontainer`,
472/// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner
473/// command match a filter; the prefix is then re-prepended to the rewrite. The
474/// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`,
475/// `nocorrect`) are always applied in addition to user-configured prefixes.
476///
477/// Matching is strict: a configured prefix `"foo bar"` matches a command that
478/// starts with `"foo bar "` (or strictly equals `"foo bar"`), not anything
479/// else. Matching is literal, not pattern-based: configure the exact concrete
480/// prefix you use.
481pub fn rewrite_command(
482    cmd: &str,
483    excluded: &[String],
484    transparent_prefixes: &[String],
485) -> Option<String> {
486    rewrite_command_with_proxy(cmd, excluded, transparent_prefixes, "rtk")
487}
488
489/// Like [`rewrite_command`], but emits rewritten command segments using
490/// `proxy_prefix` instead of the default `rtk` binary name.
491///
492/// Hosts embedding RTK can pass a shell-quoted prefix such as
493/// `'/path/to/anvil' __rtk` so the returned shell string preserves operators
494/// while dispatching each rewritten segment through the host binary.
495pub fn rewrite_command_with_proxy(
496    cmd: &str,
497    excluded: &[String],
498    transparent_prefixes: &[String],
499    proxy_prefix: &str,
500) -> Option<String> {
501    let proxy_prefix = proxy_prefix.trim();
502
503    // Bash line continuations (`\<NL>`, `\<CRLF>`) and the leading whitespace that
504    // follows are syntactically equivalent to a single space, but `cmd.trim()` does
505    // not unwrap them so a leading backslash-newline used to defeat the whole matcher.
506    // Normalize first, then trim. See issue #1564.
507    let normalized = collapse_line_continuations(cmd);
508    let trimmed = normalized.trim();
509    if trimmed.is_empty() {
510        return None;
511    }
512
513    if has_heredoc(trimmed) || trimmed.contains("$((") {
514        return None;
515    }
516
517    let compiled = compile_exclude_patterns(excluded);
518    let normalized_prefixes = normalize_transparent_prefixes(transparent_prefixes);
519
520    // Simple (non-compound) already-RTK command — return as-is.
521    // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"),
522    // fall through to rewrite_compound so the remaining segments get rewritten.
523    let has_compound = trimmed.contains("&&")
524        || trimmed.contains("||")
525        || trimmed.contains(';')
526        || trimmed.contains('|')
527        || trimmed.contains(" & ");
528    if !has_compound
529        && (trimmed.starts_with("rtk ")
530            || trimmed == "rtk"
531            || strip_word_prefix(trimmed, proxy_prefix).is_some())
532    {
533        return Some(trimmed.to_string());
534    }
535
536    rewrite_compound(trimmed, &compiled, &normalized_prefixes, proxy_prefix)
537}
538
539/// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment.
540fn rewrite_compound(
541    cmd: &str,
542    excluded: &[ExcludePattern],
543    transparent_prefixes: &[String],
544    proxy_prefix: &str,
545) -> Option<String> {
546    let tokens = tokenize(cmd);
547    let mut result = String::with_capacity(cmd.len() + 32);
548    let mut any_changed = false;
549    let mut seg_start: usize = 0;
550
551    for tok in &tokens {
552        if tok.offset < seg_start {
553            continue;
554        }
555        match tok.kind {
556            TokenKind::Operator => {
557                let seg = cmd[seg_start..tok.offset].trim();
558                let rewritten = rewrite_segment(seg, excluded, transparent_prefixes, proxy_prefix)
559                    .unwrap_or_else(|| seg.to_string());
560                if rewritten != seg {
561                    any_changed = true;
562                }
563                result.push_str(&rewritten);
564                if tok.value == ";" {
565                    result.push(';');
566                    let after = tok.offset + tok.value.len();
567                    if after < cmd.len() {
568                        result.push(' ');
569                    }
570                } else {
571                    result.push(' ');
572                    result.push_str(&tok.value);
573                    result.push(' ');
574                }
575                seg_start = tok.offset + tok.value.len();
576                while seg_start < cmd.len() && cmd.as_bytes().get(seg_start) == Some(&b' ') {
577                    seg_start += 1;
578                }
579            }
580            TokenKind::Pipe => {
581                let seg = cmd[seg_start..tok.offset].trim();
582                let is_pipe_incompatible = seg.starts_with("find ")
583                    || seg == "find"
584                    || seg.starts_with("fd ")
585                    || seg == "fd";
586                let rewritten = if is_pipe_incompatible {
587                    seg.to_string()
588                } else {
589                    rewrite_segment(seg, excluded, transparent_prefixes, proxy_prefix)
590                        .unwrap_or_else(|| seg.to_string())
591                };
592                if rewritten != seg {
593                    any_changed = true;
594                }
595                result.push_str(&rewritten);
596
597                let pipe_group_end = tokens.iter().find(|t| {
598                    t.offset > tok.offset
599                        && (t.kind == TokenKind::Operator
600                            || (t.kind == TokenKind::Shellism && t.value == "&"))
601                });
602
603                match pipe_group_end {
604                    Some(next_op) => {
605                        result.push(' ');
606                        result.push_str(cmd[tok.offset..next_op.offset].trim());
607                        seg_start = next_op.offset;
608                    }
609                    None => {
610                        result.push(' ');
611                        result.push_str(cmd[tok.offset..].trim_start());
612                        return if any_changed { Some(result) } else { None };
613                    }
614                }
615            }
616            TokenKind::Shellism if tok.value == "&" => {
617                let seg = cmd[seg_start..tok.offset].trim();
618                let rewritten = rewrite_segment(seg, excluded, transparent_prefixes, proxy_prefix)
619                    .unwrap_or_else(|| seg.to_string());
620                if rewritten != seg {
621                    any_changed = true;
622                }
623                result.push_str(&rewritten);
624                result.push_str(" & ");
625                seg_start = tok.offset + tok.value.len();
626                while seg_start < cmd.len() && cmd.as_bytes().get(seg_start) == Some(&b' ') {
627                    seg_start += 1;
628                }
629            }
630            _ => {}
631        }
632    }
633
634    let seg = cmd[seg_start..].trim();
635    let rewritten = rewrite_segment(seg, excluded, transparent_prefixes, proxy_prefix)
636        .unwrap_or_else(|| seg.to_string());
637    if rewritten != seg {
638        any_changed = true;
639    }
640    result.push_str(&rewritten);
641
642    if any_changed {
643        Some(result)
644    } else {
645        None
646    }
647}
648
649fn rewrite_line_range(cmd: &str, proxy_prefix: &str) -> Option<String> {
650    for re in [&*HEAD_N, &*HEAD_LINES] {
651        if let Some(caps) = re.captures(cmd) {
652            let n = caps.get(1)?.as_str();
653            let file = caps.get(2)?.as_str();
654            return Some(format!("{} read {} --max-lines {}", proxy_prefix, file, n));
655        }
656    }
657    if cmd.starts_with("head -") {
658        return None;
659    }
660    for re in [
661        &*TAIL_N,
662        &*TAIL_N_SPACE,
663        &*TAIL_LINES_EQ,
664        &*TAIL_LINES_SPACE,
665    ] {
666        if let Some(caps) = re.captures(cmd) {
667            let n = caps.get(1)?.as_str();
668            let file = caps.get(2)?.as_str();
669            return Some(format!("{} read {} --tail-lines {}", proxy_prefix, file, n));
670        }
671    }
672    None
673}
674
675/// Shell prefix builtins that modify how the shell runs a command
676/// but don't change which command runs. Strip before routing, re-prepend after.
677const SHELL_PREFIX_BUILTINS: &[&str] = &["noglob", "command", "builtin", "exec", "nocorrect"];
678
679const MAX_PREFIX_DEPTH: usize = 10;
680
681enum ExcludePattern {
682    Regex(Regex),
683    Prefix(String),
684}
685
686fn compile_exclude_patterns(patterns: &[String]) -> Vec<ExcludePattern> {
687    patterns
688        .iter()
689        .filter_map(|pattern| {
690            let trimmed = pattern.trim();
691            if trimmed.is_empty() || trimmed == "^" {
692                eprintln!(
693                    "rtk: warning: ignoring trivial exclude_commands pattern '{}'",
694                    pattern
695                );
696                return None;
697            }
698            let anchored = if trimmed.starts_with('^') {
699                trimmed.to_string()
700            } else {
701                format!(r"^{}($|\s)", regex::escape(trimmed))
702            };
703            Some(match Regex::new(&anchored) {
704                Ok(re) => ExcludePattern::Regex(re),
705                Err(e) => {
706                    eprintln!(
707                        "rtk: warning: invalid exclude_commands pattern '{}': {}",
708                        pattern, e
709                    );
710                    ExcludePattern::Prefix(trimmed.to_string())
711                }
712            })
713        })
714        .collect()
715}
716
717fn normalize_transparent_prefixes(prefixes: &[String]) -> Vec<String> {
718    let mut normalized: Vec<String> = prefixes
719        .iter()
720        .map(|prefix| prefix.trim())
721        .filter(|prefix| !prefix.is_empty())
722        .map(str::to_string)
723        .collect();
724
725    // Match longer wrappers first so `docker exec mycontainer` wins over `docker`.
726    normalized.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
727    normalized.dedup();
728    normalized
729}
730
731fn rewrite_segment(
732    seg: &str,
733    excluded: &[ExcludePattern],
734    transparent_prefixes: &[String],
735    proxy_prefix: &str,
736) -> Option<String> {
737    rewrite_segment_inner(seg, excluded, transparent_prefixes, proxy_prefix, 0)
738}
739
740fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool {
741    excluded.iter().any(|pat| match pat {
742        ExcludePattern::Regex(re) => re.is_match(cmd),
743        ExcludePattern::Prefix(prefix) => cmd.starts_with(prefix.as_str()),
744    })
745}
746
747fn rewrite_segment_inner(
748    seg: &str,
749    excluded: &[ExcludePattern],
750    transparent_prefixes: &[String],
751    proxy_prefix: &str,
752    depth: usize,
753) -> Option<String> {
754    let trimmed = seg.trim();
755    if trimmed.is_empty() {
756        return None;
757    }
758
759    if depth >= MAX_PREFIX_DEPTH {
760        return None;
761    }
762
763    let (env_prefix, rest_after_env) = strip_disabled_prefix(trimmed);
764    if !env_prefix.is_empty() {
765        // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
766        // #508: warn on stderr so agents learn to stop overusing it
767        if env_prefix.contains("RTK_DISABLED=") {
768            eprintln!(
769                "[rtk] RTK_DISABLED=1 detected — skipping filter for this command. \
770                 Remove RTK_DISABLED=1 to restore token savings."
771            );
772            return None;
773        }
774        let rewritten = rewrite_segment_inner(
775            rest_after_env,
776            excluded,
777            transparent_prefixes,
778            proxy_prefix,
779            depth + 1,
780        )?;
781        return Some(format!("{}{}", env_prefix, rewritten));
782    }
783
784    for &prefix in SHELL_PREFIX_BUILTINS {
785        if let Some(rest) = strip_word_prefix(trimmed, prefix) {
786            if rest.is_empty() {
787                return None;
788            }
789            return rewrite_segment_inner(
790                rest,
791                excluded,
792                transparent_prefixes,
793                proxy_prefix,
794                depth + 1,
795            )
796            .map(|rewritten| format!("{} {}", prefix, rewritten));
797        }
798    }
799
800    // User-configured wrapper prefixes (e.g. `docker exec mycontainer`). Same
801    // strip-recurse-reprepend contract as the builtin list above.
802    for prefix in transparent_prefixes {
803        if let Some(rest) = strip_word_prefix(trimmed, prefix) {
804            if rest.is_empty() {
805                return None;
806            }
807            return rewrite_segment_inner(
808                rest,
809                excluded,
810                transparent_prefixes,
811                proxy_prefix,
812                depth + 1,
813            )
814            .map(|rewritten| format!("{} {}", prefix, rewritten));
815        }
816    }
817
818    // Strip trailing stderr/stdout redirects before matching (#530)
819    // e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
820    let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed);
821
822    // Already RTK (or already dispatched through the host proxy) — pass
823    // through unchanged so a second rewrite pass is a no-op.
824    if cmd_part.starts_with("rtk ")
825        || cmd_part == "rtk"
826        || strip_word_prefix(cmd_part, proxy_prefix).is_some()
827    {
828        return Some(trimmed.to_string());
829    }
830
831    if cmd_part.starts_with("head -") || cmd_part.starts_with("tail ") {
832        return rewrite_line_range(cmd_part, proxy_prefix)
833            .map(|r| format!("{}{}", r, redirect_suffix));
834    }
835
836    // Most cat flags (-v, -A, -e, -t, -s, -b, --show-all, etc.) have different
837    // semantics than rtk read or no equivalent at all. Only `-n` (line numbers)
838    // maps correctly to `rtk read -n`. Skip rewrite for any other flag.
839    if let Some(cmd_args) = cmd_part.strip_prefix("cat ") {
840        let args = cmd_args.trim_start();
841        if args.starts_with('-') && !args.starts_with("-n ") && !args.starts_with("-n\t") {
842            return None;
843        }
844    }
845
846    // Use classify_command for correct ignore/prefix handling
847    let rtk_equivalent = match classify_command(cmd_part) {
848        Classification::Supported { rtk_equivalent, .. } => {
849            let stripped = ENV_PREFIX.replace(cmd_part, "");
850            let cmd_clean = stripped.trim();
851            if is_excluded(cmd_clean, excluded) {
852                return None;
853            }
854            rtk_equivalent
855        }
856        _ => return None,
857    };
858
859    // Find the matching rule (rtk_cmd values are unique across all rules)
860    let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?;
861
862    if let Some(parts) = parse_golangci_run_parts(cmd_part) {
863        let rewritten = if parts.global_segment.is_empty() {
864            format!("{} golangci-lint {}", proxy_prefix, parts.run_segment)
865        } else {
866            format!(
867                "{} golangci-lint {} {}",
868                proxy_prefix, parts.global_segment, parts.run_segment
869            )
870        };
871        return Some(rewritten);
872    }
873
874    // #196: gh with --json/--jq/--template produces structured output that
875    // rtk gh would corrupt — skip rewrite so the caller gets raw JSON.
876    if rule.rtk_cmd == "rtk gh" {
877        let args_lower = cmd_part.to_lowercase();
878        if args_lower.contains("--json")
879            || args_lower.contains("--jq")
880            || args_lower.contains("--template")
881        {
882            return None;
883        }
884    }
885
886    // Try each rewrite prefix (longest first) with word-boundary check
887    for &prefix in rule.rewrite_prefixes {
888        if let Some(rest) = strip_word_prefix(cmd_part, prefix) {
889            let rewritten = if rest.is_empty() {
890                format!(
891                    "{}{}",
892                    with_proxy(rule.rtk_cmd, proxy_prefix)?,
893                    redirect_suffix
894                )
895            } else {
896                format!(
897                    "{} {}{}",
898                    with_proxy(rule.rtk_cmd, proxy_prefix)?,
899                    rest,
900                    redirect_suffix
901                )
902            };
903            return Some(rewritten);
904        }
905    }
906
907    None
908}
909
910fn with_proxy(rtk_cmd: &str, proxy_prefix: &str) -> Option<String> {
911    let rest = strip_word_prefix(rtk_cmd, "rtk")?;
912    if rest.is_empty() {
913        Some(proxy_prefix.to_string())
914    } else {
915        Some(format!("{} {}", proxy_prefix, rest))
916    }
917}
918
919/// Strip a command prefix with word-boundary check.
920/// Returns the remainder of the command after the prefix, or `None` if no match.
921fn strip_word_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> {
922    if cmd == prefix {
923        Some("")
924    } else if cmd.len() > prefix.len()
925        && cmd.starts_with(prefix)
926        && cmd.as_bytes()[prefix.len()] == b' '
927    {
928        Some(cmd[prefix.len() + 1..].trim_start())
929    } else {
930        None
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::super::report::RtkStatus;
937    use super::*;
938
939    fn rewrite_command_no_prefixes(cmd: &str, excluded: &[String]) -> Option<String> {
940        super::rewrite_command(cmd, excluded, &[])
941    }
942
943    fn rewrite_command_with_host_proxy(cmd: &str) -> Option<String> {
944        super::rewrite_command_with_proxy(cmd, &[], &[], "'/opt/anvil' __rtk")
945    }
946
947    #[test]
948    fn test_classify_git_status() {
949        assert_eq!(
950            classify_command("git status"),
951            Classification::Supported {
952                rtk_equivalent: "rtk git",
953                category: "Git",
954                estimated_savings_pct: 70.0,
955                status: RtkStatus::Existing,
956            }
957        );
958    }
959
960    #[test]
961    fn test_classify_yadm_status() {
962        assert_eq!(
963            classify_command("yadm status"),
964            Classification::Supported {
965                rtk_equivalent: "rtk git",
966                category: "Git",
967                estimated_savings_pct: 70.0,
968                status: RtkStatus::Existing,
969            }
970        );
971    }
972
973    #[test]
974    fn test_classify_yadm_diff() {
975        assert_eq!(
976            classify_command("yadm diff"),
977            Classification::Supported {
978                rtk_equivalent: "rtk git",
979                category: "Git",
980                estimated_savings_pct: 80.0,
981                status: RtkStatus::Existing,
982            }
983        );
984    }
985
986    #[test]
987    fn test_rewrite_yadm_status() {
988        assert_eq!(
989            rewrite_command_no_prefixes("yadm status", &[]),
990            Some("rtk git status".to_string())
991        );
992    }
993
994    #[test]
995    fn test_rewrite_with_custom_proxy() {
996        assert_eq!(
997            rewrite_command_with_host_proxy("git status"),
998            Some("'/opt/anvil' __rtk git status".to_string())
999        );
1000    }
1001
1002    #[test]
1003    fn test_rewrite_compound_with_custom_proxy() {
1004        assert_eq!(
1005            rewrite_command_with_host_proxy("cargo test && git status"),
1006            Some("'/opt/anvil' __rtk cargo test && '/opt/anvil' __rtk git status".to_string())
1007        );
1008    }
1009
1010    #[test]
1011    fn test_with_proxy_requires_rtk_word_boundary() {
1012        assert_eq!(super::with_proxy("rtk git", "P"), Some("P git".to_string()));
1013        assert_eq!(super::with_proxy("rtk", "P"), Some("P".to_string()));
1014        // "rtkfoo" is not the rtk binary — must not be rewritten to "P foo".
1015        assert_eq!(super::with_proxy("rtkfoo bar", "P"), None);
1016    }
1017
1018    #[test]
1019    fn test_rewrite_with_custom_proxy_is_idempotent() {
1020        let once = rewrite_command_with_host_proxy("git status").expect("first rewrite");
1021        // A second pass must recognize the proxied command as already
1022        // rewritten, same as plain "rtk ..." commands.
1023        assert_eq!(rewrite_command_with_host_proxy(&once), Some(once.clone()));
1024    }
1025
1026    #[test]
1027    fn test_rewrite_compound_with_custom_proxy_is_idempotent() {
1028        let once =
1029            rewrite_command_with_host_proxy("cargo test && git status").expect("first rewrite");
1030        // None = no segment changed — the compound form must not be
1031        // proxied a second time.
1032        assert_eq!(rewrite_command_with_host_proxy(&once), None);
1033    }
1034
1035    #[test]
1036    fn test_classify_git_diff_cached() {
1037        assert_eq!(
1038            classify_command("git diff --cached"),
1039            Classification::Supported {
1040                rtk_equivalent: "rtk git",
1041                category: "Git",
1042                estimated_savings_pct: 80.0,
1043                status: RtkStatus::Existing,
1044            }
1045        );
1046    }
1047
1048    #[test]
1049    fn test_classify_cargo_test_filter() {
1050        assert_eq!(
1051            classify_command("cargo test filter::"),
1052            Classification::Supported {
1053                rtk_equivalent: "rtk cargo",
1054                category: "Cargo",
1055                estimated_savings_pct: 90.0,
1056                status: RtkStatus::Existing,
1057            }
1058        );
1059    }
1060
1061    #[test]
1062    fn test_classify_npx_tsc() {
1063        assert_eq!(
1064            classify_command("npx tsc --noEmit"),
1065            Classification::Supported {
1066                rtk_equivalent: "rtk tsc",
1067                category: "Build",
1068                estimated_savings_pct: 83.0,
1069                status: RtkStatus::Existing,
1070            }
1071        );
1072    }
1073
1074    #[test]
1075    fn test_classify_cat_file() {
1076        assert_eq!(
1077            classify_command("cat src/main.rs"),
1078            Classification::Supported {
1079                rtk_equivalent: "rtk read",
1080                category: "Files",
1081                estimated_savings_pct: 60.0,
1082                status: RtkStatus::Existing,
1083            }
1084        );
1085    }
1086
1087    #[test]
1088    fn test_classify_cat_redirect_not_supported() {
1089        // cat > file and cat >> file are writes, not reads — should not be classified as supported
1090        let write_commands = [
1091            "cat > /tmp/output.txt",
1092            "cat >> /tmp/output.txt",
1093            "cat file.txt > output.txt",
1094            "cat -n file.txt >> log.txt",
1095            "head -10 README.md > output.txt",
1096            "tail -f app.log > /dev/null",
1097        ];
1098        for cmd in &write_commands {
1099            if let Classification::Supported { .. } = classify_command(cmd) {
1100                panic!("{} should NOT be classified as Supported", cmd)
1101            }
1102            // Unsupported or Ignored is fine
1103        }
1104    }
1105
1106    #[test]
1107    fn test_classify_cd_ignored() {
1108        assert_eq!(classify_command("cd /tmp"), Classification::Ignored);
1109    }
1110
1111    #[test]
1112    fn test_classify_rtk_already() {
1113        assert_eq!(classify_command("rtk git status"), Classification::Ignored);
1114    }
1115
1116    #[test]
1117    fn test_classify_echo_ignored() {
1118        assert_eq!(
1119            classify_command("echo hello world"),
1120            Classification::Ignored
1121        );
1122    }
1123
1124    #[test]
1125    fn test_classify_htop_unsupported() {
1126        match classify_command("htop -d 10") {
1127            Classification::Unsupported { base_command } => {
1128                assert_eq!(base_command, "htop");
1129            }
1130            other => panic!("expected Unsupported, got {:?}", other),
1131        }
1132    }
1133
1134    #[test]
1135    fn test_classify_env_prefix_stripped() {
1136        assert_eq!(
1137            classify_command("GIT_SSH_COMMAND=ssh git push"),
1138            Classification::Supported {
1139                rtk_equivalent: "rtk git",
1140                category: "Git",
1141                estimated_savings_pct: 70.0,
1142                status: RtkStatus::Existing,
1143            }
1144        );
1145    }
1146
1147    #[test]
1148    fn test_classify_sudo_stripped() {
1149        assert_eq!(
1150            classify_command("sudo docker ps"),
1151            Classification::Supported {
1152                rtk_equivalent: "rtk docker",
1153                category: "Infra",
1154                estimated_savings_pct: 85.0,
1155                status: RtkStatus::Existing,
1156            }
1157        );
1158    }
1159
1160    #[test]
1161    fn test_classify_cargo_check() {
1162        assert_eq!(
1163            classify_command("cargo check"),
1164            Classification::Supported {
1165                rtk_equivalent: "rtk cargo",
1166                category: "Cargo",
1167                estimated_savings_pct: 80.0,
1168                status: RtkStatus::Existing,
1169            }
1170        );
1171    }
1172
1173    #[test]
1174    fn test_classify_cargo_check_all_targets() {
1175        assert_eq!(
1176            classify_command("cargo check --all-targets"),
1177            Classification::Supported {
1178                rtk_equivalent: "rtk cargo",
1179                category: "Cargo",
1180                estimated_savings_pct: 80.0,
1181                status: RtkStatus::Existing,
1182            }
1183        );
1184    }
1185
1186    #[test]
1187    fn test_classify_cargo_fmt_passthrough() {
1188        assert_eq!(
1189            classify_command("cargo fmt"),
1190            Classification::Supported {
1191                rtk_equivalent: "rtk cargo",
1192                category: "Cargo",
1193                estimated_savings_pct: 80.0,
1194                status: RtkStatus::Passthrough,
1195            }
1196        );
1197    }
1198
1199    #[test]
1200    fn test_classify_cargo_clippy_savings() {
1201        assert_eq!(
1202            classify_command("cargo clippy --all-targets"),
1203            Classification::Supported {
1204                rtk_equivalent: "rtk cargo",
1205                category: "Cargo",
1206                estimated_savings_pct: 80.0,
1207                status: RtkStatus::Existing,
1208            }
1209        );
1210    }
1211
1212    #[test]
1213    fn test_registry_covers_all_cargo_subcommands() {
1214        // Verify that every CargoCommand variant (Build, Test, Clippy, Check, Fmt)
1215        // except Other has a matching pattern in the registry
1216        for subcmd in ["build", "test", "clippy", "check", "fmt"] {
1217            let cmd = format!("cargo {subcmd}");
1218            match classify_command(&cmd) {
1219                Classification::Supported { .. } => {}
1220                other => panic!("cargo {subcmd} should be Supported, got {other:?}"),
1221            }
1222        }
1223    }
1224
1225    #[test]
1226    fn test_registry_covers_all_git_subcommands() {
1227        // Verify that every GitCommand subcommand has a matching pattern
1228        for subcmd in [
1229            "status", "log", "diff", "show", "add", "commit", "push", "pull", "branch", "fetch",
1230            "stash", "worktree",
1231        ] {
1232            let cmd = format!("git {subcmd}");
1233            match classify_command(&cmd) {
1234                Classification::Supported { .. } => {}
1235                other => panic!("git {subcmd} should be Supported, got {other:?}"),
1236            }
1237        }
1238    }
1239
1240    #[test]
1241    fn test_classify_find_not_blocked_by_fi() {
1242        // Regression: "fi" in IGNORED_PREFIXES used to shadow "find" commands
1243        // because "find".starts_with("fi") is true. "fi" should only match exactly.
1244        assert_eq!(
1245            classify_command("find . -name foo"),
1246            Classification::Supported {
1247                rtk_equivalent: "rtk find",
1248                category: "Files",
1249                estimated_savings_pct: 70.0,
1250                status: RtkStatus::Existing,
1251            }
1252        );
1253    }
1254
1255    #[test]
1256    fn test_fi_still_ignored_exact() {
1257        // Bare "fi" (shell keyword) should still be ignored
1258        assert_eq!(classify_command("fi"), Classification::Ignored);
1259    }
1260
1261    #[test]
1262    fn test_done_still_ignored_exact() {
1263        // Bare "done" (shell keyword) should still be ignored
1264        assert_eq!(classify_command("done"), Classification::Ignored);
1265    }
1266
1267    #[test]
1268    fn test_split_chain_and() {
1269        assert_eq!(split_command_chain("a && b"), vec!["a", "b"]);
1270    }
1271
1272    #[test]
1273    fn test_split_chain_semicolon() {
1274        assert_eq!(split_command_chain("a ; b"), vec!["a", "b"]);
1275    }
1276
1277    #[test]
1278    fn test_split_pipe_first_only() {
1279        assert_eq!(split_command_chain("a | b"), vec!["a"]);
1280    }
1281
1282    #[test]
1283    fn test_split_single() {
1284        assert_eq!(split_command_chain("git status"), vec!["git status"]);
1285    }
1286
1287    #[test]
1288    fn test_split_quoted_and() {
1289        assert_eq!(
1290            split_command_chain(r#"echo "a && b""#),
1291            vec![r#"echo "a && b""#]
1292        );
1293    }
1294
1295    #[test]
1296    fn test_split_heredoc_no_split() {
1297        let cmd = "cat <<'EOF'\nhello && world\nEOF";
1298        assert_eq!(split_command_chain(cmd), vec![cmd]);
1299    }
1300
1301    #[test]
1302    fn test_classify_mypy() {
1303        assert_eq!(
1304            classify_command("mypy src/"),
1305            Classification::Supported {
1306                rtk_equivalent: "rtk mypy",
1307                category: "Build",
1308                estimated_savings_pct: 80.0,
1309                status: RtkStatus::Existing,
1310            }
1311        );
1312    }
1313
1314    #[test]
1315    fn test_classify_python_m_mypy() {
1316        assert_eq!(
1317            classify_command("python3 -m mypy --strict"),
1318            Classification::Supported {
1319                rtk_equivalent: "rtk mypy",
1320                category: "Build",
1321                estimated_savings_pct: 80.0,
1322                status: RtkStatus::Existing,
1323            }
1324        );
1325    }
1326
1327    // --- rewrite_command tests ---
1328
1329    #[test]
1330    fn test_rewrite_git_status() {
1331        assert_eq!(
1332            rewrite_command_no_prefixes("git status", &[]),
1333            Some("rtk git status".into())
1334        );
1335    }
1336
1337    #[test]
1338    fn test_rewrite_git_log() {
1339        assert_eq!(
1340            rewrite_command_no_prefixes("git log -10", &[]),
1341            Some("rtk git log -10".into())
1342        );
1343    }
1344
1345    // --- git -C <path> support (#555) ---
1346
1347    #[test]
1348    fn test_rewrite_git_dash_c_status() {
1349        assert_eq!(
1350            rewrite_command_no_prefixes("git -C /path/to/repo status", &[]),
1351            Some("rtk git -C /path/to/repo status".into())
1352        );
1353    }
1354
1355    #[test]
1356    fn test_rewrite_git_dash_c_log() {
1357        assert_eq!(
1358            rewrite_command_no_prefixes("git -C /tmp/myrepo log --oneline -5", &[]),
1359            Some("rtk git -C /tmp/myrepo log --oneline -5".into())
1360        );
1361    }
1362
1363    #[test]
1364    fn test_rewrite_git_dash_c_diff() {
1365        assert_eq!(
1366            rewrite_command_no_prefixes("git -C /home/user/project diff --name-only", &[]),
1367            Some("rtk git -C /home/user/project diff --name-only".into())
1368        );
1369    }
1370
1371    #[test]
1372    fn test_classify_git_dash_c() {
1373        let result = classify_command("git -C /tmp status");
1374        assert!(
1375            matches!(
1376                result,
1377                Classification::Supported {
1378                    rtk_equivalent: "rtk git",
1379                    ..
1380                }
1381            ),
1382            "git -C should be classified as supported, got: {:?}",
1383            result
1384        );
1385    }
1386
1387    #[test]
1388    fn test_rewrite_cargo_test() {
1389        assert_eq!(
1390            rewrite_command_no_prefixes("cargo test", &[]),
1391            Some("rtk cargo test".into())
1392        );
1393    }
1394
1395    #[test]
1396    fn test_rewrite_compound_and() {
1397        assert_eq!(
1398            rewrite_command_no_prefixes("git add . && cargo test", &[]),
1399            Some("rtk git add . && rtk cargo test".into())
1400        );
1401    }
1402
1403    #[test]
1404    fn test_rewrite_compound_three_segments() {
1405        assert_eq!(
1406            rewrite_command_no_prefixes(
1407                "cargo fmt --all && cargo clippy --all-targets && cargo test",
1408                &[]
1409            ),
1410            Some("rtk cargo fmt --all && rtk cargo clippy --all-targets && rtk cargo test".into())
1411        );
1412    }
1413
1414    #[test]
1415    fn test_rewrite_already_rtk() {
1416        assert_eq!(
1417            rewrite_command_no_prefixes("rtk git status", &[]),
1418            Some("rtk git status".into())
1419        );
1420    }
1421
1422    #[test]
1423    fn test_rewrite_background_single_amp() {
1424        assert_eq!(
1425            rewrite_command_no_prefixes("cargo test & git status", &[]),
1426            Some("rtk cargo test & rtk git status".into())
1427        );
1428    }
1429
1430    #[test]
1431    fn test_rewrite_background_unsupported_right() {
1432        assert_eq!(
1433            rewrite_command_no_prefixes("cargo test & htop", &[]),
1434            Some("rtk cargo test & htop".into())
1435        );
1436    }
1437
1438    #[test]
1439    fn test_rewrite_background_does_not_affect_double_amp() {
1440        // `&&` must still work after adding `&` support
1441        assert_eq!(
1442            rewrite_command_no_prefixes("cargo test && git status", &[]),
1443            Some("rtk cargo test && rtk git status".into())
1444        );
1445    }
1446
1447    #[test]
1448    fn test_rewrite_unsupported_returns_none() {
1449        assert_eq!(rewrite_command_no_prefixes("htop", &[]), None);
1450    }
1451
1452    #[test]
1453    fn test_rewrite_ignored_cd() {
1454        assert_eq!(rewrite_command_no_prefixes("cd /tmp", &[]), None);
1455    }
1456
1457    #[test]
1458    fn test_rewrite_with_env_prefix() {
1459        assert_eq!(
1460            rewrite_command_no_prefixes("GIT_SSH_COMMAND=ssh git push", &[]),
1461            Some("GIT_SSH_COMMAND=ssh rtk git push".into())
1462        );
1463    }
1464
1465    #[test]
1466    fn test_rewrite_tsc() {
1467        let commands = vec![
1468            "npm exec tsc",
1469            "npm rum tsc",
1470            "npm run tsc",
1471            "npm run-script tsc",
1472            "npm urn tsc",
1473            "npm x tsc",
1474            "pnpm dlx tsc",
1475            "pnpm exec tsc",
1476            "pnpm run tsc",
1477            "pnpm run-script tsc",
1478            "npm tsc",
1479            "npx tsc",
1480            "pnpm tsc",
1481            "pnpx tsc",
1482            "tsc",
1483        ];
1484        for command in commands {
1485            assert_eq!(
1486                rewrite_command_no_prefixes(&format!("{command} --noEmit"), &[]),
1487                Some("rtk tsc --noEmit".into()),
1488                "Failed for command: {}",
1489                command
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    fn test_rewrite_cat_file() {
1496        assert_eq!(
1497            rewrite_command_no_prefixes("cat src/main.rs", &[]),
1498            Some("rtk read src/main.rs".into())
1499        );
1500    }
1501
1502    #[test]
1503    fn test_rewrite_cat_with_incompatible_flags_skipped() {
1504        // cat flags with different semantics than rtk read — skip rewrite
1505        assert_eq!(rewrite_command_no_prefixes("cat -A file.cpp", &[]), None);
1506        assert_eq!(rewrite_command_no_prefixes("cat -v file.txt", &[]), None);
1507        assert_eq!(rewrite_command_no_prefixes("cat -e file.txt", &[]), None);
1508        assert_eq!(rewrite_command_no_prefixes("cat -t file.txt", &[]), None);
1509        assert_eq!(rewrite_command_no_prefixes("cat -s file.txt", &[]), None);
1510        assert_eq!(
1511            rewrite_command_no_prefixes("cat --show-all file.txt", &[]),
1512            None
1513        );
1514    }
1515
1516    #[test]
1517    fn test_rewrite_cat_with_compatible_flags() {
1518        // cat -n (line numbers) maps to rtk read -n — allow rewrite
1519        assert_eq!(
1520            rewrite_command_no_prefixes("cat -n file.txt", &[]),
1521            Some("rtk read -n file.txt".into())
1522        );
1523    }
1524
1525    #[test]
1526    fn test_rewrite_rg_pattern() {
1527        assert_eq!(
1528            rewrite_command_no_prefixes("rg \"fn main\"", &[]),
1529            Some("rtk grep \"fn main\"".into())
1530        );
1531    }
1532
1533    #[test]
1534    fn test_rewrite_playwright() {
1535        let commands = vec![
1536            "npm exec playwright",
1537            "npm rum playwright",
1538            "npm run playwright",
1539            "npm run-script playwright",
1540            "npm urn playwright",
1541            "npm x playwright",
1542            "pnpm dlx playwright",
1543            "pnpm exec playwright",
1544            "pnpm run playwright",
1545            "pnpm run-script playwright",
1546            "npm playwright",
1547            "npx playwright",
1548            "pnpm playwright",
1549            "pnpx playwright",
1550            "playwright",
1551        ];
1552        for command in commands {
1553            assert_eq!(
1554                rewrite_command_no_prefixes(&format!("{command} test"), &[]),
1555                Some("rtk playwright test".into()),
1556                "Failed for command: {}",
1557                command
1558            );
1559        }
1560    }
1561
1562    #[test]
1563    fn test_rewrite_next_build() {
1564        let commands = vec![
1565            "npm exec next build",
1566            "npm rum next build",
1567            "npm run next build",
1568            "npm run-script next build",
1569            "npm urn next build",
1570            "npm x next build",
1571            "pnpm dlx next build",
1572            "pnpm exec next build",
1573            "pnpm run next build",
1574            "pnpm run-script next build",
1575            "npm next build",
1576            "npx next build",
1577            "pnpm next build",
1578            "pnpx next build",
1579            "next build",
1580        ];
1581        for command in commands {
1582            assert_eq!(
1583                rewrite_command_no_prefixes(&format!("{command} --turbo"), &[]),
1584                Some("rtk next --turbo".into()),
1585                "Failed for command: {}",
1586                command
1587            );
1588        }
1589    }
1590
1591    #[test]
1592    fn test_rewrite_pipe_first_only() {
1593        // After a pipe, the filter command stays raw
1594        assert_eq!(
1595            rewrite_command_no_prefixes("git log -10 | grep feat", &[]),
1596            Some("rtk git log -10 | grep feat".into())
1597        );
1598    }
1599
1600    #[test]
1601    fn test_rewrite_find_pipe_skipped() {
1602        // find in a pipe should NOT be rewritten — rtk find output format
1603        // is incompatible with pipe consumers like xargs (#439)
1604        assert_eq!(
1605            rewrite_command_no_prefixes("find . -name '*.rs' | xargs grep 'fn run'", &[]),
1606            None
1607        );
1608    }
1609
1610    #[test]
1611    fn test_rewrite_find_pipe_xargs_wc() {
1612        assert_eq!(
1613            rewrite_command_no_prefixes("find src -type f | wc -l", &[]),
1614            None
1615        );
1616    }
1617
1618    #[test]
1619    fn test_rewrite_find_no_pipe_still_rewritten() {
1620        // find WITHOUT a pipe should still be rewritten
1621        assert_eq!(
1622            rewrite_command_no_prefixes("find . -name '*.rs'", &[]),
1623            Some("rtk find . -name '*.rs'".into())
1624        );
1625    }
1626
1627    #[test]
1628    fn test_rewrite_heredoc_returns_none() {
1629        assert_eq!(
1630            rewrite_command_no_prefixes("cat <<'EOF'\nfoo\nEOF", &[]),
1631            None
1632        );
1633    }
1634
1635    #[test]
1636    fn test_rewrite_empty_returns_none() {
1637        assert_eq!(rewrite_command_no_prefixes("", &[]), None);
1638        assert_eq!(rewrite_command_no_prefixes("   ", &[]), None);
1639    }
1640
1641    #[test]
1642    fn test_rewrite_mixed_compound_partial() {
1643        // First segment already RTK, second gets rewritten
1644        assert_eq!(
1645            rewrite_command_no_prefixes("rtk git add . && cargo test", &[]),
1646            Some("rtk git add . && rtk cargo test".into())
1647        );
1648    }
1649
1650    // --- #345: RTK_DISABLED ---
1651
1652    #[test]
1653    fn test_rewrite_rtk_disabled_curl() {
1654        assert_eq!(
1655            rewrite_command_no_prefixes("RTK_DISABLED=1 curl https://example.com", &[]),
1656            None
1657        );
1658    }
1659
1660    #[test]
1661    fn test_rewrite_rtk_disabled_git_status() {
1662        assert_eq!(
1663            rewrite_command_no_prefixes("RTK_DISABLED=1 git status", &[]),
1664            None
1665        );
1666    }
1667
1668    #[test]
1669    fn test_rewrite_rtk_disabled_multi_env() {
1670        assert_eq!(
1671            rewrite_command_no_prefixes("FOO=1 RTK_DISABLED=1 git status", &[]),
1672            None
1673        );
1674    }
1675
1676    #[test]
1677    fn test_rewrite_rtk_disabled_warns_on_stderr() {
1678        assert_eq!(
1679            rewrite_command_no_prefixes("RTK_DISABLED=1 git status", &[]),
1680            None
1681        );
1682    }
1683
1684    #[test]
1685    fn test_rewrite_rtk_disabled_subprocess_warns() {
1686        let rtk_bin = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1687            .join("target")
1688            .join("debug")
1689            .join("rtk");
1690        if !rtk_bin.exists() {
1691            return;
1692        }
1693        let rtk_mtime = std::fs::metadata(&rtk_bin)
1694            .ok()
1695            .and_then(|m| m.modified().ok());
1696        let test_mtime = std::env::current_exe()
1697            .ok()
1698            .and_then(|p| std::fs::metadata(p).ok())
1699            .and_then(|m| m.modified().ok());
1700        if let (Some(rtk_t), Some(test_t)) = (rtk_mtime, test_mtime) {
1701            if rtk_t < test_t {
1702                return;
1703            }
1704        }
1705
1706        let output = std::process::Command::new(&rtk_bin)
1707            .args(["rewrite", "RTK_DISABLED=1 git status"])
1708            .output()
1709            .expect("Failed to run rtk");
1710
1711        assert!(
1712            !output.status.success(),
1713            "Should exit non-zero (no rewrite)"
1714        );
1715        let stderr = String::from_utf8_lossy(&output.stderr);
1716        assert!(
1717            stderr.contains("RTK_DISABLED=1 detected"),
1718            "Should warn on stderr, got: {}",
1719            stderr
1720        );
1721    }
1722
1723    #[test]
1724    fn test_rewrite_non_rtk_disabled_env_still_rewrites() {
1725        assert_eq!(
1726            rewrite_command_no_prefixes("SOME_VAR=1 git status", &[]),
1727            Some("SOME_VAR=1 rtk git status".into())
1728        );
1729    }
1730
1731    #[test]
1732    fn test_rewrite_env_quoted_value_with_spaces() {
1733        assert_eq!(
1734            rewrite_command_no_prefixes(
1735                r#"GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push"#,
1736                &[]
1737            ),
1738            Some(r#"GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" rtk git push"#.into())
1739        );
1740    }
1741
1742    #[test]
1743    fn test_rewrite_env_single_quoted_value_with_spaces() {
1744        assert_eq!(
1745            rewrite_command_no_prefixes("EDITOR='vim -u NONE' git commit", &[]),
1746            Some("EDITOR='vim -u NONE' rtk git commit".into())
1747        );
1748    }
1749
1750    #[test]
1751    fn test_rewrite_env_quoted_plus_unquoted() {
1752        assert_eq!(
1753            rewrite_command_no_prefixes(r#"FOO="bar baz" BAR=1 git status"#, &[]),
1754            Some(r#"FOO="bar baz" BAR=1 rtk git status"#.into())
1755        );
1756    }
1757
1758    #[test]
1759    fn test_rewrite_env_escaped_quotes_in_value() {
1760        assert_eq!(
1761            rewrite_command_no_prefixes(r#"FOO="he said \"hello\"" git status"#, &[]),
1762            Some(r#"FOO="he said \"hello\"" rtk git status"#.into())
1763        );
1764    }
1765
1766    #[test]
1767    fn test_classify_env_quoted_value_stripped() {
1768        assert_eq!(
1769            classify_command(r#"GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no" git push"#),
1770            Classification::Supported {
1771                rtk_equivalent: "rtk git",
1772                category: "Git",
1773                estimated_savings_pct: 70.0,
1774                status: RtkStatus::Existing,
1775            }
1776        );
1777    }
1778
1779    // --- #346: 2>&1 and &> redirect detection ---
1780
1781    #[test]
1782    fn test_rewrite_redirect_2_gt_amp_1_with_pipe() {
1783        assert_eq!(
1784            rewrite_command_no_prefixes("cargo test 2>&1 | head", &[]),
1785            Some("rtk cargo test 2>&1 | head".into())
1786        );
1787    }
1788
1789    #[test]
1790    fn test_rewrite_redirect_2_gt_amp_1_trailing() {
1791        assert_eq!(
1792            rewrite_command_no_prefixes("cargo test 2>&1", &[]),
1793            Some("rtk cargo test 2>&1".into())
1794        );
1795    }
1796
1797    #[test]
1798    fn test_rewrite_redirect_plain_2_devnull() {
1799        // 2>/dev/null has no `&`, never broken — non-regression
1800        assert_eq!(
1801            rewrite_command_no_prefixes("git status 2>/dev/null", &[]),
1802            Some("rtk git status 2>/dev/null".into())
1803        );
1804    }
1805
1806    #[test]
1807    fn test_rewrite_redirect_2_gt_amp_1_with_and() {
1808        assert_eq!(
1809            rewrite_command_no_prefixes("cargo test 2>&1 && echo done", &[]),
1810            Some("rtk cargo test 2>&1 && echo done".into())
1811        );
1812    }
1813
1814    #[test]
1815    fn test_rewrite_redirect_amp_gt_devnull() {
1816        assert_eq!(
1817            rewrite_command_no_prefixes("cargo test &>/dev/null", &[]),
1818            Some("rtk cargo test &>/dev/null".into())
1819        );
1820    }
1821
1822    #[test]
1823    fn test_rewrite_redirect_double() {
1824        // Double redirect: only last one stripped, but full command rewrites correctly
1825        assert_eq!(
1826            rewrite_command_no_prefixes("git status 2>&1 >/dev/null", &[]),
1827            Some("rtk git status 2>&1 >/dev/null".into())
1828        );
1829    }
1830
1831    #[test]
1832    fn test_rewrite_redirect_fd_close() {
1833        // 2>&- (close stderr fd)
1834        assert_eq!(
1835            rewrite_command_no_prefixes("git status 2>&-", &[]),
1836            Some("rtk git status 2>&-".into())
1837        );
1838    }
1839
1840    #[test]
1841    fn test_rewrite_redirect_quotes_not_stripped() {
1842        // Redirect-like chars inside quotes should NOT be stripped
1843        // Known limitation: apostrophes cause conservative no-strip (safe fallback)
1844        let result = rewrite_command_no_prefixes("git commit -m \"it's fixed\" 2>&1", &[]);
1845        assert!(
1846            result.is_some(),
1847            "Should still rewrite even with apostrophe"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_rewrite_background_amp_non_regression() {
1853        // background `&` must still work after redirect fix
1854        assert_eq!(
1855            rewrite_command_no_prefixes("cargo test & git status", &[]),
1856            Some("rtk cargo test & rtk git status".into())
1857        );
1858    }
1859
1860    // --- P0.2: head -N rewrite ---
1861
1862    #[test]
1863    fn test_rewrite_head_numeric_flag() {
1864        // head -20 file → rtk read file --max-lines 20 (not rtk read -20 file)
1865        assert_eq!(
1866            rewrite_command_no_prefixes("head -20 src/main.rs", &[]),
1867            Some("rtk read src/main.rs --max-lines 20".into())
1868        );
1869    }
1870
1871    #[test]
1872    fn test_rewrite_head_lines_long_flag() {
1873        assert_eq!(
1874            rewrite_command_no_prefixes("head --lines=50 src/lib.rs", &[]),
1875            Some("rtk read src/lib.rs --max-lines 50".into())
1876        );
1877    }
1878
1879    #[test]
1880    fn test_rewrite_head_no_flag_still_rewrites() {
1881        // plain `head file` → `rtk read file` (no numeric flag)
1882        assert_eq!(
1883            rewrite_command_no_prefixes("head src/main.rs", &[]),
1884            Some("rtk read src/main.rs".into())
1885        );
1886    }
1887
1888    #[test]
1889    fn test_rewrite_head_other_flag_skipped() {
1890        // head -c 100 file: unsupported flag, skip rewriting
1891        assert_eq!(
1892            rewrite_command_no_prefixes("head -c 100 src/main.rs", &[]),
1893            None
1894        );
1895    }
1896
1897    #[test]
1898    fn test_rewrite_tail_numeric_flag() {
1899        assert_eq!(
1900            rewrite_command_no_prefixes("tail -20 src/main.rs", &[]),
1901            Some("rtk read src/main.rs --tail-lines 20".into())
1902        );
1903    }
1904
1905    #[test]
1906    fn test_rewrite_tail_n_space_flag() {
1907        assert_eq!(
1908            rewrite_command_no_prefixes("tail -n 12 src/lib.rs", &[]),
1909            Some("rtk read src/lib.rs --tail-lines 12".into())
1910        );
1911    }
1912
1913    #[test]
1914    fn test_rewrite_tail_lines_long_flag() {
1915        assert_eq!(
1916            rewrite_command_no_prefixes("tail --lines=7 src/lib.rs", &[]),
1917            Some("rtk read src/lib.rs --tail-lines 7".into())
1918        );
1919    }
1920
1921    #[test]
1922    fn test_rewrite_tail_lines_space_flag() {
1923        assert_eq!(
1924            rewrite_command_no_prefixes("tail --lines 7 src/lib.rs", &[]),
1925            Some("rtk read src/lib.rs --tail-lines 7".into())
1926        );
1927    }
1928
1929    #[test]
1930    fn test_rewrite_tail_other_flag_skipped() {
1931        assert_eq!(
1932            rewrite_command_no_prefixes("tail -c 100 src/main.rs", &[]),
1933            None
1934        );
1935    }
1936
1937    #[test]
1938    fn test_rewrite_tail_plain_file_skipped() {
1939        assert_eq!(rewrite_command_no_prefixes("tail src/main.rs", &[]), None);
1940    }
1941
1942    // --- Issue #1362: head/tail with multiple files falls back to native command ---
1943    //
1944    // `rtk read <file> --max-lines N` only accepts a single positional file path in
1945    // a shape that maps cleanly to `head -N`. Rewriting `head -N a b c` to
1946    // `rtk read a b c --max-lines N` previously produced a command where `rtk read`
1947    // would concatenate the files without the `==> name <==` banners that native
1948    // `head` emits, so the fix is to skip the rewrite and let the shell run the
1949    // real `head`/`tail` binary.
1950
1951    #[test]
1952    fn test_rewrite_head_numeric_flag_multi_file_skipped() {
1953        assert_eq!(
1954            rewrite_command_no_prefixes("head -3 /tmp/a /tmp/b /tmp/c", &[]),
1955            None
1956        );
1957    }
1958
1959    #[test]
1960    fn test_rewrite_head_lines_long_flag_multi_file_skipped() {
1961        assert_eq!(
1962            rewrite_command_no_prefixes("head --lines=50 src/main.rs src/lib.rs", &[]),
1963            None
1964        );
1965    }
1966
1967    #[test]
1968    fn test_rewrite_tail_numeric_flag_multi_file_skipped() {
1969        assert_eq!(
1970            rewrite_command_no_prefixes("tail -20 a.log b.log", &[]),
1971            None
1972        );
1973    }
1974
1975    #[test]
1976    fn test_rewrite_tail_n_space_flag_multi_file_skipped() {
1977        assert_eq!(
1978            rewrite_command_no_prefixes("tail -n 12 a.log b.log c.log", &[]),
1979            None
1980        );
1981    }
1982
1983    #[test]
1984    fn test_rewrite_tail_lines_eq_multi_file_skipped() {
1985        assert_eq!(
1986            rewrite_command_no_prefixes("tail --lines=7 a.log b.log", &[]),
1987            None
1988        );
1989    }
1990
1991    #[test]
1992    fn test_rewrite_tail_lines_space_multi_file_skipped() {
1993        assert_eq!(
1994            rewrite_command_no_prefixes("tail --lines 7 a.log b.log", &[]),
1995            None
1996        );
1997    }
1998
1999    // --- New registry entries ---
2000
2001    #[test]
2002    fn test_classify_gh_release() {
2003        assert!(matches!(
2004            classify_command("gh release list"),
2005            Classification::Supported {
2006                rtk_equivalent: "rtk gh",
2007                ..
2008            }
2009        ));
2010    }
2011
2012    #[test]
2013    fn test_classify_glab_mr() {
2014        assert!(matches!(
2015            classify_command("glab mr list"),
2016            Classification::Supported {
2017                rtk_equivalent: "rtk glab",
2018                ..
2019            }
2020        ));
2021    }
2022
2023    #[test]
2024    fn test_classify_glab_ci() {
2025        assert!(matches!(
2026            classify_command("glab ci list"),
2027            Classification::Supported {
2028                rtk_equivalent: "rtk glab",
2029                ..
2030            }
2031        ));
2032    }
2033
2034    #[test]
2035    fn test_classify_glab_release() {
2036        assert!(matches!(
2037            classify_command("glab release list"),
2038            Classification::Supported {
2039                rtk_equivalent: "rtk glab",
2040                ..
2041            }
2042        ));
2043    }
2044
2045    #[test]
2046    fn test_rewrite_glab_mr_list() {
2047        assert_eq!(
2048            rewrite_command_no_prefixes("glab mr list", &[]),
2049            Some("rtk glab mr list".into())
2050        );
2051    }
2052
2053    #[test]
2054    fn test_rewrite_glab_ci_status() {
2055        assert_eq!(
2056            rewrite_command_no_prefixes("glab ci status", &[]),
2057            Some("rtk glab ci status".into())
2058        );
2059    }
2060
2061    #[test]
2062    fn test_classify_cargo_install() {
2063        assert!(matches!(
2064            classify_command("cargo install rtk"),
2065            Classification::Supported {
2066                rtk_equivalent: "rtk cargo",
2067                ..
2068            }
2069        ));
2070    }
2071
2072    #[test]
2073    fn test_classify_docker_run() {
2074        assert!(matches!(
2075            classify_command("docker run --rm ubuntu bash"),
2076            Classification::Supported {
2077                rtk_equivalent: "rtk docker",
2078                ..
2079            }
2080        ));
2081    }
2082
2083    #[test]
2084    fn test_classify_docker_exec() {
2085        assert!(matches!(
2086            classify_command("docker exec -it mycontainer bash"),
2087            Classification::Supported {
2088                rtk_equivalent: "rtk docker",
2089                ..
2090            }
2091        ));
2092    }
2093
2094    #[test]
2095    fn test_classify_docker_build() {
2096        assert!(matches!(
2097            classify_command("docker build -t myimage ."),
2098            Classification::Supported {
2099                rtk_equivalent: "rtk docker",
2100                ..
2101            }
2102        ));
2103    }
2104
2105    #[test]
2106    fn test_classify_kubectl_describe() {
2107        assert!(matches!(
2108            classify_command("kubectl describe pod mypod"),
2109            Classification::Supported {
2110                rtk_equivalent: "rtk kubectl",
2111                ..
2112            }
2113        ));
2114    }
2115
2116    #[test]
2117    fn test_classify_kubectl_apply() {
2118        assert!(matches!(
2119            classify_command("kubectl apply -f deploy.yaml"),
2120            Classification::Supported {
2121                rtk_equivalent: "rtk kubectl",
2122                ..
2123            }
2124        ));
2125    }
2126
2127    #[test]
2128    fn test_classify_tree() {
2129        assert!(matches!(
2130            classify_command("tree src/"),
2131            Classification::Supported {
2132                rtk_equivalent: "rtk tree",
2133                ..
2134            }
2135        ));
2136    }
2137
2138    #[test]
2139    fn test_classify_diff() {
2140        assert!(matches!(
2141            classify_command("diff file1.txt file2.txt"),
2142            Classification::Supported {
2143                rtk_equivalent: "rtk diff",
2144                ..
2145            }
2146        ));
2147    }
2148
2149    #[test]
2150    fn test_rewrite_tree() {
2151        assert_eq!(
2152            rewrite_command_no_prefixes("tree src/", &[]),
2153            Some("rtk tree src/".into())
2154        );
2155    }
2156
2157    #[test]
2158    fn test_rewrite_diff() {
2159        assert_eq!(
2160            rewrite_command_no_prefixes("diff file1.txt file2.txt", &[]),
2161            Some("rtk diff file1.txt file2.txt".into())
2162        );
2163    }
2164
2165    #[test]
2166    fn test_rewrite_gh_release() {
2167        assert_eq!(
2168            rewrite_command_no_prefixes("gh release list", &[]),
2169            Some("rtk gh release list".into())
2170        );
2171    }
2172
2173    #[test]
2174    fn test_rewrite_cargo_install() {
2175        assert_eq!(
2176            rewrite_command_no_prefixes("cargo install rtk", &[]),
2177            Some("rtk cargo install rtk".into())
2178        );
2179    }
2180
2181    #[test]
2182    fn test_rewrite_kubectl_describe() {
2183        assert_eq!(
2184            rewrite_command_no_prefixes("kubectl describe pod mypod", &[]),
2185            Some("rtk kubectl describe pod mypod".into())
2186        );
2187    }
2188
2189    #[test]
2190    fn test_rewrite_docker_run() {
2191        assert_eq!(
2192            rewrite_command_no_prefixes("docker run --rm ubuntu bash", &[]),
2193            Some("rtk docker run --rm ubuntu bash".into())
2194        );
2195    }
2196
2197    #[test]
2198    fn test_classify_swift_test() {
2199        assert!(matches!(
2200            classify_command("swift test"),
2201            Classification::Supported {
2202                rtk_equivalent: "rtk swift",
2203                category: "Build",
2204                estimated_savings_pct: 90.0,
2205                status: RtkStatus::Existing,
2206            }
2207        ));
2208    }
2209
2210    #[test]
2211    fn test_rewrite_swift_test() {
2212        assert_eq!(
2213            rewrite_command_no_prefixes("swift test --parallel", &[]),
2214            Some("rtk swift test --parallel".into())
2215        );
2216    }
2217
2218    // --- #336: docker compose supported subcommands rewritten, unsupported skipped ---
2219
2220    #[test]
2221    fn test_rewrite_docker_compose_ps() {
2222        assert_eq!(
2223            rewrite_command_no_prefixes("docker compose ps", &[]),
2224            Some("rtk docker compose ps".into())
2225        );
2226    }
2227
2228    #[test]
2229    fn test_rewrite_docker_compose_logs() {
2230        assert_eq!(
2231            rewrite_command_no_prefixes("docker compose logs web", &[]),
2232            Some("rtk docker compose logs web".into())
2233        );
2234    }
2235
2236    #[test]
2237    fn test_rewrite_docker_compose_build() {
2238        assert_eq!(
2239            rewrite_command_no_prefixes("docker compose build", &[]),
2240            Some("rtk docker compose build".into())
2241        );
2242    }
2243
2244    #[test]
2245    fn test_rewrite_docker_compose_up_skipped() {
2246        assert_eq!(
2247            rewrite_command_no_prefixes("docker compose up -d", &[]),
2248            None
2249        );
2250    }
2251
2252    #[test]
2253    fn test_rewrite_docker_compose_down_skipped() {
2254        assert_eq!(
2255            rewrite_command_no_prefixes("docker compose down", &[]),
2256            None
2257        );
2258    }
2259
2260    #[test]
2261    fn test_rewrite_docker_compose_config_skipped() {
2262        assert_eq!(
2263            rewrite_command_no_prefixes("docker compose -f foo.yaml config --services", &[]),
2264            None
2265        );
2266    }
2267
2268    // --- AWS / psql (PR #216) ---
2269
2270    #[test]
2271    fn test_classify_aws() {
2272        assert!(matches!(
2273            classify_command("aws s3 ls"),
2274            Classification::Supported {
2275                rtk_equivalent: "rtk aws",
2276                ..
2277            }
2278        ));
2279    }
2280
2281    #[test]
2282    fn test_classify_aws_ec2() {
2283        assert!(matches!(
2284            classify_command("aws ec2 describe-instances"),
2285            Classification::Supported {
2286                rtk_equivalent: "rtk aws",
2287                ..
2288            }
2289        ));
2290    }
2291
2292    #[test]
2293    fn test_classify_psql() {
2294        assert!(matches!(
2295            classify_command("psql -U postgres"),
2296            Classification::Supported {
2297                rtk_equivalent: "rtk psql",
2298                ..
2299            }
2300        ));
2301    }
2302
2303    #[test]
2304    fn test_classify_psql_url() {
2305        assert!(matches!(
2306            classify_command("psql postgres://localhost/mydb"),
2307            Classification::Supported {
2308                rtk_equivalent: "rtk psql",
2309                ..
2310            }
2311        ));
2312    }
2313
2314    #[test]
2315    fn test_rewrite_aws() {
2316        assert_eq!(
2317            rewrite_command_no_prefixes("aws s3 ls", &[]),
2318            Some("rtk aws s3 ls".into())
2319        );
2320    }
2321
2322    #[test]
2323    fn test_rewrite_aws_ec2() {
2324        assert_eq!(
2325            rewrite_command_no_prefixes("aws ec2 describe-instances --region us-east-1", &[]),
2326            Some("rtk aws ec2 describe-instances --region us-east-1".into())
2327        );
2328    }
2329
2330    #[test]
2331    fn test_rewrite_psql() {
2332        assert_eq!(
2333            rewrite_command_no_prefixes("psql -U postgres -d mydb", &[]),
2334            Some("rtk psql -U postgres -d mydb".into())
2335        );
2336    }
2337
2338    // --- Python tooling ---
2339
2340    #[test]
2341    fn test_classify_ruff_check() {
2342        assert!(matches!(
2343            classify_command("ruff check ."),
2344            Classification::Supported {
2345                rtk_equivalent: "rtk ruff",
2346                ..
2347            }
2348        ));
2349    }
2350
2351    #[test]
2352    fn test_classify_ruff_format() {
2353        assert!(matches!(
2354            classify_command("ruff format src/"),
2355            Classification::Supported {
2356                rtk_equivalent: "rtk ruff",
2357                ..
2358            }
2359        ));
2360    }
2361
2362    #[test]
2363    fn test_classify_pytest() {
2364        assert!(matches!(
2365            classify_command("pytest tests/"),
2366            Classification::Supported {
2367                rtk_equivalent: "rtk pytest",
2368                ..
2369            }
2370        ));
2371    }
2372
2373    #[test]
2374    fn test_classify_python_m_pytest() {
2375        assert!(matches!(
2376            classify_command("python -m pytest tests/"),
2377            Classification::Supported {
2378                rtk_equivalent: "rtk pytest",
2379                ..
2380            }
2381        ));
2382    }
2383
2384    #[test]
2385    fn test_classify_pip_list() {
2386        assert!(matches!(
2387            classify_command("pip list"),
2388            Classification::Supported {
2389                rtk_equivalent: "rtk pip",
2390                ..
2391            }
2392        ));
2393    }
2394
2395    #[test]
2396    fn test_classify_uv_pip_list() {
2397        assert!(matches!(
2398            classify_command("uv pip list"),
2399            Classification::Supported {
2400                rtk_equivalent: "rtk pip",
2401                ..
2402            }
2403        ));
2404    }
2405
2406    #[test]
2407    fn test_rewrite_ruff_check() {
2408        assert_eq!(
2409            rewrite_command_no_prefixes("ruff check .", &[]),
2410            Some("rtk ruff check .".into())
2411        );
2412    }
2413
2414    #[test]
2415    fn test_rewrite_ruff_format() {
2416        assert_eq!(
2417            rewrite_command_no_prefixes("ruff format src/", &[]),
2418            Some("rtk ruff format src/".into())
2419        );
2420    }
2421
2422    #[test]
2423    fn test_rewrite_pytest() {
2424        assert_eq!(
2425            rewrite_command_no_prefixes("pytest tests/", &[]),
2426            Some("rtk pytest tests/".into())
2427        );
2428    }
2429
2430    #[test]
2431    fn test_rewrite_python_m_pytest() {
2432        assert_eq!(
2433            rewrite_command_no_prefixes("python -m pytest -x tests/", &[]),
2434            Some("rtk pytest -x tests/".into())
2435        );
2436    }
2437
2438    #[test]
2439    fn test_rewrite_pip_list() {
2440        assert_eq!(
2441            rewrite_command_no_prefixes("pip list", &[]),
2442            Some("rtk pip list".into())
2443        );
2444    }
2445
2446    #[test]
2447    fn test_rewrite_pip_outdated() {
2448        assert_eq!(
2449            rewrite_command_no_prefixes("pip outdated", &[]),
2450            Some("rtk pip outdated".into())
2451        );
2452    }
2453
2454    #[test]
2455    fn test_rewrite_uv_pip_list() {
2456        assert_eq!(
2457            rewrite_command_no_prefixes("uv pip list", &[]),
2458            Some("rtk pip list".into())
2459        );
2460    }
2461
2462    // --- Go tooling ---
2463
2464    #[test]
2465    fn test_classify_go_test() {
2466        assert!(matches!(
2467            classify_command("go test ./..."),
2468            Classification::Supported {
2469                rtk_equivalent: "rtk go",
2470                ..
2471            }
2472        ));
2473    }
2474
2475    #[test]
2476    fn test_classify_go_build() {
2477        assert!(matches!(
2478            classify_command("go build ./..."),
2479            Classification::Supported {
2480                rtk_equivalent: "rtk go",
2481                ..
2482            }
2483        ));
2484    }
2485
2486    #[test]
2487    fn test_classify_go_vet() {
2488        assert!(matches!(
2489            classify_command("go vet ./..."),
2490            Classification::Supported {
2491                rtk_equivalent: "rtk go",
2492                ..
2493            }
2494        ));
2495    }
2496
2497    #[test]
2498    fn test_classify_golangci_lint() {
2499        assert!(matches!(
2500            classify_command("golangci-lint run"),
2501            Classification::Supported {
2502                rtk_equivalent: "rtk golangci-lint run",
2503                ..
2504            }
2505        ));
2506    }
2507
2508    #[test]
2509    fn test_classify_golangci_lint_with_flag_before_run() {
2510        assert!(matches!(
2511            classify_command("golangci-lint -v run ./..."),
2512            Classification::Supported {
2513                rtk_equivalent: "rtk golangci-lint run",
2514                ..
2515            }
2516        ));
2517    }
2518
2519    #[test]
2520    fn test_classify_golangci_lint_with_value_flag_before_run() {
2521        assert!(matches!(
2522            classify_command("golangci-lint --color never run ./..."),
2523            Classification::Supported {
2524                rtk_equivalent: "rtk golangci-lint run",
2525                ..
2526            }
2527        ));
2528    }
2529
2530    #[test]
2531    fn test_classify_golangci_lint_with_inline_value_flag_before_run() {
2532        assert!(matches!(
2533            classify_command("golangci-lint --color=never run ./..."),
2534            Classification::Supported {
2535                rtk_equivalent: "rtk golangci-lint run",
2536                ..
2537            }
2538        ));
2539    }
2540
2541    #[test]
2542    fn test_classify_golangci_lint_with_inline_config_flag_before_run() {
2543        assert!(matches!(
2544            classify_command("golangci-lint --config=foo.yml run ./..."),
2545            Classification::Supported {
2546                rtk_equivalent: "rtk golangci-lint run",
2547                ..
2548            }
2549        ));
2550    }
2551
2552    #[test]
2553    fn test_classify_golangci_lint_bare_is_not_compact_wrapper() {
2554        assert!(!matches!(
2555            classify_command("golangci-lint"),
2556            Classification::Supported {
2557                rtk_equivalent: "rtk golangci-lint run",
2558                ..
2559            }
2560        ));
2561    }
2562
2563    #[test]
2564    fn test_classify_golangci_lint_other_subcommand_is_not_compact_wrapper() {
2565        assert!(!matches!(
2566            classify_command("golangci-lint version"),
2567            Classification::Supported {
2568                rtk_equivalent: "rtk golangci-lint run",
2569                ..
2570            }
2571        ));
2572    }
2573
2574    #[test]
2575    fn test_rewrite_go_test() {
2576        assert_eq!(
2577            rewrite_command_no_prefixes("go test ./...", &[]),
2578            Some("rtk go test ./...".into())
2579        );
2580    }
2581
2582    #[test]
2583    fn test_rewrite_go_build() {
2584        assert_eq!(
2585            rewrite_command_no_prefixes("go build ./...", &[]),
2586            Some("rtk go build ./...".into())
2587        );
2588    }
2589
2590    #[test]
2591    fn test_rewrite_go_vet() {
2592        assert_eq!(
2593            rewrite_command_no_prefixes("go vet ./...", &[]),
2594            Some("rtk go vet ./...".into())
2595        );
2596    }
2597
2598    #[test]
2599    fn test_rewrite_golangci_lint() {
2600        assert_eq!(
2601            rewrite_command_no_prefixes("golangci-lint run ./...", &[]),
2602            Some("rtk golangci-lint run ./...".into())
2603        );
2604    }
2605
2606    #[test]
2607    fn test_rewrite_golangci_lint_with_flag_before_run() {
2608        assert_eq!(
2609            rewrite_command_no_prefixes("golangci-lint -v run ./...", &[]),
2610            Some("rtk golangci-lint -v run ./...".into())
2611        );
2612    }
2613
2614    #[test]
2615    fn test_rewrite_golangci_lint_with_value_flag_before_run() {
2616        assert_eq!(
2617            rewrite_command_no_prefixes("golangci-lint --color never run ./...", &[]),
2618            Some("rtk golangci-lint --color never run ./...".into())
2619        );
2620    }
2621
2622    #[test]
2623    fn test_rewrite_golangci_lint_with_inline_value_flag_before_run() {
2624        assert_eq!(
2625            rewrite_command_no_prefixes("golangci-lint --color=never run ./...", &[]),
2626            Some("rtk golangci-lint --color=never run ./...".into())
2627        );
2628    }
2629
2630    #[test]
2631    fn test_rewrite_golangci_lint_with_inline_config_flag_before_run() {
2632        assert_eq!(
2633            rewrite_command_no_prefixes("golangci-lint --config=foo.yml run ./...", &[]),
2634            Some("rtk golangci-lint --config=foo.yml run ./...".into())
2635        );
2636    }
2637
2638    #[test]
2639    fn test_rewrite_env_prefixed_golangci_lint_with_value_flag_before_run() {
2640        assert_eq!(
2641            rewrite_command_no_prefixes("FOO=1 golangci-lint --color never run ./...", &[]),
2642            Some("FOO=1 rtk golangci-lint --color never run ./...".into())
2643        );
2644    }
2645
2646    #[test]
2647    fn test_rewrite_env_prefixed_golangci_lint_with_inline_value_flag_before_run() {
2648        assert_eq!(
2649            rewrite_command_no_prefixes("FOO=1 golangci-lint --color=never run ./...", &[]),
2650            Some("FOO=1 rtk golangci-lint --color=never run ./...".into())
2651        );
2652    }
2653
2654    #[test]
2655    fn test_rewrite_bare_golangci_lint_skips_compact_wrapper() {
2656        assert_eq!(rewrite_command_no_prefixes("golangci-lint", &[]), None);
2657    }
2658
2659    #[test]
2660    fn test_rewrite_other_golangci_lint_subcommand_skips_compact_wrapper() {
2661        assert_eq!(
2662            rewrite_command_no_prefixes("golangci-lint version", &[]),
2663            None
2664        );
2665    }
2666
2667    // --- JS/TS tooling ---
2668
2669    #[test]
2670    fn test_classify_lint() {
2671        let commands = vec![
2672            "npm exec biome",
2673            "npm exec eslint",
2674            "npm rum biome",
2675            "npm rum eslint",
2676            "npm rum lint",
2677            "npm run biome",
2678            "npm run eslint",
2679            "npm run lint",
2680            "npm run-script biome",
2681            "npm run-script eslint",
2682            "npm run-script lint",
2683            "npm urn biome",
2684            "npm urn eslint",
2685            "npm urn lint",
2686            "npm x biome",
2687            "npm x eslint",
2688            "pnpm dlx biome",
2689            "pnpm dlx eslint",
2690            "pnpm exec biome",
2691            "pnpm exec eslint",
2692            "pnpm run biome",
2693            "pnpm run eslint",
2694            "pnpm run lint",
2695            "pnpm run-script biome",
2696            "pnpm run-script eslint",
2697            "pnpm run-script lint",
2698            "npm biome",
2699            "npm eslint",
2700            "npm lint",
2701            "npx biome",
2702            "npx eslint",
2703            "npx lint",
2704            "pnpm biome",
2705            "pnpm eslint",
2706            "pnpm lint",
2707            "pnpx biome",
2708            "pnpx eslint",
2709            "pnpx lint",
2710            "biome",
2711            "eslint",
2712            "lint",
2713        ];
2714        for command in commands {
2715            assert!(
2716                matches!(
2717                    classify_command(command),
2718                    Classification::Supported {
2719                        rtk_equivalent: "rtk lint",
2720                        ..
2721                    }
2722                ),
2723                "Failed for command: {}",
2724                command
2725            );
2726        }
2727    }
2728
2729    #[test]
2730    fn test_rewrite_lint() {
2731        let commands = vec![
2732            "npm exec biome",
2733            "npm exec eslint",
2734            "npm rum biome",
2735            "npm rum eslint",
2736            "npm rum lint",
2737            "npm run biome",
2738            "npm run eslint",
2739            "npm run lint",
2740            "npm run-script biome",
2741            "npm run-script eslint",
2742            "npm run-script lint",
2743            "npm urn biome",
2744            "npm urn eslint",
2745            "npm urn lint",
2746            "npm x biome",
2747            "npm x eslint",
2748            "pnpm dlx biome",
2749            "pnpm dlx eslint",
2750            "pnpm exec biome",
2751            "pnpm exec eslint",
2752            "pnpm run biome",
2753            "pnpm run eslint",
2754            "pnpm run lint",
2755            "pnpm run-script biome",
2756            "pnpm run-script eslint",
2757            "pnpm run-script lint",
2758            "npm biome",
2759            "npm eslint",
2760            "npm lint",
2761            "npx biome",
2762            "npx eslint",
2763            "npx lint",
2764            "pnpm biome",
2765            "pnpm eslint",
2766            "pnpm lint",
2767            "pnpx biome",
2768            "pnpx eslint",
2769            "pnpx lint",
2770            "biome",
2771            "eslint",
2772            "lint",
2773        ];
2774        for command in commands {
2775            assert_eq!(
2776                rewrite_command_no_prefixes(command, &[]),
2777                Some("rtk lint".into()),
2778                "Failed for command: {}",
2779                command
2780            );
2781        }
2782    }
2783
2784    #[test]
2785    fn test_classify_jest() {
2786        let commands = vec![
2787            "jest run",
2788            "jest",
2789            "npm exec jest run",
2790            "npm exec jest",
2791            "npm jest run",
2792            "npm jest",
2793            "npm rum jest run",
2794            "npm rum jest",
2795            "npm run jest run",
2796            "npm run jest",
2797            "npm run-script jest run",
2798            "npm run-script jest",
2799            "npm urn jest run",
2800            "npm urn jest",
2801            "npm x jest run",
2802            "npm x jest",
2803            "npx jest run",
2804            "npx jest",
2805            "pnpm dlx jest run",
2806            "pnpm dlx jest",
2807            "pnpm exec jest run",
2808            "pnpm exec jest",
2809            "pnpm jest run",
2810            "pnpm jest",
2811            "pnpm run jest run",
2812            "pnpm run jest",
2813            "pnpm run-script jest run",
2814            "pnpm run-script jest",
2815            "pnpx jest run",
2816            "pnpx jest",
2817        ];
2818        for command in commands {
2819            assert!(
2820                matches!(
2821                    classify_command(command),
2822                    Classification::Supported {
2823                        rtk_equivalent: "rtk jest",
2824                        ..
2825                    }
2826                ),
2827                "Failed for command: {}",
2828                command
2829            );
2830        }
2831    }
2832
2833    #[test]
2834    fn test_rewrite_jest() {
2835        let commands = vec![
2836            "jest run",
2837            "jest",
2838            "npm exec jest run",
2839            "npm exec jest",
2840            "npm jest run",
2841            "npm jest",
2842            "npm rum jest run",
2843            "npm rum jest",
2844            "npm run jest run",
2845            "npm run jest",
2846            "npm run-script jest run",
2847            "npm run-script jest",
2848            "npm urn jest run",
2849            "npm urn jest",
2850            "npm x jest run",
2851            "npm x jest",
2852            "npx jest run",
2853            "npx jest",
2854            "pnpm dlx jest run",
2855            "pnpm dlx jest",
2856            "pnpm exec jest run",
2857            "pnpm exec jest",
2858            "pnpm jest run",
2859            "pnpm jest",
2860            "pnpm run jest run",
2861            "pnpm run jest",
2862            "pnpm run-script jest run",
2863            "pnpm run-script jest",
2864            "pnpx jest run",
2865            "pnpx jest",
2866        ];
2867        for command in commands {
2868            assert_eq!(
2869                rewrite_command_no_prefixes(command, &[]),
2870                Some("rtk jest".into()),
2871                "Failed for command: {}",
2872                command
2873            );
2874        }
2875    }
2876
2877    #[test]
2878    fn test_classify_vitest() {
2879        let commands = vec![
2880            "npm exec vitest run",
2881            "npm exec vitest",
2882            "npm rum vitest run",
2883            "npm rum vitest",
2884            "npm run vitest run",
2885            "npm run vitest",
2886            "npm run-script vitest run",
2887            "npm run-script vitest",
2888            "npm urn vitest run",
2889            "npm urn vitest",
2890            "npm vitest run",
2891            "npm vitest",
2892            "npm x vitest run",
2893            "npm x vitest",
2894            "npx vitest run",
2895            "npx vitest",
2896            "pnpm dlx vitest run",
2897            "pnpm dlx vitest",
2898            "pnpm exec vitest run",
2899            "pnpm exec vitest",
2900            "pnpm run vitest run",
2901            "pnpm run vitest",
2902            "pnpm run-script vitest run",
2903            "pnpm run-script vitest",
2904            "pnpm vitest run",
2905            "pnpm vitest",
2906            "pnpx vitest run",
2907            "pnpx vitest",
2908            "vitest run",
2909            "vitest",
2910        ];
2911        for command in commands {
2912            assert!(
2913                matches!(
2914                    classify_command(command),
2915                    Classification::Supported {
2916                        rtk_equivalent: "rtk vitest",
2917                        ..
2918                    }
2919                ),
2920                "Failed for command: {}",
2921                command
2922            );
2923        }
2924    }
2925
2926    #[test]
2927    fn test_rewrite_vitest() {
2928        let commands = vec![
2929            "npm exec vitest run",
2930            "npm exec vitest",
2931            "npm rum vitest run",
2932            "npm rum vitest",
2933            "npm run vitest run",
2934            "npm run vitest",
2935            "npm run-script vitest run",
2936            "npm run-script vitest",
2937            "npm urn vitest run",
2938            "npm urn vitest",
2939            "npm vitest run",
2940            "npm vitest",
2941            "npm x vitest run",
2942            "npm x vitest",
2943            "npx vitest run",
2944            "npx vitest",
2945            "pnpm dlx vitest run",
2946            "pnpm dlx vitest",
2947            "pnpm exec vitest run",
2948            "pnpm exec vitest",
2949            "pnpm run vitest run",
2950            "pnpm run vitest",
2951            "pnpm run-script vitest run",
2952            "pnpm run-script vitest",
2953            "pnpm vitest run",
2954            "pnpm vitest",
2955            "pnpx vitest run",
2956            "pnpx vitest",
2957            "vitest run",
2958            "vitest",
2959        ];
2960        for command in commands {
2961            assert_eq!(
2962                rewrite_command_no_prefixes(command, &[]),
2963                Some("rtk vitest".into()),
2964                "Failed for command: {}",
2965                command
2966            );
2967        }
2968    }
2969
2970    #[test]
2971    fn test_classify_prisma() {
2972        let commands = vec![
2973            "npm exec prisma",
2974            "npm rum prisma",
2975            "npm run prisma",
2976            "npm run-script prisma",
2977            "npm urn prisma",
2978            "npm x prisma",
2979            "pnpm dlx prisma",
2980            "pnpm exec prisma",
2981            "pnpm run prisma",
2982            "pnpm run-script prisma",
2983            "npm prisma",
2984            "npx prisma",
2985            "pnpm prisma",
2986            "pnpx prisma",
2987            "prisma",
2988        ];
2989        for command in commands {
2990            assert!(
2991                matches!(
2992                    classify_command(format!("{command} migrate dev").as_str()),
2993                    Classification::Supported {
2994                        rtk_equivalent: "rtk prisma",
2995                        ..
2996                    }
2997                ),
2998                "Failed for command: {}",
2999                command
3000            );
3001        }
3002    }
3003
3004    #[test]
3005    fn test_rewrite_prisma() {
3006        let commands = vec![
3007            "npm exec prisma",
3008            "npm rum prisma",
3009            "npm run prisma",
3010            "npm run-script prisma",
3011            "npm urn prisma",
3012            "npm x prisma",
3013            "pnpm dlx prisma",
3014            "pnpm exec prisma",
3015            "pnpm run prisma",
3016            "pnpm run-script prisma",
3017            "npm prisma",
3018            "npx prisma",
3019            "pnpm prisma",
3020            "pnpx prisma",
3021            "prisma",
3022        ];
3023        for command in commands {
3024            assert_eq!(
3025                rewrite_command_no_prefixes(format!("{command} migrate dev").as_str(), &[]),
3026                Some("rtk prisma migrate dev".into()),
3027                "Failed for command: {}",
3028                command
3029            );
3030        }
3031    }
3032
3033    #[test]
3034    fn test_rewrite_prettier() {
3035        let commands = vec![
3036            "npm exec prettier",
3037            "npm rum prettier",
3038            "npm run prettier",
3039            "npm run-script prettier",
3040            "npm urn prettier",
3041            "npm x prettier",
3042            "pnpm dlx prettier",
3043            "pnpm exec prettier",
3044            "pnpm run prettier",
3045            "pnpm run-script prettier",
3046            "npm prettier",
3047            "npx prettier",
3048            "pnpm prettier",
3049            "pnpx prettier",
3050            "prettier",
3051        ];
3052        for command in commands {
3053            assert_eq!(
3054                rewrite_command_no_prefixes(format!("{command} --check src/").as_str(), &[]),
3055                Some("rtk prettier --check src/".into()),
3056                "Failed for command: {}",
3057                command
3058            );
3059        }
3060    }
3061
3062    #[test]
3063    fn test_rewrite_pnpm_command() {
3064        let commands = vec![
3065            "exec",
3066            "i",
3067            "install",
3068            "list",
3069            "ls",
3070            "outdated",
3071            "run",
3072            "run-script",
3073        ];
3074        for command in commands {
3075            assert_eq!(
3076                rewrite_command_no_prefixes(format!("pnpm {command}").as_str(), &[]),
3077                Some(format!("rtk pnpm {command}")),
3078                "Failed for command: pnpm {}",
3079                command
3080            );
3081        }
3082    }
3083
3084    #[test]
3085    fn test_rewrite_npm_bare_subcommand() {
3086        let commands = vec!["exec", "run", "run-script", "x"];
3087        for command in commands {
3088            assert_eq!(
3089                rewrite_command_no_prefixes(format!("npm {command}").as_str(), &[]),
3090                Some(format!("rtk npm {command}")),
3091                "Failed for bare command: npm {}",
3092                command
3093            );
3094        }
3095    }
3096
3097    #[test]
3098    fn test_rewrite_npm_with_args() {
3099        assert_eq!(
3100            rewrite_command_no_prefixes("npm run test", &[]),
3101            Some("rtk npm run test".to_string()),
3102        );
3103        assert_eq!(
3104            rewrite_command_no_prefixes("npm exec vitest", &[]),
3105            Some("rtk vitest".to_string()),
3106        );
3107    }
3108
3109    #[test]
3110    fn test_rewrite_npx() {
3111        assert_eq!(
3112            rewrite_command_no_prefixes("npx svgo", &[]),
3113            Some("rtk npx svgo".to_string()),
3114        );
3115    }
3116
3117    // --- Gradle ---
3118
3119    #[test]
3120    fn test_classify_gradlew() {
3121        assert!(matches!(
3122            classify_command("./gradlew assembleDebug"),
3123            Classification::Supported {
3124                rtk_equivalent: "rtk gradlew",
3125                ..
3126            }
3127        ));
3128    }
3129
3130    #[test]
3131    fn test_classify_gradlew_no_dot_slash() {
3132        assert!(matches!(
3133            classify_command("gradlew build"),
3134            Classification::Supported {
3135                rtk_equivalent: "rtk gradlew",
3136                ..
3137            }
3138        ));
3139    }
3140
3141    #[test]
3142    fn test_classify_gradlew_bat() {
3143        assert!(matches!(
3144            classify_command("gradlew.bat clean"),
3145            Classification::Supported {
3146                rtk_equivalent: "rtk gradlew",
3147                ..
3148            }
3149        ));
3150    }
3151
3152    #[test]
3153    fn test_classify_gradle() {
3154        assert!(matches!(
3155            classify_command("gradle build"),
3156            Classification::Supported {
3157                rtk_equivalent: "rtk gradlew",
3158                ..
3159            }
3160        ));
3161    }
3162
3163    #[test]
3164    fn test_rewrite_gradlew() {
3165        assert_eq!(
3166            rewrite_command_no_prefixes("./gradlew assembleDebug", &[]),
3167            Some("rtk gradlew assembleDebug".into())
3168        );
3169    }
3170
3171    #[test]
3172    fn test_rewrite_gradlew_no_dot_slash() {
3173        assert_eq!(
3174            rewrite_command_no_prefixes("gradlew build", &[]),
3175            Some("rtk gradlew build".into())
3176        );
3177    }
3178
3179    #[test]
3180    fn test_rewrite_gradlew_bat() {
3181        assert_eq!(
3182            rewrite_command_no_prefixes("gradlew.bat clean", &[]),
3183            Some("rtk gradlew clean".into())
3184        );
3185    }
3186
3187    #[test]
3188    fn test_rewrite_gradle() {
3189        assert_eq!(
3190            rewrite_command_no_prefixes("gradle build", &[]),
3191            Some("rtk gradlew build".into())
3192        );
3193    }
3194
3195    #[test]
3196    fn test_rewrite_gradlew_test_savings() {
3197        assert_eq!(
3198            classify_command("./gradlew test"),
3199            Classification::Supported {
3200                rtk_equivalent: "rtk gradlew",
3201                category: "Build",
3202                estimated_savings_pct: 90.0,
3203                status: RtkStatus::Existing,
3204            }
3205        );
3206    }
3207
3208    // --- Maven ---
3209
3210    #[test]
3211    fn test_classify_mvn_test() {
3212        assert!(matches!(
3213            classify_command("mvn test"),
3214            Classification::Supported {
3215                rtk_equivalent: "rtk mvn",
3216                ..
3217            }
3218        ));
3219    }
3220
3221    #[test]
3222    fn test_classify_mvn_integration_test() {
3223        assert!(matches!(
3224            classify_command("mvn integration-test"),
3225            Classification::Supported {
3226                rtk_equivalent: "rtk mvn",
3227                ..
3228            }
3229        ));
3230    }
3231
3232    #[test]
3233    fn test_classify_mvn_flags_before_goal() {
3234        assert!(matches!(
3235            classify_command("mvn -B -DskipTests=false clean install"),
3236            Classification::Supported {
3237                rtk_equivalent: "rtk mvn",
3238                ..
3239            }
3240        ));
3241    }
3242
3243    #[test]
3244    fn test_classify_mvnw_wrapper() {
3245        assert!(matches!(
3246            classify_command("./mvnw verify"),
3247            Classification::Supported {
3248                rtk_equivalent: "rtk mvn",
3249                ..
3250            }
3251        ));
3252    }
3253
3254    #[test]
3255    fn test_classify_mvnw_cmd_wrapper() {
3256        assert!(matches!(
3257            classify_command("mvnw.cmd package"),
3258            Classification::Supported {
3259                rtk_equivalent: "rtk mvn",
3260                ..
3261            }
3262        ));
3263    }
3264
3265    #[test]
3266    fn test_classify_mvn_clean_bypassed() {
3267        // `clean` deliberately excluded from the alternation to avoid 0-overhead fork.
3268        assert!(!matches!(
3269            classify_command("mvn clean"),
3270            Classification::Supported {
3271                rtk_equivalent: "rtk mvn",
3272                ..
3273            }
3274        ));
3275    }
3276
3277    #[test]
3278    fn test_classify_mvn_site_bypassed() {
3279        assert!(!matches!(
3280            classify_command("mvn site"),
3281            Classification::Supported {
3282                rtk_equivalent: "rtk mvn",
3283                ..
3284            }
3285        ));
3286    }
3287
3288    #[test]
3289    fn test_classify_mvn_plugin_goal_bypassed() {
3290        assert!(!matches!(
3291            classify_command("mvn dependency:tree"),
3292            Classification::Supported {
3293                rtk_equivalent: "rtk mvn",
3294                ..
3295            }
3296        ));
3297    }
3298
3299    #[test]
3300    fn test_classify_mvn_bare_bypassed() {
3301        assert!(!matches!(
3302            classify_command("mvn"),
3303            Classification::Supported {
3304                rtk_equivalent: "rtk mvn",
3305                ..
3306            }
3307        ));
3308    }
3309
3310    #[test]
3311    fn test_classify_mvn_version_bypassed() {
3312        assert!(!matches!(
3313            classify_command("mvn --version"),
3314            Classification::Supported {
3315                rtk_equivalent: "rtk mvn",
3316                ..
3317            }
3318        ));
3319    }
3320
3321    #[test]
3322    fn test_rewrite_mvn_clean_install() {
3323        assert_eq!(
3324            rewrite_command_no_prefixes("mvn -B clean install", &[]),
3325            Some("rtk mvn -B clean install".into())
3326        );
3327    }
3328
3329    #[test]
3330    fn test_rewrite_mvnw_test() {
3331        assert_eq!(
3332            rewrite_command_no_prefixes("./mvnw test", &[]),
3333            Some("rtk mvn test".into())
3334        );
3335    }
3336
3337    // --- Compound operator edge cases ---
3338
3339    #[test]
3340    fn test_rewrite_compound_or() {
3341        // `||` fallback: left rewritten, right rewritten
3342        assert_eq!(
3343            rewrite_command_no_prefixes("cargo test || cargo build", &[]),
3344            Some("rtk cargo test || rtk cargo build".into())
3345        );
3346    }
3347
3348    #[test]
3349    fn test_rewrite_compound_semicolon() {
3350        assert_eq!(
3351            rewrite_command_no_prefixes("git status; cargo test", &[]),
3352            Some("rtk git status; rtk cargo test".into())
3353        );
3354    }
3355
3356    #[test]
3357    fn test_rewrite_compound_pipe_raw_filter() {
3358        // Pipe: rewrite first segment only, pass through rest unchanged
3359        assert_eq!(
3360            rewrite_command_no_prefixes("cargo test | grep FAILED", &[]),
3361            Some("rtk cargo test | grep FAILED".into())
3362        );
3363    }
3364
3365    #[test]
3366    fn test_rewrite_compound_pipe_git_grep() {
3367        assert_eq!(
3368            rewrite_command_no_prefixes("git log -10 | grep feat", &[]),
3369            Some("rtk git log -10 | grep feat".into())
3370        );
3371    }
3372
3373    #[test]
3374    fn test_rewrite_compound_four_segments() {
3375        assert_eq!(
3376            rewrite_command_no_prefixes(
3377                "cargo fmt --all && cargo clippy && cargo test && git status",
3378                &[]
3379            ),
3380            Some(
3381                "rtk cargo fmt --all && rtk cargo clippy && rtk cargo test && rtk git status"
3382                    .into()
3383            )
3384        );
3385    }
3386
3387    #[test]
3388    fn test_rewrite_compound_mixed_supported_unsupported() {
3389        // unsupported segments stay raw
3390        assert_eq!(
3391            rewrite_command_no_prefixes("cargo test && htop", &[]),
3392            Some("rtk cargo test && htop".into())
3393        );
3394    }
3395
3396    #[test]
3397    fn test_rewrite_compound_all_unsupported_returns_none() {
3398        // No rewrite at all: returns None
3399        assert_eq!(rewrite_command_no_prefixes("htop && top", &[]), None);
3400    }
3401
3402    // --- sudo / env prefix + rewrite ---
3403
3404    #[test]
3405    fn test_rewrite_sudo_docker() {
3406        assert_eq!(
3407            rewrite_command_no_prefixes("sudo docker ps", &[]),
3408            Some("sudo rtk docker ps".into())
3409        );
3410    }
3411
3412    #[test]
3413    fn test_rewrite_env_var_prefix() {
3414        assert_eq!(
3415            rewrite_command_no_prefixes("GIT_SSH_COMMAND=ssh git push origin main", &[]),
3416            Some("GIT_SSH_COMMAND=ssh rtk git push origin main".into())
3417        );
3418    }
3419
3420    // --- find with native flags ---
3421
3422    #[test]
3423    fn test_rewrite_find_with_flags() {
3424        assert_eq!(
3425            rewrite_command_no_prefixes("find . -name '*.rs' -type f", &[]),
3426            Some("rtk find . -name '*.rs' -type f".into())
3427        );
3428    }
3429
3430    #[test]
3431    fn test_all_rules_are_complete() {
3432        for rule in RULES {
3433            assert!(
3434                !rule.pattern.is_empty(),
3435                "Rule '{}' has empty pattern",
3436                rule.rtk_cmd
3437            );
3438            assert!(!rule.rtk_cmd.is_empty(), "Rule with empty rtk_cmd found");
3439            assert!(
3440                rule.rtk_cmd.starts_with("rtk "),
3441                "rtk_cmd '{}' must start with 'rtk '",
3442                rule.rtk_cmd
3443            );
3444            assert!(
3445                !rule.rewrite_prefixes.is_empty(),
3446                "Rule '{}' has no rewrite_prefixes",
3447                rule.rtk_cmd
3448            );
3449        }
3450    }
3451
3452    // --- exclude_commands (#243) ---
3453
3454    #[test]
3455    fn test_rewrite_excludes_curl() {
3456        let excluded = vec!["curl".to_string()];
3457        assert_eq!(
3458            rewrite_command_no_prefixes("curl https://api.example.com/health", &excluded),
3459            None
3460        );
3461    }
3462
3463    #[test]
3464    fn test_rewrite_exclude_does_not_affect_other_commands() {
3465        let excluded = vec!["curl".to_string()];
3466        assert_eq!(
3467            rewrite_command_no_prefixes("git status", &excluded),
3468            Some("rtk git status".into())
3469        );
3470    }
3471
3472    #[test]
3473    fn test_rewrite_empty_excludes_rewrites_curl() {
3474        let excluded: Vec<String> = vec![];
3475        assert!(rewrite_command_no_prefixes("curl https://api.example.com", &excluded).is_some());
3476    }
3477
3478    #[test]
3479    fn test_rewrite_compound_partial_exclude() {
3480        // curl excluded but git still rewrites
3481        let excluded = vec!["curl".to_string()];
3482        assert_eq!(
3483            rewrite_command_no_prefixes("git status && curl https://api.example.com", &excluded),
3484            Some("rtk git status && curl https://api.example.com".into())
3485        );
3486    }
3487
3488    #[test]
3489    fn test_exclude_env_prefixed_command() {
3490        let excluded = vec!["psql".to_string()];
3491        assert_eq!(
3492            rewrite_command_no_prefixes("PGPASSWORD=postgres psql -h localhost", &excluded),
3493            None
3494        );
3495    }
3496
3497    #[test]
3498    fn test_exclude_subcommand_pattern() {
3499        let excluded = vec!["git push".to_string()];
3500        assert_eq!(
3501            rewrite_command_no_prefixes("git push origin main", &excluded),
3502            None
3503        );
3504    }
3505
3506    #[test]
3507    fn test_exclude_regex_pattern() {
3508        let excluded = vec!["^curl".to_string()];
3509        assert_eq!(
3510            rewrite_command_no_prefixes("curl http://example.com", &excluded),
3511            None
3512        );
3513    }
3514
3515    #[test]
3516    fn test_exclude_invalid_regex_fallback() {
3517        let excluded = vec!["curl[".to_string()];
3518        assert!(rewrite_command_no_prefixes("curl http://example.com", &excluded).is_some());
3519    }
3520
3521    #[test]
3522    fn test_exclude_does_not_substring_match() {
3523        let excluded = vec!["go".to_string()];
3524        assert!(rewrite_command_no_prefixes("golangci-lint run ./...", &excluded).is_some());
3525    }
3526
3527    #[test]
3528    fn test_exclude_does_not_match_hyphenated_command() {
3529        let excluded = vec!["golangci".to_string()];
3530        assert!(rewrite_command_no_prefixes("golangci-lint run ./...", &excluded).is_some());
3531    }
3532
3533    #[test]
3534    fn test_exclude_empty_pattern_ignored() {
3535        let excluded = vec!["".to_string()];
3536        assert!(rewrite_command_no_prefixes("git status", &excluded).is_some());
3537    }
3538
3539    #[test]
3540    fn test_exclude_bare_anchor_ignored() {
3541        let excluded = vec!["^".to_string()];
3542        assert!(rewrite_command_no_prefixes("git status", &excluded).is_some());
3543    }
3544
3545    #[test]
3546    fn test_all_patterns_are_valid_regex() {
3547        use regex::Regex;
3548        for (i, rule) in RULES.iter().enumerate() {
3549            assert!(
3550                Regex::new(rule.pattern).is_ok(),
3551                "RULES[{i}] ({}) has invalid pattern '{}'",
3552                rule.rtk_cmd,
3553                rule.pattern
3554            );
3555        }
3556    }
3557
3558    // --- #196: gh --json/--jq/--template passthrough ---
3559
3560    #[test]
3561    fn test_rewrite_gh_json_skipped() {
3562        assert_eq!(
3563            rewrite_command_no_prefixes("gh pr list --json number,title", &[]),
3564            None
3565        );
3566    }
3567
3568    #[test]
3569    fn test_rewrite_gh_jq_skipped() {
3570        assert_eq!(
3571            rewrite_command_no_prefixes("gh pr list --json number --jq '.[].number'", &[]),
3572            None
3573        );
3574    }
3575
3576    #[test]
3577    fn test_rewrite_gh_template_skipped() {
3578        assert_eq!(
3579            rewrite_command_no_prefixes("gh pr view 42 --template '{{.title}}'", &[]),
3580            None
3581        );
3582    }
3583
3584    #[test]
3585    fn test_rewrite_gh_api_json_skipped() {
3586        assert_eq!(
3587            rewrite_command_no_prefixes("gh api repos/owner/repo --jq '.name'", &[]),
3588            None
3589        );
3590    }
3591
3592    #[test]
3593    fn test_rewrite_gh_without_json_still_works() {
3594        assert_eq!(
3595            rewrite_command_no_prefixes("gh pr list", &[]),
3596            Some("rtk gh pr list".into())
3597        );
3598    }
3599
3600    // --- #508: RTK_DISABLED detection helpers ---
3601
3602    #[test]
3603    fn test_cmd_has_rtk_disabled_prefix() {
3604        assert!(cmd_has_rtk_disabled_prefix("RTK_DISABLED=1 git status"));
3605        assert!(cmd_has_rtk_disabled_prefix(
3606            "FOO=1 RTK_DISABLED=1 cargo test"
3607        ));
3608        assert!(cmd_has_rtk_disabled_prefix(
3609            "RTK_DISABLED=true git log --oneline"
3610        ));
3611        assert!(!cmd_has_rtk_disabled_prefix("git status"));
3612        assert!(!cmd_has_rtk_disabled_prefix("rtk git status"));
3613        assert!(!cmd_has_rtk_disabled_prefix("SOME_VAR=1 git status"));
3614    }
3615
3616    #[test]
3617    fn test_strip_disabled_prefix() {
3618        assert_eq!(
3619            strip_disabled_prefix("RTK_DISABLED=1 git status"),
3620            ("RTK_DISABLED=1 ", "git status")
3621        );
3622        assert_eq!(
3623            strip_disabled_prefix("FOO=1 RTK_DISABLED=1 cargo test"),
3624            ("FOO=1 RTK_DISABLED=1 ", "cargo test")
3625        );
3626        assert_eq!(strip_disabled_prefix("git status"), ("", "git status"));
3627    }
3628
3629    // --- #485: absolute path normalization ---
3630
3631    #[test]
3632    fn test_classify_absolute_path_grep() {
3633        assert_eq!(
3634            classify_command("/usr/bin/grep -rni pattern"),
3635            Classification::Supported {
3636                rtk_equivalent: "rtk grep",
3637                category: "Files",
3638                estimated_savings_pct: 75.0,
3639                status: RtkStatus::Existing,
3640            }
3641        );
3642    }
3643
3644    #[test]
3645    fn test_classify_absolute_path_ls() {
3646        assert_eq!(
3647            classify_command("/bin/ls -la"),
3648            Classification::Supported {
3649                rtk_equivalent: "rtk ls",
3650                category: "Files",
3651                estimated_savings_pct: 65.0,
3652                status: RtkStatus::Existing,
3653            }
3654        );
3655    }
3656
3657    #[test]
3658    fn test_classify_absolute_path_git() {
3659        assert_eq!(
3660            classify_command("/usr/local/bin/git status"),
3661            Classification::Supported {
3662                rtk_equivalent: "rtk git",
3663                category: "Git",
3664                estimated_savings_pct: 70.0,
3665                status: RtkStatus::Existing,
3666            }
3667        );
3668    }
3669
3670    #[test]
3671    fn test_classify_absolute_path_no_args() {
3672        // /usr/bin/find alone → still classified
3673        assert_eq!(
3674            classify_command("/usr/bin/find ."),
3675            Classification::Supported {
3676                rtk_equivalent: "rtk find",
3677                category: "Files",
3678                estimated_savings_pct: 70.0,
3679                status: RtkStatus::Existing,
3680            }
3681        );
3682    }
3683
3684    #[test]
3685    fn test_strip_absolute_path_helper() {
3686        assert_eq!(strip_absolute_path("/usr/bin/grep -rn foo"), "grep -rn foo");
3687        assert_eq!(strip_absolute_path("/bin/ls -la"), "ls -la");
3688        assert_eq!(strip_absolute_path("grep -rn foo"), "grep -rn foo");
3689        assert_eq!(strip_absolute_path("/usr/local/bin/git"), "git");
3690    }
3691
3692    // --- #163: git global options ---
3693
3694    #[test]
3695    fn test_classify_git_with_dash_c_path() {
3696        assert_eq!(
3697            classify_command("git -C /tmp status"),
3698            Classification::Supported {
3699                rtk_equivalent: "rtk git",
3700                category: "Git",
3701                estimated_savings_pct: 70.0,
3702                status: RtkStatus::Existing,
3703            }
3704        );
3705    }
3706
3707    #[test]
3708    fn test_classify_git_no_pager_log() {
3709        assert_eq!(
3710            classify_command("git --no-pager log -5"),
3711            Classification::Supported {
3712                rtk_equivalent: "rtk git",
3713                category: "Git",
3714                estimated_savings_pct: 70.0,
3715                status: RtkStatus::Existing,
3716            }
3717        );
3718    }
3719
3720    #[test]
3721    fn test_classify_git_git_dir() {
3722        assert_eq!(
3723            classify_command("git --git-dir /tmp/.git status"),
3724            Classification::Supported {
3725                rtk_equivalent: "rtk git",
3726                category: "Git",
3727                estimated_savings_pct: 70.0,
3728                status: RtkStatus::Existing,
3729            }
3730        );
3731    }
3732
3733    #[test]
3734    fn test_rewrite_git_dash_c() {
3735        assert_eq!(
3736            rewrite_command_no_prefixes("git -C /tmp status", &[]),
3737            Some("rtk git -C /tmp status".to_string())
3738        );
3739    }
3740
3741    #[test]
3742    fn test_rewrite_git_no_pager() {
3743        assert_eq!(
3744            rewrite_command_no_prefixes("git --no-pager log -5", &[]),
3745            Some("rtk git --no-pager log -5".to_string())
3746        );
3747    }
3748
3749    #[test]
3750    fn test_strip_git_global_opts_helper() {
3751        assert_eq!(strip_git_global_opts("git -C /tmp status"), "git status");
3752        assert_eq!(strip_git_global_opts("git --no-pager log"), "git log");
3753        assert_eq!(strip_git_global_opts("git status"), "git status");
3754        assert_eq!(strip_git_global_opts("cargo test"), "cargo test");
3755    }
3756
3757    #[test]
3758    fn test_strip_golangci_global_opts_helper() {
3759        assert_eq!(
3760            strip_golangci_global_opts("golangci-lint -v run ./..."),
3761            "golangci-lint run ./..."
3762        );
3763        assert_eq!(
3764            strip_golangci_global_opts("golangci-lint --color never run ./..."),
3765            "golangci-lint run ./..."
3766        );
3767        assert_eq!(
3768            strip_golangci_global_opts("golangci-lint --color=never run ./..."),
3769            "golangci-lint run ./..."
3770        );
3771        assert_eq!(
3772            strip_golangci_global_opts("golangci-lint --config=foo.yml run ./..."),
3773            "golangci-lint run ./..."
3774        );
3775        assert_eq!(
3776            strip_golangci_global_opts("golangci-lint version"),
3777            "golangci-lint version"
3778        );
3779        assert_eq!(strip_golangci_global_opts("cargo test"), "cargo test");
3780    }
3781
3782    // --- #wc: wc filter was silently ignored by the hook ---
3783
3784    #[test]
3785    fn test_classify_wc_supported() {
3786        // BUG: "wc " was in IGNORED_PREFIXES despite wc_cmd.rs having a full filter.
3787        // This test documents the bug: it must FAIL before the fix and PASS after.
3788        assert_eq!(
3789            classify_command("wc -l src/main.rs"),
3790            Classification::Supported {
3791                rtk_equivalent: "rtk wc",
3792                category: "Files",
3793                estimated_savings_pct: 60.0,
3794                status: RtkStatus::Existing,
3795            }
3796        );
3797    }
3798
3799    #[test]
3800    fn test_classify_wc_multi_file() {
3801        assert_eq!(
3802            classify_command("wc src/*.rs"),
3803            Classification::Supported {
3804                rtk_equivalent: "rtk wc",
3805                category: "Files",
3806                estimated_savings_pct: 60.0,
3807                status: RtkStatus::Existing,
3808            }
3809        );
3810    }
3811
3812    #[test]
3813    fn test_rewrite_wc() {
3814        assert_eq!(
3815            rewrite_command_no_prefixes("wc -l src/main.rs", &[]),
3816            Some("rtk wc -l src/main.rs".into())
3817        );
3818    }
3819
3820    #[test]
3821    fn test_rewrite_wc_multi_file() {
3822        assert_eq!(
3823            rewrite_command_no_prefixes("wc src/*.rs", &[]),
3824            Some("rtk wc src/*.rs".into())
3825        );
3826    }
3827
3828    #[test]
3829    fn test_classify_command_substitution_passthrough() {
3830        assert_eq!(
3831            classify_command("git log $(git rev-parse HEAD~1)"),
3832            Classification::Supported {
3833                rtk_equivalent: "rtk git",
3834                category: "Git",
3835                estimated_savings_pct: 70.0,
3836                status: RtkStatus::Existing,
3837            }
3838        );
3839    }
3840
3841    #[test]
3842    fn test_rewrite_command_substitution_passthrough() {
3843        assert_eq!(
3844            rewrite_command_no_prefixes("git log $(git rev-parse HEAD~1)", &[]),
3845            Some("rtk git log $(git rev-parse HEAD~1)".into())
3846        );
3847    }
3848
3849    #[test]
3850    fn test_split_command_substitution_no_split() {
3851        assert_eq!(
3852            split_command_chain("git log $(git rev-parse HEAD~1)"),
3853            vec!["git log $(git rev-parse HEAD~1)"]
3854        );
3855    }
3856
3857    #[test]
3858    fn test_shell_prefix_noglob() {
3859        assert_eq!(
3860            rewrite_command_no_prefixes("noglob git status", &[]),
3861            Some("noglob rtk git status".into())
3862        );
3863    }
3864
3865    #[test]
3866    fn test_shell_prefix_command() {
3867        assert_eq!(
3868            rewrite_command_no_prefixes("command git status", &[]),
3869            Some("command rtk git status".into())
3870        );
3871    }
3872
3873    #[test]
3874    fn test_shell_prefix_builtin_exec_nocorrect() {
3875        assert_eq!(
3876            rewrite_command_no_prefixes("builtin git status", &[]),
3877            Some("builtin rtk git status".into())
3878        );
3879        assert_eq!(
3880            rewrite_command_no_prefixes("exec git status", &[]),
3881            Some("exec rtk git status".into())
3882        );
3883        assert_eq!(
3884            rewrite_command_no_prefixes("nocorrect git status", &[]),
3885            Some("nocorrect rtk git status".into())
3886        );
3887    }
3888
3889    #[test]
3890    fn test_shell_prefix_unknown_inner() {
3891        assert_eq!(
3892            rewrite_command_no_prefixes("noglob unknown_cmd --flag", &[]),
3893            None
3894        );
3895    }
3896
3897    // --- transparent_prefixes tests ---
3898
3899    #[test]
3900    fn test_transparent_prefix_strips_and_reprepends() {
3901        let prefixes = vec!["shadowenv exec --".to_string()];
3902        assert_eq!(
3903            super::rewrite_command("shadowenv exec -- git status", &[], &prefixes),
3904            Some("shadowenv exec -- rtk git status".into())
3905        );
3906    }
3907
3908    #[test]
3909    fn test_transparent_prefix_with_test_runner() {
3910        let prefixes = vec!["shadowenv exec --".to_string()];
3911        assert_eq!(
3912            super::rewrite_command("shadowenv exec -- cargo test", &[], &prefixes),
3913            Some("shadowenv exec -- rtk cargo test".into())
3914        );
3915    }
3916
3917    #[test]
3918    fn test_transparent_prefix_unknown_inner_returns_none() {
3919        let prefixes = vec!["shadowenv exec --".to_string()];
3920        assert_eq!(
3921            super::rewrite_command("shadowenv exec -- htop", &[], &prefixes),
3922            None
3923        );
3924    }
3925
3926    #[test]
3927    fn test_transparent_prefix_not_matched_is_passthrough() {
3928        // Without the prefix configured, the wrapper breaks routing.
3929        assert_eq!(
3930            super::rewrite_command("shadowenv exec -- git status", &[], &[]),
3931            None
3932        );
3933    }
3934
3935    #[test]
3936    fn test_transparent_prefix_composed_with_builtin() {
3937        // `noglob shadowenv exec -- git status` — builtin layer strips noglob,
3938        // user layer strips shadowenv exec --, inner `git status` routes.
3939        let prefixes = vec!["shadowenv exec --".to_string()];
3940        assert_eq!(
3941            super::rewrite_command("noglob shadowenv exec -- git status", &[], &prefixes),
3942            Some("noglob shadowenv exec -- rtk git status".into())
3943        );
3944    }
3945
3946    #[test]
3947    fn test_transparent_prefix_composed_with_env_prefix() {
3948        let prefixes = vec!["bundle exec".to_string()];
3949        assert_eq!(
3950            super::rewrite_command("RAILS_ENV=test bundle exec git status", &[], &prefixes),
3951            Some("RAILS_ENV=test bundle exec rtk git status".into())
3952        );
3953    }
3954
3955    #[test]
3956    fn test_env_prefix_composed_with_builtin() {
3957        assert_eq!(
3958            rewrite_command_no_prefixes("sudo noglob git status", &[]),
3959            Some("sudo noglob rtk git status".into())
3960        );
3961    }
3962
3963    #[test]
3964    fn test_transparent_prefix_multiple_configured() {
3965        let prefixes = vec!["shadowenv exec --".to_string(), "direnv exec .".to_string()];
3966        assert_eq!(
3967            super::rewrite_command("direnv exec . git status", &[], &prefixes),
3968            Some("direnv exec . rtk git status".into())
3969        );
3970    }
3971
3972    #[test]
3973    fn test_transparent_prefixes_normalize_once() {
3974        let prefixes = vec![
3975            "  docker exec mycontainer  ".to_string(),
3976            "".to_string(),
3977            "docker".to_string(),
3978            "docker exec mycontainer".to_string(),
3979        ];
3980        assert_eq!(
3981            normalize_transparent_prefixes(&prefixes),
3982            vec!["docker exec mycontainer".to_string(), "docker".to_string()]
3983        );
3984    }
3985
3986    #[test]
3987    fn test_transparent_prefix_overlapping_entries_use_longest_match() {
3988        let prefixes = vec!["docker".to_string(), "docker exec app".to_string()];
3989        assert_eq!(
3990            super::rewrite_command("docker exec app git status", &[], &prefixes),
3991            Some("docker exec app rtk git status".into())
3992        );
3993    }
3994
3995    #[test]
3996    fn test_transparent_prefix_whole_word_matching() {
3997        // A prefix `"foo"` must NOT match `"foobar git status"`.
3998        let prefixes = vec!["foo".to_string()];
3999        assert_eq!(
4000            super::rewrite_command("foobar git status", &[], &prefixes),
4001            None
4002        );
4003    }
4004
4005    #[test]
4006    fn test_transparent_prefix_empty_rest_returns_none() {
4007        let prefixes = vec!["shadowenv exec --".to_string()];
4008        assert_eq!(
4009            super::rewrite_command("shadowenv exec --", &[], &prefixes),
4010            None
4011        );
4012    }
4013
4014    #[test]
4015    fn test_transparent_prefix_empty_entry_is_skipped() {
4016        // A blank entry in the config should not cause spurious matches or panics.
4017        let prefixes = vec!["".to_string(), "   ".to_string()];
4018        assert_eq!(
4019            super::rewrite_command("git status", &[], &prefixes),
4020            Some("rtk git status".into())
4021        );
4022    }
4023
4024    #[test]
4025    fn test_transparent_prefix_inside_compound() {
4026        // Each segment of `&&` / `;` should independently get prefix-stripped.
4027        let prefixes = vec!["shadowenv exec --".to_string()];
4028        assert_eq!(
4029            super::rewrite_command(
4030                "shadowenv exec -- git status && shadowenv exec -- cargo test",
4031                &[],
4032                &prefixes
4033            ),
4034            Some("shadowenv exec -- rtk git status && shadowenv exec -- rtk cargo test".into())
4035        );
4036    }
4037
4038    #[test]
4039    fn test_transparent_prefix_respects_excluded() {
4040        // An excluded inner command should still produce no rewrite even behind
4041        // a transparent prefix.
4042        let prefixes = vec!["shadowenv exec --".to_string()];
4043        let excluded = vec!["git".to_string()];
4044        assert_eq!(
4045            super::rewrite_command("shadowenv exec -- git status", &excluded, &prefixes),
4046            None
4047        );
4048    }
4049
4050    #[test]
4051    fn test_transparent_prefix_recursion_bounded() {
4052        // A prefix that could recurse forever (e.g. one that maps to itself)
4053        // must terminate once MAX_PREFIX_DEPTH is reached.
4054        let prefixes = vec!["wrap".to_string()];
4055        let mut cmd = String::new();
4056        for _ in 0..(MAX_PREFIX_DEPTH + 2) {
4057            cmd.push_str("wrap ");
4058        }
4059        cmd.push_str("git status");
4060        // Doesn't matter exactly what it returns — just that it doesn't stack-
4061        // overflow or loop forever. Exercise the code path.
4062        let _ = super::rewrite_command(&cmd, &[], &prefixes);
4063    }
4064
4065    #[test]
4066    fn test_python3_m_pytest() {
4067        assert_eq!(
4068            rewrite_command_no_prefixes("python3 -m pytest tests/", &[]),
4069            Some("rtk pytest tests/".into())
4070        );
4071    }
4072
4073    #[test]
4074    fn test_pip_show() {
4075        assert_eq!(
4076            rewrite_command_no_prefixes("pip show flask", &[]),
4077            Some("rtk pip show flask".into())
4078        );
4079    }
4080
4081    #[test]
4082    fn test_gt_graphite() {
4083        assert_eq!(
4084            rewrite_command_no_prefixes("gt log", &[]),
4085            Some("rtk gt log".into())
4086        );
4087    }
4088
4089    #[test]
4090    fn test_command_no_longer_ignored() {
4091        assert_ne!(
4092            classify_command("command git status"),
4093            Classification::Ignored
4094        );
4095    }
4096
4097    // --- Pipe + operator rewrite ---
4098
4099    #[test]
4100    fn test_rewrite_pipe_then_and() {
4101        assert_eq!(
4102            rewrite_command_no_prefixes("git log | head -5 && git stash", &[]),
4103            Some("rtk git log | head -5 && rtk git stash".into())
4104        );
4105    }
4106
4107    #[test]
4108    fn test_rewrite_pipe_then_semicolon() {
4109        assert_eq!(
4110            rewrite_command_no_prefixes("cargo test | head; git status", &[]),
4111            Some("rtk cargo test | head; rtk git status".into())
4112        );
4113    }
4114
4115    #[test]
4116    fn test_rewrite_pipe_then_or() {
4117        assert_eq!(
4118            rewrite_command_no_prefixes("cargo test | grep FAIL || git stash", &[]),
4119            Some("rtk cargo test | grep FAIL || rtk git stash".into())
4120        );
4121    }
4122
4123    #[test]
4124    fn test_rewrite_env_pipe_then_and() {
4125        assert_eq!(
4126            rewrite_command_no_prefixes(
4127                "RUST_BACKTRACE=1 cargo test 2>&1 | grep FAILED && git stash",
4128                &[]
4129            ),
4130            Some("RUST_BACKTRACE=1 rtk cargo test 2>&1 | grep FAILED && rtk git stash".into())
4131        );
4132    }
4133
4134    #[test]
4135    fn test_rewrite_and_then_pipe() {
4136        assert_eq!(
4137            rewrite_command_no_prefixes("git status && cargo test | grep FAIL", &[]),
4138            Some("rtk git status && rtk cargo test | grep FAIL".into())
4139        );
4140    }
4141
4142    #[test]
4143    fn test_rewrite_multi_pipe_then_and() {
4144        assert_eq!(
4145            rewrite_command_no_prefixes("git log | head | tail && git status", &[]),
4146            Some("rtk git log | head | tail && rtk git status".into())
4147        );
4148    }
4149
4150    // --- line-continuation handling (issue #1564) -------------------
4151
4152    #[test]
4153    fn test_rewrite_leading_backslash_newline() {
4154        // The exact reproduction from #1564: a leading `\<NL>` made
4155        // the matcher see `\` as the command and bail out.
4156        assert_eq!(
4157            rewrite_command_no_prefixes("\\\ngit diff HEAD~1", &[]),
4158            Some("rtk git diff HEAD~1".into())
4159        );
4160    }
4161
4162    #[test]
4163    fn test_rewrite_leading_backslash_crlf() {
4164        // CRLF line ending — same shape, Windows shells / Git Bash.
4165        assert_eq!(
4166            rewrite_command_no_prefixes("\\\r\ngit diff HEAD~1", &[]),
4167            Some("rtk git diff HEAD~1".into())
4168        );
4169    }
4170
4171    #[test]
4172    fn test_rewrite_internal_backslash_newline() {
4173        // Embedded line continuation between subcommand and args:
4174        // `git diff \<NL>HEAD~1` is exactly equivalent to
4175        // `git diff HEAD~1` per bash semantics.
4176        assert_eq!(
4177            rewrite_command_no_prefixes("git diff \\\nHEAD~1", &[]),
4178            Some("rtk git diff HEAD~1".into())
4179        );
4180    }
4181
4182    #[test]
4183    fn test_rewrite_backslash_newline_with_indent() {
4184        // Continuation followed by indentation — also collapsed.
4185        assert_eq!(
4186            rewrite_command_no_prefixes("git \\\n    diff HEAD~1", &[]),
4187            Some("rtk git diff HEAD~1".into())
4188        );
4189    }
4190
4191    #[test]
4192    fn test_rewrite_no_line_continuation_unchanged() {
4193        // Sanity check: a command without any `\<NL>` should match
4194        // unchanged. This pins that the normalization step does not
4195        // regress the no-op fast path.
4196        assert_eq!(
4197            rewrite_command_no_prefixes("git diff HEAD~1", &[]),
4198            Some("rtk git diff HEAD~1".into())
4199        );
4200    }
4201
4202    #[test]
4203    fn test_collapse_line_continuations_no_op() {
4204        // Helper-level: no continuations → returns Borrowed (no
4205        // allocation). We can only spot-check the equality here, but
4206        // the `Cow::Borrowed` variant is implied by `replace_all`
4207        // when no replacement occurs.
4208        assert_eq!(
4209            collapse_line_continuations("git diff HEAD~1"),
4210            std::borrow::Cow::<str>::Borrowed("git diff HEAD~1"),
4211        );
4212    }
4213}