Skip to content

Commit edd9c02

Browse files
authored
Merge pull request #797 from rtk-ai/fix/strip-stderr-redirects
fix: strip trailing stderr redirects before rewrite matching (#530)
2 parents a051a6f + 36a6f48 commit edd9c02

1 file changed

Lines changed: 74 additions & 16 deletions

File tree

src/discover/registry.rs

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,34 @@ pub fn strip_disabled_prefix(cmd: &str) -> &str {
327327
trimmed[prefix_len..].trim_start()
328328
}
329329

330-
/// Rewrite a raw command to its RTK equivalent.
331-
///
332-
/// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK.
330+
lazy_static! {
331+
// Match trailing shell redirections:
332+
// Alt 1: N>&M or N>&- (fd redirect/close): 2>&1, 1>&2, 2>&-
333+
// Alt 2: &>file or &>>file (bash redirect both): &>/dev/null
334+
// Alt 3: N>file or N>>file (fd to file): 2>/dev/null, >/tmp/out, 1>>log
335+
// Note: [^(\\s] excludes process substitutions like >(tee) from false-positive matching
336+
static ref TRAILING_REDIRECT: Regex =
337+
Regex::new(r"\s+(?:[0-9]?>&[0-9-]|&>>?\S+|[0-9]?>>?\s*[^(\s]\S*)\s*$").unwrap();
338+
}
339+
340+
/// Strip trailing stderr/stdout redirects from a command segment (#530).
341+
/// Returns (command_without_redirects, redirect_suffix).
342+
fn strip_trailing_redirects(cmd: &str) -> (&str, &str) {
343+
if let Some(m) = TRAILING_REDIRECT.find(cmd) {
344+
// Verify redirect is not inside quotes (single-pass count)
345+
let before = &cmd[..m.start()];
346+
let (sq, dq) = before.chars().fold((0u32, 0u32), |(s, d), c| match c {
347+
'\'' => (s + 1, d),
348+
'"' => (s, d + 1),
349+
_ => (s, d),
350+
});
351+
if sq % 2 == 0 && dq % 2 == 0 {
352+
return (&cmd[..m.start()], &cmd[m.start()..]);
353+
}
354+
}
355+
(cmd, "")
356+
}
357+
333358
/// Returns `None` if the command is unsupported or ignored (hook should pass through).
334359
///
335360
/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
@@ -565,30 +590,34 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
565590
return None;
566591
}
567592

593+
// Strip trailing stderr/stdout redirects before matching (#530)
594+
// e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
595+
let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed);
596+
568597
// Already RTK — pass through unchanged
569-
if trimmed.starts_with("rtk ") || trimmed == "rtk" {
598+
if cmd_part.starts_with("rtk ") || cmd_part == "rtk" {
570599
return Some(trimmed.to_string());
571600
}
572601

573602
// Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N`
574603
// Must intercept before generic prefix replacement, which would produce `rtk read -20 file`.
575604
// Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls
576605
// through to the generic rewrite below and produces `rtk read file` as expected.
577-
if trimmed.starts_with("head -") {
578-
return rewrite_head_numeric(trimmed);
606+
if cmd_part.starts_with("head -") {
607+
return rewrite_head_numeric(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
579608
}
580609

581610
// tail has several forms that are not compatible with generic prefix replacement.
582611
// Only rewrite recognized numeric line forms; otherwise skip rewrite.
583-
if trimmed.starts_with("tail ") {
584-
return rewrite_tail_lines(trimmed);
612+
if cmd_part.starts_with("tail ") {
613+
return rewrite_tail_lines(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
585614
}
586615

587616
// Use classify_command for correct ignore/prefix handling
588-
let rtk_equivalent = match classify_command(trimmed) {
617+
let rtk_equivalent = match classify_command(cmd_part) {
589618
Classification::Supported { rtk_equivalent, .. } => {
590619
// Check if the base command is excluded from rewriting (#243)
591-
let base = trimmed.split_whitespace().next().unwrap_or("");
620+
let base = cmd_part.split_whitespace().next().unwrap_or("");
592621
if excluded.iter().any(|e| e == base) {
593622
return None;
594623
}
@@ -601,13 +630,13 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
601630
let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?;
602631

603632
// Extract env prefix (sudo, env VAR=val, etc.)
604-
let stripped_cow = ENV_PREFIX.replace(trimmed, "");
605-
let env_prefix_len = trimmed.len() - stripped_cow.len();
606-
let env_prefix = &trimmed[..env_prefix_len];
633+
let stripped_cow = ENV_PREFIX.replace(cmd_part, "");
634+
let env_prefix_len = cmd_part.len() - stripped_cow.len();
635+
let env_prefix = &cmd_part[..env_prefix_len];
607636
let cmd_clean = stripped_cow.trim();
608637

609638
// #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
610-
if has_rtk_disabled_prefix(trimmed) {
639+
if has_rtk_disabled_prefix(cmd_part) {
611640
return None;
612641
}
613642

@@ -627,9 +656,9 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
627656
for &prefix in rule.rewrite_prefixes {
628657
if let Some(rest) = strip_word_prefix(cmd_clean, prefix) {
629658
let rewritten = if rest.is_empty() {
630-
format!("{}{}", env_prefix, rule.rtk_cmd)
659+
format!("{}{}{}", env_prefix, rule.rtk_cmd, redirect_suffix)
631660
} else {
632-
format!("{}{} {}", env_prefix, rule.rtk_cmd, rest)
661+
format!("{}{} {}{}", env_prefix, rule.rtk_cmd, rest, redirect_suffix)
633662
};
634663
return Some(rewritten);
635664
}
@@ -1285,6 +1314,35 @@ mod tests {
12851314
);
12861315
}
12871316

1317+
#[test]
1318+
fn test_rewrite_redirect_double() {
1319+
// Double redirect: only last one stripped, but full command rewrites correctly
1320+
assert_eq!(
1321+
rewrite_command("git status 2>&1 >/dev/null", &[]),
1322+
Some("rtk git status 2>&1 >/dev/null".into())
1323+
);
1324+
}
1325+
1326+
#[test]
1327+
fn test_rewrite_redirect_fd_close() {
1328+
// 2>&- (close stderr fd)
1329+
assert_eq!(
1330+
rewrite_command("git status 2>&-", &[]),
1331+
Some("rtk git status 2>&-".into())
1332+
);
1333+
}
1334+
1335+
#[test]
1336+
fn test_rewrite_redirect_quotes_not_stripped() {
1337+
// Redirect-like chars inside quotes should NOT be stripped
1338+
// Known limitation: apostrophes cause conservative no-strip (safe fallback)
1339+
let result = rewrite_command("git commit -m \"it's fixed\" 2>&1", &[]);
1340+
assert!(
1341+
result.is_some(),
1342+
"Should still rewrite even with apostrophe"
1343+
);
1344+
}
1345+
12881346
#[test]
12891347
fn test_rewrite_background_amp_non_regression() {
12901348
// background `&` must still work after redirect fix

0 commit comments

Comments
 (0)