From 548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Sat, 16 May 2026 18:18:14 +0200 Subject: [PATCH 1/3] fix(tee): safe truncation caps and compose-ps tee content fix Replace all bare magic offset literals (e.g. 21 for cap=20) with named constants (MAX_XXX + 1) so offsets stay in sync if caps change. Fix compose_ps to tee pre-formatted lines instead of raw tab-separated input, so tail -n +N gives the agent readable content. All 13 affected modules updated: container, dotnet, gh, glab, go, lint, pnpm, pytest, rubocop, cargo, aws. 1884 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/cloud/aws_cmd.rs | 16 +-- src/cmds/cloud/container.rs | 121 ++++++++++++++--------- src/cmds/dotnet/dotnet_cmd.rs | 180 +++++++++++++++++++++++++++++----- src/cmds/git/gh_cmd.rs | 83 +++++++++------- src/cmds/git/glab_cmd.rs | 116 +++++++++++++--------- src/cmds/go/go_cmd.rs | 22 +++-- src/cmds/js/lint_cmd.rs | 47 +++++++-- src/cmds/js/pnpm_cmd.rs | 39 +++++--- src/cmds/python/pytest_cmd.rs | 4 + src/cmds/ruby/rubocop_cmd.rs | 18 +++- src/cmds/rust/cargo_cmd.rs | 61 ++++++++++-- src/core/utils.rs | 2 +- 12 files changed, 498 insertions(+), 211 deletions(-) diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 2657218b8f..aa53fc3a0d 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -494,7 +494,7 @@ fn filter_s3_ls(output: &str) -> FilterResult { if total > limit { let text = format!( - "{}\n... +{} more items", + "{}\n… +{} more items", lines[..limit].join("\n"), total - limit ); @@ -553,7 +553,7 @@ fn filter_ec2_instances(json_str: &str) -> Option { } if truncated { - result.push_str(&format!(" ... +{} more\n", total - MAX_ITEMS)); + result.push_str(&format!(" … +{} more\n", total - MAX_ITEMS)); } let text = result.trim_end().to_string(); @@ -759,7 +759,7 @@ fn filter_logs_events(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more events", total - MAX_LOG_EVENTS)); + lines.push(format!("… +{} more events", total - MAX_LOG_EVENTS)); } let text = lines.join("\n"); @@ -1132,7 +1132,7 @@ fn filter_dynamodb_items(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more items", total - MAX_ITEMS)); + lines.push(format!("… +{} more items", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1426,7 +1426,7 @@ fn filter_logs_query_results(json_str: &str) -> Option { } if truncated { - lines.push(format!("... +{} more rows", total - MAX_ITEMS)); + lines.push(format!("… +{} more rows", total - MAX_ITEMS)); } let text = lines.join("\n"); @@ -1616,7 +1616,7 @@ mod tests { } let input = lines.join("\n"); let result = filter_s3_ls(&input); - assert!(result.text.contains("... +20 more items")); + assert!(result.text.contains("… +20 more items")); assert!(result.truncated); } @@ -1852,7 +1852,7 @@ mod tests { } let json = format!(r#"{{"DBInstances": [{}]}}"#, dbs.join(",")); let result = filter_rds_instances(&json).unwrap(); - assert!(result.text.contains("... +5 more instances")); + assert!(result.text.contains("… +5 more instances")); assert!(result.truncated); } @@ -1893,7 +1893,7 @@ mod tests { } let json = format!(r#"{{"events": [{}]}}"#, events.join(",")); let result = filter_logs_events(&json).unwrap(); - assert!(result.text.contains("... +10 more events")); + assert!(result.text.contains("… +10 more events")); assert!(result.truncated); } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index ee2ac6e4bf..09ef999fb8 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -348,12 +348,19 @@ fn format_kubectl_pods(json: &Value) -> String { let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", ")); if !issues.is_empty() { + const MAX_PODS_ISSUES: usize = 10; out.push_str("[warn] Issues:\n"); - for issue in issues.iter().take(10) { + for issue in issues.iter().take(MAX_PODS_ISSUES) { out.push_str(&format!(" {}\n", issue)); } - if issues.len() > 10 { - out.push_str(&format!(" … +{} more", issues.len() - 10)); + if issues.len() > MAX_PODS_ISSUES { + out.push_str(&format!(" … +{} more", issues.len() - MAX_PODS_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "kubectl-pods", MAX_PODS_ISSUES + 1) + { + out.push_str(&format!(" {}", hint)); + } } } out @@ -374,39 +381,48 @@ fn format_kubectl_services(json: &Value) -> String { }; let mut out = format!("{} services:\n", services.len()); - for svc in services.iter().take(15) { - let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); - let name = svc["metadata"]["name"].as_str().unwrap_or("-"); - let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); - let ports: Vec = svc["spec"]["ports"] - .as_array() - .map(|arr| { - arr.iter() - .map(|p| { - let port = p["port"].as_i64().unwrap_or(0); - let target = p["targetPort"] - .as_i64() - .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) - .unwrap_or(port); - if port == target { - format!("{}", port) - } else { - format!("{}→{}", port, target) - } - }) - .collect() - }) - .unwrap_or_default(); - out.push_str(&format!( - " {}/{} {} [{}]\n", - ns, - name, - svc_type, - ports.join(",") - )); - } - if services.len() > 15 { - out.push_str(&format!(" … +{} more", services.len() - 15)); + let all_lines: Vec = services + .iter() + .map(|svc| { + let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-"); + let name = svc["metadata"]["name"].as_str().unwrap_or("-"); + let svc_type = svc["spec"]["type"].as_str().unwrap_or("-"); + let ports: Vec = svc["spec"]["ports"] + .as_array() + .map(|arr| { + arr.iter() + .map(|p| { + let port = p["port"].as_i64().unwrap_or(0); + let target = p["targetPort"] + .as_i64() + .or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok())) + .unwrap_or(port); + if port == target { + format!("{}", port) + } else { + format!("{}→{}", port, target) + } + }) + .collect() + }) + .unwrap_or_default(); + format!(" {}/{} {} [{}]", ns, name, svc_type, ports.join(",")) + }) + .collect(); + + const MAX_KUBECTL_SERVICES: usize = 15; + for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_KUBECTL_SERVICES { + out.push_str(&format!(" … +{} more", all_lines.len() - MAX_KUBECTL_SERVICES)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "kubectl-services", MAX_KUBECTL_SERVICES + 1) + { + out.push_str(&format!(" {}", hint)); + } + out.push('\n'); } out } @@ -444,6 +460,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { /// Expects tab-separated lines: Name\tImage\tStatus\tPorts /// (no header row — `--format` output is headerless) pub fn format_compose_ps(raw: &str) -> String { + const MAX_COMPOSE_SERVICES: usize = 20; let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { @@ -452,16 +469,19 @@ pub fn format_compose_ps(raw: &str) -> String { let mut result = format!("[compose] {} services:\n", lines.len()); - for line in lines.iter().take(20) { - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() >= 4 { + // Pre-build all formatted lines so the tee file matches what the agent sees. + let all_formatted: Vec = lines + .iter() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 4 { + return None; + } let name = parts[0]; let image = parts[1]; let status = parts[2]; let ports = parts[3]; - let short_image = image.split('/').next_back().unwrap_or(image); - let port_str = if ports.trim().is_empty() { String::new() } else { @@ -472,15 +492,20 @@ pub fn format_compose_ps(raw: &str) -> String { format!(" [{}]", compact) } }; + Some(format!(" {} ({}) {}{}", name, short_image, status, port_str)) + }) + .collect(); - result.push_str(&format!( - " {} ({}) {}{}\n", - name, short_image, status, port_str - )); - } + for line in all_formatted.iter().take(MAX_COMPOSE_SERVICES) { + result.push_str(line); + result.push('\n'); } - if lines.len() > 20 { - result.push_str(&format!(" … +{} more\n", lines.len() - 20)); + if all_formatted.len() > MAX_COMPOSE_SERVICES { + result.push_str(&format!(" … +{} more\n", all_formatted.len() - MAX_COMPOSE_SERVICES)); + let all_text = all_formatted.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "compose-ps", MAX_COMPOSE_SERVICES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim_end().to_string() diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 4c307ff509..32d0244862 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -369,7 +369,13 @@ fn format_dotnet_format_output( let mut output = format!("Format: {} files need formatting", changed_count); output.push_str("\n---------------------------------------"); - for (index, file) in summary.files_with_changes.iter().take(20).enumerate() { + const MAX_FORMAT_FILES: usize = 20; + for (index, file) in summary + .files_with_changes + .iter() + .take(MAX_FORMAT_FILES) + .enumerate() + { let first_change = &file.changes[0]; let rule = if first_change.diagnostic_id.is_empty() { first_change.format_description.as_str() @@ -386,8 +392,21 @@ fn format_dotnet_format_output( )); } - if changed_count > 20 { - output.push_str(&format!("\n... +{} more files", changed_count - 20)); + if changed_count > MAX_FORMAT_FILES { + output.push_str(&format!("\n… +{} more files", changed_count - MAX_FORMAT_FILES)); + let all_files = summary + .files_with_changes + .iter() + .map(|f| f.path.as_str()) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_files, + "dotnet-format-files", + MAX_FORMAT_FILES + 1, + ) { + output.push_str(&format!(" {}", hint)); + } } output.push_str(&format!( @@ -979,31 +998,60 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S let status_icon = if summary.succeeded { "ok" } else { "fail" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_BUILD_ERRORS: usize = 20; + const MAX_BUILD_WARNINGS: usize = 10; + let mut errors = String::new(); if !summary.errors.is_empty() { errors.push_str("Errors:\n"); - for issue in summary.errors.iter().take(20) { + for issue in summary.errors.iter().take(MAX_BUILD_ERRORS) { errors.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if summary.errors.len() > 20 { + if summary.errors.len() > MAX_BUILD_ERRORS { errors.push_str(&format!( - " ... +{} more errors\n", - summary.errors.len() - 20 + " … +{} more errors\n", + summary.errors.len() - MAX_BUILD_ERRORS )); + let all_errors = summary + .errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-build-errors", + MAX_BUILD_ERRORS + 1, + ) { + errors.push_str(&format!(" {}\n", hint)); + } } } let mut warnings = String::new(); if !summary.warnings.is_empty() { warnings.push_str("Warnings:\n"); - for issue in summary.warnings.iter().take(10) { + for issue in summary.warnings.iter().take(MAX_BUILD_WARNINGS) { warnings.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if summary.warnings.len() > 10 { + if summary.warnings.len() > MAX_BUILD_WARNINGS { warnings.push_str(&format!( - " ... +{} more warnings\n", - summary.warnings.len() - 10 + " … +{} more warnings\n", + summary.warnings.len() - MAX_BUILD_WARNINGS )); + let all_warnings = summary + .warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-build-warnings", + MAX_BUILD_WARNINGS + 1, + ) { + warnings.push_str(&format!(" {}\n", hint)); + } } } @@ -1089,31 +1137,82 @@ fn format_test_output( } if summary.failed_tests.len() > 15 { failed_tests_section.push_str(&format!( - "... +{} more failed tests\n", + "… +{} more failed tests\n", summary.failed_tests.len() - 15 )); + let all_failed = summary + .failed_tests + .iter() + .skip(15) + .map(|t| { + let mut s = t.name.clone(); + for detail in &t.details { + s.push_str(&format!("\n {}", truncate(detail, 320))); + } + s + }) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_failed, "dotnet-test-failures") + { + failed_tests_section.push_str(&format!(" {}\n", hint)); + } } } + const MAX_TEST_ERRORS: usize = 10; + const MAX_TEST_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(10) { + for issue in errors.iter().take(MAX_TEST_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 10 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); + if errors.len() > MAX_TEST_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_TEST_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-test-errors", + MAX_TEST_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_TEST_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_TEST_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_TEST_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-test-warnings", + MAX_TEST_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } @@ -1155,25 +1254,58 @@ fn format_restore_output( let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + const MAX_FORMAT_ERRORS: usize = 20; + const MAX_FORMAT_WARNINGS: usize = 10; + let mut errors_section = String::new(); if !errors.is_empty() { errors_section.push_str("Errors:\n"); - for issue in errors.iter().take(20) { + for issue in errors.iter().take(MAX_FORMAT_ERRORS) { errors_section.push_str(&format!("{}\n", format_issue(issue, "error"))); } - if errors.len() > 20 { - errors_section.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_FORMAT_ERRORS { + errors_section.push_str(&format!( + " … +{} more errors\n", + errors.len() - MAX_FORMAT_ERRORS + )); + let all_errors = errors + .iter() + .map(|e| format_issue(e, "error")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_errors, + "dotnet-format-errors", + MAX_FORMAT_ERRORS + 1, + ) { + errors_section.push_str(&format!(" {}\n", hint)); + } } } let mut warnings_section = String::new(); if !warnings.is_empty() { warnings_section.push_str("Warnings:\n"); - for issue in warnings.iter().take(10) { + for issue in warnings.iter().take(MAX_FORMAT_WARNINGS) { warnings_section.push_str(&format!("{}\n", format_issue(issue, "warning"))); } - if warnings.len() > 10 { - warnings_section.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + if warnings.len() > MAX_FORMAT_WARNINGS { + warnings_section.push_str(&format!( + " … +{} more warnings\n", + warnings.len() - MAX_FORMAT_WARNINGS + )); + let all_warnings = warnings + .iter() + .map(|w| format_issue(w, "warning")) + .collect::>() + .join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint( + &all_warnings, + "dotnet-format-warnings", + MAX_FORMAT_WARNINGS + 1, + ) { + warnings_section.push_str(&format!(" {}\n", hint)); + } } } diff --git a/src/cmds/git/gh_cmd.rs b/src/cmds/git/gh_cmd.rs index 3535fd4ec7..cd22e3f919 100644 --- a/src/cmds/git/gh_cmd.rs +++ b/src/cmds/git/gh_cmd.rs @@ -249,25 +249,27 @@ fn format_pr_list(json: &Value, ultra_compact: bool) -> String { } else { "Pull Requests\n" }); - for pr in prs.iter().take(20) { - let number = pr["number"].as_i64().unwrap_or(0); - let title = pr["title"].as_str().unwrap_or("???"); - let state = pr["state"].as_str().unwrap_or("???"); - let author = pr["author"]["login"].as_str().unwrap_or("???"); - let icon = state_icon(state, ultra_compact); - out.push_str(&format!( - " {} #{} {} ({})\n", - icon, - number, - truncate(title, 60), - author - )); - } - if prs.len() > 20 { - out.push_str(&format!( - " ... {} more (use gh pr list for all)\n", - prs.len() - 20 - )); + let all_lines: Vec = prs + .iter() + .map(|pr| { + let number = pr["number"].as_i64().unwrap_or(0); + let title = pr["title"].as_str().unwrap_or("???"); + let state = pr["state"].as_str().unwrap_or("???"); + let author = pr["author"]["login"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} #{} {} ({})", icon, number, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-prs", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } @@ -589,25 +591,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { } let mut out = String::new(); out.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let number = issue["number"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - let icon = if ultra_compact { - if state == "OPEN" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let number = issue["number"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "OPEN" { "O" } else { "C" } + } else if state == "OPEN" { + "[open]" } else { - "C" - } - } else if state == "OPEN" { - "[open]" - } else { - "[closed]" - }; - out.push_str(&format!(" {} #{} {}\n", icon, number, truncate(title, 60))); - } - if issues.len() > 20 { - out.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, number, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + out.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + out.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "gh-issues", MAX_LIST + 1) { + out.push_str(&format!(" {}\n", hint)); + } } out } diff --git a/src/cmds/git/glab_cmd.rs b/src/cmds/git/glab_cmd.rs index 139ed58987..01a04055d8 100644 --- a/src/cmds/git/glab_cmd.rs +++ b/src/cmds/git/glab_cmd.rs @@ -304,27 +304,27 @@ fn format_mr_list(json: &Value, ultra_compact: bool) -> String { "Merge Requests\n" }); - for mr in mrs.iter().take(20) { - let iid = mr["iid"].as_i64().unwrap_or(0); - let title = mr["title"].as_str().unwrap_or("???"); - let state = mr["state"].as_str().unwrap_or("???"); - let author = mr["author"]["username"].as_str().unwrap_or("???"); - - let icon = state_icon(state, ultra_compact); - filtered.push_str(&format!( - " {} !{} {} ({})\n", - icon, - iid, - truncate(title, 60), - author - )); - } - - if mrs.len() > 20 { - filtered.push_str(&format!( - " ... {} more (use glab mr list for all)\n", - mrs.len() - 20 - )); + let all_lines: Vec = mrs + .iter() + .map(|mr| { + let iid = mr["iid"].as_i64().unwrap_or(0); + let title = mr["title"].as_str().unwrap_or("???"); + let state = mr["state"].as_str().unwrap_or("???"); + let author = mr["author"]["username"].as_str().unwrap_or("???"); + let icon = state_icon(state, ultra_compact); + format!(" {} !{} {} ({})", icon, iid, truncate(title, 60), author) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-mrs", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -524,27 +524,32 @@ fn format_issue_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Issues\n"); - for issue in issues.iter().take(20) { - let iid = issue["iid"].as_i64().unwrap_or(0); - let title = issue["title"].as_str().unwrap_or("???"); - let state = issue["state"].as_str().unwrap_or("???"); - - let icon = if ultra_compact { - if state == "opened" { - "O" + let all_lines: Vec = issues + .iter() + .map(|issue| { + let iid = issue["iid"].as_i64().unwrap_or(0); + let title = issue["title"].as_str().unwrap_or("???"); + let state = issue["state"].as_str().unwrap_or("???"); + let icon = if ultra_compact { + if state == "opened" { "O" } else { "C" } + } else if state == "opened" { + "[open]" } else { - "C" - } - } else if state == "opened" { - "[open]" - } else { - "[closed]" - }; - filtered.push_str(&format!(" {} #{} {}\n", icon, iid, truncate(title, 60))); - } - - if issues.len() > 20 { - filtered.push_str(&format!(" ... {} more\n", issues.len() - 20)); + "[closed]" + }; + format!(" {} #{} {}", icon, iid, truncate(title, 60)) + }) + .collect(); + const MAX_LIST: usize = 20; + for line in all_lines.iter().take(MAX_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "glab-issues", MAX_LIST + 1) { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered @@ -646,13 +651,28 @@ fn format_ci_list(json: &Value, ultra_compact: bool) -> String { let mut filtered = String::new(); filtered.push_str("Pipelines\n"); - for pipeline in pipelines.iter().take(10) { - let id = pipeline["id"].as_i64().unwrap_or(0); - let status = pipeline["status"].as_str().unwrap_or("???"); - let ref_name = pipeline["ref"].as_str().unwrap_or("???"); - - let icon = pipeline_icon(status, ultra_compact); - filtered.push_str(&format!(" {} #{} {} ({})\n", icon, id, status, ref_name)); + let all_lines: Vec = pipelines + .iter() + .map(|pipeline| { + let id = pipeline["id"].as_i64().unwrap_or(0); + let status = pipeline["status"].as_str().unwrap_or("???"); + let ref_name = pipeline["ref"].as_str().unwrap_or("???"); + let icon = pipeline_icon(status, ultra_compact); + format!(" {} #{} {} ({})", icon, id, status, ref_name) + }) + .collect(); + const MAX_CI_LIST: usize = 10; + for line in all_lines.iter().take(MAX_CI_LIST) { + filtered.push_str(&format!("{}\n", line)); + } + if all_lines.len() > MAX_CI_LIST { + filtered.push_str(&format!(" … +{} more\n", all_lines.len() - MAX_CI_LIST)); + let all_text = all_lines.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_text, "glab-pipelines", MAX_CI_LIST + 1) + { + filtered.push_str(&format!(" {}\n", hint)); + } } filtered } diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index 965a7571b8..f70f20945a 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -587,12 +587,17 @@ pub(crate) fn filter_go_build(output: &str) -> String { result.push_str(&format!("Go build: {} errors\n", errors.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, error) in errors.iter().take(20).enumerate() { + const MAX_GO_BUILD_ERRORS: usize = 20; + for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); } - if errors.len() > 20 { - result.push_str(&format!("\n... +{} more errors\n", errors.len() - 20)); + if errors.len() > MAX_GO_BUILD_ERRORS { + result.push_str(&format!("\n… +{} more errors\n", errors.len() - MAX_GO_BUILD_ERRORS)); + let all_errors = errors.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_errors, "go-build", MAX_GO_BUILD_ERRORS + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -674,12 +679,17 @@ fn filter_go_vet(output: &str) -> String { result.push_str(&format!("Go vet: {} issues\n", issues.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, issue) in issues.iter().take(20).enumerate() { + const MAX_GO_VET_ISSUES: usize = 20; + for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { result.push_str(&format!("{}. {}\n", i + 1, truncate(issue, 120))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_GO_VET_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_GO_VET_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_issues, "go-vet", MAX_GO_VET_ISSUES + 1) { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 04ea8a5dbf..78e146f35f 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -282,8 +282,9 @@ fn filter_eslint_json(output: &str) -> String { } // Show top files with most issues, plus the top rules in each + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file_result, count) in by_file.iter().take(10) { + for (file_result, count) in by_file.iter().take(MAX_FILES) { let short_path = compact_path(&file_result.file_path); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -300,8 +301,18 @@ fn filter_eslint_json(output: &str) -> String { } } - if by_file.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", by_file.len() - 10)); + if by_file.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", by_file.len() - MAX_FILES)); + let all_file_lines = by_file + .iter() + .map(|(r, count)| format!("{} ({} issues)", compact_path(&r.file_path), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "eslint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -397,8 +408,9 @@ fn filter_pylint_json(output: &str) -> String { } // Show top files + const MAX_FILES: usize = 10; result.push_str("Top files:\n"); - for (file, count) in file_counts.iter().take(10) { + for (file, count) in file_counts.iter().take(MAX_FILES) { let short_path = compact_path(file); result.push_str(&format!(" {} ({} issues)\n", short_path, count)); @@ -417,8 +429,18 @@ fn filter_pylint_json(output: &str) -> String { } } - if file_counts.len() > 10 { - result.push_str(&format!("\n... +{} more files\n", file_counts.len() - 10)); + if file_counts.len() > MAX_FILES { + result.push_str(&format!("\n… +{} more files\n", file_counts.len() - MAX_FILES)); + let all_file_lines = file_counts + .iter() + .map(|(file, count)| format!("{} ({} issues)", compact_path(file), count)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_file_lines, "pylint-files", MAX_FILES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() @@ -450,12 +472,19 @@ fn filter_generic_lint(output: &str) -> String { result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); result.push_str("═══════════════════════════════════════\n"); - for issue in issues.iter().take(20) { + const MAX_ISSUES: usize = 20; + for issue in issues.iter().take(MAX_ISSUES) { result.push_str(&format!("{}\n", truncate(issue, 100))); } - if issues.len() > 20 { - result.push_str(&format!("\n... +{} more issues\n", issues.len() - 20)); + if issues.len() > MAX_ISSUES { + result.push_str(&format!("\n… +{} more issues\n", issues.len() - MAX_ISSUES)); + let all_issues = issues.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_issues, "lint-issues", MAX_ISSUES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index ce5f98444e..0add005557 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -310,11 +310,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && prod.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --prod | tail -n +{}`", - prod.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", prod.len() - MAX_LISTING)); + let all_prod = prod + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_prod, "pnpm-prod", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -325,11 +331,17 @@ fn format_dependency_listing(state: &DependencyState, cap: bool) -> String { lines.push(format!(" {} {}", dep.name, dep.current_version)); } if cap && dev.len() > MAX_LISTING { - lines.push(format!( - " … +{} more — run `pnpm list --dev | tail -n +{}`", - dev.len() - MAX_LISTING, - MAX_LISTING + 3 - )); + lines.push(format!(" … +{} more", dev.len() - MAX_LISTING)); + let all_dev = dev + .iter() + .map(|dep| format!(" {} {}", dep.name, dep.current_version)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_dev, "pnpm-dev", MAX_LISTING + 1) + { + lines.push(format!(" {}", hint)); + } } } @@ -628,13 +640,8 @@ mod tests { let prod_count = 60usize; assert!( out.contains(&format!("… +{} more", prod_count - MAX_LISTING)), - "truncation count missing" - ); - assert!( - out.contains(&format!("tail -n +{}", MAX_LISTING + 3)), - "tail offset missing: got\n{out}" + "truncation count missing: got\n{out}" ); - assert!(out.contains("pnpm list --prod"), "hint command missing"); } #[test] diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index cda1768f2a..0bd5e9aa61 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -272,6 +272,10 @@ fn build_pytest_summary( if failures.len() > 5 { result.push_str(&format!("\n… +{} more failures\n", failures.len() - 5)); + let all_failures = failures.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_failures, "pytest-failures") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/cmds/ruby/rubocop_cmd.rs b/src/cmds/ruby/rubocop_cmd.rs index 5ad541ca76..bed3153f7f 100644 --- a/src/cmds/ruby/rubocop_cmd.rs +++ b/src/cmds/ruby/rubocop_cmd.rs @@ -184,7 +184,7 @@ fn filter_rubocop_json(output: &str) -> String { } if sorted_offenses.len() > max_offenses_per_file { result.push_str(&format!( - " ... +{} more\n", + " … +{} more\n", sorted_offenses.len() - max_offenses_per_file )); } @@ -192,9 +192,19 @@ fn filter_rubocop_json(output: &str) -> String { if files_with_offenses.len() > max_files { result.push_str(&format!( - "\n... +{} more files\n", + "\n… +{} more files\n", files_with_offenses.len() - max_files )); + let all_files = files_with_offenses + .iter() + .map(|f| compact_ruby_path(&f.path)) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_files, "rubocop-files", max_files + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } if correctable_count > 0 { @@ -530,7 +540,7 @@ mod tests { let result = filter_rubocop_json(json); assert!(result.contains(":5 Cop/E"), "should show 5th offense"); assert!(!result.contains(":6 Cop/F"), "should not show 6th inline"); - assert!(result.contains("+2 more"), "should show overflow"); + assert!(result.contains("… +2 more"), "should show overflow"); } #[test] @@ -620,7 +630,7 @@ mod tests { ); let result = filter_rubocop_json(&json); assert!( - result.contains("+2 more files"), + result.contains("… +2 more files"), "should show +2 more files overflow: {}", result ); diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 04f3f6324e..f1e75f1729 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -536,7 +536,12 @@ fn filter_cargo_install(output: &str) -> String { } if errors.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", errors.len() - 15)); + let all_errors = errors.join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } return result.trim().to_string(); @@ -835,7 +840,15 @@ fn filter_cargo_build(output: &str) -> String { } } if blocks.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", blocks.len() - 15)); + result.push_str(&format!("\n… +{} more issues\n", blocks.len() - 15)); + let all_blocks: String = blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&all_blocks, "cargo-check-issues") { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() } @@ -1028,11 +1041,18 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); result.push_str("═══════════════════════════════════════\n"); - for (i, failure) in failures.iter().enumerate().take(10) { + const MAX_FAILURES: usize = 10; + for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); } - if failures.len() > 10 { - result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + if failures.len() > MAX_FAILURES { + result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); + let all_failures = failures.join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.push('\n'); } @@ -1189,7 +1209,17 @@ fn filter_cargo_clippy(output: &str) -> String { result.push('\n'); } if error_blocks.len() > 10 { - result.push_str(&format!(" ... +{} more errors\n", error_blocks.len() - 10)); + result.push_str(&format!(" … +{} more errors\n", error_blocks.len() - 10)); + let all_blocks: String = error_blocks + .iter() + .map(|b| b.join("\n")) + .collect::>() + .join("\n\n"); + if let Some(hint) = + crate::core::tee::force_tee_hint(&all_blocks, "cargo-clippy-errors") + { + result.push_str(&format!(" {}\n", hint)); + } } } @@ -1197,18 +1227,29 @@ fn filter_cargo_clippy(output: &str) -> String { let mut rule_counts: Vec<_> = by_rule.iter().collect(); rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len())); - for (rule, locations) in rule_counts.iter().take(15) { + const MAX_RULES: usize = 15; + for (rule, locations) in rule_counts.iter().take(MAX_RULES) { result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); for loc in locations.iter().take(3) { result.push_str(&format!(" {}\n", loc)); } if locations.len() > 3 { - result.push_str(&format!(" ... +{} more\n", locations.len() - 3)); + result.push_str(&format!(" … +{} more\n", locations.len() - 3)); } } - if by_rule.len() > 15 { - result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15)); + if by_rule.len() > MAX_RULES { + result.push_str(&format!("\n… +{} more rules\n", by_rule.len() - MAX_RULES)); + let all_rules = rule_counts + .iter() + .map(|(rule, locs)| format!("{} ({}x)", rule, locs.len())) + .collect::>() + .join("\n"); + if let Some(hint) = + crate::core::tee::force_tee_tail_hint(&all_rules, "cargo-clippy-rules", MAX_RULES + 1) + { + result.push_str(&format!(" {}\n", hint)); + } } result.trim().to_string() diff --git a/src/core/utils.rs b/src/core/utils.rs index a73eba9bcf..a3bc84fe00 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -147,7 +147,7 @@ pub fn format_cpt(cpt: f64) -> String { pub fn join_with_overflow(items: &[String], total: usize, max: usize, label: &str) -> String { let mut out = items.join("\n"); if total > max { - out.push_str(&format!("\n... +{} more {}", total - max, label)); + out.push_str(&format!("\n… +{} more {}", total - max, label)); } out } From 49606303d6738525c250149230752fb6133383d1 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 12:48:17 +0200 Subject: [PATCH 2/3] fix(rust): multi-line blocks used with tail hint --- src/cmds/rust/cargo_cmd.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index f1e75f1729..eee916d2a5 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -1047,9 +1047,9 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { } if failures.len() > MAX_FAILURES { result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES)); - let all_failures = failures.join("\n"); + let all_failures = failures.join("\n\n"); if let Some(hint) = - crate::core::tee::force_tee_tail_hint(&all_failures, "cargo-test-failures", MAX_FAILURES + 1) + crate::core::tee::force_tee_hint(&all_failures, "cargo-test-failures") { result.push_str(&format!(" {}\n", hint)); } From 4acdcf29f73400c67045a7e7c615a40c4193b69b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Sun, 17 May 2026 14:18:06 +0200 Subject: [PATCH 3/3] docs(cmds): truncations hints and recovery guidelines --- CONTRIBUTING.md | 3 ++- src/cmds/README.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cd87369b3..19c0c79aa5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ Don't invent new output formats. Don't add RTK-specific headers or markers in th If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified. -Every filter needs a fallback path. Every hook must handle malformed input gracefully. +Every filter needs a fallback path. Every hook must handle malformed input gracefully. Truncation follows the same rule: capping output at N items is only acceptable if accompanied by a hint that lets the agent recover the hidden data. ### Zero Overhead @@ -262,6 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test - [ ] Unit tests added/updated for changed code - [ ] Snapshot tests reviewed (`cargo insta review`) - [ ] Token savings >=60% verified +- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`) - [ ] Edge cases covered - [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes - [ ] Manual test: run `rtk ` and inspect output diff --git a/src/cmds/README.md b/src/cmds/README.md index 010e9495a0..c26da7da93 100644 --- a/src/cmds/README.md +++ b/src/cmds/README.md @@ -252,6 +252,21 @@ When filtering fails, fall back to raw output and warn on stderr. Never block th Modules that parse structured output (JSON, NDJSON, state machines) must call `tee::tee_and_hint()` so users can recover full output on failure. +### Internal Truncation Recovery + +When a filter caps a list at N items (e.g. `take(20)`), the remaining items must be accessible via a tee hint. **Never show `"… +N more"` without a recovery path** — the agent has no way to retrieve the hidden content. + +**Choosing the right hint:** + +| Content type | Function | Condition | +|---|---|---| +| Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string | +| Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless | + +**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`. + +**The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec` of formatted lines and use it for both the display loop and the tee. + ### Stderr Handling Modules must capture stderr and include it in the raw string passed to `timer.track()`, so token savings reflect total output. @@ -278,6 +293,7 @@ Adding a new filter or command requires changes in multiple places. For TOML-vs- - Use `RunOptions::default()` when filtering combined text output - Add `.tee("label")` when the filter parses structured output (enables raw output recovery on failure) - **Exit codes**: handled automatically by `run_filtered()` — just return its result + - **Truncation**: if the filter caps any list at N items, emit `force_tee_tail_hint` (flat lists) or `force_tee_hint` (multi-line blocks) so the agent can recover hidden items — see [Internal Truncation Recovery](#internal-truncation-recovery). Use a named constant for the cap; derive the offset from it (`MAX_XXX + 1`) 2. **Register module**: - Ecosystem `mod.rs` files use `automod::dir!()` — any `.rs` file in the directory becomes a public module automatically. No manual `pub mod` needed, but be aware: WIP or helper files will also be exposed. Only commit command-ready modules. - Add variant to `Commands` enum in `main.rs` with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]`