diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 35a56da525..5ecbc74896 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -114,6 +114,11 @@ where if arg.contains('/') || arg.contains('\\') { return path_exists(arg); } + // Filename with extension (README.md) - treat as path if it exists. + // This is a safe middle-ground between "bare word" (main) and a ref. + if arg.contains('.') { + return path_exists(arg); + } // Bare word (no separator, no special prefix) — never inject `--` // This avoids misidentifying a ref/branch as a path even if a same-named // file happens to exist on disk. @@ -1956,6 +1961,17 @@ mod tests { assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); } + /// Baseline: `--` already present with multiple pathspecs → no-op, args unchanged. + #[test] + fn test_normalize_diff_args_noop_when_separator_present_multiple_paths() { + let args = vec![ + "--".to_string(), + "README.md".to_string(), + "src/cmds/system/README.md".to_string(), + ]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + /// Core regression (issue #1215): clap ate `--` before a real file path. /// When the path exists on disk, `--` must be re-inserted. #[test] @@ -1990,6 +2006,13 @@ mod tests { ); } + /// Ref with explicit separator before a filename-with-extension → no-op, args unchanged. + #[test] + fn test_normalize_diff_args_noop_ref_then_separator_then_filename() { + let args = vec!["HEAD".to_string(), "--".to_string(), "README.md".to_string()]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + /// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists. #[test] fn test_normalize_diff_args_reinserts_separator_after_flag() { @@ -2005,6 +2028,20 @@ mod tests { ); } + /// Flag then filename-with-extension pathspec → inject separator after flag. + #[test] + fn test_normalize_diff_args_inject_after_flag_for_filename_with_extension() { + let args = vec!["--name-only".to_string(), "README.md".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["README.md"])), + vec![ + "--name-only".to_string(), + "--".to_string(), + "README.md".to_string() + ] + ); + } + /// Pure flags (no paths) → no injection. #[test] fn test_normalize_diff_args_no_injection_for_pure_flags() { @@ -2064,6 +2101,36 @@ mod tests { ); } + /// Filename with extension that exists on disk → inject `--`. + #[test] + fn test_normalize_diff_args_inject_for_filename_with_extension() { + let args = vec!["README.md".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["README.md"])), + vec!["--".to_string(), "README.md".to_string()] + ); + } + + /// Multiple existing paths (including a filename-with-extension) → inject once before first path. + #[test] + fn test_normalize_diff_args_inject_for_multiple_existing_paths() { + let args = vec![ + "README.md".to_string(), + "src/cmds/system/README.md".to_string(), + ]; + assert_eq!( + normalize_diff_args_impl( + &args, + exists_mock(&["README.md", "src/cmds/system/README.md"]), + ), + vec![ + "--".to_string(), + "README.md".to_string(), + "src/cmds/system/README.md".to_string() + ] + ); + } + #[test] fn test_is_blob_show_arg() { assert!(is_blob_show_arg("develop:modules/pairs_backtest.py")); diff --git a/src/cmds/system/ctest_cmd.rs b/src/cmds/system/ctest_cmd.rs new file mode 100644 index 0000000000..fcfe9d7e36 --- /dev/null +++ b/src/cmds/system/ctest_cmd.rs @@ -0,0 +1,384 @@ +//! Filters ctest output to keep GoogleTest failures and final summaries. + +use crate::core::runner; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("ctest"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: ctest {}", args.join(" ")); + } + + runner::run_filtered( + cmd, + "ctest", + &args.join(" "), + filter_ctest_output, + runner::RunOptions::default().tee("ctest"), + ) +} + +pub(crate) fn filter_ctest_output(output: &str) -> String { + let mut out: Vec = Vec::new(); + let mut current_test: Option = None; + let mut emitting_failure_block = false; + let mut emitted_test_header_for_block = false; + + for line in output.lines() { + let trimmed = line.trim_end(); + let t = trimmed.trim(); + let m = normalize_ctest_verbose_prefix(t); + + if t.is_empty() { + // Keep spacing inside a failure block (assert details often rely on it), + // but avoid extra blank lines otherwise. + if emitting_failure_block { + out.push(String::new()); + } + continue; + } + + // GoogleTest markers + if let Some(name) = parse_gtest_run(m) { + current_test = Some(name); + emitting_failure_block = false; + emitted_test_header_for_block = false; + continue; // drop passing run spam; failures will re-introduce name on first Failure line + } + + if is_gtest_ok(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + current_test = None; + continue; + } + + // Start/continue a failure block when we see ": Failure" (GoogleTest failure header). + if is_gtest_failure_header(m) { + if !emitted_test_header_for_block { + if let Some(name) = current_test.as_deref() { + out.push(name.to_string()); + } + emitted_test_header_for_block = true; + } + emitting_failure_block = true; + out.push(trimmed.to_string()); + continue; + } + + // Preserve gtest failed summary/listing blocks. + if is_gtest_failed_summary_line(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + // Preserve ctest summaries. + if is_ctest_summary_line(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + // Preserve important non-gtest crash/error lines. + if is_important_non_gtest_line(m) { + out.push(trimmed.to_string()); + continue; + } + + // Keep assertion details / stack traces while inside a failure block. + if emitting_failure_block { + // Stop the block when we reach the gtest failure terminator for the test. + if is_gtest_failed_test_line(m) { + out.push(trimmed.to_string()); + emitting_failure_block = false; + emitted_test_header_for_block = false; + current_test = None; + continue; + } + + // Drop noisy separators even inside failure blocks. + if is_noisy_separator(m) { + continue; + } + + out.push(trimmed.to_string()); + } + } + + compact_lines(out).join("\n") +} + +fn normalize_ctest_verbose_prefix(line: &str) -> &str { + // ctest -V commonly prefixes child output lines as: "1: " + let s = line.trim_start(); + let mut i = 0; + for (idx, ch) in s.char_indices() { + if ch.is_ascii_digit() { + i = idx + ch.len_utf8(); + continue; + } + i = idx; + break; + } + if i == 0 { + return line; + } + let rest = &s[i..]; + if !rest.starts_with(':') { + return line; + } + let rest = &rest[1..]; + let rest = rest.strip_prefix(' ').unwrap_or(rest); + if rest.is_empty() { + line + } else { + rest + } +} + +fn parse_gtest_run(line: &str) -> Option { + // Example: "[ RUN ] FooTest/0.DoesThing" + // Example: "[ RUN ] FooSuite/BarTest.DoesThing/1" + if !line.starts_with("[ RUN") { + return None; + } + let idx = line.find("]")?; + let rest = line[idx + 1..].trim(); + if rest.is_empty() { + None + } else { + Some(rest.to_string()) + } +} + +fn is_gtest_ok(line: &str) -> bool { + line.starts_with("[ OK ]") +} + +fn is_gtest_failure_header(line: &str) -> bool { + // Typical: "/path/foo_test.cc:42: Failure" + line.ends_with(": Failure") +} + +fn is_gtest_failed_test_line(line: &str) -> bool { + // Typical: "[ FAILED ] Foo.Fail (0 ms)" + line.starts_with("[ FAILED ]") && line.contains('(') +} + +fn is_gtest_failed_summary_line(line: &str) -> bool { + // Summary/listing: + // "[ FAILED ] 1 test, listed below:" + // "[ FAILED ] Foo.Fail" + // "[ PASSED ] 2 tests." + if line.starts_with("[ FAILED ]") { + return true; + } + if line.starts_with("[ PASSED ]") && line.contains("tests") { + return true; + } + false +} + +fn is_ctest_summary_line(line: &str) -> bool { + let l = line.to_lowercase(); + l.contains("tests failed") + || l.contains("% tests passed") + || l.contains("the following tests failed") + || l.contains("errors while running ctest") + || l.contains("ctest error") +} + +fn is_important_non_gtest_line(line: &str) -> bool { + let l = line.to_lowercase(); + l.contains("addresssanitizer") + || l.contains("runtime error") + || l.contains("undefined behavior") + || l.contains("segmentation fault") + || l.contains("abort") + || l.starts_with("error:") + || l.contains("fatal error") + || l.contains("cmake error") + || l.contains("clang: error") + || l.contains("gcc: error") + || l.contains("ld: error") +} + +fn is_noisy_separator(line: &str) -> bool { + line.chars().all(|c| c == '=' || c == '-' || c == '─' || c == '═') +} + +fn compact_lines(mut lines: Vec) -> Vec { + // Trim leading/trailing empties, collapse multiple blank lines. + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + + let mut out: Vec = Vec::with_capacity(lines.len()); + let mut last_blank = false; + for l in lines.drain(..) { + let blank = l.trim().is_empty(); + if blank { + if last_blank { + continue; + } + last_blank = true; + out.push(String::new()); + } else { + last_blank = false; + out.push(l); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::filter_ctest_output; + use super::normalize_ctest_verbose_prefix; + + #[test] + fn gtest_all_passed_compacts() { + let input = r#" +[==========] Running 2 tests from 1 test suite. +[ RUN ] Foo.Pass +[ OK ] Foo.Pass (1 ms) +[ RUN ] Foo.Pass2 +[ OK ] Foo.Pass2 (1 ms) +[==========] 2 tests from 1 test suite ran. (2 ms total) +[ PASSED ] 2 tests. +"#; + let out = filter_ctest_output(input); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("[ RUN")); + assert!(out.contains("[ PASSED ] 2 tests.")); + } + + #[test] + fn gtest_one_failed_keeps_block_and_summary() { + let input = r#" +[==========] Running 3 tests from 1 test suite. +[ RUN ] Foo.Pass +[ OK ] Foo.Pass (1 ms) +[ RUN ] Foo.Fail +/path/foo_test.cc:42: Failure +Expected equality of these values: + actual + expected +[ FAILED ] Foo.Fail (0 ms) +[ RUN ] Foo.Pass2 +[ OK ] Foo.Pass2 (1 ms) +[==========] 3 tests from 1 test suite ran. +[ PASSED ] 2 tests. +[ FAILED ] 1 test, listed below: +[ FAILED ] Foo.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("Foo.Fail")); + assert!(out.contains("/path/foo_test.cc:42: Failure")); + assert!(out.contains("Expected equality of these values:")); + assert!(out.contains("[ FAILED ] 1 test, listed below:")); + assert!(out.contains("[ FAILED ] Foo.Fail")); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("Foo.Pass2")); + } + + #[test] + fn gtest_multiple_failed_keeps_both_blocks() { + let input = r#" +[ RUN ] A.Fail +/p/a.cc:1: Failure +boom +[ FAILED ] A.Fail (0 ms) +[ RUN ] B.Fail +/p/b.cc:2: Failure +kaboom +[ FAILED ] B.Fail (0 ms) +[ FAILED ] 2 tests, listed below: +[ FAILED ] A.Fail +[ FAILED ] B.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("A.Fail")); + assert!(out.contains("/p/a.cc:1: Failure")); + assert!(out.contains("B.Fail")); + assert!(out.contains("/p/b.cc:2: Failure")); + assert!(out.contains("[ FAILED ] A.Fail")); + assert!(out.contains("[ FAILED ] B.Fail")); + } + + #[test] + fn gtest_parameterized_names_survive() { + let input = r#" +[ RUN ] FooTest/0.DoesThing +/p/t.cc:3: Failure +nope +[ FAILED ] FooTest/0.DoesThing (0 ms) +[ RUN ] FooSuite/BarTest.DoesThing/1 +/p/u.cc:4: Failure +nope2 +[ FAILED ] FooSuite/BarTest.DoesThing/1 (0 ms) +[ FAILED ] 2 tests, listed below: +[ FAILED ] FooTest/0.DoesThing +[ FAILED ] FooSuite/BarTest.DoesThing/1 +"#; + let out = filter_ctest_output(input); + assert!(out.contains("FooTest/0.DoesThing")); + assert!(out.contains("FooSuite/BarTest.DoesThing/1")); + } + + #[test] + fn keeps_non_gtest_important_lines() { + let input = r#" +[ RUN ] Foo.Fail +/p/t.cc:3: Failure +nope +AddressSanitizer: heap-use-after-free +[ FAILED ] Foo.Fail (0 ms) +"#; + let out = filter_ctest_output(input); + assert!(out.contains("AddressSanitizer: heap-use-after-free")); + assert!(out.contains("/p/t.cc:3: Failure")); + } + + #[test] + fn ctest_verbose_prefix_gtest_failure_survives_and_pass_noise_drops() { + let input = r#" +1: [ RUN ] Foo.Pass +1: [ OK ] Foo.Pass (1 ms) +1: [ RUN ] Foo.Fail +1: /path/foo_test.cc:42: Failure +1: Expected equality of these values: +1: actual +1: expected +1: [ FAILED ] Foo.Fail (0 ms) +1: [ RUN ] Foo.Pass2 +1: [ OK ] Foo.Pass2 (1 ms) +1: [ FAILED ] 1 test, listed below: +1: [ FAILED ] Foo.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("Foo.Fail")); + assert!(out.contains("1: /path/foo_test.cc:42: Failure")); + assert!(out.contains("1: Expected equality of these values:")); + assert!(out.contains("1: [ FAILED ] Foo.Fail")); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("Foo.Pass2")); + } + + #[test] + fn ctest_verbose_prefix_normalizer_requires_colon() { + assert_eq!(normalize_ctest_verbose_prefix("123 tests failed"), "123 tests failed"); + assert_eq!(normalize_ctest_verbose_prefix("404 error happened"), "404 error happened"); + } +} diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index fd9942d0b1..4bee761e0e 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -140,7 +140,7 @@ fn analyze_logs(content: &str) -> String { .map(|s| s.as_str()) .unwrap_or(normalized); - let truncated = if original.len() > 100 { + let truncated = if original.chars().count() > 100 { let t: String = original.chars().take(97).collect(); format!("{}...", t) } else { @@ -180,7 +180,7 @@ fn analyze_logs(content: &str) -> String { .map(|s| s.as_str()) .unwrap_or(normalized); - let truncated = if original.len() > 100 { + let truncated = if original.chars().count() > 100 { let t: String = original.chars().take(97).collect(); format!("{}...", t) } else { @@ -251,4 +251,23 @@ mod tests { // Should not panic even with very long multi-byte messages assert!(result.contains("ERRORS")); } + + #[test] + fn test_analyze_logs_does_not_truncate_when_under_char_limit_but_over_byte_limit() { + let msg = "界".repeat(70); // keep total line <= 100 chars, but >100 bytes + let line = format!("2024-01-01 10:00:00 ERROR: {msg}"); + let logs = format!("{line}\n"); + let result = analyze_logs(&logs); + assert!(result.contains(&line)); + } + + #[test] + fn test_analyze_logs_truncates_when_over_char_limit() { + let msg = "a".repeat(101); + let line = format!("2024-01-01 10:00:00 ERROR: {msg}"); + let logs = format!("{line}\n"); + let result = analyze_logs(&logs); + let expected_prefix: String = line.chars().take(97).collect(); + assert!(result.contains(&format!("{expected_prefix}..."))); + } } diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index fe569a597d..89786e90ea 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -7,6 +7,7 @@ pub fn resolve_filter(name: &str) -> Option String> { match name { "cargo-test" | "cargo" => Some(crate::cmds::rust::cargo_cmd::filter_cargo_test), "pytest" => Some(crate::cmds::python::pytest_cmd::filter_pytest_output), + "ctest" => Some(crate::cmds::system::ctest_cmd::filter_ctest_output), "go-test" => Some(go_test_wrapper), "go-build" => Some(crate::cmds::go::go_cmd::filter_go_build), "tsc" => Some(crate::cmds::js::tsc_cmd::filter_tsc_output), diff --git a/src/main.rs b/src/main.rs index ccb096ef01..01a531e841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,8 +19,8 @@ use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ - deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, - read, summary, tree, wc_cmd, + ctest_cmd, deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, + pipe_cmd, read, summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -665,6 +665,13 @@ enum Commands { args: Vec, }, + /// CTest runner with compact GoogleTest failure output + Ctest { + /// CTest arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Mypy type checker with grouped error output Mypy { /// Mypy arguments @@ -2063,6 +2070,8 @@ fn run_cli() -> Result { Commands::Pytest { args } => pytest_cmd::run(&args, cli.verbose)?, + Commands::Ctest { args } => ctest_cmd::run(&args, cli.verbose)?, + Commands::Mypy { args } => mypy_cmd::run(&args, cli.verbose)?, Commands::Rake { args } => rake_cmd::run(&args, cli.verbose)?,