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..6401bb0 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 failures only (auto TRX + fallback parsing) +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,8 +274,19 @@ 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 (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 new file mode 100644 index 0000000..7fd4054 --- /dev/null +++ b/src/binlog.rs @@ -0,0 +1,1519 @@ +use anyhow::{Context, Result}; +use flate2::read::GzDecoder; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; +use std::io::{Cursor, 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*:\s*)?(?P.*)$" + ) + .expect("valid regex"); + 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\[]+)\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( + 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"); + 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"); +} + +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", +]; + +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 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: 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; + } + if issues_quality_score(&fallback) > issues_quality_score(&primary) { + 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 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 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) +} + +#[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); + let _ = + parse_event_record(kind, &mut event_reader, file_format_version, &mut parsed); + } + } + } + + Ok(parsed) +} + +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()?; + 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 { + let total_seconds = ticks.div_euclid(10_000_000); + 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; + 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 { + 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| { + let msg = m.as_str().trim(); + if msg.is_empty() { + "diagnostic without message".to_string() + } else { + msg.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 = 0; + let mut error_count_from_summary = 0; + + 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().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() { + 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(), + line: 0, + column: 0, + message: format!("Build error #{} (details omitted)", idx + 1), + }); + } + } + + if summary.warnings.is_empty() { + 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(), + line: 0, + column: 0, + message: format!("Build warning #{} (details omitted)", idx + 1), + }); + } + } + } + + 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().to_ascii_lowercase()) + .as_deref(), + 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); + } + + 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 FAILED_TEST_HEAD_RE.is_match(detail_line) { + idx = idx.saturating_sub(1); + break; + } + 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; + } + + 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; + } + 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 (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: 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() +} + +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 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)) +} + +#[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() { + 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_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#" +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_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#" + 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_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"); + 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 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.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] + 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_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"); + 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_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 new file mode 100644 index 0000000..341be77 --- /dev/null +++ b/src/dotnet_cmd.rs @@ -0,0 +1,1124 @@ +use crate::binlog; +use crate::dotnet_trx; +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}; + +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) +} + +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.env(DOTNET_CLI_UI_LANGUAGE, DOTNET_CLI_UI_LANGUAGE_VALUE); + 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); + 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" { + Some(build_trx_path()) + } else { + None + }; + + 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()) { + 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 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" => { + // First try to parse from binlog/console output + 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( + merged_summary, + trx_path.as_deref(), + dotnet_trx::find_recent_trx_in_testresults(), + ); + + let summary = normalize_test_summary(summary, output.status.success()); + 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( + 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(), + }; + + 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 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) = dotnet_trx::parse_trx_file(trx) { + std::fs::remove_file(trx).ok(); + return trx_summary; + } + } + + if let Some(trx) = fallback_trx_path { + if let Some(trx_summary) = dotnet_trx::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 subcommand != "test" && !has_binlog_arg(args) { + effective.push(format!("-bl:{}", binlog_path.display())); + } + + if subcommand != "test" && !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(); + 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| matches!(arg.to_ascii_lowercase().as_str(), "-nologo" | "/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 merge_build_summaries( + mut binlog_summary: binlog::BuildSummary, + raw_summary: binlog::BuildSummary, +) -> binlog::BuildSummary { + 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; + } + if binlog_summary.duration_text.is_none() { + binlog_summary.duration_text = raw_summary.duration_text; + } + + binlog_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 merge_test_summaries( + mut binlog_summary: binlog::TestSummary, + raw_summary: binlog::TestSummary, +) -> binlog::TestSummary { + 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; + 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, +) -> 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 binlog_summary.errors == 0 { + binlog_summary.errors = raw_summary.errors; + } + if binlog_summary.warnings == 0 { + 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)); + } + if issue.code.is_empty() { + return format!( + " {}({},{}) {}: {}", + issue.file, + issue.line, + issue.column, + 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, + 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 + && summary.total == 0 + && summary.failed_tests.is_empty(); + + let mut out = if counts_unavailable { + format!( + "{} dotnet test: completed (binlog-only mode, counts unavailable, {} warnings) ({})", + status_icon, warning_count, duration + ) + } else if has_failures { + format!( + "{} 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, {} warnings in {} projects ({})", + status_icon, summary.passed, warning_count, 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, 320))); + } + out.push('\n'); + } + if summary.failed_tests.len() > 15 { + out.push_str(&format!( + "... +{} more failed tests\n", + summary.failed_tests.len() - 15 + )); + } + } + + 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 +} + +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"); + + let mut out = format!( + "{} 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 +} + +#[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() { + 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_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 { + 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_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 { + 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_merge_build_summaries_keeps_structured_issues_when_present() { + 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(), 1); + assert_eq!(merged.errors[0].file, "IDE0055"); + assert_eq!(merged.errors[0].line, 0); + assert_eq!(merged.errors[0].column, 0); + } + + #[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 { + 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); + } + + #[test] + fn test_merge_test_summaries_keeps_structured_counts_and_fills_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, 8); + assert_eq!(merged.total, 948); + 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 { + 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_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_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![ + "--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_uses_raw_value_with_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")); + assert!(!trx_arg.contains('"')); + } + + #[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()); + } + + #[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); + } +} 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 cef7f3e..0fb77f5 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,8 @@ mod deps; mod diff_cmd; mod discover; mod display_helpers; +mod dotnet_cmd; +mod dotnet_trx; mod env_cmd; mod filter; mod find_cmd; @@ -410,6 +413,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 +816,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 +1245,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)?; } 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)