From ff105991ca348669d2db98c8f3fa6da0b6116621 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 10:34:42 +0100 Subject: [PATCH 01/11] Basic dotnet implementation --- Cargo.lock | 42 +++ Cargo.toml | 1 + README.md | 10 +- src/binlog.rs | 799 ++++++++++++++++++++++++++++++++++++++++++++++ src/dotnet_cmd.rs | 495 ++++++++++++++++++++++++++++ src/main.rs | 48 +++ 6 files changed, 1394 insertions(+), 1 deletion(-) create mode 100644 src/binlog.rs create mode 100644 src/dotnet_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 4b22c38..39d0382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -207,6 +213,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -293,6 +308,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -482,6 +507,16 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -588,6 +623,7 @@ dependencies = [ "clap", "colored", "dirs", + "flate2", "ignore", "lazy_static", "regex", @@ -701,6 +737,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index f333d7c..0432a4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ toml = "0.8" chrono = "0.4" thiserror = "1.0" tempfile = "3" +flate2 = "1.0" [dev-dependencies] diff --git a/README.md b/README.md index 19a2250..da1050b 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ rtk pytest # Python tests (failures only, 90% reduction) rtk pip list # Python packages (auto-detect uv, 70% reduction) rtk go test # Go tests (NDJSON, 90% reduction) rtk golangci-lint run # Go linting (JSON, 85% reduction) +rtk dotnet build # .NET build summary with binlog +rtk dotnet test # .NET test failures only +rtk dotnet restore # .NET restore summary ``` ### Data & Analytics @@ -256,7 +259,7 @@ rtk prisma migrate dev --name x # Migration summary rtk prisma db-push # Schema push summary ``` -### Python & Go Stack +### Python, Go & .NET Stack ```bash # Python rtk ruff check # Ruff linter (JSON, 80% reduction) @@ -271,6 +274,11 @@ rtk go test # NDJSON streaming parser (90% reduction) rtk go build # Build errors only (80% reduction) rtk go vet # Vet issues (75% reduction) rtk golangci-lint run # JSON grouped by rule (85% reduction) + +# .NET +rtk dotnet build # Build errors/warnings summary with binlog +rtk dotnet test # Failed tests only with compact details +rtk dotnet restore # Restore project/package summary ``` ## Examples diff --git a/src/binlog.rs b/src/binlog.rs new file mode 100644 index 0000000..e292b1f --- /dev/null +++ b/src/binlog.rs @@ -0,0 +1,799 @@ +use anyhow::{Context, Result}; +use flate2::read::{DeflateDecoder, GzDecoder}; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; +use std::io::Read; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BinlogIssue { + pub code: String, + pub file: String, + pub line: u32, + pub column: u32, + pub message: String, +} + +#[derive(Debug, Clone, Default)] +pub struct BuildSummary { + pub succeeded: bool, + pub project_count: usize, + pub errors: Vec, + pub warnings: Vec, + pub duration_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FailedTest { + pub name: String, + pub details: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct TestSummary { + pub passed: usize, + pub failed: usize, + pub skipped: usize, + pub total: usize, + pub project_count: usize, + pub failed_tests: Vec, + pub duration_text: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct RestoreSummary { + pub restored_projects: usize, + pub warnings: usize, + pub errors: usize, + pub duration_text: Option, +} + +lazy_static! { + static ref ISSUE_RE: Regex = Regex::new( + r"(?m)^\s*(?P[^\r\n:(]+)\((?P\d+),(?P\d+)\):\s*(?Perror|warning)\s*(?P[A-Za-z]+\d+):\s*(?P.+)$" + ) + .expect("valid regex"); + static ref BUILD_SUMMARY_RE: Regex = Regex::new(r"(?m)^\s*(?P\d+)\s+(?PWarning|Error)\(s\)") + .expect("valid regex"); + static ref DURATION_RE: Regex = + Regex::new(r"(?m)^\s*Time Elapsed\s+(?P[^\r\n]+)$").expect("valid regex"); + static ref TEST_RESULT_RE: Regex = Regex::new( + r"(?m)(?:Passed!|Failed!)\s*-\s*Failed:\s*(?P\d+),\s*Passed:\s*(?P\d+),\s*Skipped:\s*(?P\d+),\s*Total:\s*(?P\d+),\s*Duration:\s*(?P[^\r\n-]+)" + ) + .expect("valid regex"); + static ref FAILED_TEST_HEAD_RE: Regex = + Regex::new(r"(?m)^\s*Failed\s+(?P[^\r\n\[]+)").expect("valid regex"); + static ref RESTORE_PROJECT_RE: Regex = + Regex::new(r"(?m)^\s*Restored\s+.+\.csproj\s*\(").expect("valid regex"); + static ref WARNING_COUNT_RE: Regex = Regex::new(r"(?m)^\s*warning\s+").expect("valid regex"); + static ref ERROR_COUNT_RE: Regex = Regex::new(r"(?m)^\s*error\s+").expect("valid regex"); + static ref PROJECT_PATH_RE: Regex = + Regex::new(r"(?m)^\s*([A-Za-z]:)?[^\r\n]*\.csproj(?:\s|$)").expect("valid regex"); + static ref PRINTABLE_RUN_RE: Regex = Regex::new(r"[\x20-\x7E]{5,}").expect("valid regex"); + static ref DIAGNOSTIC_CODE_RE: Regex = + Regex::new(r"^[A-Za-z]{2,}\d{3,}$").expect("valid regex"); + static ref SOURCE_FILE_RE: Regex = Regex::new(r"(?i)([A-Za-z]:)?[/\\][^\s]+\.(cs|vb|fs)") + .expect("valid regex"); + // TRX (Visual Studio Test Results) parsing + // Note: (?s) enables DOTALL mode so . matches newlines + static ref TRX_COUNTERS_RE: Regex = Regex::new( + r#"\d+)"\s+executed="(?P\d+)"\s+passed="(?P\d+)"\s+failed="(?P\d+)""# + ).expect("valid regex"); + static ref TRX_TEST_RESULT_RE: Regex = Regex::new( + r#"(?s)]*testName="(?P[^"]+)"[^>]*outcome="(?P[^"]+)"[^>]*>(.*?)"# + ).expect("valid regex"); + static ref TRX_ERROR_MESSAGE_RE: Regex = Regex::new( + r#"(?s).*?(?P.*?).*?(?P.*?).*?"# + ).expect("valid regex"); +} + +const SENSITIVE_ENV_VARS: &[&str] = &[ + "PATH", + "HOME", + "USERPROFILE", + "USERNAME", + "USER", + "APPDATA", + "LOCALAPPDATA", + "TEMP", + "TMP", + "SSH_AUTH_SOCK", + "SSH_AGENT_LAUNCHER", + "GITHUB_TOKEN", + "NUGET_API_KEY", + "AZURE_DEVOPS_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "DOCKER_CONFIG", + "KUBECONFIG", +]; + +pub fn parse_build(binlog_path: &Path, fallback_output: &str) -> Result { + let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); + Ok(parse_build_from_text(&source)) +} + +pub fn parse_test(binlog_path: &Path, fallback_output: &str) -> Result { + let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); + Ok(parse_test_from_text(&source)) +} + +pub fn parse_restore(binlog_path: &Path, fallback_output: &str) -> Result { + let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); + Ok(parse_restore_from_text(&source)) +} + +pub fn scrub_sensitive_env_vars(input: &str) -> String { + let mut output = input.to_string(); + + for key in SENSITIVE_ENV_VARS { + let escaped_key = regex::escape(key); + + let equals_pattern = format!(r"(?P\b{}\s*=\s*)(?P[^\s;]+)", escaped_key); + if let Ok(re) = Regex::new(&equals_pattern) { + output = re.replace_all(&output, "${prefix}[REDACTED]").into_owned(); + } + + let colon_pattern = format!(r"(?P\b{}\s*:\s*)(?P[^\s;]+)", escaped_key); + if let Ok(re) = Regex::new(&colon_pattern) { + output = re.replace_all(&output, "${prefix}[REDACTED]").into_owned(); + } + } + + output +} + +pub fn parse_build_from_text(text: &str) -> BuildSummary { + let scrubbed = scrub_sensitive_env_vars(text); + let mut seen_errors: HashSet<(String, String, u32, u32, String)> = HashSet::new(); + let mut seen_warnings: HashSet<(String, String, u32, u32, String)> = HashSet::new(); + let mut summary = BuildSummary { + succeeded: scrubbed.contains("Build succeeded") && !scrubbed.contains("Build FAILED"), + project_count: count_projects(&scrubbed), + errors: Vec::new(), + warnings: Vec::new(), + duration_text: extract_duration(&scrubbed), + }; + + for captures in ISSUE_RE.captures_iter(&scrubbed) { + let issue = BinlogIssue { + code: captures + .name("code") + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + file: captures + .name("file") + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + line: captures + .name("line") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0), + column: captures + .name("column") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0), + message: captures + .name("msg") + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default(), + }; + + let key = ( + issue.code.clone(), + issue.file.clone(), + issue.line, + issue.column, + issue.message.clone(), + ); + + match captures.name("kind").map(|m| m.as_str()) { + Some("error") => { + if seen_errors.insert(key) { + summary.errors.push(issue); + } + } + Some("warning") => { + if seen_warnings.insert(key) { + summary.warnings.push(issue); + } + } + _ => {} + } + } + + if summary.errors.is_empty() || summary.warnings.is_empty() { + let mut warning_count_from_summary = None; + let mut error_count_from_summary = None; + + for captures in BUILD_SUMMARY_RE.captures_iter(&scrubbed) { + let count = captures + .name("count") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + + match captures.name("kind").map(|m| m.as_str()) { + Some("Warning") => warning_count_from_summary = Some(count), + Some("Error") => error_count_from_summary = Some(count), + _ => {} + } + } + + if summary.errors.is_empty() { + for idx in 0..error_count_from_summary.unwrap_or(0) { + summary.errors.push(BinlogIssue { + code: String::new(), + file: String::new(), + line: 0, + column: 0, + message: format!("Build error #{} (details omitted)", idx + 1), + }); + } + } + + if summary.warnings.is_empty() { + for idx in 0..warning_count_from_summary.unwrap_or(0) { + summary.warnings.push(BinlogIssue { + code: String::new(), + file: String::new(), + line: 0, + column: 0, + message: format!("Build warning #{} (details omitted)", idx + 1), + }); + } + } + } + + if summary.errors.is_empty() { + summary.errors = extract_binary_like_issues(&scrubbed); + } + + if summary.project_count == 0 + && (scrubbed.contains("Build succeeded") + || scrubbed.contains("Build FAILED") + || scrubbed.contains(" -> ")) + { + summary.project_count = 1; + } + + summary +} + +pub fn parse_test_from_text(text: &str) -> TestSummary { + let scrubbed = scrub_sensitive_env_vars(text); + let mut summary = TestSummary { + passed: 0, + failed: 0, + skipped: 0, + total: 0, + project_count: count_projects(&scrubbed).max(1), + failed_tests: Vec::new(), + duration_text: extract_duration(&scrubbed), + }; + + if let Some(captures) = TEST_RESULT_RE.captures(&scrubbed) { + summary.passed = captures + .name("passed") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + summary.failed = captures + .name("failed") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + summary.skipped = captures + .name("skipped") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + summary.total = captures + .name("total") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + if let Some(duration) = captures.name("duration") { + summary.duration_text = Some(duration.as_str().trim().to_string()); + } + } + + let lines: Vec<&str> = scrubbed.lines().collect(); + let mut idx = 0; + while idx < lines.len() { + let line = lines[idx]; + if let Some(captures) = FAILED_TEST_HEAD_RE.captures(line) { + let name = captures + .name("name") + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let mut details = Vec::new(); + idx += 1; + while idx < lines.len() { + let detail_line = lines[idx].trim_end(); + if detail_line.trim().is_empty() { + break; + } + if FAILED_TEST_HEAD_RE.is_match(detail_line) { + idx = idx.saturating_sub(1); + break; + } + if detail_line.trim_start().starts_with("Failed ") + || detail_line.trim_start().starts_with("Passed ") + { + idx = idx.saturating_sub(1); + break; + } + + details.push(detail_line.trim().to_string()); + if details.len() >= 4 { + break; + } + idx += 1; + } + summary.failed_tests.push(FailedTest { name, details }); + } + idx += 1; + } + + if summary.failed == 0 { + summary.failed = summary.failed_tests.len(); + } + if summary.total == 0 { + summary.total = summary.passed + summary.failed + summary.skipped; + } + + summary +} + +pub fn parse_restore_from_text(text: &str) -> RestoreSummary { + let scrubbed = scrub_sensitive_env_vars(text); + RestoreSummary { + restored_projects: RESTORE_PROJECT_RE.captures_iter(&scrubbed).count(), + warnings: WARNING_COUNT_RE.captures_iter(&scrubbed).count(), + errors: ERROR_COUNT_RE.captures_iter(&scrubbed).count(), + duration_text: extract_duration(&scrubbed), + } +} + +fn count_projects(text: &str) -> usize { + PROJECT_PATH_RE.captures_iter(text).count() +} + +fn extract_duration(text: &str) -> Option { + DURATION_RE + .captures(text) + .and_then(|c| c.name("duration")) + .map(|m| m.as_str().trim().to_string()) +} + +fn load_binlog_text(path: &Path) -> Option { + if !path.exists() { + return None; + } + + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read binlog at {}", path.display())) + .ok()?; + + if bytes.is_empty() { + return None; + } + + if let Some(decoded) = try_gzip_decode(&bytes) { + let text = String::from_utf8_lossy(&decoded).into_owned(); + if looks_like_console_output(&text) { + return Some(text); + } + } + + if let Some(decoded) = try_deflate_decode(&bytes) { + let text = String::from_utf8_lossy(&decoded).into_owned(); + if looks_like_console_output(&text) { + return Some(text); + } + } + + let plain = String::from_utf8_lossy(&bytes).into_owned(); + if looks_like_console_output(&plain) { + return Some(plain); + } + + None +} + +fn looks_like_console_output(text: &str) -> bool { + let markers = [ + "Build succeeded", + "Build FAILED", + "Passed!", + "Failed!", + "Time Elapsed", + ".csproj", + ": error ", + ": warning ", + "Restored ", + ]; + + markers.iter().any(|marker| text.contains(marker)) +} + +fn try_gzip_decode(bytes: &[u8]) -> Option> { + let mut decoder = GzDecoder::new(bytes); + let mut output = Vec::new(); + if decoder.read_to_end(&mut output).is_ok() && !output.is_empty() { + return Some(output); + } + None +} + +fn try_deflate_decode(bytes: &[u8]) -> Option> { + let mut decoder = DeflateDecoder::new(bytes); + let mut output = Vec::new(); + if decoder.read_to_end(&mut output).is_ok() && !output.is_empty() { + return Some(output); + } + None +} + +fn extract_printable_runs(text: &str) -> Vec { + let mut runs = Vec::new(); + for captures in PRINTABLE_RUN_RE.captures_iter(text) { + let Some(matched) = captures.get(0) else { + continue; + }; + + let run = matched.as_str().trim(); + if run.len() < 5 { + continue; + } + runs.push(run.to_string()); + } + runs +} + +fn extract_binary_like_issues(text: &str) -> Vec { + let runs = extract_printable_runs(text); + if runs.is_empty() { + return Vec::new(); + } + + let mut issues = Vec::new(); + let mut seen: HashSet<(String, String, String)> = HashSet::new(); + + for idx in 0..runs.len() { + let code = runs[idx].trim(); + if !DIAGNOSTIC_CODE_RE.is_match(code) || !is_likely_diagnostic_code(code) { + continue; + } + + let message = (1..=4) + .filter_map(|delta| idx.checked_sub(delta)) + .map(|j| runs[j].trim()) + .find(|candidate| { + !DIAGNOSTIC_CODE_RE.is_match(candidate) + && !SOURCE_FILE_RE.is_match(candidate) + && candidate.chars().any(|c| c.is_ascii_alphabetic()) + && candidate.contains(' ') + && !candidate.contains("Copyright") + && !candidate.contains("Compiler version") + }) + .unwrap_or("Build issue") + .to_string(); + + let file = (1..=4) + .filter_map(|delta| runs.get(idx + delta)) + .find_map(|candidate| { + SOURCE_FILE_RE + .captures(candidate) + .and_then(|caps| caps.get(0)) + .map(|m| m.as_str().to_string()) + }) + .unwrap_or_default(); + + if file.is_empty() && message == "Build issue" { + continue; + } + + let key = (code.to_string(), file.clone(), message.clone()); + if !seen.insert(key) { + continue; + } + + issues.push(BinlogIssue { + code: code.to_string(), + file, + line: 0, + column: 0, + message, + }); + } + + issues +} + +fn is_likely_diagnostic_code(code: &str) -> bool { + const ALLOWED_PREFIXES: &[&str] = &[ + "CS", "MSB", "NU", "FS", "BC", "CA", "SA", "IDE", "IL", "VB", "AD", "TS", "C", "LNK", + ]; + + ALLOWED_PREFIXES + .iter() + .any(|prefix| code.starts_with(prefix)) +} + +/// Parse TRX (Visual Studio Test Results) file to extract test summary. +/// Returns None if the file doesn't exist or isn't a valid TRX file. +pub fn parse_trx_file(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + parse_trx_content(&content) +} + +fn parse_trx_content(content: &str) -> Option { + // Quick check if this looks like a TRX file + if !content.contains("") { + return None; + } + + let mut summary = TestSummary::default(); + + // Extract counters from ResultSummary + if let Some(captures) = TRX_COUNTERS_RE.captures(content) { + summary.total = captures + .name("total") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + summary.passed = captures + .name("passed") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + summary.failed = captures + .name("failed") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + } + + // Extract failed tests with details + for captures in TRX_TEST_RESULT_RE.captures_iter(content) { + let outcome = captures + .name("outcome") + .map(|m| m.as_str()) + .unwrap_or("Unknown"); + + if outcome != "Failed" { + continue; + } + + let name = captures + .name("name") + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let full_match = captures.get(0).map(|m| m.as_str()).unwrap_or(""); + let mut details = Vec::new(); + + // Try to extract error message and stack trace + if let Some(error_caps) = TRX_ERROR_MESSAGE_RE.captures(full_match) { + if let Some(msg) = error_caps.name("message") { + details.push(msg.as_str().trim().to_string()); + } + if let Some(stack) = error_caps.name("stack") { + // Include first few lines of stack trace + let stack_lines: Vec<&str> = stack.as_str().lines().take(3).collect(); + if !stack_lines.is_empty() { + details.push(stack_lines.join("\n")); + } + } + } + + summary.failed_tests.push(FailedTest { name, details }); + } + + // Calculate skipped from counters if available + if summary.total > 0 { + summary.skipped = summary + .total + .saturating_sub(summary.passed + summary.failed); + } + + // Set project count to at least 1 if there were any tests + if summary.total > 0 { + summary.project_count = 1; + } + + Some(summary) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scrub_sensitive_env_vars_masks_values() { + let input = "PATH=/usr/local/bin HOME: /Users/daniel GITHUB_TOKEN=ghp_123"; + let scrubbed = scrub_sensitive_env_vars(input); + + assert!(scrubbed.contains("PATH=[REDACTED]")); + assert!(scrubbed.contains("HOME: [REDACTED]")); + assert!(scrubbed.contains("GITHUB_TOKEN=[REDACTED]")); + assert!(!scrubbed.contains("/usr/local/bin")); + assert!(!scrubbed.contains("ghp_123")); + } + + #[test] + fn test_parse_build_from_text_extracts_issues() { + let input = r#" +Build FAILED. +src/Program.cs(42,15): error CS0103: The name 'foo' does not exist +src/Program.cs(25,10): warning CS0219: Variable 'x' is assigned but never used + 1 Warning(s) + 1 Error(s) +Time Elapsed 00:00:03.45 +"#; + + let summary = parse_build_from_text(input); + assert!(!summary.succeeded); + assert_eq!(summary.errors.len(), 1); + assert_eq!(summary.warnings.len(), 1); + assert_eq!(summary.errors[0].code, "CS0103"); + assert_eq!(summary.warnings[0].code, "CS0219"); + assert_eq!(summary.duration_text.as_deref(), Some("00:00:03.45")); + } + + #[test] + fn test_parse_test_from_text_extracts_failure_summary() { + let input = r#" +Failed! - Failed: 2, Passed: 245, Skipped: 0, Total: 247, Duration: 1 s + Failed MyApp.Tests.UnitTests.CalculatorTests.Add_ShouldReturnSum [5 ms] + Error Message: + Assert.Equal() Failure: Expected 5, Actual 4 + + Failed MyApp.Tests.IntegrationTests.DatabaseTests.CanConnect [20 ms] + Error Message: + System.InvalidOperationException: Connection refused +"#; + + let summary = parse_test_from_text(input); + assert_eq!(summary.passed, 245); + assert_eq!(summary.failed, 2); + assert_eq!(summary.total, 247); + assert_eq!(summary.failed_tests.len(), 2); + assert!(summary.failed_tests[0] + .name + .contains("CalculatorTests.Add_ShouldReturnSum")); + } + + #[test] + fn test_parse_restore_from_text_extracts_project_count() { + let input = r#" + Restored /tmp/App/App.csproj (in 1.1 sec). + Restored /tmp/App.Tests/App.Tests.csproj (in 1.2 sec). +"#; + + let summary = parse_restore_from_text(input); + assert_eq!(summary.restored_projects, 2); + assert_eq!(summary.errors, 0); + } + + #[test] + fn test_parse_build_uses_fallback_when_binlog_is_binary() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("build.binlog"); + std::fs::write(&binlog_path, [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00]) + .expect("write binary file"); + + let fallback = include_str!("../tests/fixtures/dotnet/build_failed.txt"); + let summary = parse_build(&binlog_path, fallback).expect("parse should not fail"); + + assert_eq!(summary.errors.len(), 1); + assert_eq!(summary.warnings.len(), 0); + assert_eq!(summary.errors[0].code, "CS1525"); + } + + #[test] + fn test_parse_build_from_fixture_text() { + let input = include_str!("../tests/fixtures/dotnet/build_failed.txt"); + let summary = parse_build_from_text(input); + + assert_eq!(summary.errors.len(), 1); + assert_eq!(summary.errors[0].code, "CS1525"); + assert_eq!(summary.duration_text.as_deref(), Some("00:00:00.76")); + } + + #[test] + fn test_parse_build_sets_project_count_floor() { + let input = r#" +RtkDotnetSmoke -> /tmp/RtkDotnetSmoke.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:00.12 +"#; + + let summary = parse_build_from_text(input); + assert_eq!(summary.project_count, 1); + assert!(summary.succeeded); + } + + #[test] + fn test_parse_test_from_fixture_text() { + let input = include_str!("../tests/fixtures/dotnet/test_failed.txt"); + let summary = parse_test_from_text(input); + + assert_eq!(summary.failed, 1); + assert_eq!(summary.passed, 0); + assert_eq!(summary.total, 1); + assert_eq!(summary.failed_tests.len(), 1); + assert!(summary.failed_tests[0] + .name + .contains("RtkDotnetSmoke.UnitTest1.Test1")); + } + + #[test] + fn test_extract_binary_like_issues_recovers_code_message_and_path() { + let noisy = + "\x0bInvalid expression term ';'\x18\x06CS1525\x18%/tmp/RtkDotnetSmoke/Broken.cs\x09"; + let issues = extract_binary_like_issues(noisy); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].code, "CS1525"); + assert_eq!(issues[0].file, "/tmp/RtkDotnetSmoke/Broken.cs"); + assert!(issues[0].message.contains("Invalid expression term")); + } + + #[test] + fn test_is_likely_diagnostic_code_filters_framework_monikers() { + assert!(is_likely_diagnostic_code("CS1525")); + assert!(is_likely_diagnostic_code("MSB4018")); + assert!(!is_likely_diagnostic_code("NET451")); + assert!(!is_likely_diagnostic_code("NET10")); + } + + #[test] + fn test_parse_trx_content_extracts_passed_counts() { + let trx = r#" + + + + +"#; + + let summary = parse_trx_content(trx).expect("valid TRX"); + assert_eq!(summary.total, 5); + assert_eq!(summary.passed, 5); + assert_eq!(summary.failed, 0); + assert_eq!(summary.failed_tests.len(), 0); + } + + #[test] + fn test_parse_trx_content_extracts_failed_tests_with_details() { + let trx = r#" + + + + + + Expected 2 but was 3 + at MyTest.ShouldFail() in /src/Test.cs:line 10 + + + + + + + +"#; + + let summary = parse_trx_content(trx).expect("valid TRX"); + assert_eq!(summary.total, 3); + assert_eq!(summary.passed, 2); + assert_eq!(summary.failed, 1); + assert_eq!(summary.failed_tests.len(), 1); + assert_eq!(summary.failed_tests[0].name, "MyTest.ShouldFail"); + assert!(summary.failed_tests[0].details[0].contains("Expected 2")); + } + + #[test] + fn test_parse_trx_content_returns_none_for_invalid_xml() { + let not_trx = "This is not a TRX file"; + assert!(parse_trx_content(not_trx).is_none()); + } +} diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs new file mode 100644 index 0000000..64abf30 --- /dev/null +++ b/src/dotnet_cmd.rs @@ -0,0 +1,495 @@ +use crate::binlog; +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn run_build(args: &[String], verbose: u8) -> Result<()> { + run_dotnet_with_binlog("build", args, verbose) +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + run_dotnet_with_binlog("test", args, verbose) +} + +pub fn run_restore(args: &[String], verbose: u8) -> Result<()> { + run_dotnet_with_binlog("restore", args, verbose) +} + +pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("dotnet: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + let subcommand = args[0].to_string_lossy().to_string(); + + let mut cmd = Command::new("dotnet"); + cmd.arg(&subcommand); + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: dotnet {} ...", subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("dotnet {}", subcommand), + &format!("rtk dotnet {}", subcommand), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + let binlog_path = build_binlog_path(subcommand); + + // For test commands, also create a TRX file for detailed results + let trx_path = if subcommand == "test" { + Some(build_trx_path()) + } else { + None + }; + + let mut cmd = Command::new("dotnet"); + cmd.arg(subcommand); + + if !has_binlog_arg(args) { + cmd.arg(format!("-bl:{}", binlog_path.display())); + } + + if !has_verbosity_arg(args) { + cmd.arg("-v:minimal"); + } + + if !has_nologo_arg(args) { + cmd.arg("-nologo"); + } + + // Add TRX logger for test commands if not already specified + if let Some(ref trx) = trx_path { + if !has_logger_arg(args) { + cmd.arg(format!("--logger")); + cmd.arg(format!("trx;LogFileName={}", trx.display())); + } + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: dotnet {} {}", subcommand, args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run dotnet {}", subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = match subcommand { + "build" => { + let summary = normalize_build_summary( + binlog::parse_build(&binlog_path, &raw)?, + output.status.success(), + ); + format_build_output(&summary, &binlog_path) + } + "test" => { + // First try to parse from binlog/console output + let mut summary = binlog::parse_test(&binlog_path, &raw)?; + + // If binlog parsing didn't yield useful data, try TRX file + if summary.total == 0 && summary.failed_tests.is_empty() { + if let Some(ref trx) = trx_path { + if let Some(trx_summary) = binlog::parse_trx_file(trx) { + summary = trx_summary; + } + } + } + + let summary = normalize_test_summary(summary, output.status.success()); + format_test_output(&summary, &binlog_path) + } + "restore" => { + let summary = binlog::parse_restore(&binlog_path, &raw)?; + format_restore_output(&summary, &binlog_path) + } + _ => raw.clone(), + }; + + println!("{}", filtered); + + timer.track( + &format!("dotnet {} {}", subcommand, args.join(" ")), + &format!("rtk dotnet {} {}", subcommand, args.join(" ")), + &raw, + &filtered, + ); + + if verbose > 0 { + eprintln!("Binlog saved: {}", binlog_path.display()); + } + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn build_binlog_path(subcommand: &str) -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + std::env::temp_dir().join(format!("rtk_dotnet_{}_{}.binlog", subcommand, ts)) +} + +fn build_trx_path() -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + std::env::temp_dir().join(format!("rtk_dotnet_test_{}.trx", ts)) +} + +fn has_binlog_arg(args: &[String]) -> bool { + args.iter().any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("-bl") || lower.starts_with("/bl") + }) +} + +fn has_verbosity_arg(args: &[String]) -> bool { + args.iter().any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("-v:") || lower.starts_with("/v:") || lower == "-v" || lower == "/v" + }) +} + +fn has_nologo_arg(args: &[String]) -> bool { + args.iter().any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower == "-nologo" || lower == "/nologo" + }) +} + +fn has_logger_arg(args: &[String]) -> bool { + args.iter().any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("--logger") || lower.starts_with("-l") || lower.contains("logger") + }) +} + +fn normalize_build_summary( + mut summary: binlog::BuildSummary, + command_success: bool, +) -> binlog::BuildSummary { + if command_success { + summary.succeeded = true; + if summary.project_count == 0 { + summary.project_count = 1; + } + } + + summary +} + +fn normalize_test_summary( + mut summary: binlog::TestSummary, + command_success: bool, +) -> binlog::TestSummary { + if !command_success && summary.failed == 0 && summary.failed_tests.is_empty() { + summary.failed = 1; + if summary.total == 0 { + summary.total = 1; + } + } + + if command_success && summary.total == 0 && summary.passed == 0 { + summary.project_count = summary.project_count.max(1); + } + + summary +} + +fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { + if issue.file.is_empty() { + return format!(" {} {}", kind, truncate(&issue.message, 180)); + } + + format!( + " {}({},{}) {} {}: {}", + issue.file, + issue.line, + issue.column, + kind, + issue.code, + truncate(&issue.message, 180) + ) +} + +fn format_build_output(summary: &binlog::BuildSummary, binlog_path: &Path) -> String { + let status_icon = if summary.succeeded { "ok" } else { "fail" }; + let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + + let mut out = format!( + "{} dotnet build: {} projects, {} errors, {} warnings ({})", + status_icon, + summary.project_count, + summary.errors.len(), + summary.warnings.len(), + duration + ); + + if !summary.errors.is_empty() { + out.push_str("\n---------------------------------------\n\nErrors:\n"); + for issue in summary.errors.iter().take(20) { + out.push_str(&format!("{}\n", format_issue(issue, "error"))); + } + if summary.errors.len() > 20 { + out.push_str(&format!( + " ... +{} more errors\n", + summary.errors.len() - 20 + )); + } + } + + if !summary.warnings.is_empty() { + out.push_str("\nWarnings:\n"); + for issue in summary.warnings.iter().take(10) { + out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + } + if summary.warnings.len() > 10 { + out.push_str(&format!( + " ... +{} more warnings\n", + summary.warnings.len() - 10 + )); + } + } + + out.push_str(&format!("\nBinlog: {}", binlog_path.display())); + out +} + +fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> String { + let has_failures = summary.failed > 0 || !summary.failed_tests.is_empty(); + let status_icon = if has_failures { "fail" } else { "ok" }; + let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + let counts_unavailable = summary.passed == 0 + && summary.failed == 0 + && summary.skipped == 0 + && summary.total == 0 + && summary.failed_tests.is_empty(); + + let mut out = if counts_unavailable { + format!( + "{} dotnet test: completed (binlog-only mode, counts unavailable) ({})", + status_icon, duration + ) + } else if has_failures { + format!( + "{} dotnet test: {} passed, {} failed, {} skipped in {} projects ({})", + status_icon, + summary.passed, + summary.failed, + summary.skipped, + summary.project_count, + duration + ) + } else { + format!( + "{} dotnet test: {} tests passed in {} projects ({})", + status_icon, summary.passed, summary.project_count, duration + ) + }; + + if has_failures && !summary.failed_tests.is_empty() { + out.push_str("\n---------------------------------------\n\nFailed Tests:\n"); + for failed in summary.failed_tests.iter().take(15) { + out.push_str(&format!(" {}\n", failed.name)); + for detail in &failed.details { + out.push_str(&format!(" {}\n", truncate(detail, 180))); + } + out.push('\n'); + } + if summary.failed_tests.len() > 15 { + out.push_str(&format!( + "... +{} more failed tests\n", + summary.failed_tests.len() - 15 + )); + } + } + + out.push_str(&format!("\nBinlog: {}", binlog_path.display())); + out +} + +fn format_restore_output(summary: &binlog::RestoreSummary, binlog_path: &Path) -> String { + let has_errors = summary.errors > 0; + let status_icon = if has_errors { "fail" } else { "ok" }; + let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + + let mut out = format!( + "{} dotnet restore: {} projects, {} errors, {} warnings ({})", + status_icon, summary.restored_projects, summary.errors, summary.warnings, duration + ); + out.push_str(&format!("\nBinlog: {}", binlog_path.display())); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_binlog_arg_detects_variants() { + let args = vec!["-bl:my.binlog".to_string()]; + assert!(has_binlog_arg(&args)); + + let args = vec!["/bl".to_string()]; + assert!(has_binlog_arg(&args)); + + let args = vec!["--configuration".to_string(), "Release".to_string()]; + assert!(!has_binlog_arg(&args)); + } + + #[test] + fn test_format_build_output_includes_errors_and_warnings() { + let summary = binlog::BuildSummary { + succeeded: false, + project_count: 2, + errors: vec![binlog::BinlogIssue { + code: "CS0103".to_string(), + file: "src/Program.cs".to_string(), + line: 42, + column: 15, + message: "The name 'foo' does not exist".to_string(), + }], + warnings: vec![binlog::BinlogIssue { + code: "CS0219".to_string(), + file: "src/Program.cs".to_string(), + line: 25, + column: 10, + message: "Variable 'x' is assigned but never used".to_string(), + }], + duration_text: Some("00:00:04.20".to_string()), + }; + + let output = format_build_output(&summary, Path::new("/tmp/build.binlog")); + assert!(output.contains("dotnet build: 2 projects, 1 errors, 1 warnings")); + assert!(output.contains("error CS0103")); + assert!(output.contains("warning CS0219")); + } + + #[test] + fn test_format_test_output_shows_failures() { + let summary = binlog::TestSummary { + passed: 10, + failed: 1, + skipped: 0, + total: 11, + project_count: 1, + failed_tests: vec![binlog::FailedTest { + name: "MyTests.ShouldFail".to_string(), + details: vec!["Assert.Equal failure".to_string()], + }], + duration_text: Some("1 s".to_string()), + }; + + let output = format_test_output(&summary, Path::new("/tmp/test.binlog")); + assert!(output.contains("10 passed, 1 failed")); + assert!(output.contains("MyTests.ShouldFail")); + } + + #[test] + fn test_format_restore_output_success() { + let summary = binlog::RestoreSummary { + restored_projects: 3, + warnings: 1, + errors: 0, + duration_text: Some("00:00:01.10".to_string()), + }; + + let output = format_restore_output(&summary, Path::new("/tmp/restore.binlog")); + assert!(output.starts_with("ok dotnet restore")); + assert!(output.contains("3 projects")); + assert!(output.contains("1 warnings")); + } + + #[test] + fn test_format_test_output_handles_binlog_only_without_counts() { + let summary = binlog::TestSummary { + passed: 0, + failed: 0, + skipped: 0, + total: 0, + project_count: 0, + failed_tests: Vec::new(), + duration_text: Some("unknown".to_string()), + }; + + let output = format_test_output(&summary, Path::new("/tmp/test.binlog")); + assert!(output.contains("counts unavailable")); + } + + #[test] + fn test_normalize_build_summary_sets_success_floor() { + let summary = binlog::BuildSummary { + succeeded: false, + project_count: 0, + errors: Vec::new(), + warnings: Vec::new(), + duration_text: None, + }; + + let normalized = normalize_build_summary(summary, true); + assert!(normalized.succeeded); + assert_eq!(normalized.project_count, 1); + } + + #[test] + fn test_normalize_test_summary_sets_failure_floor() { + let summary = binlog::TestSummary { + passed: 0, + failed: 0, + skipped: 0, + total: 0, + project_count: 0, + failed_tests: Vec::new(), + duration_text: None, + }; + + let normalized = normalize_test_summary(summary, false); + assert_eq!(normalized.failed, 1); + assert_eq!(normalized.total, 1); + } +} diff --git a/src/main.rs b/src/main.rs index cef7f3e..992f6ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod binlog; mod cargo_cmd; mod cc_economics; mod ccusage; @@ -8,6 +9,7 @@ mod deps; mod diff_cmd; mod discover; mod display_helpers; +mod dotnet_cmd; mod env_cmd; mod filter; mod find_cmd; @@ -410,6 +412,12 @@ enum Commands { command: CargoCommands, }, + /// .NET CLI commands with compact output + Dotnet { + #[command(subcommand)] + command: DotnetCommands, + }, + /// npm run with filtered output (strip boilerplate) Npm { /// npm run arguments (script name + options) @@ -807,6 +815,31 @@ enum GoCommands { Other(Vec), } +#[derive(Subcommand)] +enum DotnetCommands { + /// Build with compact output (errors/warnings summary) + Build { + /// Additional dotnet build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Test with compact output (failed tests only) + Test { + /// Additional dotnet test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Restore with compact output + Restore { + /// Additional dotnet restore arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported dotnet subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -1211,6 +1244,21 @@ fn main() -> Result<()> { } }, + Commands::Dotnet { command } => match command { + DotnetCommands::Build { args } => { + dotnet_cmd::run_build(&args, cli.verbose)?; + } + DotnetCommands::Test { args } => { + dotnet_cmd::run_test(&args, cli.verbose)?; + } + DotnetCommands::Restore { args } => { + dotnet_cmd::run_restore(&args, cli.verbose)?; + } + DotnetCommands::Other(args) => { + dotnet_cmd::run_passthrough(&args, cli.verbose)?; + } + }, + Commands::Npm { args } => { npm_cmd::run(&args, cli.verbose, cli.skip_env)?; } From be6111f2ad09c1d2e3af8d7072bf047b50572966 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 10:45:45 +0100 Subject: [PATCH 02/11] Better default behavior --- README.md | 10 +- src/binlog.rs | 69 +++++++++++- src/dotnet_cmd.rs | 275 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 320 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index da1050b..6401bb0 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ rtk pip list # Python packages (auto-detect uv, 70% reductio rtk go test # Go tests (NDJSON, 90% reduction) rtk golangci-lint run # Go linting (JSON, 85% reduction) rtk dotnet build # .NET build summary with binlog -rtk dotnet test # .NET test failures only +rtk dotnet test # .NET failures only (auto TRX + fallback parsing) rtk dotnet restore # .NET restore summary ``` @@ -277,10 +277,16 @@ rtk golangci-lint run # JSON grouped by rule (85% reduction) # .NET rtk dotnet build # Build errors/warnings summary with binlog -rtk dotnet test # Failed tests only with compact details +rtk dotnet test # Failed tests only (auto TRX cleanup, TestResults fallback) rtk dotnet restore # Restore project/package summary ``` +Dotnet behavior notes: +- RTK forwards your dotnet args as-is (`--configuration`, `--framework`, `--project`, `--no-build`, `--no-restore`, `--filter`, etc.). +- RTK only injects defaults when missing (`-bl`, `-v:minimal`, `-nologo`) and does not override your explicit `-v` / `--logger`. +- For `rtk dotnet test`, RTK auto-generates a TRX file, parses it when binlog/console counts are unavailable, then cleans up that temp TRX file. +- If temp TRX is missing, RTK falls back to the newest `./TestResults/*.trx` file. + ## Examples ### Standard vs rtk diff --git a/src/binlog.rs b/src/binlog.rs index e292b1f..0b7ba3e 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -4,7 +4,7 @@ use lazy_static::lazy_static; use regex::Regex; use std::collections::HashSet; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BinlogIssue { @@ -525,6 +525,34 @@ pub fn parse_trx_file(path: &Path) -> Option { parse_trx_content(&content) } +pub fn find_recent_trx_in_testresults() -> Option { + find_recent_trx_in_dir(Path::new("./TestResults")) +} + +fn find_recent_trx_in_dir(dir: &Path) -> Option { + if !dir.exists() { + return None; + } + + std::fs::read_dir(dir) + .ok()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + let is_trx = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("trx")); + if !is_trx { + return None; + } + + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, path)) + }) + .max_by_key(|(modified, _)| *modified) + .map(|(_, path)| path) +} + fn parse_trx_content(content: &str) -> Option { // Quick check if this looks like a TRX file if !content.contains("") { @@ -603,6 +631,7 @@ fn parse_trx_content(content: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] fn test_scrub_sensitive_env_vars_masks_values() { @@ -796,4 +825,42 @@ Time Elapsed 00:00:00.12 let not_trx = "This is not a TRX file"; assert!(parse_trx_content(not_trx).is_none()); } + + #[test] + fn test_find_recent_trx_in_dir_returns_none_when_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let missing_dir = temp_dir.path().join("TestResults"); + + let found = find_recent_trx_in_dir(&missing_dir); + assert!(found.is_none()); + } + + #[test] + fn test_find_recent_trx_in_dir_picks_newest_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let testresults_dir = temp_dir.path().join("TestResults"); + std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); + + let old_trx = testresults_dir.join("old.trx"); + let new_trx = testresults_dir.join("new.trx"); + std::fs::write(&old_trx, "old").expect("write old"); + std::thread::sleep(Duration::from_millis(5)); + std::fs::write(&new_trx, "new").expect("write new"); + + let found = find_recent_trx_in_dir(&testresults_dir).expect("should find newest trx"); + assert_eq!(found, new_trx); + } + + #[test] + fn test_find_recent_trx_in_dir_ignores_non_trx_files() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let testresults_dir = temp_dir.path().join("TestResults"); + std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); + + let txt = testresults_dir.join("notes.txt"); + std::fs::write(&txt, "noop").expect("write txt"); + + let found = find_recent_trx_in_dir(&testresults_dir); + assert!(found.is_none()); + } } diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 64abf30..635a314 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -76,27 +76,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let mut cmd = Command::new("dotnet"); cmd.arg(subcommand); - if !has_binlog_arg(args) { - cmd.arg(format!("-bl:{}", binlog_path.display())); - } - - if !has_verbosity_arg(args) { - cmd.arg("-v:minimal"); - } - - if !has_nologo_arg(args) { - cmd.arg("-nologo"); - } - - // Add TRX logger for test commands if not already specified - if let Some(ref trx) = trx_path { - if !has_logger_arg(args) { - cmd.arg(format!("--logger")); - cmd.arg(format!("trx;LogFileName={}", trx.display())); - } - } - - for arg in args { + for arg in build_effective_dotnet_args(subcommand, args, &binlog_path, trx_path.as_deref()) { cmd.arg(arg); } @@ -122,16 +102,12 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } "test" => { // First try to parse from binlog/console output - let mut summary = binlog::parse_test(&binlog_path, &raw)?; - - // If binlog parsing didn't yield useful data, try TRX file - if summary.total == 0 && summary.failed_tests.is_empty() { - if let Some(ref trx) = trx_path { - if let Some(trx_summary) = binlog::parse_trx_file(trx) { - summary = trx_summary; - } - } - } + let parsed_summary = binlog::parse_test(&binlog_path, &raw)?; + let summary = maybe_fill_test_summary_from_trx( + parsed_summary, + trx_path.as_deref(), + binlog::find_recent_trx_in_testresults(), + ); let summary = normalize_test_summary(summary, output.status.success()); format_test_output(&summary, &binlog_path) @@ -181,6 +157,67 @@ fn build_trx_path() -> PathBuf { std::env::temp_dir().join(format!("rtk_dotnet_test_{}.trx", ts)) } +fn parse_trx_with_cleanup(path: &Path) -> Option { + let summary = binlog::parse_trx_file(path)?; + std::fs::remove_file(path).ok(); + Some(summary) +} + +fn maybe_fill_test_summary_from_trx( + summary: binlog::TestSummary, + trx_path: Option<&Path>, + fallback_trx_path: Option, +) -> binlog::TestSummary { + if summary.total != 0 || !summary.failed_tests.is_empty() { + return summary; + } + + if let Some(trx) = trx_path.filter(|path| path.exists()) { + if let Some(trx_summary) = parse_trx_with_cleanup(trx) { + return trx_summary; + } + } + + if let Some(trx) = fallback_trx_path { + if let Some(trx_summary) = binlog::parse_trx_file(&trx) { + return trx_summary; + } + } + + summary +} + +fn build_effective_dotnet_args( + subcommand: &str, + args: &[String], + binlog_path: &Path, + trx_path: Option<&Path>, +) -> Vec { + let mut effective = Vec::new(); + + if !has_binlog_arg(args) { + effective.push(format!("-bl:{}", binlog_path.display())); + } + + if !has_verbosity_arg(args) { + effective.push("-v:minimal".to_string()); + } + + if !has_nologo_arg(args) { + effective.push("-nologo".to_string()); + } + + if subcommand == "test" && !has_logger_arg(args) { + if let Some(trx) = trx_path { + effective.push("--logger".to_string()); + effective.push(format!("trx;LogFileName=\"{}\"", trx.display())); + } + } + + effective.extend(args.iter().cloned()); + effective +} + fn has_binlog_arg(args: &[String]) -> bool { args.iter().any(|arg| { let lower = arg.to_ascii_lowercase(); @@ -369,6 +406,34 @@ fn format_restore_output(summary: &binlog::RestoreSummary, binlog_path: &Path) - #[cfg(test)] mod tests { use super::*; + use std::fs; + + fn build_dotnet_args_for_test( + subcommand: &str, + args: &[String], + with_trx: bool, + ) -> Vec { + let binlog_path = Path::new("/tmp/test.binlog"); + let trx_path = if with_trx { + Some(Path::new("/tmp/test results/test.trx")) + } else { + None + }; + + build_effective_dotnet_args(subcommand, args, binlog_path, trx_path) + } + + fn trx_with_counts(total: usize, passed: usize, failed: usize) -> String { + format!( + r#" + + + + +"#, + total, total, passed, failed + ) + } #[test] fn test_has_binlog_arg_detects_variants() { @@ -492,4 +557,152 @@ mod tests { assert_eq!(normalized.failed, 1); assert_eq!(normalized.total, 1); } + + #[test] + fn test_parse_trx_with_cleanup_deletes_file_after_parse() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let trx_path = temp_dir.path().join("results.trx"); + let trx = r#" + + + + +"#; + fs::write(&trx_path, trx).expect("write trx"); + + let summary = parse_trx_with_cleanup(&trx_path); + assert!(summary.is_some()); + assert!(!trx_path.exists()); + } + + #[test] + fn test_parse_trx_with_cleanup_non_existent_path_returns_none() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let trx_path = temp_dir.path().join("missing.trx"); + + let summary = parse_trx_with_cleanup(&trx_path); + assert!(summary.is_none()); + } + + #[test] + fn test_forwarding_args_with_spaces() { + let args = vec![ + "--filter".to_string(), + "FullyQualifiedName~MyTests.Calculator*".to_string(), + "-c".to_string(), + "Release".to_string(), + ]; + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(injected.contains(&"--filter".to_string())); + assert!(injected.contains(&"FullyQualifiedName~MyTests.Calculator*".to_string())); + assert!(injected.contains(&"-c".to_string())); + assert!(injected.contains(&"Release".to_string())); + } + + #[test] + fn test_forwarding_config_and_framework() { + let args = vec![ + "--configuration".to_string(), + "Release".to_string(), + "--framework".to_string(), + "net8.0".to_string(), + ]; + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(injected.contains(&"--configuration".to_string())); + assert!(injected.contains(&"Release".to_string())); + assert!(injected.contains(&"--framework".to_string())); + assert!(injected.contains(&"net8.0".to_string())); + } + + #[test] + fn test_forwarding_project_file() { + let args = vec![ + "--project".to_string(), + "src/My App.Tests/My App.Tests.csproj".to_string(), + ]; + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(injected.contains(&"--project".to_string())); + assert!(injected.contains(&"src/My App.Tests/My App.Tests.csproj".to_string())); + } + + #[test] + fn test_forwarding_no_build_and_no_restore() { + let args = vec!["--no-build".to_string(), "--no-restore".to_string()]; + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(injected.contains(&"--no-build".to_string())); + assert!(injected.contains(&"--no-restore".to_string())); + } + + #[test] + fn test_user_verbose_override() { + let args = vec!["-v:detailed".to_string()]; + + let injected = build_dotnet_args_for_test("test", &args, true); + let verbose_count = injected.iter().filter(|a| a.starts_with("-v:")).count(); + assert_eq!(verbose_count, 1); + assert!(injected.contains(&"-v:detailed".to_string())); + assert!(!injected.contains(&"-v:minimal".to_string())); + } + + #[test] + fn test_user_logger_override() { + let args = vec![ + "--logger".to_string(), + "console;verbosity=detailed".to_string(), + ]; + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(injected.contains(&"--logger".to_string())); + assert!(injected.contains(&"console;verbosity=detailed".to_string())); + assert!(!injected.iter().any(|a| a.contains("trx;LogFileName="))); + } + + #[test] + fn test_trx_logger_path_is_quoted_when_path_contains_spaces() { + let args = Vec::::new(); + + let injected = build_dotnet_args_for_test("test", &args, true); + let trx_arg = injected + .iter() + .find(|a| a.starts_with("trx;LogFileName=")) + .expect("trx logger argument exists"); + + assert!(trx_arg.contains("LogFileName=\"/tmp/test results/test.trx\"")); + } + + #[test] + fn test_maybe_fill_test_summary_from_trx_uses_primary_and_cleans_file() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let primary = temp_dir.path().join("primary.trx"); + fs::write(&primary, trx_with_counts(3, 3, 0)).expect("write primary trx"); + + let filled = + maybe_fill_test_summary_from_trx(binlog::TestSummary::default(), Some(&primary), None); + + assert_eq!(filled.total, 3); + assert_eq!(filled.passed, 3); + assert!(!primary.exists()); + } + + #[test] + fn test_maybe_fill_test_summary_from_trx_falls_back_to_testresults() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let fallback = temp_dir.path().join("fallback.trx"); + fs::write(&fallback, trx_with_counts(2, 1, 1)).expect("write fallback trx"); + let missing_primary = temp_dir.path().join("missing.trx"); + + let filled = maybe_fill_test_summary_from_trx( + binlog::TestSummary::default(), + Some(&missing_primary), + Some(fallback.clone()), + ); + + assert_eq!(filled.total, 2); + assert_eq!(filled.failed, 1); + assert!(fallback.exists()); + } } From 70bd78c1ea6d8ab69238cc723e6b52c9ae4a5510 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 11:08:05 +0100 Subject: [PATCH 03/11] Isolate trx handling --- src/binlog.rs | 212 +------------------------------------------ src/dotnet_cmd.rs | 7 +- src/dotnet_trx.rs | 222 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 228 insertions(+), 214 deletions(-) create mode 100644 src/dotnet_trx.rs diff --git a/src/binlog.rs b/src/binlog.rs index 0b7ba3e..b1ee32f 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -4,7 +4,7 @@ use lazy_static::lazy_static; use regex::Regex; use std::collections::HashSet; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BinlogIssue { @@ -75,17 +75,6 @@ lazy_static! { Regex::new(r"^[A-Za-z]{2,}\d{3,}$").expect("valid regex"); static ref SOURCE_FILE_RE: Regex = Regex::new(r"(?i)([A-Za-z]:)?[/\\][^\s]+\.(cs|vb|fs)") .expect("valid regex"); - // TRX (Visual Studio Test Results) parsing - // Note: (?s) enables DOTALL mode so . matches newlines - static ref TRX_COUNTERS_RE: Regex = Regex::new( - r#"\d+)"\s+executed="(?P\d+)"\s+passed="(?P\d+)"\s+failed="(?P\d+)""# - ).expect("valid regex"); - static ref TRX_TEST_RESULT_RE: Regex = Regex::new( - r#"(?s)]*testName="(?P[^"]+)"[^>]*outcome="(?P[^"]+)"[^>]*>(.*?)"# - ).expect("valid regex"); - static ref TRX_ERROR_MESSAGE_RE: Regex = Regex::new( - r#"(?s).*?(?P.*?).*?(?P.*?).*?"# - ).expect("valid regex"); } const SENSITIVE_ENV_VARS: &[&str] = &[ @@ -518,120 +507,9 @@ fn is_likely_diagnostic_code(code: &str) -> bool { .any(|prefix| code.starts_with(prefix)) } -/// Parse TRX (Visual Studio Test Results) file to extract test summary. -/// Returns None if the file doesn't exist or isn't a valid TRX file. -pub fn parse_trx_file(path: &Path) -> Option { - let content = std::fs::read_to_string(path).ok()?; - parse_trx_content(&content) -} - -pub fn find_recent_trx_in_testresults() -> Option { - find_recent_trx_in_dir(Path::new("./TestResults")) -} - -fn find_recent_trx_in_dir(dir: &Path) -> Option { - if !dir.exists() { - return None; - } - - std::fs::read_dir(dir) - .ok()? - .filter_map(|entry| entry.ok()) - .filter_map(|entry| { - let path = entry.path(); - let is_trx = path - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("trx")); - if !is_trx { - return None; - } - - let modified = entry.metadata().ok()?.modified().ok()?; - Some((modified, path)) - }) - .max_by_key(|(modified, _)| *modified) - .map(|(_, path)| path) -} - -fn parse_trx_content(content: &str) -> Option { - // Quick check if this looks like a TRX file - if !content.contains("") { - return None; - } - - let mut summary = TestSummary::default(); - - // Extract counters from ResultSummary - if let Some(captures) = TRX_COUNTERS_RE.captures(content) { - summary.total = captures - .name("total") - .and_then(|m| m.as_str().parse().ok()) - .unwrap_or(0); - summary.passed = captures - .name("passed") - .and_then(|m| m.as_str().parse().ok()) - .unwrap_or(0); - summary.failed = captures - .name("failed") - .and_then(|m| m.as_str().parse().ok()) - .unwrap_or(0); - } - - // Extract failed tests with details - for captures in TRX_TEST_RESULT_RE.captures_iter(content) { - let outcome = captures - .name("outcome") - .map(|m| m.as_str()) - .unwrap_or("Unknown"); - - if outcome != "Failed" { - continue; - } - - let name = captures - .name("name") - .map(|m| m.as_str().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - let full_match = captures.get(0).map(|m| m.as_str()).unwrap_or(""); - let mut details = Vec::new(); - - // Try to extract error message and stack trace - if let Some(error_caps) = TRX_ERROR_MESSAGE_RE.captures(full_match) { - if let Some(msg) = error_caps.name("message") { - details.push(msg.as_str().trim().to_string()); - } - if let Some(stack) = error_caps.name("stack") { - // Include first few lines of stack trace - let stack_lines: Vec<&str> = stack.as_str().lines().take(3).collect(); - if !stack_lines.is_empty() { - details.push(stack_lines.join("\n")); - } - } - } - - summary.failed_tests.push(FailedTest { name, details }); - } - - // Calculate skipped from counters if available - if summary.total > 0 { - summary.skipped = summary - .total - .saturating_sub(summary.passed + summary.failed); - } - - // Set project count to at least 1 if there were any tests - if summary.total > 0 { - summary.project_count = 1; - } - - Some(summary) -} - #[cfg(test)] mod tests { use super::*; - use std::time::Duration; #[test] fn test_scrub_sensitive_env_vars_masks_values() { @@ -775,92 +653,4 @@ Time Elapsed 00:00:00.12 assert!(!is_likely_diagnostic_code("NET451")); assert!(!is_likely_diagnostic_code("NET10")); } - - #[test] - fn test_parse_trx_content_extracts_passed_counts() { - let trx = r#" - - - - -"#; - - let summary = parse_trx_content(trx).expect("valid TRX"); - assert_eq!(summary.total, 5); - assert_eq!(summary.passed, 5); - assert_eq!(summary.failed, 0); - assert_eq!(summary.failed_tests.len(), 0); - } - - #[test] - fn test_parse_trx_content_extracts_failed_tests_with_details() { - let trx = r#" - - - - - - Expected 2 but was 3 - at MyTest.ShouldFail() in /src/Test.cs:line 10 - - - - - - - -"#; - - let summary = parse_trx_content(trx).expect("valid TRX"); - assert_eq!(summary.total, 3); - assert_eq!(summary.passed, 2); - assert_eq!(summary.failed, 1); - assert_eq!(summary.failed_tests.len(), 1); - assert_eq!(summary.failed_tests[0].name, "MyTest.ShouldFail"); - assert!(summary.failed_tests[0].details[0].contains("Expected 2")); - } - - #[test] - fn test_parse_trx_content_returns_none_for_invalid_xml() { - let not_trx = "This is not a TRX file"; - assert!(parse_trx_content(not_trx).is_none()); - } - - #[test] - fn test_find_recent_trx_in_dir_returns_none_when_missing() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let missing_dir = temp_dir.path().join("TestResults"); - - let found = find_recent_trx_in_dir(&missing_dir); - assert!(found.is_none()); - } - - #[test] - fn test_find_recent_trx_in_dir_picks_newest_trx() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let testresults_dir = temp_dir.path().join("TestResults"); - std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); - - let old_trx = testresults_dir.join("old.trx"); - let new_trx = testresults_dir.join("new.trx"); - std::fs::write(&old_trx, "old").expect("write old"); - std::thread::sleep(Duration::from_millis(5)); - std::fs::write(&new_trx, "new").expect("write new"); - - let found = find_recent_trx_in_dir(&testresults_dir).expect("should find newest trx"); - assert_eq!(found, new_trx); - } - - #[test] - fn test_find_recent_trx_in_dir_ignores_non_trx_files() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let testresults_dir = temp_dir.path().join("TestResults"); - std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); - - let txt = testresults_dir.join("notes.txt"); - std::fs::write(&txt, "noop").expect("write txt"); - - let found = find_recent_trx_in_dir(&testresults_dir); - assert!(found.is_none()); - } } diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 635a314..0575458 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -1,4 +1,5 @@ use crate::binlog; +use crate::dotnet_trx; use crate::tracking; use crate::utils::truncate; use anyhow::{Context, Result}; @@ -106,7 +107,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let summary = maybe_fill_test_summary_from_trx( parsed_summary, trx_path.as_deref(), - binlog::find_recent_trx_in_testresults(), + dotnet_trx::find_recent_trx_in_testresults(), ); let summary = normalize_test_summary(summary, output.status.success()); @@ -158,7 +159,7 @@ fn build_trx_path() -> PathBuf { } fn parse_trx_with_cleanup(path: &Path) -> Option { - let summary = binlog::parse_trx_file(path)?; + let summary = dotnet_trx::parse_trx_file(path)?; std::fs::remove_file(path).ok(); Some(summary) } @@ -179,7 +180,7 @@ fn maybe_fill_test_summary_from_trx( } if let Some(trx) = fallback_trx_path { - if let Some(trx_summary) = binlog::parse_trx_file(&trx) { + if let Some(trx_summary) = dotnet_trx::parse_trx_file(&trx) { return trx_summary; } } diff --git a/src/dotnet_trx.rs b/src/dotnet_trx.rs new file mode 100644 index 0000000..5969089 --- /dev/null +++ b/src/dotnet_trx.rs @@ -0,0 +1,222 @@ +use crate::binlog::{FailedTest, TestSummary}; +use lazy_static::lazy_static; +use regex::Regex; +use std::path::{Path, PathBuf}; + +lazy_static! { + // Note: (?s) enables DOTALL mode so . matches newlines + static ref TRX_COUNTERS_RE: Regex = Regex::new( + r#"\d+)"\s+executed="(?P\d+)"\s+passed="(?P\d+)"\s+failed="(?P\d+)""# + ) + .expect("valid regex"); + static ref TRX_TEST_RESULT_RE: Regex = Regex::new( + r#"(?s)]*testName="(?P[^"]+)"[^>]*outcome="(?P[^"]+)"[^>]*>(.*?)"# + ) + .expect("valid regex"); + static ref TRX_ERROR_MESSAGE_RE: Regex = Regex::new( + r#"(?s).*?(?P.*?).*?(?P.*?).*?"# + ) + .expect("valid regex"); +} + +/// Parse TRX (Visual Studio Test Results) file to extract test summary. +/// Returns None if the file doesn't exist or isn't a valid TRX file. +pub fn parse_trx_file(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + parse_trx_content(&content) +} + +pub fn find_recent_trx_in_testresults() -> Option { + find_recent_trx_in_dir(Path::new("./TestResults")) +} + +fn find_recent_trx_in_dir(dir: &Path) -> Option { + if !dir.exists() { + return None; + } + + std::fs::read_dir(dir) + .ok()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + let is_trx = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("trx")); + if !is_trx { + return None; + } + + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, path)) + }) + .max_by_key(|(modified, _)| *modified) + .map(|(_, path)| path) +} + +fn parse_trx_content(content: &str) -> Option { + // Quick check if this looks like a TRX file + if !content.contains("") { + return None; + } + + let mut summary = TestSummary::default(); + + // Extract counters from ResultSummary + if let Some(captures) = TRX_COUNTERS_RE.captures(content) { + summary.total = captures + .name("total") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + summary.passed = captures + .name("passed") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + summary.failed = captures + .name("failed") + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + } + + // Extract failed tests with details + for captures in TRX_TEST_RESULT_RE.captures_iter(content) { + let outcome = captures + .name("outcome") + .map(|m| m.as_str()) + .unwrap_or("Unknown"); + + if outcome != "Failed" { + continue; + } + + let name = captures + .name("name") + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let full_match = captures.get(0).map(|m| m.as_str()).unwrap_or(""); + let mut details = Vec::new(); + + // Try to extract error message and stack trace + if let Some(error_caps) = TRX_ERROR_MESSAGE_RE.captures(full_match) { + if let Some(msg) = error_caps.name("message") { + details.push(msg.as_str().trim().to_string()); + } + if let Some(stack) = error_caps.name("stack") { + // Include first few lines of stack trace + let stack_lines: Vec<&str> = stack.as_str().lines().take(3).collect(); + if !stack_lines.is_empty() { + details.push(stack_lines.join("\n")); + } + } + } + + summary.failed_tests.push(FailedTest { name, details }); + } + + // Calculate skipped from counters if available + if summary.total > 0 { + summary.skipped = summary + .total + .saturating_sub(summary.passed + summary.failed); + } + + // Set project count to at least 1 if there were any tests + if summary.total > 0 { + summary.project_count = 1; + } + + Some(summary) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_parse_trx_content_extracts_passed_counts() { + let trx = r#" + + + + +"#; + + let summary = parse_trx_content(trx).expect("valid TRX"); + assert_eq!(summary.total, 42); + assert_eq!(summary.passed, 40); + assert_eq!(summary.failed, 2); + assert_eq!(summary.skipped, 0); + } + + #[test] + fn test_parse_trx_content_extracts_failed_tests_with_details() { + let trx = r#" + + + + + + Expected: 5, Actual: 4 + at MyTests.Calculator.Add_ShouldFail()\nat line 42 + + + + + +"#; + + let summary = parse_trx_content(trx).expect("valid TRX"); + assert_eq!(summary.failed_tests.len(), 1); + assert_eq!( + summary.failed_tests[0].name, + "MyTests.Calculator.Add_ShouldFail" + ); + assert!(summary.failed_tests[0].details[0].contains("Expected: 5, Actual: 4")); + } + + #[test] + fn test_parse_trx_content_returns_none_for_invalid_xml() { + let not_trx = "This is not a TRX file"; + assert!(parse_trx_content(not_trx).is_none()); + } + + #[test] + fn test_find_recent_trx_in_dir_returns_none_when_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let missing_dir = temp_dir.path().join("TestResults"); + + let found = find_recent_trx_in_dir(&missing_dir); + assert!(found.is_none()); + } + + #[test] + fn test_find_recent_trx_in_dir_picks_newest_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let testresults_dir = temp_dir.path().join("TestResults"); + std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); + + let old_trx = testresults_dir.join("old.trx"); + let new_trx = testresults_dir.join("new.trx"); + std::fs::write(&old_trx, "old").expect("write old"); + std::thread::sleep(Duration::from_millis(5)); + std::fs::write(&new_trx, "new").expect("write new"); + + let found = find_recent_trx_in_dir(&testresults_dir).expect("should find newest trx"); + assert_eq!(found, new_trx); + } + + #[test] + fn test_find_recent_trx_in_dir_ignores_non_trx_files() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let testresults_dir = temp_dir.path().join("TestResults"); + std::fs::create_dir_all(&testresults_dir).expect("create TestResults"); + + let txt = testresults_dir.join("notes.txt"); + std::fs::write(&txt, "noop").expect("write txt"); + + let found = find_recent_trx_in_dir(&testresults_dir); + assert!(found.is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 992f6ba..0fb77f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod diff_cmd; mod discover; mod display_helpers; mod dotnet_cmd; +mod dotnet_trx; mod env_cmd; mod filter; mod find_cmd; From 31e8b3a15efb6c2860816987a5e8d8811b13ebab Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 11:11:19 +0100 Subject: [PATCH 04/11] Fixtures --- tests/fixtures/dotnet/build_failed.txt | 11 +++++++++++ tests/fixtures/dotnet/test_failed.txt | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/fixtures/dotnet/build_failed.txt create mode 100644 tests/fixtures/dotnet/test_failed.txt diff --git a/tests/fixtures/dotnet/build_failed.txt b/tests/fixtures/dotnet/build_failed.txt new file mode 100644 index 0000000..be4bdec --- /dev/null +++ b/tests/fixtures/dotnet/build_failed.txt @@ -0,0 +1,11 @@ + Determining projects to restore... + All projects are up-to-date for restore. +/private/tmp/RtkDotnetSmoke/Broken.cs(7,17): error CS1525: Invalid expression term ';' [/private/tmp/RtkDotnetSmoke/RtkDotnetSmoke.csproj] + +Build FAILED. + +/private/tmp/RtkDotnetSmoke/Broken.cs(7,17): error CS1525: Invalid expression term ';' [/private/tmp/RtkDotnetSmoke/RtkDotnetSmoke.csproj] + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:00.76 diff --git a/tests/fixtures/dotnet/test_failed.txt b/tests/fixtures/dotnet/test_failed.txt new file mode 100644 index 0000000..7bca9cc --- /dev/null +++ b/tests/fixtures/dotnet/test_failed.txt @@ -0,0 +1,18 @@ + Determining projects to restore... + All projects are up-to-date for restore. + RtkDotnetSmoke -> /private/tmp/RtkDotnetSmoke/bin/Debug/net10.0/RtkDotnetSmoke.dll +Test run for /private/tmp/RtkDotnetSmoke/bin/Debug/net10.0/RtkDotnetSmoke.dll (.NETCoreApp,Version=v10.0) +VSTest version 18.0.1 (arm64) + +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. +[xUnit.net 00:00:00.11] RtkDotnetSmoke.UnitTest1.Test1 [FAIL] + Failed RtkDotnetSmoke.UnitTest1.Test1 [4 ms] + Error Message: + Assert.Equal() Failure: Values differ +Expected: 2 +Actual: 3 + Stack Trace: + at RtkDotnetSmoke.UnitTest1.Test1() in /private/tmp/RtkDotnetSmoke/UnitTest1.cs:line 8 + +Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 13 ms - RtkDotnetSmoke.dll (net10.0) From 92be0825e5ac63b7e807a42bda52742d079b8a6b Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 11:53:59 +0100 Subject: [PATCH 05/11] Actual proper binlog parsing --- src/binlog.rs | 691 ++++++++++++++++++++++++++++++++++++++++------ src/dotnet_cmd.rs | 6 +- 2 files changed, 608 insertions(+), 89 deletions(-) diff --git a/src/binlog.rs b/src/binlog.rs index b1ee32f..cd073f0 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result}; -use flate2::read::{DeflateDecoder, GzDecoder}; +use flate2::read::GzDecoder; use lazy_static::lazy_static; use regex::Regex; use std::collections::HashSet; -use std::io::Read; +use std::io::{Cursor, Read}; use std::path::Path; #[derive(Debug, Clone, PartialEq, Eq)] @@ -98,19 +98,414 @@ const SENSITIVE_ENV_VARS: &[&str] = &[ "KUBECONFIG", ]; -pub fn parse_build(binlog_path: &Path, fallback_output: &str) -> Result { - let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); - Ok(parse_build_from_text(&source)) +const RECORD_END_OF_FILE: i32 = 0; +const RECORD_BUILD_STARTED: i32 = 1; +const RECORD_BUILD_FINISHED: i32 = 2; +const RECORD_PROJECT_STARTED: i32 = 3; +const RECORD_PROJECT_FINISHED: i32 = 4; +const RECORD_ERROR: i32 = 9; +const RECORD_WARNING: i32 = 10; +const RECORD_MESSAGE: i32 = 11; +const RECORD_CRITICAL_BUILD_MESSAGE: i32 = 13; +const RECORD_PROJECT_IMPORT_ARCHIVE: i32 = 17; +const RECORD_NAME_VALUE_LIST: i32 = 23; +const RECORD_STRING: i32 = 24; + +const FLAG_BUILD_EVENT_CONTEXT: i32 = 1 << 0; +const FLAG_MESSAGE: i32 = 1 << 2; +const FLAG_TIMESTAMP: i32 = 1 << 5; +const FLAG_ARGUMENTS: i32 = 1 << 14; +const FLAG_IMPORTANCE: i32 = 1 << 15; +const FLAG_EXTENDED: i32 = 1 << 16; + +const STRING_RECORD_START_INDEX: i32 = 10; + +pub fn parse_build(binlog_path: &Path) -> Result { + let parsed = parse_events_from_binlog(binlog_path) + .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; + + let duration_text = match (parsed.build_started_ticks, parsed.build_finished_ticks) { + (Some(start), Some(end)) if end >= start => Some(format_ticks_duration(end - start)), + _ => None, + }; + + Ok(BuildSummary { + succeeded: parsed.build_succeeded.unwrap_or(false), + project_count: parsed.project_files.len(), + errors: parsed.errors, + warnings: parsed.warnings, + duration_text, + }) +} + +pub fn parse_test(binlog_path: &Path) -> Result { + let parsed = parse_events_from_binlog(binlog_path) + .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; + let mut summary = parse_test_from_text(&parsed.messages.join("\n")); + summary.project_count = summary.project_count.max(parsed.project_files.len()); + Ok(summary) +} + +pub fn parse_restore(binlog_path: &Path) -> Result { + let parsed = parse_events_from_binlog(binlog_path) + .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; + let mut summary = parse_restore_from_text(&parsed.messages.join("\n")); + summary.restored_projects = summary.restored_projects.max(parsed.project_files.len()); + Ok(summary) } -pub fn parse_test(binlog_path: &Path, fallback_output: &str) -> Result { - let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); - Ok(parse_test_from_text(&source)) +#[derive(Default)] +struct ParsedBinlog { + string_records: Vec, + messages: Vec, + project_files: HashSet, + errors: Vec, + warnings: Vec, + build_succeeded: Option, + build_started_ticks: Option, + build_finished_ticks: Option, +} + +#[derive(Default)] +struct ParsedEventFields { + message: Option, + timestamp_ticks: Option, +} + +fn parse_events_from_binlog(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read binlog at {}", path.display()))?; + if bytes.is_empty() { + anyhow::bail!("Failed to parse binlog at {}: empty file", path.display()); + } + + let mut decoder = GzDecoder::new(bytes.as_slice()); + let mut payload = Vec::new(); + decoder.read_to_end(&mut payload).with_context(|| { + format!( + "Failed to parse binlog at {}: gzip decode failed", + path.display() + ) + })?; + + let mut reader = BinReader::new(&payload); + let file_format_version = reader + .read_i32_le() + .context("binlog header missing file format version")?; + let _minimum_reader_version = reader + .read_i32_le() + .context("binlog header missing minimum reader version")?; + + if file_format_version < 18 { + anyhow::bail!( + "Failed to parse binlog at {}: unsupported binlog format {}", + path.display(), + file_format_version + ); + } + + let mut parsed = ParsedBinlog::default(); + + while !reader.is_eof() { + let kind = reader + .read_7bit_i32() + .context("failed to read record kind")?; + if kind == RECORD_END_OF_FILE { + break; + } + + match kind { + RECORD_STRING => { + let text = reader + .read_dotnet_string() + .context("failed to read string record")?; + parsed.string_records.push(text); + } + RECORD_NAME_VALUE_LIST | RECORD_PROJECT_IMPORT_ARCHIVE => { + let len = reader + .read_7bit_i32() + .context("failed to read record length")?; + if len < 0 { + anyhow::bail!("negative record length: {}", len); + } + reader + .skip(len as usize) + .context("failed to skip auxiliary record payload")?; + } + _ => { + let len = reader + .read_7bit_i32() + .context("failed to read event length")?; + if len < 0 { + anyhow::bail!("negative event length: {}", len); + } + + let payload = reader + .read_exact(len as usize) + .context("failed to read event payload")?; + let mut event_reader = BinReader::new(payload); + parse_event_record(kind, &mut event_reader, file_format_version, &mut parsed)?; + } + } + } + + Ok(parsed) } -pub fn parse_restore(binlog_path: &Path, fallback_output: &str) -> Result { - let source = load_binlog_text(binlog_path).unwrap_or_else(|| fallback_output.to_string()); - Ok(parse_restore_from_text(&source)) +fn parse_event_record( + kind: i32, + reader: &mut BinReader<'_>, + file_format_version: i32, + parsed: &mut ParsedBinlog, +) -> Result<()> { + match kind { + RECORD_BUILD_STARTED => { + let fields = read_event_fields(reader, file_format_version, parsed, false)?; + parsed.build_started_ticks = fields.timestamp_ticks; + } + RECORD_BUILD_FINISHED => { + let fields = read_event_fields(reader, file_format_version, parsed, false)?; + parsed.build_finished_ticks = fields.timestamp_ticks; + parsed.build_succeeded = Some(reader.read_bool()?); + } + RECORD_PROJECT_STARTED => { + let _fields = read_event_fields(reader, file_format_version, parsed, false)?; + if reader.read_bool()? { + skip_build_event_context(reader, file_format_version)?; + } + if let Some(project_file) = read_optional_string(reader, parsed)? { + if !project_file.is_empty() { + parsed.project_files.insert(project_file); + } + } + } + RECORD_PROJECT_FINISHED => { + let _fields = read_event_fields(reader, file_format_version, parsed, false)?; + if let Some(project_file) = read_optional_string(reader, parsed)? { + if !project_file.is_empty() { + parsed.project_files.insert(project_file); + } + } + let _ = reader.read_bool()?; + } + RECORD_ERROR | RECORD_WARNING => { + let fields = read_event_fields(reader, file_format_version, parsed, false)?; + + let _subcategory = read_optional_string(reader, parsed)?; + let code = read_optional_string(reader, parsed)?.unwrap_or_default(); + let file = read_optional_string(reader, parsed)?.unwrap_or_default(); + let _project_file = read_optional_string(reader, parsed)?; + let line = reader.read_7bit_i32()?.max(0) as u32; + let column = reader.read_7bit_i32()?.max(0) as u32; + let _ = reader.read_7bit_i32()?; + let _ = reader.read_7bit_i32()?; + + let issue = BinlogIssue { + code, + file, + line, + column, + message: fields.message.unwrap_or_default(), + }; + + if kind == RECORD_ERROR { + parsed.errors.push(issue); + } else { + parsed.warnings.push(issue); + } + } + RECORD_MESSAGE => { + let fields = read_event_fields(reader, file_format_version, parsed, true)?; + if let Some(message) = fields.message { + parsed.messages.push(message); + } + } + RECORD_CRITICAL_BUILD_MESSAGE => { + let fields = read_event_fields(reader, file_format_version, parsed, false)?; + if let Some(message) = fields.message { + parsed.messages.push(message); + } + } + _ => {} + } + + Ok(()) +} + +fn read_event_fields( + reader: &mut BinReader<'_>, + file_format_version: i32, + parsed: &ParsedBinlog, + read_importance: bool, +) -> Result { + let flags = reader.read_7bit_i32()?; + let mut result = ParsedEventFields::default(); + + if flags & FLAG_MESSAGE != 0 { + result.message = read_deduplicated_string(reader, parsed)?; + } + + if flags & FLAG_BUILD_EVENT_CONTEXT != 0 { + skip_build_event_context(reader, file_format_version)?; + } + + if flags & FLAG_TIMESTAMP != 0 { + result.timestamp_ticks = Some(reader.read_i64_le()?); + let _ = reader.read_7bit_i32()?; + } + + if flags & FLAG_EXTENDED != 0 { + let _ = read_optional_string(reader, parsed)?; + skip_string_dictionary(reader, file_format_version)?; + let _ = read_optional_string(reader, parsed)?; + } + + if flags & FLAG_ARGUMENTS != 0 { + let count = reader.read_7bit_i32()?.max(0) as usize; + for _ in 0..count { + let _ = read_deduplicated_string(reader, parsed)?; + } + } + + if (file_format_version < 13 && read_importance) || (flags & FLAG_IMPORTANCE != 0) { + let _ = reader.read_7bit_i32()?; + } + + Ok(result) +} + +fn skip_build_event_context(reader: &mut BinReader<'_>, file_format_version: i32) -> Result<()> { + let count = if file_format_version > 1 { 7 } else { 6 }; + for _ in 0..count { + let _ = reader.read_7bit_i32()?; + } + Ok(()) +} + +fn skip_string_dictionary(reader: &mut BinReader<'_>, file_format_version: i32) -> Result<()> { + if file_format_version < 10 { + anyhow::bail!("legacy dictionary format is unsupported"); + } + + let _ = reader.read_7bit_i32()?; + Ok(()) +} + +fn read_optional_string( + reader: &mut BinReader<'_>, + parsed: &ParsedBinlog, +) -> Result> { + read_deduplicated_string(reader, parsed) +} + +fn read_deduplicated_string( + reader: &mut BinReader<'_>, + parsed: &ParsedBinlog, +) -> Result> { + let index = reader.read_7bit_i32()?; + match index { + 0 => Ok(None), + 1 => Ok(Some(String::new())), + i if i >= STRING_RECORD_START_INDEX => { + let record_idx = (i - STRING_RECORD_START_INDEX) as usize; + parsed + .string_records + .get(record_idx) + .cloned() + .map(Some) + .with_context(|| format!("invalid string record index {}", i)) + } + _ => Ok(None), + } +} + +fn format_ticks_duration(ticks: i64) -> String { + let total_seconds = ticks.div_euclid(10_000_000); + let centiseconds = (ticks.rem_euclid(10_000_000) / 100_000) as i64; + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + format!( + "{:02}:{:02}:{:02}.{:02}", + hours, minutes, seconds, centiseconds + ) +} + +struct BinReader<'a> { + cursor: Cursor<&'a [u8]>, +} + +impl<'a> BinReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { + cursor: Cursor::new(bytes), + } + } + + fn is_eof(&self) -> bool { + (self.cursor.position() as usize) >= self.cursor.get_ref().len() + } + + fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> { + let start = self.cursor.position() as usize; + let end = start.saturating_add(len); + if end > self.cursor.get_ref().len() { + anyhow::bail!("unexpected end of stream"); + } + self.cursor.set_position(end as u64); + Ok(&self.cursor.get_ref()[start..end]) + } + + fn skip(&mut self, len: usize) -> Result<()> { + let _ = self.read_exact(len)?; + Ok(()) + } + + fn read_u8(&mut self) -> Result { + Ok(self.read_exact(1)?[0]) + } + + fn read_bool(&mut self) -> Result { + Ok(self.read_u8()? != 0) + } + + fn read_i32_le(&mut self) -> Result { + let b = self.read_exact(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn read_i64_le(&mut self) -> Result { + let b = self.read_exact(8)?; + Ok(i64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + fn read_7bit_i32(&mut self) -> Result { + let mut value: u32 = 0; + let mut shift = 0; + loop { + let byte = self.read_u8()?; + value |= ((byte & 0x7F) as u32) << shift; + if (byte & 0x80) == 0 { + return Ok(value as i32); + } + + shift += 7; + if shift >= 35 { + anyhow::bail!("invalid 7-bit encoded integer"); + } + } + } + + fn read_dotnet_string(&mut self) -> Result { + let len = self.read_7bit_i32()?; + if len < 0 { + anyhow::bail!("negative string length: {}", len); + } + let bytes = self.read_exact(len as usize)?; + String::from_utf8(bytes.to_vec()).context("invalid UTF-8 string") + } } pub fn scrub_sensitive_env_vars(input: &str) -> String { @@ -234,7 +629,18 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { } } - if summary.errors.is_empty() { + let has_error_signal = scrubbed.contains("Build FAILED") + || scrubbed.contains(": error ") + || BUILD_SUMMARY_RE.captures_iter(&scrubbed).any(|captures| { + let is_error = matches!(captures.name("kind").map(|m| m.as_str()), Some("Error")); + let count = captures + .name("count") + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + is_error && count > 0 + }); + + if summary.errors.is_empty() && !summary.succeeded && has_error_signal { summary.errors = extract_binary_like_issues(&scrubbed); } @@ -352,75 +758,6 @@ fn extract_duration(text: &str) -> Option { .map(|m| m.as_str().trim().to_string()) } -fn load_binlog_text(path: &Path) -> Option { - if !path.exists() { - return None; - } - - let bytes = std::fs::read(path) - .with_context(|| format!("Failed to read binlog at {}", path.display())) - .ok()?; - - if bytes.is_empty() { - return None; - } - - if let Some(decoded) = try_gzip_decode(&bytes) { - let text = String::from_utf8_lossy(&decoded).into_owned(); - if looks_like_console_output(&text) { - return Some(text); - } - } - - if let Some(decoded) = try_deflate_decode(&bytes) { - let text = String::from_utf8_lossy(&decoded).into_owned(); - if looks_like_console_output(&text) { - return Some(text); - } - } - - let plain = String::from_utf8_lossy(&bytes).into_owned(); - if looks_like_console_output(&plain) { - return Some(plain); - } - - None -} - -fn looks_like_console_output(text: &str) -> bool { - let markers = [ - "Build succeeded", - "Build FAILED", - "Passed!", - "Failed!", - "Time Elapsed", - ".csproj", - ": error ", - ": warning ", - "Restored ", - ]; - - markers.iter().any(|marker| text.contains(marker)) -} - -fn try_gzip_decode(bytes: &[u8]) -> Option> { - let mut decoder = GzDecoder::new(bytes); - let mut output = Vec::new(); - if decoder.read_to_end(&mut output).is_ok() && !output.is_empty() { - return Some(output); - } - None -} - -fn try_deflate_decode(bytes: &[u8]) -> Option> { - let mut decoder = DeflateDecoder::new(bytes); - let mut output = Vec::new(); - if decoder.read_to_end(&mut output).is_ok() && !output.is_empty() { - return Some(output); - } - None -} - fn extract_printable_runs(text: &str) -> Vec { let mut runs = Vec::new(); for captures in PRINTABLE_RUN_RE.captures_iter(text) { @@ -510,6 +847,40 @@ fn is_likely_diagnostic_code(code: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + fn write_7bit_i32(buf: &mut Vec, value: i32) { + let mut v = value as u32; + while v >= 0x80 { + buf.push(((v as u8) & 0x7F) | 0x80); + v >>= 7; + } + buf.push(v as u8); + } + + fn write_dotnet_string(buf: &mut Vec, value: &str) { + write_7bit_i32(buf, value.len() as i32); + buf.extend_from_slice(value.as_bytes()); + } + + fn write_event_record(target: &mut Vec, kind: i32, payload: &[u8]) { + write_7bit_i32(target, kind); + write_7bit_i32(target, payload.len() as i32); + target.extend_from_slice(payload); + } + + fn build_minimal_binlog(records: &[u8]) -> Vec { + let mut plain = Vec::new(); + plain.extend_from_slice(&25_i32.to_le_bytes()); + plain.extend_from_slice(&18_i32.to_le_bytes()); + plain.extend_from_slice(records); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&plain).expect("write plain payload"); + encoder.finish().expect("finish gzip") + } #[test] fn test_scrub_sensitive_env_vars_masks_values() { @@ -579,18 +950,157 @@ Failed! - Failed: 2, Passed: 245, Skipped: 0, Total: 247, Duration: } #[test] - fn test_parse_build_uses_fallback_when_binlog_is_binary() { + fn test_parse_build_fails_when_binlog_is_unparseable() { let temp_dir = tempfile::tempdir().expect("create temp dir"); let binlog_path = temp_dir.path().join("build.binlog"); std::fs::write(&binlog_path, [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00]) .expect("write binary file"); - let fallback = include_str!("../tests/fixtures/dotnet/build_failed.txt"); - let summary = parse_build(&binlog_path, fallback).expect("parse should not fail"); + let err = parse_build(&binlog_path).expect_err("parse should fail"); + assert!( + err.to_string().contains("Failed to parse binlog"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_parse_build_fails_when_binlog_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("build.binlog"); + + let err = parse_build(&binlog_path).expect_err("parse should fail"); + assert!( + err.to_string().contains("Failed to parse binlog"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_parse_build_reads_structured_events() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("build.binlog"); + let mut records = Vec::new(); + + // String records (index starts at 10) + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "Build started"); // 10 + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "Build finished"); // 11 + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "src/App.csproj"); // 12 + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "The name 'foo' does not exist"); // 13 + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "CS0103"); // 14 + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string(&mut records, "src/Program.cs"); // 15 + + // BuildStarted (message + timestamp) + let mut build_started = Vec::new(); + write_7bit_i32(&mut build_started, FLAG_MESSAGE | FLAG_TIMESTAMP); + write_7bit_i32(&mut build_started, 10); + build_started.extend_from_slice(&1_000_000_000_i64.to_le_bytes()); + write_7bit_i32(&mut build_started, 1); + write_event_record(&mut records, RECORD_BUILD_STARTED, &build_started); + + // ProjectFinished + let mut project_finished = Vec::new(); + write_7bit_i32(&mut project_finished, 0); + write_7bit_i32(&mut project_finished, 12); + project_finished.push(1); + write_event_record(&mut records, RECORD_PROJECT_FINISHED, &project_finished); + + // Error event + let mut error_event = Vec::new(); + write_7bit_i32(&mut error_event, FLAG_MESSAGE); + write_7bit_i32(&mut error_event, 13); + write_7bit_i32(&mut error_event, 0); // subcategory + write_7bit_i32(&mut error_event, 14); // code + write_7bit_i32(&mut error_event, 15); // file + write_7bit_i32(&mut error_event, 0); // project file + write_7bit_i32(&mut error_event, 42); + write_7bit_i32(&mut error_event, 10); + write_7bit_i32(&mut error_event, 42); + write_7bit_i32(&mut error_event, 10); + write_event_record(&mut records, RECORD_ERROR, &error_event); + + // BuildFinished (message + timestamp + succeeded) + let mut build_finished = Vec::new(); + write_7bit_i32(&mut build_finished, FLAG_MESSAGE | FLAG_TIMESTAMP); + write_7bit_i32(&mut build_finished, 11); + build_finished.extend_from_slice(&1_010_000_000_i64.to_le_bytes()); + write_7bit_i32(&mut build_finished, 1); + build_finished.push(1); + write_event_record(&mut records, RECORD_BUILD_FINISHED, &build_finished); + + write_7bit_i32(&mut records, RECORD_END_OF_FILE); + + let binlog_bytes = build_minimal_binlog(&records); + std::fs::write(&binlog_path, binlog_bytes).expect("write binlog"); + + let summary = parse_build(&binlog_path).expect("parse should succeed"); + assert!(summary.succeeded); + assert_eq!(summary.project_count, 1); assert_eq!(summary.errors.len(), 1); - assert_eq!(summary.warnings.len(), 0); - assert_eq!(summary.errors[0].code, "CS1525"); + assert_eq!(summary.errors[0].code, "CS0103"); + assert_eq!(summary.duration_text.as_deref(), Some("00:00:01.00")); + } + + #[test] + fn test_parse_test_reads_message_events() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("test.binlog"); + + let mut records = Vec::new(); + write_7bit_i32(&mut records, RECORD_STRING); + write_dotnet_string( + &mut records, + "Failed! - Failed: 1, Passed: 2, Skipped: 0, Total: 3, Duration: 1 s", + ); // 10 + + let mut message_event = Vec::new(); + write_7bit_i32(&mut message_event, FLAG_MESSAGE | FLAG_IMPORTANCE); + write_7bit_i32(&mut message_event, 10); + write_7bit_i32(&mut message_event, 1); + write_event_record(&mut records, RECORD_MESSAGE, &message_event); + + write_7bit_i32(&mut records, RECORD_END_OF_FILE); + let binlog_bytes = build_minimal_binlog(&records); + std::fs::write(&binlog_path, binlog_bytes).expect("write binlog"); + + let summary = parse_test(&binlog_path).expect("parse should succeed"); + assert_eq!(summary.failed, 1); + assert_eq!(summary.passed, 2); + assert_eq!(summary.total, 3); + } + + #[test] + fn test_parse_test_fails_when_binlog_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("test.binlog"); + + let err = parse_test(&binlog_path).expect_err("parse should fail"); + assert!( + err.to_string().contains("Failed to parse binlog"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_parse_restore_fails_when_binlog_missing() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binlog_path = temp_dir.path().join("restore.binlog"); + + let err = parse_restore(&binlog_path).expect_err("parse should fail"); + assert!( + err.to_string().contains("Failed to parse binlog"), + "unexpected error: {}", + err + ); } #[test] @@ -620,6 +1130,15 @@ Time Elapsed 00:00:00.12 assert!(summary.succeeded); } + #[test] + fn test_parse_build_does_not_infer_binary_errors_on_successful_build() { + let input = "\x0bInvalid expression term ';'\x18\x06CS1525\x18%/tmp/App/Broken.cs\x09\nBuild succeeded.\n 0 Warning(s)\n 0 Error(s)\n"; + + let summary = parse_build_from_text(input); + assert!(summary.succeeded); + assert!(summary.errors.is_empty()); + } + #[test] fn test_parse_test_from_fixture_text() { let input = include_str!("../tests/fixtures/dotnet/test_failed.txt"); diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 0575458..d9c1055 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -96,14 +96,14 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let filtered = match subcommand { "build" => { let summary = normalize_build_summary( - binlog::parse_build(&binlog_path, &raw)?, + binlog::parse_build(&binlog_path)?, output.status.success(), ); format_build_output(&summary, &binlog_path) } "test" => { // First try to parse from binlog/console output - let parsed_summary = binlog::parse_test(&binlog_path, &raw)?; + let parsed_summary = binlog::parse_test(&binlog_path)?; let summary = maybe_fill_test_summary_from_trx( parsed_summary, trx_path.as_deref(), @@ -114,7 +114,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res format_test_output(&summary, &binlog_path) } "restore" => { - let summary = binlog::parse_restore(&binlog_path, &raw)?; + let summary = binlog::parse_restore(&binlog_path)?; format_restore_output(&summary, &binlog_path) } _ => raw.clone(), From c39120f91fac98022f9bee551e4643468203733d Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 12:33:54 +0100 Subject: [PATCH 06/11] Fix summary and failure detection --- src/binlog.rs | 134 +++++++++++++++++++++++++++++++++--- src/dotnet_cmd.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 292 insertions(+), 10 deletions(-) diff --git a/src/binlog.rs b/src/binlog.rs index cd073f0..4fca1c8 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -123,34 +123,105 @@ const STRING_RECORD_START_INDEX: i32 = 10; pub fn parse_build(binlog_path: &Path) -> Result { let parsed = parse_events_from_binlog(binlog_path) .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; + let strings_blob = parsed.string_records.join("\n"); + let text_fallback = parse_build_from_text(&strings_blob); let duration_text = match (parsed.build_started_ticks, parsed.build_finished_ticks) { (Some(start), Some(end)) if end >= start => Some(format_ticks_duration(end - start)), _ => None, }; + let parsed_project_count = parsed.project_files.len(); + Ok(BuildSummary { succeeded: parsed.build_succeeded.unwrap_or(false), - project_count: parsed.project_files.len(), - errors: parsed.errors, - warnings: parsed.warnings, + project_count: if parsed_project_count > 0 { + parsed_project_count + } else { + text_fallback.project_count + }, + errors: select_best_issues(parsed.errors, text_fallback.errors), + warnings: select_best_issues(parsed.warnings, text_fallback.warnings), duration_text, }) } +fn select_best_issues(primary: Vec, fallback: Vec) -> Vec { + if primary.is_empty() { + return fallback; + } + if fallback.is_empty() { + return primary; + } + + if primary.iter().all(is_suspicious_issue) && fallback.iter().any(is_contextual_issue) { + return fallback; + } + + let primary_score = issues_quality_score(&primary); + let fallback_score = issues_quality_score(&fallback); + if fallback_score > primary_score { + fallback + } else { + primary + } +} + +fn issues_quality_score(issues: &[BinlogIssue]) -> usize { + issues.iter().map(issue_quality_score).sum() +} + +fn issue_quality_score(issue: &BinlogIssue) -> usize { + let mut score = 0; + + if is_contextual_issue(issue) { + score += 4; + } + if !issue.code.is_empty() && is_likely_diagnostic_code(&issue.code) { + score += 2; + } + if issue.line > 0 { + score += 1; + } + if issue.column > 0 { + score += 1; + } + if !issue.message.is_empty() && issue.message != "Build issue" { + score += 1; + } + + score +} + +fn is_contextual_issue(issue: &BinlogIssue) -> bool { + !issue.file.is_empty() && !is_likely_diagnostic_code(&issue.file) +} + +fn is_suspicious_issue(issue: &BinlogIssue) -> bool { + issue.code.is_empty() && is_likely_diagnostic_code(&issue.file) +} + pub fn parse_test(binlog_path: &Path) -> Result { let parsed = parse_events_from_binlog(binlog_path) .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; - let mut summary = parse_test_from_text(&parsed.messages.join("\n")); - summary.project_count = summary.project_count.max(parsed.project_files.len()); + let blob = parsed.string_records.join("\n"); + let mut summary = parse_test_from_text(&blob); + let parsed_project_count = parsed.project_files.len(); + if parsed_project_count > 0 { + summary.project_count = parsed_project_count; + } Ok(summary) } pub fn parse_restore(binlog_path: &Path) -> Result { let parsed = parse_events_from_binlog(binlog_path) .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?; - let mut summary = parse_restore_from_text(&parsed.messages.join("\n")); - summary.restored_projects = summary.restored_projects.max(parsed.project_files.len()); + let blob = parsed.string_records.join("\n"); + let mut summary = parse_restore_from_text(&blob); + let parsed_project_count = parsed.project_files.len(); + if parsed_project_count > 0 { + summary.restored_projects = parsed_project_count; + } Ok(summary) } @@ -244,7 +315,8 @@ fn parse_events_from_binlog(path: &Path) -> Result { .read_exact(len as usize) .context("failed to read event payload")?; let mut event_reader = BinReader::new(payload); - parse_event_record(kind, &mut event_reader, file_format_version, &mut parsed)?; + let _ = + parse_event_record(kind, &mut event_reader, file_format_version, &mut parsed); } } } @@ -421,7 +493,7 @@ fn read_deduplicated_string( fn format_ticks_duration(ticks: i64) -> String { let total_seconds = ticks.div_euclid(10_000_000); - let centiseconds = (ticks.rem_euclid(10_000_000) / 100_000) as i64; + let centiseconds = ticks.rem_euclid(10_000_000) / 100_000; let hours = total_seconds / 3600; let minutes = (total_seconds % 3600) / 60; let seconds = total_seconds % 60; @@ -1172,4 +1244,48 @@ Time Elapsed 00:00:00.12 assert!(!is_likely_diagnostic_code("NET451")); assert!(!is_likely_diagnostic_code("NET10")); } + + #[test] + fn test_select_best_issues_prefers_fallback_when_primary_loses_context() { + let primary = vec![BinlogIssue { + code: String::new(), + file: "CS1525".to_string(), + line: 51, + column: 1, + message: "Invalid expression term ';'".to_string(), + }]; + + let fallback = vec![BinlogIssue { + code: "CS1525".to_string(), + file: "/Users/dev/project/src/NServiceBus.Core/Class1.cs".to_string(), + line: 1, + column: 9, + message: "Invalid expression term ';'".to_string(), + }]; + + let selected = select_best_issues(primary, fallback.clone()); + assert_eq!(selected, fallback); + } + + #[test] + fn test_select_best_issues_keeps_primary_when_context_is_good() { + let primary = vec![BinlogIssue { + code: "CS0103".to_string(), + file: "src/Program.cs".to_string(), + line: 42, + column: 15, + message: "The name 'foo' does not exist".to_string(), + }]; + + let fallback = vec![BinlogIssue { + code: "CS0103".to_string(), + file: String::new(), + line: 0, + column: 0, + message: "Build error #1 (details omitted)".to_string(), + }]; + + let selected = select_best_issues(primary.clone(), fallback); + assert_eq!(selected, primary); + } } diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index d9c1055..d2c1ebb 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -95,10 +95,15 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let filtered = match subcommand { "build" => { - let summary = normalize_build_summary( + let binlog_summary = normalize_build_summary( binlog::parse_build(&binlog_path)?, output.status.success(), ); + let raw_summary = normalize_build_summary( + binlog::parse_build_from_text(&raw), + output.status.success(), + ); + let summary = merge_build_summaries(binlog_summary, raw_summary); format_build_output(&summary, &binlog_path) } "test" => { @@ -261,6 +266,87 @@ fn normalize_build_summary( summary } +fn merge_build_summaries( + mut binlog_summary: binlog::BuildSummary, + raw_summary: binlog::BuildSummary, +) -> binlog::BuildSummary { + binlog_summary.errors = select_preferred_issues(binlog_summary.errors, raw_summary.errors); + binlog_summary.warnings = + select_preferred_issues(binlog_summary.warnings, raw_summary.warnings); + + if binlog_summary.project_count == 0 { + binlog_summary.project_count = raw_summary.project_count; + } + if binlog_summary.duration_text.is_none() { + binlog_summary.duration_text = raw_summary.duration_text; + } + + binlog_summary +} + +fn select_preferred_issues( + binlog_issues: Vec, + raw_issues: Vec, +) -> Vec { + if binlog_issues.is_empty() { + return raw_issues; + } + if raw_issues.is_empty() { + return binlog_issues; + } + + let binlog_score = issues_quality_score(&binlog_issues); + let raw_score = issues_quality_score(&raw_issues); + + if raw_score > binlog_score + || (raw_score == binlog_score && raw_issues.len() > binlog_issues.len()) + { + raw_issues + } else { + binlog_issues + } +} + +fn issues_quality_score(issues: &[binlog::BinlogIssue]) -> usize { + issues.iter().map(issue_quality_score).sum() +} + +fn issue_quality_score(issue: &binlog::BinlogIssue) -> usize { + let mut score = 0; + + if !issue.file.is_empty() && !looks_like_diagnostic_token(&issue.file) { + score += 4; + } + if !issue.code.is_empty() { + score += 2; + } + if issue.line > 0 { + score += 1; + } + if issue.column > 0 { + score += 1; + } + + score +} + +fn looks_like_diagnostic_token(value: &str) -> bool { + let mut letters = 0; + let mut digits = 0; + + for c in value.chars() { + if c.is_ascii_alphabetic() { + letters += 1; + } else if c.is_ascii_digit() { + digits += 1; + } else { + return false; + } + } + + letters >= 2 && digits >= 3 +} + fn normalize_test_summary( mut summary: binlog::TestSummary, command_success: bool, @@ -542,6 +628,86 @@ mod tests { assert_eq!(normalized.project_count, 1); } + #[test] + fn test_merge_build_summaries_prefers_raw_when_binlog_loses_context() { + let binlog_summary = binlog::BuildSummary { + succeeded: false, + project_count: 11, + errors: vec![binlog::BinlogIssue { + code: String::new(), + file: "IDE0055".to_string(), + line: 0, + column: 0, + message: "Fix formatting".to_string(), + }], + warnings: Vec::new(), + duration_text: Some("00:00:03.54".to_string()), + }; + + let raw_summary = binlog::BuildSummary { + succeeded: false, + project_count: 2, + errors: vec![ + binlog::BinlogIssue { + code: "IDE0055".to_string(), + file: "/repo/src/Behavior.cs".to_string(), + line: 13, + column: 32, + message: "Fix formatting".to_string(), + }, + binlog::BinlogIssue { + code: "IDE0055".to_string(), + file: "/repo/src/Behavior.cs".to_string(), + line: 13, + column: 41, + message: "Fix formatting".to_string(), + }, + ], + warnings: Vec::new(), + duration_text: Some("00:00:03.54".to_string()), + }; + + let merged = merge_build_summaries(binlog_summary, raw_summary); + assert_eq!(merged.project_count, 11); + assert_eq!(merged.errors.len(), 2); + assert_eq!(merged.errors[0].line, 13); + assert_eq!(merged.errors[0].column, 32); + } + + #[test] + fn test_merge_build_summaries_keeps_binlog_when_context_is_good() { + let binlog_summary = binlog::BuildSummary { + succeeded: false, + project_count: 2, + errors: vec![binlog::BinlogIssue { + code: "CS0103".to_string(), + file: "src/Program.cs".to_string(), + line: 42, + column: 15, + message: "The name 'foo' does not exist".to_string(), + }], + warnings: Vec::new(), + duration_text: Some("00:00:01.00".to_string()), + }; + + let raw_summary = binlog::BuildSummary { + succeeded: false, + project_count: 2, + errors: vec![binlog::BinlogIssue { + code: "CS0103".to_string(), + file: String::new(), + line: 0, + column: 0, + message: "Build error #1 (details omitted)".to_string(), + }], + warnings: Vec::new(), + duration_text: None, + }; + + let merged = merge_build_summaries(binlog_summary.clone(), raw_summary); + assert_eq!(merged.errors, binlog_summary.errors); + } + #[test] fn test_normalize_test_summary_sets_failure_floor() { let summary = binlog::TestSummary { From 0136a857273231c1d4597b188bcb953882f026e0 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 12:52:36 +0100 Subject: [PATCH 07/11] Restore fix --- src/binlog.rs | 93 +++++++++++++++++++++++++++-- src/dotnet_cmd.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 8 deletions(-) diff --git a/src/binlog.rs b/src/binlog.rs index 4fca1c8..6308d3d 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -66,8 +66,10 @@ lazy_static! { Regex::new(r"(?m)^\s*Failed\s+(?P[^\r\n\[]+)").expect("valid regex"); static ref RESTORE_PROJECT_RE: Regex = Regex::new(r"(?m)^\s*Restored\s+.+\.csproj\s*\(").expect("valid regex"); - static ref WARNING_COUNT_RE: Regex = Regex::new(r"(?m)^\s*warning\s+").expect("valid regex"); - static ref ERROR_COUNT_RE: Regex = Regex::new(r"(?m)^\s*error\s+").expect("valid regex"); + static ref RESTORE_DIAGNOSTIC_RE: Regex = Regex::new( + r"(?mi)^\s*(?:(?P.+?)\s+:\s+)?(?Pwarning|error)\s+(?P[A-Za-z]{2,}\d{3,})\s*:\s*(?P.+)$" + ) + .expect("valid regex"); static ref PROJECT_PATH_RE: Regex = Regex::new(r"(?m)^\s*([A-Za-z]:)?[^\r\n]*\.csproj(?:\s|$)").expect("valid regex"); static ref PRINTABLE_RUN_RE: Regex = Regex::new(r"[\x20-\x7E]{5,}").expect("valid regex"); @@ -810,15 +812,71 @@ pub fn parse_test_from_text(text: &str) -> TestSummary { } pub fn parse_restore_from_text(text: &str) -> RestoreSummary { + let (errors, warnings) = parse_restore_issues_from_text(text); let scrubbed = scrub_sensitive_env_vars(text); + RestoreSummary { restored_projects: RESTORE_PROJECT_RE.captures_iter(&scrubbed).count(), - warnings: WARNING_COUNT_RE.captures_iter(&scrubbed).count(), - errors: ERROR_COUNT_RE.captures_iter(&scrubbed).count(), + warnings: warnings.len(), + errors: errors.len(), duration_text: extract_duration(&scrubbed), } } +pub fn parse_restore_issues_from_text(text: &str) -> (Vec, Vec) { + let scrubbed = scrub_sensitive_env_vars(text); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + let mut seen_errors: HashSet<(String, String, u32, u32, String)> = HashSet::new(); + let mut seen_warnings: HashSet<(String, String, u32, u32, String)> = HashSet::new(); + + for captures in RESTORE_DIAGNOSTIC_RE.captures_iter(&scrubbed) { + let issue = BinlogIssue { + code: captures + .name("code") + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default(), + file: captures + .name("file") + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default(), + line: 0, + column: 0, + message: captures + .name("msg") + .map(|m| m.as_str().trim().to_string()) + .unwrap_or_default(), + }; + + let key = ( + issue.code.clone(), + issue.file.clone(), + issue.line, + issue.column, + issue.message.clone(), + ); + + match captures + .name("kind") + .map(|m| m.as_str().to_ascii_lowercase()) + { + Some(kind) if kind == "error" => { + if seen_errors.insert(key) { + errors.push(issue); + } + } + Some(kind) if kind == "warning" => { + if seen_warnings.insert(key) { + warnings.push(issue); + } + } + _ => {} + } + } + + (errors, warnings) +} + fn count_projects(text: &str) -> usize { PROJECT_PATH_RE.captures_iter(text).count() } @@ -1021,6 +1079,33 @@ Failed! - Failed: 2, Passed: 245, Skipped: 0, Total: 247, Duration: assert_eq!(summary.errors, 0); } + #[test] + fn test_parse_restore_from_text_extracts_nuget_error_diagnostic() { + let input = r#" +/Users/dev/src/App/App.csproj : error NU1101: Unable to find package Foo.Bar. No packages exist with this id in source(s): nuget.org + +Restore failed with 1 error(s) in 1.0s +"#; + + let summary = parse_restore_from_text(input); + assert_eq!(summary.errors, 1); + assert_eq!(summary.warnings, 0); + } + + #[test] + fn test_parse_restore_issues_ignores_summary_warning_error_counts() { + let input = r#" + 0 Warning(s) + 1 Error(s) + + Time Elapsed 00:00:01.23 +"#; + + let (errors, warnings) = parse_restore_issues_from_text(input); + assert_eq!(errors.len(), 0); + assert_eq!(warnings.len(), 0); + } + #[test] fn test_parse_build_fails_when_binlog_is_unparseable() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index d2c1ebb..8ac0bf7 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -119,8 +119,19 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res format_test_output(&summary, &binlog_path) } "restore" => { - let summary = binlog::parse_restore(&binlog_path)?; - format_restore_output(&summary, &binlog_path) + let binlog_summary = normalize_restore_summary( + binlog::parse_restore(&binlog_path)?, + output.status.success(), + ); + let raw_summary = normalize_restore_summary( + binlog::parse_restore_from_text(&raw), + output.status.success(), + ); + let summary = merge_restore_summaries(binlog_summary, raw_summary); + + let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw); + + format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path) } _ => raw.clone(), }; @@ -365,6 +376,37 @@ fn normalize_test_summary( summary } +fn normalize_restore_summary( + mut summary: binlog::RestoreSummary, + command_success: bool, +) -> binlog::RestoreSummary { + if !command_success && summary.errors == 0 { + summary.errors = 1; + } + + summary +} + +fn merge_restore_summaries( + mut binlog_summary: binlog::RestoreSummary, + raw_summary: binlog::RestoreSummary, +) -> binlog::RestoreSummary { + if binlog_summary.restored_projects == 0 { + binlog_summary.restored_projects = raw_summary.restored_projects; + } + if raw_summary.errors > binlog_summary.errors { + binlog_summary.errors = raw_summary.errors; + } + if raw_summary.warnings > binlog_summary.warnings { + binlog_summary.warnings = raw_summary.warnings; + } + if binlog_summary.duration_text.is_none() { + binlog_summary.duration_text = raw_summary.duration_text; + } + + binlog_summary +} + fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { if issue.file.is_empty() { return format!(" {} {}", kind, truncate(&issue.message, 180)); @@ -477,7 +519,12 @@ fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> Stri out } -fn format_restore_output(summary: &binlog::RestoreSummary, binlog_path: &Path) -> String { +fn format_restore_output( + summary: &binlog::RestoreSummary, + errors: &[binlog::BinlogIssue], + warnings: &[binlog::BinlogIssue], + binlog_path: &Path, +) -> String { let has_errors = summary.errors > 0; let status_icon = if has_errors { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); @@ -486,6 +533,27 @@ fn format_restore_output(summary: &binlog::RestoreSummary, binlog_path: &Path) - "{} dotnet restore: {} projects, {} errors, {} warnings ({})", status_icon, summary.restored_projects, summary.errors, summary.warnings, duration ); + + if !errors.is_empty() { + out.push_str("\n---------------------------------------\n\nErrors:\n"); + for issue in errors.iter().take(20) { + out.push_str(&format!("{}\n", format_issue(issue, "error"))); + } + if errors.len() > 20 { + out.push_str(&format!(" ... +{} more errors\n", errors.len() - 20)); + } + } + + if !warnings.is_empty() { + out.push_str("\nWarnings:\n"); + for issue in warnings.iter().take(10) { + out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + } + if warnings.len() > 10 { + out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + } + } + out.push_str(&format!("\nBinlog: {}", binlog_path.display())); out } @@ -591,12 +659,50 @@ mod tests { duration_text: Some("00:00:01.10".to_string()), }; - let output = format_restore_output(&summary, Path::new("/tmp/restore.binlog")); + let output = format_restore_output(&summary, &[], &[], Path::new("/tmp/restore.binlog")); assert!(output.starts_with("ok dotnet restore")); assert!(output.contains("3 projects")); assert!(output.contains("1 warnings")); } + #[test] + fn test_format_restore_output_failure() { + let summary = binlog::RestoreSummary { + restored_projects: 2, + warnings: 0, + errors: 1, + duration_text: Some("00:00:01.00".to_string()), + }; + + let output = format_restore_output(&summary, &[], &[], Path::new("/tmp/restore.binlog")); + assert!(output.starts_with("fail dotnet restore")); + assert!(output.contains("1 errors")); + } + + #[test] + fn test_format_restore_output_includes_error_details() { + let summary = binlog::RestoreSummary { + restored_projects: 2, + warnings: 0, + errors: 1, + duration_text: Some("00:00:01.00".to_string()), + }; + + let issues = vec![binlog::BinlogIssue { + code: "NU1101".to_string(), + file: "/repo/src/App/App.csproj".to_string(), + line: 0, + column: 0, + message: "Unable to find package Foo.Bar".to_string(), + }]; + + let output = + format_restore_output(&summary, &issues, &[], Path::new("/tmp/restore.binlog")); + assert!(output.contains("Errors:")); + assert!(output.contains("error NU1101")); + assert!(output.contains("Unable to find package Foo.Bar")); + } + #[test] fn test_format_test_output_handles_binlog_only_without_counts() { let summary = binlog::TestSummary { @@ -725,6 +831,40 @@ mod tests { assert_eq!(normalized.total, 1); } + #[test] + fn test_normalize_restore_summary_sets_error_floor_on_failed_command() { + let summary = binlog::RestoreSummary { + restored_projects: 2, + warnings: 0, + errors: 0, + duration_text: None, + }; + + let normalized = normalize_restore_summary(summary, false); + assert_eq!(normalized.errors, 1); + } + + #[test] + fn test_merge_restore_summaries_prefers_raw_error_count() { + let binlog_summary = binlog::RestoreSummary { + restored_projects: 2, + warnings: 0, + errors: 0, + duration_text: Some("unknown".to_string()), + }; + + let raw_summary = binlog::RestoreSummary { + restored_projects: 0, + warnings: 0, + errors: 1, + duration_text: Some("unknown".to_string()), + }; + + let merged = merge_restore_summaries(binlog_summary, raw_summary); + assert_eq!(merged.errors, 1); + assert_eq!(merged.restored_projects, 2); + } + #[test] fn test_parse_trx_with_cleanup_deletes_file_after_parse() { let temp_dir = tempfile::tempdir().expect("create temp dir"); From 9dce881b6cf41c14c6178e9aae5aa565969dfd2d Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 13:04:04 +0100 Subject: [PATCH 08/11] Fix test --- src/binlog.rs | 187 +++++++++++++++++++++++++++++++++----- src/dotnet_cmd.rs | 222 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 372 insertions(+), 37 deletions(-) diff --git a/src/binlog.rs b/src/binlog.rs index 6308d3d..342fa0a 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -51,19 +51,31 @@ pub struct RestoreSummary { lazy_static! { static ref ISSUE_RE: Regex = Regex::new( - r"(?m)^\s*(?P[^\r\n:(]+)\((?P\d+),(?P\d+)\):\s*(?Perror|warning)\s*(?P[A-Za-z]+\d+):\s*(?P.+)$" + r"(?m)^\s*(?P[^\r\n:(]+)\((?P\d+),(?P\d+)\):\s*(?Perror|warning)\s*(?:(?P[A-Za-z]+\d+)\s*:\s*)?(?P.*)$" ) .expect("valid regex"); - static ref BUILD_SUMMARY_RE: Regex = Regex::new(r"(?m)^\s*(?P\d+)\s+(?PWarning|Error)\(s\)") + static ref BUILD_SUMMARY_RE: Regex = Regex::new(r"(?mi)^\s*(?P\d+)\s+(?Pwarning|error)\(s\)") .expect("valid regex"); + static ref ERROR_COUNT_RE: Regex = + Regex::new(r"(?i)\b(?P\d+)\s+error\(s\)").expect("valid regex"); + static ref WARNING_COUNT_RE: Regex = + Regex::new(r"(?i)\b(?P\d+)\s+warning\(s\)").expect("valid regex"); + static ref FALLBACK_ERROR_LINE_RE: Regex = + Regex::new(r"(?mi)^.+\(\d+,\d+\):\s*error(?:\s+[A-Za-z]{2,}\d{3,})?(?:\s*:.*)?$") + .expect("valid regex"); + static ref FALLBACK_WARNING_LINE_RE: Regex = + Regex::new(r"(?mi)^.+\(\d+,\d+\):\s*warning(?:\s+[A-Za-z]{2,}\d{3,})?(?:\s*:.*)?$") + .expect("valid regex"); static ref DURATION_RE: Regex = Regex::new(r"(?m)^\s*Time Elapsed\s+(?P[^\r\n]+)$").expect("valid regex"); static ref TEST_RESULT_RE: Regex = Regex::new( r"(?m)(?:Passed!|Failed!)\s*-\s*Failed:\s*(?P\d+),\s*Passed:\s*(?P\d+),\s*Skipped:\s*(?P\d+),\s*Total:\s*(?P\d+),\s*Duration:\s*(?P[^\r\n-]+)" ) .expect("valid regex"); - static ref FAILED_TEST_HEAD_RE: Regex = - Regex::new(r"(?m)^\s*Failed\s+(?P[^\r\n\[]+)").expect("valid regex"); + static ref FAILED_TEST_HEAD_RE: Regex = Regex::new( + r"(?m)^\s*Failed\s+(?P[^\r\n\[]+)\s+\[[^\]\r\n]+\]\s*$" + ) + .expect("valid regex"); static ref RESTORE_PROJECT_RE: Regex = Regex::new(r"(?m)^\s*Restored\s+.+\.csproj\s*\(").expect("valid regex"); static ref RESTORE_DIAGNOSTIC_RE: Regex = Regex::new( @@ -634,7 +646,14 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { .unwrap_or(0), message: captures .name("msg") - .map(|m| m.as_str().trim().to_string()) + .map(|m| { + let msg = m.as_str().trim(); + if msg.is_empty() { + "diagnostic without message".to_string() + } else { + msg.to_string() + } + }) .unwrap_or_default(), }; @@ -662,8 +681,8 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { } if summary.errors.is_empty() || summary.warnings.is_empty() { - let mut warning_count_from_summary = None; - let mut error_count_from_summary = None; + let mut warning_count_from_summary = 0; + let mut error_count_from_summary = 0; for captures in BUILD_SUMMARY_RE.captures_iter(&scrubbed) { let count = captures @@ -671,15 +690,68 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0); - match captures.name("kind").map(|m| m.as_str()) { - Some("Warning") => warning_count_from_summary = Some(count), - Some("Error") => error_count_from_summary = Some(count), + match captures + .name("kind") + .map(|m| m.as_str().to_ascii_lowercase()) + .as_deref() + { + Some("warning") => { + warning_count_from_summary = warning_count_from_summary.max(count) + } + Some("error") => error_count_from_summary = error_count_from_summary.max(count), _ => {} } } + let inline_error_count = ERROR_COUNT_RE + .captures_iter(&scrubbed) + .filter_map(|captures| { + captures + .name("count") + .and_then(|m| m.as_str().parse::().ok()) + }) + .max() + .unwrap_or(0); + let inline_warning_count = WARNING_COUNT_RE + .captures_iter(&scrubbed) + .filter_map(|captures| { + captures + .name("count") + .and_then(|m| m.as_str().parse::().ok()) + }) + .max() + .unwrap_or(0); + + warning_count_from_summary = warning_count_from_summary.max(inline_warning_count); + error_count_from_summary = error_count_from_summary.max(inline_error_count); + + if summary.errors.is_empty() { + for idx in 0..error_count_from_summary { + summary.errors.push(BinlogIssue { + code: String::new(), + file: String::new(), + line: 0, + column: 0, + message: format!("Build error #{} (details omitted)", idx + 1), + }); + } + } + + if summary.warnings.is_empty() { + for idx in 0..warning_count_from_summary { + summary.warnings.push(BinlogIssue { + code: String::new(), + file: String::new(), + line: 0, + column: 0, + message: format!("Build warning #{} (details omitted)", idx + 1), + }); + } + } + if summary.errors.is_empty() { - for idx in 0..error_count_from_summary.unwrap_or(0) { + let fallback_error_lines = FALLBACK_ERROR_LINE_RE.captures_iter(&scrubbed).count(); + for idx in 0..fallback_error_lines { summary.errors.push(BinlogIssue { code: String::new(), file: String::new(), @@ -691,7 +763,8 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { } if summary.warnings.is_empty() { - for idx in 0..warning_count_from_summary.unwrap_or(0) { + let fallback_warning_lines = FALLBACK_WARNING_LINE_RE.captures_iter(&scrubbed).count(); + for idx in 0..fallback_warning_lines { summary.warnings.push(BinlogIssue { code: String::new(), file: String::new(), @@ -706,7 +779,13 @@ pub fn parse_build_from_text(text: &str) -> BuildSummary { let has_error_signal = scrubbed.contains("Build FAILED") || scrubbed.contains(": error ") || BUILD_SUMMARY_RE.captures_iter(&scrubbed).any(|captures| { - let is_error = matches!(captures.name("kind").map(|m| m.as_str()), Some("Error")); + let is_error = matches!( + captures + .name("kind") + .map(|m| m.as_str().to_ascii_lowercase()) + .as_deref(), + Some("error") + ); let count = captures .name("count") .and_then(|m| m.as_str().parse::().ok()) @@ -776,22 +855,28 @@ pub fn parse_test_from_text(text: &str) -> TestSummary { idx += 1; while idx < lines.len() { let detail_line = lines[idx].trim_end(); - if detail_line.trim().is_empty() { - break; - } if FAILED_TEST_HEAD_RE.is_match(detail_line) { idx = idx.saturating_sub(1); break; } - if detail_line.trim_start().starts_with("Failed ") - || detail_line.trim_start().starts_with("Passed ") + let detail_trimmed = detail_line.trim_start(); + if detail_trimmed.starts_with("Failed! -") + || detail_trimmed.starts_with("Passed! -") + || detail_trimmed.starts_with("Test summary:") + || detail_trimmed.starts_with("Build ") { idx = idx.saturating_sub(1); break; } - details.push(detail_line.trim().to_string()); - if details.len() >= 4 { + if detail_line.trim().is_empty() { + if !details.is_empty() { + details.push(String::new()); + } + } else { + details.push(detail_line.trim().to_string()); + } + if details.len() >= 20 { break; } idx += 1; @@ -1044,6 +1129,33 @@ Time Elapsed 00:00:03.45 assert_eq!(summary.duration_text.as_deref(), Some("00:00:03.45")); } + #[test] + fn test_parse_build_from_text_extracts_warning_without_code() { + let input = r#" +/Users/dev/sdk/Microsoft.TestPlatform.targets(48,5): warning +Build succeeded with 1 warning(s) in 0.5s +"#; + + let summary = parse_build_from_text(input); + assert_eq!(summary.warnings.len(), 1); + assert_eq!( + summary.warnings[0].file, + "/Users/dev/sdk/Microsoft.TestPlatform.targets" + ); + assert_eq!(summary.warnings[0].code, ""); + } + + #[test] + fn test_parse_build_from_text_extracts_inline_warning_counts() { + let input = r#" +Build failed with 1 error(s) and 4 warning(s) in 4.7s +"#; + + let summary = parse_build_from_text(input); + assert_eq!(summary.errors.len(), 1); + assert_eq!(summary.warnings.len(), 4); + } + #[test] fn test_parse_test_from_text_extracts_failure_summary() { let input = r#" @@ -1067,6 +1179,41 @@ Failed! - Failed: 2, Passed: 245, Skipped: 0, Total: 247, Duration: .contains("CalculatorTests.Add_ShouldReturnSum")); } + #[test] + fn test_parse_test_from_text_keeps_multiline_failure_details() { + let input = r#" +Failed! - Failed: 1, Passed: 10, Skipped: 0, Total: 11, Duration: 1 s + Failed MyApp.Tests.SampleTests.ShouldFail [5 ms] + Error Message: + Assert.That(messageInstance, Is.Null) + Expected: null + But was: + + Stack Trace: + at MyApp.Tests.SampleTests.ShouldFail() in /repo/SampleTests.cs:line 42 +"#; + + let summary = parse_test_from_text(input); + assert_eq!(summary.failed, 1); + assert_eq!(summary.failed_tests.len(), 1); + let details = summary.failed_tests[0].details.join("\n"); + assert!(details.contains("Expected: null")); + assert!(details.contains("But was:")); + assert!(details.contains("Stack Trace:")); + } + + #[test] + fn test_parse_test_from_text_ignores_non_test_failed_prefix_lines() { + let input = r#" +Passed! - Failed: 0, Passed: 940, Skipped: 7, Total: 947, Duration: 1 s + Failed to load prune package data from PrunePackageData folder, loading from targeting packs instead +"#; + + let summary = parse_test_from_text(input); + assert_eq!(summary.failed, 0); + assert!(summary.failed_tests.is_empty()); + } + #[test] fn test_parse_restore_from_text_extracts_project_count() { let input = r#" diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 8ac0bf7..edf88fd 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -66,6 +66,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); let binlog_path = build_binlog_path(subcommand); + let should_expect_binlog = subcommand != "test" || has_binlog_arg(args); // For test commands, also create a TRX file for detailed results let trx_path = if subcommand == "test" { @@ -108,15 +109,39 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res } "test" => { // First try to parse from binlog/console output - let parsed_summary = binlog::parse_test(&binlog_path)?; + let parsed_summary = if should_expect_binlog && binlog_path.exists() { + binlog::parse_test(&binlog_path).unwrap_or_default() + } else { + binlog::TestSummary::default() + }; + let raw_summary = binlog::parse_test_from_text(&raw); + let merged_summary = merge_test_summaries(parsed_summary, raw_summary); let summary = maybe_fill_test_summary_from_trx( - parsed_summary, + merged_summary, trx_path.as_deref(), dotnet_trx::find_recent_trx_in_testresults(), ); let summary = normalize_test_summary(summary, output.status.success()); - format_test_output(&summary, &binlog_path) + let binlog_diagnostics = if should_expect_binlog && binlog_path.exists() { + normalize_build_summary( + binlog::parse_build(&binlog_path).unwrap_or_default(), + output.status.success(), + ) + } else { + binlog::BuildSummary::default() + }; + let raw_diagnostics = normalize_build_summary( + binlog::parse_build_from_text(&raw), + output.status.success(), + ); + let test_build_summary = merge_build_summaries(binlog_diagnostics, raw_diagnostics); + format_test_output( + &summary, + &test_build_summary.errors, + &test_build_summary.warnings, + &binlog_path, + ) } "restore" => { let binlog_summary = normalize_restore_summary( @@ -212,11 +237,11 @@ fn build_effective_dotnet_args( ) -> Vec { let mut effective = Vec::new(); - if !has_binlog_arg(args) { + if subcommand != "test" && !has_binlog_arg(args) { effective.push(format!("-bl:{}", binlog_path.display())); } - if !has_verbosity_arg(args) { + if subcommand != "test" && !has_verbosity_arg(args) { effective.push("-v:minimal".to_string()); } @@ -227,7 +252,7 @@ fn build_effective_dotnet_args( if subcommand == "test" && !has_logger_arg(args) { if let Some(trx) = trx_path { effective.push("--logger".to_string()); - effective.push(format!("trx;LogFileName=\"{}\"", trx.display())); + effective.push(format!("trx;LogFileName={}", trx.display())); } } @@ -376,6 +401,32 @@ fn normalize_test_summary( summary } +fn merge_test_summaries( + mut binlog_summary: binlog::TestSummary, + raw_summary: binlog::TestSummary, +) -> binlog::TestSummary { + if raw_summary.total > 0 { + binlog_summary.passed = raw_summary.passed; + binlog_summary.failed = raw_summary.failed; + binlog_summary.skipped = raw_summary.skipped; + binlog_summary.total = raw_summary.total; + } + + if !raw_summary.failed_tests.is_empty() { + binlog_summary.failed_tests = raw_summary.failed_tests; + } + + if binlog_summary.project_count == 0 { + binlog_summary.project_count = raw_summary.project_count; + } + + if binlog_summary.duration_text.is_none() { + binlog_summary.duration_text = raw_summary.duration_text; + } + + binlog_summary +} + fn normalize_restore_summary( mut summary: binlog::RestoreSummary, command_success: bool, @@ -412,6 +463,17 @@ fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { return format!(" {} {}", kind, truncate(&issue.message, 180)); } + if issue.code.is_empty() { + return format!( + " {}({},{}) {}: {}", + issue.file, + issue.line, + issue.column, + kind, + truncate(&issue.message, 180) + ); + } + format!( " {}({},{}) {} {}: {}", issue.file, @@ -466,10 +528,16 @@ fn format_build_output(summary: &binlog::BuildSummary, binlog_path: &Path) -> St out } -fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> String { +fn format_test_output( + summary: &binlog::TestSummary, + errors: &[binlog::BinlogIssue], + warnings: &[binlog::BinlogIssue], + binlog_path: &Path, +) -> String { let has_failures = summary.failed > 0 || !summary.failed_tests.is_empty(); let status_icon = if has_failures { "fail" } else { "ok" }; let duration = summary.duration_text.as_deref().unwrap_or("unknown"); + let warning_count = warnings.len(); let counts_unavailable = summary.passed == 0 && summary.failed == 0 && summary.skipped == 0 @@ -478,23 +546,24 @@ fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> Stri let mut out = if counts_unavailable { format!( - "{} dotnet test: completed (binlog-only mode, counts unavailable) ({})", - status_icon, duration + "{} dotnet test: completed (binlog-only mode, counts unavailable, {} warnings) ({})", + status_icon, warning_count, duration ) } else if has_failures { format!( - "{} dotnet test: {} passed, {} failed, {} skipped in {} projects ({})", + "{} dotnet test: {} passed, {} failed, {} skipped, {} warnings in {} projects ({})", status_icon, summary.passed, summary.failed, summary.skipped, + warning_count, summary.project_count, duration ) } else { format!( - "{} dotnet test: {} tests passed in {} projects ({})", - status_icon, summary.passed, summary.project_count, duration + "{} dotnet test: {} tests passed, {} warnings in {} projects ({})", + status_icon, summary.passed, warning_count, summary.project_count, duration ) }; @@ -503,7 +572,7 @@ fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> Stri for failed in summary.failed_tests.iter().take(15) { out.push_str(&format!(" {}\n", failed.name)); for detail in &failed.details { - out.push_str(&format!(" {}\n", truncate(detail, 180))); + out.push_str(&format!(" {}\n", truncate(detail, 320))); } out.push('\n'); } @@ -515,6 +584,26 @@ fn format_test_output(summary: &binlog::TestSummary, binlog_path: &Path) -> Stri } } + if !errors.is_empty() { + out.push_str("\nErrors:\n"); + for issue in errors.iter().take(10) { + out.push_str(&format!("{}\n", format_issue(issue, "error"))); + } + if errors.len() > 10 { + out.push_str(&format!(" ... +{} more errors\n", errors.len() - 10)); + } + } + + if !warnings.is_empty() { + out.push_str("\nWarnings:\n"); + for issue in warnings.iter().take(10) { + out.push_str(&format!("{}\n", format_issue(issue, "warning"))); + } + if warnings.len() > 10 { + out.push_str(&format!(" ... +{} more warnings\n", warnings.len() - 10)); + } + } + out.push_str(&format!("\nBinlog: {}", binlog_path.display())); out } @@ -645,11 +734,66 @@ mod tests { duration_text: Some("1 s".to_string()), }; - let output = format_test_output(&summary, Path::new("/tmp/test.binlog")); + let output = format_test_output(&summary, &[], &[], Path::new("/tmp/test.binlog")); assert!(output.contains("10 passed, 1 failed")); assert!(output.contains("MyTests.ShouldFail")); } + #[test] + fn test_format_test_output_surfaces_warnings() { + let summary = binlog::TestSummary { + passed: 940, + failed: 0, + skipped: 7, + total: 947, + project_count: 1, + failed_tests: Vec::new(), + duration_text: Some("1 s".to_string()), + }; + + let warnings = vec![binlog::BinlogIssue { + code: String::new(), + file: "/sdk/Microsoft.TestPlatform.targets".to_string(), + line: 48, + column: 5, + message: "Violators:".to_string(), + }]; + + let output = format_test_output(&summary, &[], &warnings, Path::new("/tmp/test.binlog")); + assert!(output.contains("940 tests passed, 1 warnings")); + assert!(output.contains("Warnings:")); + assert!(output.contains("Microsoft.TestPlatform.targets")); + } + + #[test] + fn test_format_test_output_surfaces_errors() { + let summary = binlog::TestSummary { + passed: 939, + failed: 1, + skipped: 7, + total: 947, + project_count: 1, + failed_tests: Vec::new(), + duration_text: Some("1 s".to_string()), + }; + + let errors = vec![binlog::BinlogIssue { + code: "TESTERROR".to_string(), + file: "/repo/MessageMapperTests.cs".to_string(), + line: 135, + column: 0, + message: "CreateInstance_should_initialize_interface_message_type_on_demand" + .to_string(), + }]; + + let output = format_test_output(&summary, &errors, &[], Path::new("/tmp/test.binlog")); + assert!(output.contains("Errors:")); + assert!(output.contains("error TESTERROR")); + assert!( + output.contains("CreateInstance_should_initialize_interface_message_type_on_demand") + ); + } + #[test] fn test_format_restore_output_success() { let summary = binlog::RestoreSummary { @@ -715,7 +859,7 @@ mod tests { duration_text: Some("unknown".to_string()), }; - let output = format_test_output(&summary, Path::new("/tmp/test.binlog")); + let output = format_test_output(&summary, &[], &[], Path::new("/tmp/test.binlog")); assert!(output.contains("counts unavailable")); } @@ -831,6 +975,41 @@ mod tests { assert_eq!(normalized.total, 1); } + #[test] + fn test_merge_test_summaries_prefers_raw_counts_and_failed_tests() { + let binlog_summary = binlog::TestSummary { + passed: 939, + failed: 1, + skipped: 8, + total: 948, + project_count: 1, + failed_tests: Vec::new(), + duration_text: Some("unknown".to_string()), + }; + + let raw_summary = binlog::TestSummary { + passed: 939, + failed: 1, + skipped: 7, + total: 947, + project_count: 0, + failed_tests: vec![binlog::FailedTest { + name: "MessageMapperTests.CreateInstance_should_initialize_interface_message_type_on_demand" + .to_string(), + details: vec!["Assert.That(messageInstance, Is.Null)".to_string()], + }], + duration_text: Some("1 s".to_string()), + }; + + let merged = merge_test_summaries(binlog_summary, raw_summary); + assert_eq!(merged.skipped, 7); + assert_eq!(merged.total, 947); + assert_eq!(merged.failed_tests.len(), 1); + assert!(merged.failed_tests[0] + .name + .contains("CreateInstance_should_initialize")); + } + #[test] fn test_normalize_restore_summary_sets_error_floor_on_failed_command() { let summary = binlog::RestoreSummary { @@ -955,6 +1134,14 @@ mod tests { assert!(!injected.contains(&"-v:minimal".to_string())); } + #[test] + fn test_test_subcommand_does_not_inject_minimal_verbosity_by_default() { + let args = Vec::::new(); + + let injected = build_dotnet_args_for_test("test", &args, true); + assert!(!injected.contains(&"-v:minimal".to_string())); + } + #[test] fn test_user_logger_override() { let args = vec![ @@ -969,7 +1156,7 @@ mod tests { } #[test] - fn test_trx_logger_path_is_quoted_when_path_contains_spaces() { + fn test_trx_logger_path_uses_raw_value_with_spaces() { let args = Vec::::new(); let injected = build_dotnet_args_for_test("test", &args, true); @@ -978,7 +1165,8 @@ mod tests { .find(|a| a.starts_with("trx;LogFileName=")) .expect("trx logger argument exists"); - assert!(trx_arg.contains("LogFileName=\"/tmp/test results/test.trx\"")); + assert!(trx_arg.contains("LogFileName=/tmp/test results/test.trx")); + assert!(!trx_arg.contains('"')); } #[test] From 24ef195c6653ae4547979d2bc040c59cde807782 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 13:46:08 +0100 Subject: [PATCH 09/11] Prefer binlog --- src/dotnet_cmd.rs | 93 +++++++++-------------------------------------- 1 file changed, 17 insertions(+), 76 deletions(-) diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index edf88fd..fa414a5 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -306,9 +306,12 @@ fn merge_build_summaries( mut binlog_summary: binlog::BuildSummary, raw_summary: binlog::BuildSummary, ) -> binlog::BuildSummary { - binlog_summary.errors = select_preferred_issues(binlog_summary.errors, raw_summary.errors); - binlog_summary.warnings = - select_preferred_issues(binlog_summary.warnings, raw_summary.warnings); + if binlog_summary.errors.is_empty() { + binlog_summary.errors = raw_summary.errors; + } + if binlog_summary.warnings.is_empty() { + binlog_summary.warnings = raw_summary.warnings; + } if binlog_summary.project_count == 0 { binlog_summary.project_count = raw_summary.project_count; @@ -320,69 +323,6 @@ fn merge_build_summaries( binlog_summary } -fn select_preferred_issues( - binlog_issues: Vec, - raw_issues: Vec, -) -> Vec { - if binlog_issues.is_empty() { - return raw_issues; - } - if raw_issues.is_empty() { - return binlog_issues; - } - - let binlog_score = issues_quality_score(&binlog_issues); - let raw_score = issues_quality_score(&raw_issues); - - if raw_score > binlog_score - || (raw_score == binlog_score && raw_issues.len() > binlog_issues.len()) - { - raw_issues - } else { - binlog_issues - } -} - -fn issues_quality_score(issues: &[binlog::BinlogIssue]) -> usize { - issues.iter().map(issue_quality_score).sum() -} - -fn issue_quality_score(issue: &binlog::BinlogIssue) -> usize { - let mut score = 0; - - if !issue.file.is_empty() && !looks_like_diagnostic_token(&issue.file) { - score += 4; - } - if !issue.code.is_empty() { - score += 2; - } - if issue.line > 0 { - score += 1; - } - if issue.column > 0 { - score += 1; - } - - score -} - -fn looks_like_diagnostic_token(value: &str) -> bool { - let mut letters = 0; - let mut digits = 0; - - for c in value.chars() { - if c.is_ascii_alphabetic() { - letters += 1; - } else if c.is_ascii_digit() { - digits += 1; - } else { - return false; - } - } - - letters >= 2 && digits >= 3 -} - fn normalize_test_summary( mut summary: binlog::TestSummary, command_success: bool, @@ -405,7 +345,7 @@ fn merge_test_summaries( mut binlog_summary: binlog::TestSummary, raw_summary: binlog::TestSummary, ) -> binlog::TestSummary { - if raw_summary.total > 0 { + if binlog_summary.total == 0 && raw_summary.total > 0 { binlog_summary.passed = raw_summary.passed; binlog_summary.failed = raw_summary.failed; binlog_summary.skipped = raw_summary.skipped; @@ -445,10 +385,10 @@ fn merge_restore_summaries( if binlog_summary.restored_projects == 0 { binlog_summary.restored_projects = raw_summary.restored_projects; } - if raw_summary.errors > binlog_summary.errors { + if binlog_summary.errors == 0 { binlog_summary.errors = raw_summary.errors; } - if raw_summary.warnings > binlog_summary.warnings { + if binlog_summary.warnings == 0 { binlog_summary.warnings = raw_summary.warnings; } if binlog_summary.duration_text.is_none() { @@ -879,7 +819,7 @@ mod tests { } #[test] - fn test_merge_build_summaries_prefers_raw_when_binlog_loses_context() { + fn test_merge_build_summaries_keeps_structured_issues_when_present() { let binlog_summary = binlog::BuildSummary { succeeded: false, project_count: 11, @@ -919,9 +859,10 @@ mod tests { let merged = merge_build_summaries(binlog_summary, raw_summary); assert_eq!(merged.project_count, 11); - assert_eq!(merged.errors.len(), 2); - assert_eq!(merged.errors[0].line, 13); - assert_eq!(merged.errors[0].column, 32); + assert_eq!(merged.errors.len(), 1); + assert_eq!(merged.errors[0].file, "IDE0055"); + assert_eq!(merged.errors[0].line, 0); + assert_eq!(merged.errors[0].column, 0); } #[test] @@ -976,7 +917,7 @@ mod tests { } #[test] - fn test_merge_test_summaries_prefers_raw_counts_and_failed_tests() { + fn test_merge_test_summaries_keeps_structured_counts_and_fills_failed_tests() { let binlog_summary = binlog::TestSummary { passed: 939, failed: 1, @@ -1002,8 +943,8 @@ mod tests { }; let merged = merge_test_summaries(binlog_summary, raw_summary); - assert_eq!(merged.skipped, 7); - assert_eq!(merged.total, 947); + assert_eq!(merged.skipped, 8); + assert_eq!(merged.total, 948); assert_eq!(merged.failed_tests.len(), 1); assert!(merged.failed_tests[0] .name From 18e4177b765bcb421fbc65dd0fa2e3deeab9bf49 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 13:51:03 +0100 Subject: [PATCH 10/11] Set the CLI language --- src/dotnet_cmd.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index fa414a5..4883f3a 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -8,6 +8,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +const DOTNET_CLI_UI_LANGUAGE: &str = "DOTNET_CLI_UI_LANGUAGE"; +const DOTNET_CLI_UI_LANGUAGE_VALUE: &str = "en-US"; + pub fn run_build(args: &[String], verbose: u8) -> Result<()> { run_dotnet_with_binlog("build", args, verbose) } @@ -29,6 +32,7 @@ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { let subcommand = args[0].to_string_lossy().to_string(); let mut cmd = Command::new("dotnet"); + cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE); cmd.arg(&subcommand); for arg in &args[1..] { cmd.arg(arg); @@ -76,6 +80,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res }; let mut cmd = Command::new("dotnet"); + cmd.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE); cmd.arg(subcommand); for arg in build_effective_dotnet_args(subcommand, args, &binlog_path, trx_path.as_deref()) { From cafeba1375fcf64830bcc0c266fd1afe43ce3662 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 17 Feb 2026 18:01:04 +0100 Subject: [PATCH 11/11] Simplify --- src/binlog.rs | 38 +++++++++++++++------------------ src/dotnet_cmd.rs | 53 +++++++++++++---------------------------------- 2 files changed, 31 insertions(+), 60 deletions(-) diff --git a/src/binlog.rs b/src/binlog.rs index 342fa0a..7fd4054 100644 --- a/src/binlog.rs +++ b/src/binlog.rs @@ -167,14 +167,10 @@ fn select_best_issues(primary: Vec, fallback: Vec) -> if fallback.is_empty() { return primary; } - if primary.iter().all(is_suspicious_issue) && fallback.iter().any(is_contextual_issue) { return fallback; } - - let primary_score = issues_quality_score(&primary); - let fallback_score = issues_quality_score(&fallback); - if fallback_score > primary_score { + if issues_quality_score(&fallback) > issues_quality_score(&primary) { fallback } else { primary @@ -187,7 +183,6 @@ fn issues_quality_score(issues: &[BinlogIssue]) -> usize { fn issue_quality_score(issue: &BinlogIssue) -> usize { let mut score = 0; - if is_contextual_issue(issue) { score += 4; } @@ -203,7 +198,6 @@ fn issue_quality_score(issue: &BinlogIssue) -> usize { if !issue.message.is_empty() && issue.message != "Build issue" { score += 1; } - score } @@ -489,20 +483,22 @@ fn read_deduplicated_string( parsed: &ParsedBinlog, ) -> Result> { let index = reader.read_7bit_i32()?; - match index { - 0 => Ok(None), - 1 => Ok(Some(String::new())), - i if i >= STRING_RECORD_START_INDEX => { - let record_idx = (i - STRING_RECORD_START_INDEX) as usize; - parsed - .string_records - .get(record_idx) - .cloned() - .map(Some) - .with_context(|| format!("invalid string record index {}", i)) - } - _ => Ok(None), - } + if index == 0 { + return Ok(None); + } + if index == 1 { + return Ok(Some(String::new())); + } + if index < STRING_RECORD_START_INDEX { + return Ok(None); + } + let record_idx = (index - STRING_RECORD_START_INDEX) as usize; + parsed + .string_records + .get(record_idx) + .cloned() + .map(Some) + .with_context(|| format!("invalid string record index {}", index)) } fn format_ticks_duration(ticks: i64) -> String { diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 4883f3a..341be77 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -204,12 +204,6 @@ fn build_trx_path() -> PathBuf { std::env::temp_dir().join(format!("rtk_dotnet_test_{}.trx", ts)) } -fn parse_trx_with_cleanup(path: &Path) -> Option { - let summary = dotnet_trx::parse_trx_file(path)?; - std::fs::remove_file(path).ok(); - Some(summary) -} - fn maybe_fill_test_summary_from_trx( summary: binlog::TestSummary, trx_path: Option<&Path>, @@ -220,7 +214,8 @@ fn maybe_fill_test_summary_from_trx( } if let Some(trx) = trx_path.filter(|path| path.exists()) { - if let Some(trx_summary) = parse_trx_with_cleanup(trx) { + if let Some(trx_summary) = dotnet_trx::parse_trx_file(trx) { + std::fs::remove_file(trx).ok(); return trx_summary; } } @@ -280,10 +275,8 @@ fn has_verbosity_arg(args: &[String]) -> bool { } fn has_nologo_arg(args: &[String]) -> bool { - args.iter().any(|arg| { - let lower = arg.to_ascii_lowercase(); - lower == "-nologo" || lower == "/nologo" - }) + args.iter() + .any(|arg| matches!(arg.to_ascii_lowercase().as_str(), "-nologo" | "/nologo")) } fn has_logger_arg(args: &[String]) -> bool { @@ -407,7 +400,6 @@ fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { if issue.file.is_empty() { return format!(" {} {}", kind, truncate(&issue.message, 180)); } - if issue.code.is_empty() { return format!( " {}({},{}) {}: {}", @@ -418,7 +410,6 @@ fn format_issue(issue: &binlog::BinlogIssue, kind: &str) -> String { truncate(&issue.message, 180) ); } - format!( " {}({},{}) {} {}: {}", issue.file, @@ -990,32 +981,6 @@ mod tests { assert_eq!(merged.restored_projects, 2); } - #[test] - fn test_parse_trx_with_cleanup_deletes_file_after_parse() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let trx_path = temp_dir.path().join("results.trx"); - let trx = r#" - - - - -"#; - fs::write(&trx_path, trx).expect("write trx"); - - let summary = parse_trx_with_cleanup(&trx_path); - assert!(summary.is_some()); - assert!(!trx_path.exists()); - } - - #[test] - fn test_parse_trx_with_cleanup_non_existent_path_returns_none() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let trx_path = temp_dir.path().join("missing.trx"); - - let summary = parse_trx_with_cleanup(&trx_path); - assert!(summary.is_none()); - } - #[test] fn test_forwarding_args_with_spaces() { let args = vec![ @@ -1146,4 +1111,14 @@ mod tests { assert_eq!(filled.failed, 1); assert!(fallback.exists()); } + + #[test] + fn test_maybe_fill_test_summary_from_trx_returns_default_when_no_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let missing = temp_dir.path().join("missing.trx"); + + let filled = + maybe_fill_test_summary_from_trx(binlog::TestSummary::default(), Some(&missing), None); + assert_eq!(filled.total, 0); + } }