diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73a..15cae684b 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -559,24 +559,27 @@ pub(crate) fn filter_log_output( if user_format { let lines: Vec<&str> = output.lines().collect(); let max_lines = if user_set_limit { lines.len() } else { limit }; - return lines + let mut result = lines .iter() .take(max_lines) .map(|l| truncate_line(l, truncate_width)) - .collect::>() - .join("\n"); + .collect::>(); + if !user_set_limit && lines.len() >= max_lines && !result.is_empty() { + result.push(log_limit_marker(max_lines)); + } + return result.join("\n"); } // RTK injected format: split output into commit blocks separated by ---END--- - let commits: Vec<&str> = output.split("---END---").collect(); + let commits: Vec<&str> = output + .split("---END---") + .map(str::trim) + .filter(|block| !block.is_empty()) + .collect(); let max_commits = if user_set_limit { commits.len() } else { limit }; let mut result = Vec::new(); for block in commits.iter().take(max_commits) { - let block = block.trim(); - if block.is_empty() { - continue; - } let mut lines = block.lines(); // First line is the header: hash subject (date) let header = match lines.next() { @@ -609,9 +612,20 @@ pub(crate) fn filter_log_output( } } + if !user_set_limit && commits.len() >= max_commits && !result.is_empty() { + result.push(log_limit_marker(max_commits)); + } + result.join("\n").trim().to_string() } +fn log_limit_marker(limit: usize) -> String { + format!( + "[limited by RTK: showing up to {} commits; use `rtk git log -n ` or `rtk proxy git log` for full output]", + limit + ) +} + /// Truncate a single line to `width` characters, appending "..." if needed fn truncate_line(line: &str, width: usize) -> String { if line.chars().count() > width { @@ -2221,7 +2235,29 @@ A added.rs .collect::>() .join("\n"); let result = filter_log_output(&output, 5, false, false); - assert_eq!(result.lines().count(), 5); + assert_eq!(result.lines().filter(|l| l.starts_with("hash")).count(), 5); + assert!( + result.contains("[limited by RTK: showing up to 5 commits"), + "default git log cap must be visible, got:\n{result}" + ); + assert!( + result.contains("rtk git log -n "), + "limit marker must include a recovery hint, got:\n{result}" + ); + } + + #[test] + fn test_filter_log_output_user_format_cap_marks_limit() { + let output = (0..20) + .map(|i| format!("hash{} message {}", i, i)) + .collect::>() + .join("\n"); + let result = filter_log_output(&output, 5, false, true); + assert_eq!(result.lines().filter(|l| l.starts_with("hash")).count(), 5); + assert!( + result.contains("[limited by RTK: showing up to 5 commits"), + "user-format git log cap must be visible, got:\n{result}" + ); } #[test] @@ -2461,7 +2497,14 @@ no changes added to commit (use "git add" and/or "git commit -a") // user_set_limit=false means cap at limit let result = filter_log_output(oneline_output, 3, false, true); - assert_eq!(result.lines().count(), 3); + assert_eq!( + result + .lines() + .filter(|l| l.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())) + .count(), + 3 + ); + assert!(result.contains("[limited by RTK: showing up to 3 commits")); } /// Regression test: `git branch ` must create, not list. diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 865b9b8b0..8c797ffd3 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -1,10 +1,10 @@ //! Filters golangci-lint output, grouping issues by rule. -use crate::core::config; use crate::core::runner; use crate::core::stream::exec_capture; use crate::core::truncate::CAP_WARNINGS; -use crate::core::utils::{resolved_command, truncate}; +use crate::core::utils::resolved_command; +use crate::parser::truncate_passthrough; use anyhow::Result; use serde::Deserialize; use std::collections::HashMap; @@ -271,7 +271,7 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { return format!( "golangci-lint (JSON parse failed: {})\n{}", e, - truncate(output, config::limits().passthrough_max_chars) + truncate_passthrough(output) ); } }; @@ -431,6 +431,18 @@ mod tests { assert!(result.contains("utils.go")); } + #[test] + fn test_filter_golangci_parse_failure_marks_truncation() { + let output = format!( + "not-json\n{}", + "x".repeat(crate::core::config::limits().passthrough_max_chars + 16) + ); + let result = filter_golangci_json(&output, 1); + + assert!(result.contains("JSON parse failed")); + assert!(result.contains("[RTK:PASSTHROUGH] Output truncated")); + } + #[test] fn test_compact_path() { assert_eq!( diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index afc3e7962..8ff43b33f 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -1,11 +1,11 @@ //! Filters ESLint and Biome linter output, grouping violations by rule. -use crate::core::config; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::{CAP_ERRORS, CAP_WARNINGS}; use crate::core::utils::{package_manager_exec, resolved_command, truncate}; use crate::mypy_cmd; +use crate::parser::truncate_passthrough; use crate::ruff_cmd; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -230,7 +230,7 @@ fn filter_eslint_json(output: &str) -> String { return format!( "ESLint output (JSON parse failed: {})\n{}", e, - truncate(output, config::limits().passthrough_max_chars) + truncate_passthrough(output) ); } }; @@ -329,7 +329,7 @@ fn filter_pylint_json(output: &str) -> String { return format!( "Pylint output (JSON parse failed: {})\n{}", e, - truncate(output, config::limits().passthrough_max_chars) + truncate_passthrough(output) ); } }; @@ -555,6 +555,18 @@ mod tests { assert!(result.contains("src/utils.ts")); } + #[test] + fn test_filter_eslint_json_parse_failure_marks_truncation() { + let output = format!( + "not-json\n{}", + "x".repeat(crate::core::config::limits().passthrough_max_chars + 16) + ); + let result = filter_eslint_json(&output); + + assert!(result.contains("JSON parse failed")); + assert!(result.contains("[RTK:PASSTHROUGH] Output truncated")); + } + #[test] fn test_compact_path() { assert_eq!( @@ -624,6 +636,18 @@ mod tests { assert!(result.contains("utils.py")); } + #[test] + fn test_filter_pylint_json_parse_failure_marks_truncation() { + let output = format!( + "not-json\n{}", + "x".repeat(crate::core::config::limits().passthrough_max_chars + 16) + ); + let result = filter_pylint_json(&output); + + assert!(result.contains("JSON parse failed")); + assert!(result.contains("[RTK:PASSTHROUGH] Output truncated")); + } + #[test] fn test_strip_pm_prefix_npx() { let args: Vec = vec!["npx".into(), "eslint".into(), "src/".into()]; diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index f8a512035..97239b7b6 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -1,9 +1,9 @@ //! Filters Ruff linter and formatter output. -use crate::core::config; use crate::core::runner; use crate::core::truncate::CAP_WARNINGS; use crate::core::utils::{resolved_command, truncate}; +use crate::parser::truncate_passthrough; use anyhow::Result; use serde::Deserialize; use std::collections::HashMap; @@ -101,7 +101,7 @@ pub fn filter_ruff_check_json(output: &str) -> String { return format!( "Ruff check (JSON parse failed: {})\n{}", e, - truncate(output, config::limits().passthrough_max_chars) + truncate_passthrough(output) ); } }; @@ -387,6 +387,18 @@ mod tests { assert!(result.contains("1:8"), "line:col location missing"); } + #[test] + fn test_filter_ruff_check_parse_failure_marks_truncation() { + let output = format!( + "not-json\n{}", + "x".repeat(crate::core::config::limits().passthrough_max_chars + 16) + ); + let result = filter_ruff_check_json(&output); + + assert!(result.contains("JSON parse failed")); + assert!(result.contains("[RTK:PASSTHROUGH] Output truncated")); + } + #[test] fn test_filter_ruff_format_all_formatted() { let output = "5 files left unchanged";