@hasna/terminal 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/dist/cli.js +29 -12
  2. package/package.json +1 -1
  3. package/src/ai.ts +50 -36
  4. package/src/cli.tsx +29 -12
  5. package/src/context-hints.ts +89 -0
  6. package/src/discover.ts +238 -0
  7. package/src/economy.ts +53 -0
  8. package/src/output-store.ts +65 -0
  9. package/src/providers/index.ts +4 -4
  10. package/src/sessions-db.ts +81 -0
  11. package/temp/rtk/.claude/agents/code-reviewer.md +221 -0
  12. package/temp/rtk/.claude/agents/debugger.md +519 -0
  13. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +461 -0
  14. package/temp/rtk/.claude/agents/rust-rtk.md +511 -0
  15. package/temp/rtk/.claude/agents/technical-writer.md +355 -0
  16. package/temp/rtk/.claude/commands/diagnose.md +352 -0
  17. package/temp/rtk/.claude/commands/test-routing.md +362 -0
  18. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +16 -0
  19. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +70 -0
  20. package/temp/rtk/.claude/hooks/rtk-suggest.sh +152 -0
  21. package/temp/rtk/.claude/rules/cli-testing.md +526 -0
  22. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +348 -0
  23. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +134 -0
  24. package/temp/rtk/.claude/skills/performance.md +435 -0
  25. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +315 -0
  26. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +71 -0
  27. package/temp/rtk/.claude/skills/repo-recap.md +206 -0
  28. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +78 -0
  29. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +124 -0
  30. package/temp/rtk/.claude/skills/security-guardian.md +503 -0
  31. package/temp/rtk/.claude/skills/ship.md +404 -0
  32. package/temp/rtk/.github/workflows/benchmark.yml +34 -0
  33. package/temp/rtk/.github/workflows/dco-check.yaml +12 -0
  34. package/temp/rtk/.github/workflows/release-please.yml +51 -0
  35. package/temp/rtk/.github/workflows/release.yml +343 -0
  36. package/temp/rtk/.github/workflows/security-check.yml +135 -0
  37. package/temp/rtk/.github/workflows/validate-docs.yml +78 -0
  38. package/temp/rtk/.release-please-manifest.json +3 -0
  39. package/temp/rtk/ARCHITECTURE.md +1491 -0
  40. package/temp/rtk/CHANGELOG.md +640 -0
  41. package/temp/rtk/CLAUDE.md +605 -0
  42. package/temp/rtk/CONTRIBUTING.md +199 -0
  43. package/temp/rtk/Cargo.lock +1668 -0
  44. package/temp/rtk/Cargo.toml +64 -0
  45. package/temp/rtk/Formula/rtk.rb +43 -0
  46. package/temp/rtk/INSTALL.md +390 -0
  47. package/temp/rtk/LICENSE +21 -0
  48. package/temp/rtk/README.md +386 -0
  49. package/temp/rtk/README_es.md +159 -0
  50. package/temp/rtk/README_fr.md +197 -0
  51. package/temp/rtk/README_ja.md +159 -0
  52. package/temp/rtk/README_ko.md +159 -0
  53. package/temp/rtk/README_zh.md +167 -0
  54. package/temp/rtk/ROADMAP.md +15 -0
  55. package/temp/rtk/SECURITY.md +217 -0
  56. package/temp/rtk/TEST_EXEC_TIME.md +102 -0
  57. package/temp/rtk/build.rs +57 -0
  58. package/temp/rtk/docs/AUDIT_GUIDE.md +432 -0
  59. package/temp/rtk/docs/FEATURES.md +1410 -0
  60. package/temp/rtk/docs/TROUBLESHOOTING.md +309 -0
  61. package/temp/rtk/docs/filter-workflow.md +102 -0
  62. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  63. package/temp/rtk/docs/tracking.md +583 -0
  64. package/temp/rtk/hooks/opencode-rtk.ts +39 -0
  65. package/temp/rtk/hooks/rtk-awareness.md +29 -0
  66. package/temp/rtk/hooks/rtk-rewrite.sh +61 -0
  67. package/temp/rtk/hooks/test-rtk-rewrite.sh +442 -0
  68. package/temp/rtk/install.sh +124 -0
  69. package/temp/rtk/release-please-config.json +10 -0
  70. package/temp/rtk/scripts/benchmark.sh +592 -0
  71. package/temp/rtk/scripts/check-installation.sh +162 -0
  72. package/temp/rtk/scripts/install-local.sh +37 -0
  73. package/temp/rtk/scripts/rtk-economics.sh +137 -0
  74. package/temp/rtk/scripts/test-all.sh +561 -0
  75. package/temp/rtk/scripts/test-aristote.sh +227 -0
  76. package/temp/rtk/scripts/test-tracking.sh +79 -0
  77. package/temp/rtk/scripts/update-readme-metrics.sh +32 -0
  78. package/temp/rtk/scripts/validate-docs.sh +73 -0
  79. package/temp/rtk/src/aws_cmd.rs +880 -0
  80. package/temp/rtk/src/binlog.rs +1645 -0
  81. package/temp/rtk/src/cargo_cmd.rs +1727 -0
  82. package/temp/rtk/src/cc_economics.rs +1157 -0
  83. package/temp/rtk/src/ccusage.rs +340 -0
  84. package/temp/rtk/src/config.rs +187 -0
  85. package/temp/rtk/src/container.rs +855 -0
  86. package/temp/rtk/src/curl_cmd.rs +134 -0
  87. package/temp/rtk/src/deps.rs +268 -0
  88. package/temp/rtk/src/diff_cmd.rs +367 -0
  89. package/temp/rtk/src/discover/mod.rs +274 -0
  90. package/temp/rtk/src/discover/provider.rs +388 -0
  91. package/temp/rtk/src/discover/registry.rs +2022 -0
  92. package/temp/rtk/src/discover/report.rs +202 -0
  93. package/temp/rtk/src/discover/rules.rs +667 -0
  94. package/temp/rtk/src/display_helpers.rs +402 -0
  95. package/temp/rtk/src/dotnet_cmd.rs +1771 -0
  96. package/temp/rtk/src/dotnet_format_report.rs +133 -0
  97. package/temp/rtk/src/dotnet_trx.rs +593 -0
  98. package/temp/rtk/src/env_cmd.rs +204 -0
  99. package/temp/rtk/src/filter.rs +462 -0
  100. package/temp/rtk/src/filters/README.md +52 -0
  101. package/temp/rtk/src/filters/ansible-playbook.toml +34 -0
  102. package/temp/rtk/src/filters/basedpyright.toml +47 -0
  103. package/temp/rtk/src/filters/biome.toml +45 -0
  104. package/temp/rtk/src/filters/brew-install.toml +37 -0
  105. package/temp/rtk/src/filters/composer-install.toml +40 -0
  106. package/temp/rtk/src/filters/df.toml +16 -0
  107. package/temp/rtk/src/filters/dotnet-build.toml +64 -0
  108. package/temp/rtk/src/filters/du.toml +16 -0
  109. package/temp/rtk/src/filters/fail2ban-client.toml +15 -0
  110. package/temp/rtk/src/filters/gcc.toml +49 -0
  111. package/temp/rtk/src/filters/gcloud.toml +22 -0
  112. package/temp/rtk/src/filters/hadolint.toml +24 -0
  113. package/temp/rtk/src/filters/helm.toml +29 -0
  114. package/temp/rtk/src/filters/iptables.toml +27 -0
  115. package/temp/rtk/src/filters/jj.toml +28 -0
  116. package/temp/rtk/src/filters/jq.toml +24 -0
  117. package/temp/rtk/src/filters/make.toml +41 -0
  118. package/temp/rtk/src/filters/markdownlint.toml +24 -0
  119. package/temp/rtk/src/filters/mix-compile.toml +27 -0
  120. package/temp/rtk/src/filters/mix-format.toml +15 -0
  121. package/temp/rtk/src/filters/mvn-build.toml +44 -0
  122. package/temp/rtk/src/filters/oxlint.toml +43 -0
  123. package/temp/rtk/src/filters/ping.toml +63 -0
  124. package/temp/rtk/src/filters/pio-run.toml +40 -0
  125. package/temp/rtk/src/filters/poetry-install.toml +50 -0
  126. package/temp/rtk/src/filters/pre-commit.toml +35 -0
  127. package/temp/rtk/src/filters/ps.toml +16 -0
  128. package/temp/rtk/src/filters/quarto-render.toml +41 -0
  129. package/temp/rtk/src/filters/rsync.toml +48 -0
  130. package/temp/rtk/src/filters/shellcheck.toml +27 -0
  131. package/temp/rtk/src/filters/shopify-theme.toml +29 -0
  132. package/temp/rtk/src/filters/skopeo.toml +45 -0
  133. package/temp/rtk/src/filters/sops.toml +16 -0
  134. package/temp/rtk/src/filters/ssh.toml +44 -0
  135. package/temp/rtk/src/filters/stat.toml +34 -0
  136. package/temp/rtk/src/filters/swift-build.toml +41 -0
  137. package/temp/rtk/src/filters/systemctl-status.toml +33 -0
  138. package/temp/rtk/src/filters/terraform-plan.toml +35 -0
  139. package/temp/rtk/src/filters/tofu-fmt.toml +16 -0
  140. package/temp/rtk/src/filters/tofu-init.toml +38 -0
  141. package/temp/rtk/src/filters/tofu-plan.toml +35 -0
  142. package/temp/rtk/src/filters/tofu-validate.toml +17 -0
  143. package/temp/rtk/src/filters/trunk-build.toml +39 -0
  144. package/temp/rtk/src/filters/ty.toml +50 -0
  145. package/temp/rtk/src/filters/uv-sync.toml +37 -0
  146. package/temp/rtk/src/filters/xcodebuild.toml +99 -0
  147. package/temp/rtk/src/filters/yamllint.toml +25 -0
  148. package/temp/rtk/src/find_cmd.rs +598 -0
  149. package/temp/rtk/src/format_cmd.rs +386 -0
  150. package/temp/rtk/src/gain.rs +723 -0
  151. package/temp/rtk/src/gh_cmd.rs +1651 -0
  152. package/temp/rtk/src/git.rs +2012 -0
  153. package/temp/rtk/src/go_cmd.rs +592 -0
  154. package/temp/rtk/src/golangci_cmd.rs +254 -0
  155. package/temp/rtk/src/grep_cmd.rs +288 -0
  156. package/temp/rtk/src/gt_cmd.rs +810 -0
  157. package/temp/rtk/src/hook_audit_cmd.rs +283 -0
  158. package/temp/rtk/src/hook_check.rs +171 -0
  159. package/temp/rtk/src/init.rs +1859 -0
  160. package/temp/rtk/src/integrity.rs +537 -0
  161. package/temp/rtk/src/json_cmd.rs +231 -0
  162. package/temp/rtk/src/learn/detector.rs +628 -0
  163. package/temp/rtk/src/learn/mod.rs +119 -0
  164. package/temp/rtk/src/learn/report.rs +184 -0
  165. package/temp/rtk/src/lint_cmd.rs +694 -0
  166. package/temp/rtk/src/local_llm.rs +316 -0
  167. package/temp/rtk/src/log_cmd.rs +248 -0
  168. package/temp/rtk/src/ls.rs +324 -0
  169. package/temp/rtk/src/main.rs +2482 -0
  170. package/temp/rtk/src/mypy_cmd.rs +389 -0
  171. package/temp/rtk/src/next_cmd.rs +241 -0
  172. package/temp/rtk/src/npm_cmd.rs +236 -0
  173. package/temp/rtk/src/parser/README.md +267 -0
  174. package/temp/rtk/src/parser/error.rs +46 -0
  175. package/temp/rtk/src/parser/formatter.rs +336 -0
  176. package/temp/rtk/src/parser/mod.rs +311 -0
  177. package/temp/rtk/src/parser/types.rs +119 -0
  178. package/temp/rtk/src/pip_cmd.rs +302 -0
  179. package/temp/rtk/src/playwright_cmd.rs +479 -0
  180. package/temp/rtk/src/pnpm_cmd.rs +573 -0
  181. package/temp/rtk/src/prettier_cmd.rs +221 -0
  182. package/temp/rtk/src/prisma_cmd.rs +482 -0
  183. package/temp/rtk/src/psql_cmd.rs +382 -0
  184. package/temp/rtk/src/pytest_cmd.rs +384 -0
  185. package/temp/rtk/src/read.rs +217 -0
  186. package/temp/rtk/src/rewrite_cmd.rs +50 -0
  187. package/temp/rtk/src/ruff_cmd.rs +402 -0
  188. package/temp/rtk/src/runner.rs +271 -0
  189. package/temp/rtk/src/summary.rs +297 -0
  190. package/temp/rtk/src/tee.rs +405 -0
  191. package/temp/rtk/src/telemetry.rs +248 -0
  192. package/temp/rtk/src/toml_filter.rs +1655 -0
  193. package/temp/rtk/src/tracking.rs +1416 -0
  194. package/temp/rtk/src/tree.rs +209 -0
  195. package/temp/rtk/src/tsc_cmd.rs +259 -0
  196. package/temp/rtk/src/utils.rs +432 -0
  197. package/temp/rtk/src/verify_cmd.rs +47 -0
  198. package/temp/rtk/src/vitest_cmd.rs +385 -0
  199. package/temp/rtk/src/wc_cmd.rs +401 -0
  200. package/temp/rtk/src/wget_cmd.rs +260 -0
  201. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +11 -0
  202. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +31 -0
  203. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +1 -0
  204. package/temp/rtk/tests/fixtures/dotnet/format_success.json +12 -0
  205. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +18 -0
@@ -0,0 +1,1645 @@
1
+ use crate::utils::strip_ansi;
2
+ use anyhow::{Context, Result};
3
+ use flate2::read::GzDecoder;
4
+ use lazy_static::lazy_static;
5
+ use regex::Regex;
6
+ use std::collections::HashSet;
7
+ use std::io::{Cursor, Read};
8
+ use std::path::Path;
9
+
10
+ #[derive(Debug, Clone, PartialEq, Eq)]
11
+ pub struct BinlogIssue {
12
+ pub code: String,
13
+ pub file: String,
14
+ pub line: u32,
15
+ pub column: u32,
16
+ pub message: String,
17
+ }
18
+
19
+ #[derive(Debug, Clone, Default)]
20
+ pub struct BuildSummary {
21
+ pub succeeded: bool,
22
+ pub project_count: usize,
23
+ pub errors: Vec<BinlogIssue>,
24
+ pub warnings: Vec<BinlogIssue>,
25
+ pub duration_text: Option<String>,
26
+ }
27
+
28
+ #[derive(Debug, Clone, PartialEq, Eq)]
29
+ pub struct FailedTest {
30
+ pub name: String,
31
+ pub details: Vec<String>,
32
+ }
33
+
34
+ #[derive(Debug, Clone, Default)]
35
+ pub struct TestSummary {
36
+ pub passed: usize,
37
+ pub failed: usize,
38
+ pub skipped: usize,
39
+ pub total: usize,
40
+ pub project_count: usize,
41
+ pub failed_tests: Vec<FailedTest>,
42
+ pub duration_text: Option<String>,
43
+ }
44
+
45
+ #[derive(Debug, Clone, Default)]
46
+ pub struct RestoreSummary {
47
+ pub restored_projects: usize,
48
+ pub warnings: usize,
49
+ pub errors: usize,
50
+ pub duration_text: Option<String>,
51
+ }
52
+
53
+ lazy_static! {
54
+ static ref ISSUE_RE: Regex = Regex::new(
55
+ r"(?m)^\s*(?P<file>[^\r\n:(]+)\((?P<line>\d+),(?P<column>\d+)\):\s*(?P<kind>error|warning)\s*(?:(?P<code>[A-Za-z]+\d+)\s*:\s*)?(?P<msg>.*)$"
56
+ )
57
+ .expect("valid regex");
58
+ static ref BUILD_SUMMARY_RE: Regex = Regex::new(r"(?mi)^\s*(?P<count>\d+)\s+(?P<kind>warning|error)\(s\)")
59
+ .expect("valid regex");
60
+ static ref ERROR_COUNT_RE: Regex =
61
+ Regex::new(r"(?i)\b(?P<count>\d+)\s+error\(s\)").expect("valid regex");
62
+ static ref WARNING_COUNT_RE: Regex =
63
+ Regex::new(r"(?i)\b(?P<count>\d+)\s+warning\(s\)").expect("valid regex");
64
+ static ref FALLBACK_ERROR_LINE_RE: Regex =
65
+ Regex::new(r"(?mi)^.+\(\d+,\d+\):\s*error(?:\s+[A-Za-z]{2,}\d{3,})?(?:\s*:.*)?$")
66
+ .expect("valid regex");
67
+ static ref FALLBACK_WARNING_LINE_RE: Regex =
68
+ Regex::new(r"(?mi)^.+\(\d+,\d+\):\s*warning(?:\s+[A-Za-z]{2,}\d{3,})?(?:\s*:.*)?$")
69
+ .expect("valid regex");
70
+ static ref DURATION_RE: Regex =
71
+ Regex::new(r"(?m)^\s*Time Elapsed\s+(?P<duration>[^\r\n]+)$").expect("valid regex");
72
+ static ref TEST_RESULT_RE: Regex = Regex::new(
73
+ r"(?m)(?:Passed!|Failed!)\s*-\s*Failed:\s*(?P<failed>\d+),\s*Passed:\s*(?P<passed>\d+),\s*Skipped:\s*(?P<skipped>\d+),\s*Total:\s*(?P<total>\d+),\s*Duration:\s*(?P<duration>[^\r\n-]+)"
74
+ )
75
+ .expect("valid regex");
76
+ static ref TEST_SUMMARY_RE: Regex = Regex::new(
77
+ r"(?mi)^\s*Test summary:\s*total:\s*(?P<total>\d+),\s*failed:\s*(?P<failed>\d+),\s*(?:succeeded|passed):\s*(?P<passed>\d+),\s*skipped:\s*(?P<skipped>\d+),\s*duration:\s*(?P<duration>[^\r\n]+)$"
78
+ )
79
+ .expect("valid regex");
80
+ static ref FAILED_TEST_HEAD_RE: Regex = Regex::new(
81
+ r"(?m)^\s*Failed\s+(?P<name>[^\r\n\[]+)\s+\[[^\]\r\n]+\]\s*$"
82
+ )
83
+ .expect("valid regex");
84
+ static ref RESTORE_PROJECT_RE: Regex =
85
+ Regex::new(r"(?m)^\s*Restored\s+.+\.csproj\s*\(").expect("valid regex");
86
+ static ref RESTORE_DIAGNOSTIC_RE: Regex = Regex::new(
87
+ r"(?mi)^\s*(?:(?P<file>.+?)\s+:\s+)?(?P<kind>warning|error)\s+(?P<code>[A-Za-z]{2,}\d{3,})\s*:\s*(?P<msg>.+)$"
88
+ )
89
+ .expect("valid regex");
90
+ static ref PROJECT_PATH_RE: Regex =
91
+ Regex::new(r"(?m)^\s*([A-Za-z]:)?[^\r\n]*\.csproj(?:\s|$)").expect("valid regex");
92
+ static ref PRINTABLE_RUN_RE: Regex = Regex::new(r"[\x20-\x7E]{5,}").expect("valid regex");
93
+ static ref DIAGNOSTIC_CODE_RE: Regex =
94
+ Regex::new(r"^[A-Za-z]{2,}\d{3,}$").expect("valid regex");
95
+ static ref SOURCE_FILE_RE: Regex = Regex::new(r"(?i)([A-Za-z]:)?[/\\][^\s]+\.(cs|vb|fs)")
96
+ .expect("valid regex");
97
+ static ref SENSITIVE_ENV_RE: Regex = {
98
+ let keys = SENSITIVE_ENV_VARS
99
+ .iter()
100
+ .map(|key| regex::escape(key))
101
+ .collect::<Vec<_>>()
102
+ .join("|");
103
+ Regex::new(&format!(
104
+ r"(?P<prefix>\b(?:{})\s*(?:=|:)\s*)(?P<value>[^\s;]+)",
105
+ keys
106
+ ))
107
+ .expect("valid regex")
108
+ };
109
+ }
110
+
111
+ const SENSITIVE_ENV_VARS: &[&str] = &[
112
+ "PATH",
113
+ "HOME",
114
+ "USERPROFILE",
115
+ "USERNAME",
116
+ "USER",
117
+ "APPDATA",
118
+ "LOCALAPPDATA",
119
+ "TEMP",
120
+ "TMP",
121
+ "SSH_AUTH_SOCK",
122
+ "SSH_AGENT_LAUNCHER",
123
+ "GH_TOKEN",
124
+ "GITHUB_TOKEN",
125
+ "GITHUB_PAT",
126
+ "NUGET_API_KEY",
127
+ "NUGET_AUTH_TOKEN",
128
+ "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS",
129
+ "AZURE_DEVOPS_TOKEN",
130
+ "AZURE_CLIENT_SECRET",
131
+ "AZURE_TENANT_ID",
132
+ "AZURE_CLIENT_ID",
133
+ "AWS_ACCESS_KEY_ID",
134
+ "AWS_SECRET_ACCESS_KEY",
135
+ "AWS_SESSION_TOKEN",
136
+ "API_TOKEN",
137
+ "AUTH_TOKEN",
138
+ "ACCESS_TOKEN",
139
+ "BEARER_TOKEN",
140
+ "PASSWORD",
141
+ "CONNECTION_STRING",
142
+ "DATABASE_URL",
143
+ "DOCKER_CONFIG",
144
+ "KUBECONFIG",
145
+ ];
146
+
147
+ const RECORD_END_OF_FILE: i32 = 0;
148
+ const RECORD_BUILD_STARTED: i32 = 1;
149
+ const RECORD_BUILD_FINISHED: i32 = 2;
150
+ const RECORD_PROJECT_STARTED: i32 = 3;
151
+ const RECORD_PROJECT_FINISHED: i32 = 4;
152
+ const RECORD_ERROR: i32 = 9;
153
+ const RECORD_WARNING: i32 = 10;
154
+ const RECORD_MESSAGE: i32 = 11;
155
+ const RECORD_CRITICAL_BUILD_MESSAGE: i32 = 13;
156
+ const RECORD_PROJECT_IMPORT_ARCHIVE: i32 = 17;
157
+ const RECORD_NAME_VALUE_LIST: i32 = 23;
158
+ const RECORD_STRING: i32 = 24;
159
+
160
+ const FLAG_BUILD_EVENT_CONTEXT: i32 = 1 << 0;
161
+ const FLAG_MESSAGE: i32 = 1 << 2;
162
+ const FLAG_TIMESTAMP: i32 = 1 << 5;
163
+ const FLAG_ARGUMENTS: i32 = 1 << 14;
164
+ const FLAG_IMPORTANCE: i32 = 1 << 15;
165
+ const FLAG_EXTENDED: i32 = 1 << 16;
166
+
167
+ const STRING_RECORD_START_INDEX: i32 = 10;
168
+
169
+ pub fn parse_build(binlog_path: &Path) -> Result<BuildSummary> {
170
+ let parsed = parse_events_from_binlog(binlog_path)
171
+ .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?;
172
+ let strings_blob = parsed.string_records.join("\n");
173
+ let text_fallback = parse_build_from_text(&strings_blob);
174
+
175
+ let duration_text = match (parsed.build_started_ticks, parsed.build_finished_ticks) {
176
+ (Some(start), Some(end)) if end >= start => Some(format_ticks_duration(end - start)),
177
+ _ => None,
178
+ };
179
+
180
+ let parsed_project_count = parsed.project_files.len();
181
+
182
+ Ok(BuildSummary {
183
+ succeeded: parsed.build_succeeded.unwrap_or(false),
184
+ project_count: if parsed_project_count > 0 {
185
+ parsed_project_count
186
+ } else {
187
+ text_fallback.project_count
188
+ },
189
+ errors: select_best_issues(parsed.errors, text_fallback.errors),
190
+ warnings: select_best_issues(parsed.warnings, text_fallback.warnings),
191
+ duration_text,
192
+ })
193
+ }
194
+
195
+ fn select_best_issues(primary: Vec<BinlogIssue>, fallback: Vec<BinlogIssue>) -> Vec<BinlogIssue> {
196
+ if primary.is_empty() {
197
+ return fallback;
198
+ }
199
+ if fallback.is_empty() {
200
+ return primary;
201
+ }
202
+ if primary.iter().all(is_suspicious_issue) && fallback.iter().any(is_contextual_issue) {
203
+ return fallback;
204
+ }
205
+ if issues_quality_score(&fallback) > issues_quality_score(&primary) {
206
+ fallback
207
+ } else {
208
+ primary
209
+ }
210
+ }
211
+
212
+ fn issues_quality_score(issues: &[BinlogIssue]) -> usize {
213
+ issues.iter().map(issue_quality_score).sum()
214
+ }
215
+
216
+ fn issue_quality_score(issue: &BinlogIssue) -> usize {
217
+ let mut score = 0;
218
+ if is_contextual_issue(issue) {
219
+ score += 4;
220
+ }
221
+ if !issue.code.is_empty() && is_likely_diagnostic_code(&issue.code) {
222
+ score += 2;
223
+ }
224
+ if issue.line > 0 {
225
+ score += 1;
226
+ }
227
+ if issue.column > 0 {
228
+ score += 1;
229
+ }
230
+ if !issue.message.is_empty() && issue.message != "Build issue" {
231
+ score += 1;
232
+ }
233
+ score
234
+ }
235
+
236
+ fn is_contextual_issue(issue: &BinlogIssue) -> bool {
237
+ !issue.file.is_empty() && !is_likely_diagnostic_code(&issue.file)
238
+ }
239
+
240
+ fn is_suspicious_issue(issue: &BinlogIssue) -> bool {
241
+ issue.code.is_empty() && is_likely_diagnostic_code(&issue.file)
242
+ }
243
+
244
+ pub fn parse_test(binlog_path: &Path) -> Result<TestSummary> {
245
+ let parsed = parse_events_from_binlog(binlog_path)
246
+ .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?;
247
+ let blob = parsed.string_records.join("\n");
248
+ let mut summary = parse_test_from_text(&blob);
249
+ let parsed_project_count = parsed.project_files.len();
250
+ if parsed_project_count > 0 {
251
+ summary.project_count = parsed_project_count;
252
+ }
253
+ Ok(summary)
254
+ }
255
+
256
+ pub fn parse_restore(binlog_path: &Path) -> Result<RestoreSummary> {
257
+ let parsed = parse_events_from_binlog(binlog_path)
258
+ .with_context(|| format!("Failed to parse binlog at {}", binlog_path.display()))?;
259
+ let blob = parsed.string_records.join("\n");
260
+ let mut summary = parse_restore_from_text(&blob);
261
+ let parsed_project_count = parsed.project_files.len();
262
+ if parsed_project_count > 0 {
263
+ summary.restored_projects = parsed_project_count;
264
+ }
265
+ Ok(summary)
266
+ }
267
+
268
+ #[derive(Default)]
269
+ struct ParsedBinlog {
270
+ string_records: Vec<String>,
271
+ messages: Vec<String>,
272
+ project_files: HashSet<String>,
273
+ errors: Vec<BinlogIssue>,
274
+ warnings: Vec<BinlogIssue>,
275
+ build_succeeded: Option<bool>,
276
+ build_started_ticks: Option<i64>,
277
+ build_finished_ticks: Option<i64>,
278
+ }
279
+
280
+ #[derive(Default)]
281
+ struct ParsedEventFields {
282
+ message: Option<String>,
283
+ timestamp_ticks: Option<i64>,
284
+ }
285
+
286
+ fn parse_events_from_binlog(path: &Path) -> Result<ParsedBinlog> {
287
+ let bytes = std::fs::read(path)
288
+ .with_context(|| format!("Failed to read binlog at {}", path.display()))?;
289
+ if bytes.is_empty() {
290
+ anyhow::bail!("Failed to parse binlog at {}: empty file", path.display());
291
+ }
292
+
293
+ let mut decoder = GzDecoder::new(bytes.as_slice());
294
+ let mut payload = Vec::new();
295
+ decoder.read_to_end(&mut payload).with_context(|| {
296
+ format!(
297
+ "Failed to parse binlog at {}: gzip decode failed",
298
+ path.display()
299
+ )
300
+ })?;
301
+
302
+ let mut reader = BinReader::new(&payload);
303
+ let file_format_version = reader
304
+ .read_i32_le()
305
+ .context("binlog header missing file format version")?;
306
+ let _minimum_reader_version = reader
307
+ .read_i32_le()
308
+ .context("binlog header missing minimum reader version")?;
309
+
310
+ if file_format_version < 18 {
311
+ anyhow::bail!(
312
+ "Failed to parse binlog at {}: unsupported binlog format {}",
313
+ path.display(),
314
+ file_format_version
315
+ );
316
+ }
317
+
318
+ let mut parsed = ParsedBinlog::default();
319
+
320
+ while !reader.is_eof() {
321
+ let kind = reader
322
+ .read_7bit_i32()
323
+ .context("failed to read record kind")?;
324
+ if kind == RECORD_END_OF_FILE {
325
+ break;
326
+ }
327
+
328
+ match kind {
329
+ RECORD_STRING => {
330
+ let text = reader
331
+ .read_dotnet_string()
332
+ .context("failed to read string record")?;
333
+ parsed.string_records.push(text);
334
+ }
335
+ RECORD_NAME_VALUE_LIST | RECORD_PROJECT_IMPORT_ARCHIVE => {
336
+ let len = reader
337
+ .read_7bit_i32()
338
+ .context("failed to read record length")?;
339
+ if len < 0 {
340
+ anyhow::bail!("negative record length: {}", len);
341
+ }
342
+ reader
343
+ .skip(len as usize)
344
+ .context("failed to skip auxiliary record payload")?;
345
+ }
346
+ _ => {
347
+ let len = reader
348
+ .read_7bit_i32()
349
+ .context("failed to read event length")?;
350
+ if len < 0 {
351
+ anyhow::bail!("negative event length: {}", len);
352
+ }
353
+
354
+ let payload = reader
355
+ .read_exact(len as usize)
356
+ .context("failed to read event payload")?;
357
+ let mut event_reader = BinReader::new(payload);
358
+ let _ =
359
+ parse_event_record(kind, &mut event_reader, file_format_version, &mut parsed);
360
+ }
361
+ }
362
+ }
363
+
364
+ Ok(parsed)
365
+ }
366
+
367
+ fn parse_event_record(
368
+ kind: i32,
369
+ reader: &mut BinReader<'_>,
370
+ file_format_version: i32,
371
+ parsed: &mut ParsedBinlog,
372
+ ) -> Result<()> {
373
+ match kind {
374
+ RECORD_BUILD_STARTED => {
375
+ let fields = read_event_fields(reader, file_format_version, parsed, false)?;
376
+ parsed.build_started_ticks = fields.timestamp_ticks;
377
+ }
378
+ RECORD_BUILD_FINISHED => {
379
+ let fields = read_event_fields(reader, file_format_version, parsed, false)?;
380
+ parsed.build_finished_ticks = fields.timestamp_ticks;
381
+ parsed.build_succeeded = Some(reader.read_bool()?);
382
+ }
383
+ RECORD_PROJECT_STARTED => {
384
+ let _fields = read_event_fields(reader, file_format_version, parsed, false)?;
385
+ if reader.read_bool()? {
386
+ skip_build_event_context(reader, file_format_version)?;
387
+ }
388
+ if let Some(project_file) = read_optional_string(reader, parsed)? {
389
+ if !project_file.is_empty() {
390
+ parsed.project_files.insert(project_file);
391
+ }
392
+ }
393
+ }
394
+ RECORD_PROJECT_FINISHED => {
395
+ let _fields = read_event_fields(reader, file_format_version, parsed, false)?;
396
+ if let Some(project_file) = read_optional_string(reader, parsed)? {
397
+ if !project_file.is_empty() {
398
+ parsed.project_files.insert(project_file);
399
+ }
400
+ }
401
+ let _ = reader.read_bool()?;
402
+ }
403
+ RECORD_ERROR | RECORD_WARNING => {
404
+ let fields = read_event_fields(reader, file_format_version, parsed, false)?;
405
+
406
+ let _subcategory = read_optional_string(reader, parsed)?;
407
+ let code = read_optional_string(reader, parsed)?.unwrap_or_default();
408
+ let file = read_optional_string(reader, parsed)?.unwrap_or_default();
409
+ let _project_file = read_optional_string(reader, parsed)?;
410
+ let line = reader.read_7bit_i32()?.max(0) as u32;
411
+ let column = reader.read_7bit_i32()?.max(0) as u32;
412
+ let _ = reader.read_7bit_i32()?;
413
+ let _ = reader.read_7bit_i32()?;
414
+
415
+ let issue = BinlogIssue {
416
+ code,
417
+ file,
418
+ line,
419
+ column,
420
+ message: fields.message.unwrap_or_default(),
421
+ };
422
+
423
+ if kind == RECORD_ERROR {
424
+ parsed.errors.push(issue);
425
+ } else {
426
+ parsed.warnings.push(issue);
427
+ }
428
+ }
429
+ RECORD_MESSAGE => {
430
+ let fields = read_event_fields(reader, file_format_version, parsed, true)?;
431
+ if let Some(message) = fields.message {
432
+ parsed.messages.push(message);
433
+ }
434
+ }
435
+ RECORD_CRITICAL_BUILD_MESSAGE => {
436
+ let fields = read_event_fields(reader, file_format_version, parsed, false)?;
437
+ if let Some(message) = fields.message {
438
+ parsed.messages.push(message);
439
+ }
440
+ }
441
+ _ => {}
442
+ }
443
+
444
+ Ok(())
445
+ }
446
+
447
+ fn read_event_fields(
448
+ reader: &mut BinReader<'_>,
449
+ file_format_version: i32,
450
+ parsed: &ParsedBinlog,
451
+ read_importance: bool,
452
+ ) -> Result<ParsedEventFields> {
453
+ let flags = reader.read_7bit_i32()?;
454
+ let mut result = ParsedEventFields::default();
455
+
456
+ if flags & FLAG_MESSAGE != 0 {
457
+ result.message = read_deduplicated_string(reader, parsed)?;
458
+ }
459
+
460
+ if flags & FLAG_BUILD_EVENT_CONTEXT != 0 {
461
+ skip_build_event_context(reader, file_format_version)?;
462
+ }
463
+
464
+ if flags & FLAG_TIMESTAMP != 0 {
465
+ result.timestamp_ticks = Some(reader.read_i64_le()?);
466
+ let _ = reader.read_7bit_i32()?;
467
+ }
468
+
469
+ if flags & FLAG_EXTENDED != 0 {
470
+ let _ = read_optional_string(reader, parsed)?;
471
+ skip_string_dictionary(reader, file_format_version)?;
472
+ let _ = read_optional_string(reader, parsed)?;
473
+ }
474
+
475
+ if flags & FLAG_ARGUMENTS != 0 {
476
+ let count = reader.read_7bit_i32()?.max(0) as usize;
477
+ for _ in 0..count {
478
+ let _ = read_deduplicated_string(reader, parsed)?;
479
+ }
480
+ }
481
+
482
+ if (file_format_version < 13 && read_importance) || (flags & FLAG_IMPORTANCE != 0) {
483
+ let _ = reader.read_7bit_i32()?;
484
+ }
485
+
486
+ Ok(result)
487
+ }
488
+
489
+ fn skip_build_event_context(reader: &mut BinReader<'_>, file_format_version: i32) -> Result<()> {
490
+ let count = if file_format_version > 1 { 7 } else { 6 };
491
+ for _ in 0..count {
492
+ let _ = reader.read_7bit_i32()?;
493
+ }
494
+ Ok(())
495
+ }
496
+
497
+ fn skip_string_dictionary(reader: &mut BinReader<'_>, file_format_version: i32) -> Result<()> {
498
+ if file_format_version < 10 {
499
+ anyhow::bail!("legacy dictionary format is unsupported");
500
+ }
501
+
502
+ let _ = reader.read_7bit_i32()?;
503
+ Ok(())
504
+ }
505
+
506
+ fn read_optional_string(
507
+ reader: &mut BinReader<'_>,
508
+ parsed: &ParsedBinlog,
509
+ ) -> Result<Option<String>> {
510
+ read_deduplicated_string(reader, parsed)
511
+ }
512
+
513
+ fn read_deduplicated_string(
514
+ reader: &mut BinReader<'_>,
515
+ parsed: &ParsedBinlog,
516
+ ) -> Result<Option<String>> {
517
+ let index = reader.read_7bit_i32()?;
518
+ if index == 0 {
519
+ return Ok(None);
520
+ }
521
+ if index == 1 {
522
+ return Ok(Some(String::new()));
523
+ }
524
+ if index < STRING_RECORD_START_INDEX {
525
+ return Ok(None);
526
+ }
527
+ let record_idx = (index - STRING_RECORD_START_INDEX) as usize;
528
+ parsed
529
+ .string_records
530
+ .get(record_idx)
531
+ .cloned()
532
+ .map(Some)
533
+ .with_context(|| format!("invalid string record index {}", index))
534
+ }
535
+
536
+ fn format_ticks_duration(ticks: i64) -> String {
537
+ let total_seconds = ticks.div_euclid(10_000_000);
538
+ let centiseconds = ticks.rem_euclid(10_000_000) / 100_000;
539
+ let hours = total_seconds / 3600;
540
+ let minutes = (total_seconds % 3600) / 60;
541
+ let seconds = total_seconds % 60;
542
+ format!(
543
+ "{:02}:{:02}:{:02}.{:02}",
544
+ hours, minutes, seconds, centiseconds
545
+ )
546
+ }
547
+
548
+ struct BinReader<'a> {
549
+ cursor: Cursor<&'a [u8]>,
550
+ }
551
+
552
+ impl<'a> BinReader<'a> {
553
+ fn new(bytes: &'a [u8]) -> Self {
554
+ Self {
555
+ cursor: Cursor::new(bytes),
556
+ }
557
+ }
558
+
559
+ fn is_eof(&self) -> bool {
560
+ (self.cursor.position() as usize) >= self.cursor.get_ref().len()
561
+ }
562
+
563
+ fn read_exact(&mut self, len: usize) -> Result<&'a [u8]> {
564
+ let start = self.cursor.position() as usize;
565
+ let end = start.saturating_add(len);
566
+ if end > self.cursor.get_ref().len() {
567
+ anyhow::bail!("unexpected end of stream");
568
+ }
569
+ self.cursor.set_position(end as u64);
570
+ Ok(&self.cursor.get_ref()[start..end])
571
+ }
572
+
573
+ fn skip(&mut self, len: usize) -> Result<()> {
574
+ let _ = self.read_exact(len)?;
575
+ Ok(())
576
+ }
577
+
578
+ fn read_u8(&mut self) -> Result<u8> {
579
+ Ok(self.read_exact(1)?[0])
580
+ }
581
+
582
+ fn read_bool(&mut self) -> Result<bool> {
583
+ Ok(self.read_u8()? != 0)
584
+ }
585
+
586
+ fn read_i32_le(&mut self) -> Result<i32> {
587
+ let b = self.read_exact(4)?;
588
+ Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
589
+ }
590
+
591
+ fn read_i64_le(&mut self) -> Result<i64> {
592
+ let b = self.read_exact(8)?;
593
+ Ok(i64::from_le_bytes([
594
+ b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
595
+ ]))
596
+ }
597
+
598
+ fn read_7bit_i32(&mut self) -> Result<i32> {
599
+ let mut value: u32 = 0;
600
+ let mut shift = 0;
601
+ loop {
602
+ let byte = self.read_u8()?;
603
+ value |= ((byte & 0x7F) as u32) << shift;
604
+ if (byte & 0x80) == 0 {
605
+ return Ok(value as i32);
606
+ }
607
+
608
+ shift += 7;
609
+ if shift >= 35 {
610
+ anyhow::bail!("invalid 7-bit encoded integer");
611
+ }
612
+ }
613
+ }
614
+
615
+ fn read_dotnet_string(&mut self) -> Result<String> {
616
+ let len = self.read_7bit_i32()?;
617
+ if len < 0 {
618
+ anyhow::bail!("negative string length: {}", len);
619
+ }
620
+ let bytes = self.read_exact(len as usize)?;
621
+ String::from_utf8(bytes.to_vec()).context("invalid UTF-8 string")
622
+ }
623
+ }
624
+
625
+ pub fn scrub_sensitive_env_vars(input: &str) -> String {
626
+ SENSITIVE_ENV_RE
627
+ .replace_all(input, "${prefix}[REDACTED]")
628
+ .into_owned()
629
+ }
630
+
631
+ pub fn parse_build_from_text(text: &str) -> BuildSummary {
632
+ let clean = strip_ansi(text);
633
+ let scrubbed = scrub_sensitive_env_vars(&clean);
634
+ let mut seen_errors: HashSet<(String, String, u32, u32, String)> = HashSet::new();
635
+ let mut seen_warnings: HashSet<(String, String, u32, u32, String)> = HashSet::new();
636
+ let mut summary = BuildSummary {
637
+ succeeded: scrubbed.contains("Build succeeded") && !scrubbed.contains("Build FAILED"),
638
+ project_count: count_projects(&scrubbed),
639
+ errors: Vec::new(),
640
+ warnings: Vec::new(),
641
+ duration_text: extract_duration(&scrubbed),
642
+ };
643
+
644
+ for captures in ISSUE_RE.captures_iter(&scrubbed) {
645
+ let issue = BinlogIssue {
646
+ code: captures
647
+ .name("code")
648
+ .map(|m| m.as_str().to_string())
649
+ .unwrap_or_default(),
650
+ file: captures
651
+ .name("file")
652
+ .map(|m| m.as_str().to_string())
653
+ .unwrap_or_default(),
654
+ line: captures
655
+ .name("line")
656
+ .and_then(|m| m.as_str().parse::<u32>().ok())
657
+ .unwrap_or(0),
658
+ column: captures
659
+ .name("column")
660
+ .and_then(|m| m.as_str().parse::<u32>().ok())
661
+ .unwrap_or(0),
662
+ message: captures
663
+ .name("msg")
664
+ .map(|m| {
665
+ let msg = m.as_str().trim();
666
+ if msg.is_empty() {
667
+ "diagnostic without message".to_string()
668
+ } else {
669
+ msg.to_string()
670
+ }
671
+ })
672
+ .unwrap_or_default(),
673
+ };
674
+
675
+ let key = (
676
+ issue.code.clone(),
677
+ issue.file.clone(),
678
+ issue.line,
679
+ issue.column,
680
+ issue.message.clone(),
681
+ );
682
+
683
+ match captures.name("kind").map(|m| m.as_str()) {
684
+ Some("error") => {
685
+ if seen_errors.insert(key) {
686
+ summary.errors.push(issue);
687
+ }
688
+ }
689
+ Some("warning") => {
690
+ if seen_warnings.insert(key) {
691
+ summary.warnings.push(issue);
692
+ }
693
+ }
694
+ _ => {}
695
+ }
696
+ }
697
+
698
+ if summary.errors.is_empty() || summary.warnings.is_empty() {
699
+ let mut warning_count_from_summary = 0;
700
+ let mut error_count_from_summary = 0;
701
+
702
+ for captures in BUILD_SUMMARY_RE.captures_iter(&scrubbed) {
703
+ let count = captures
704
+ .name("count")
705
+ .and_then(|m| m.as_str().parse::<usize>().ok())
706
+ .unwrap_or(0);
707
+
708
+ match captures
709
+ .name("kind")
710
+ .map(|m| m.as_str().to_ascii_lowercase())
711
+ .as_deref()
712
+ {
713
+ Some("warning") => {
714
+ warning_count_from_summary = warning_count_from_summary.max(count)
715
+ }
716
+ Some("error") => error_count_from_summary = error_count_from_summary.max(count),
717
+ _ => {}
718
+ }
719
+ }
720
+
721
+ let inline_error_count = ERROR_COUNT_RE
722
+ .captures_iter(&scrubbed)
723
+ .filter_map(|captures| {
724
+ captures
725
+ .name("count")
726
+ .and_then(|m| m.as_str().parse::<usize>().ok())
727
+ })
728
+ .max()
729
+ .unwrap_or(0);
730
+ let inline_warning_count = WARNING_COUNT_RE
731
+ .captures_iter(&scrubbed)
732
+ .filter_map(|captures| {
733
+ captures
734
+ .name("count")
735
+ .and_then(|m| m.as_str().parse::<usize>().ok())
736
+ })
737
+ .max()
738
+ .unwrap_or(0);
739
+
740
+ warning_count_from_summary = warning_count_from_summary.max(inline_warning_count);
741
+ error_count_from_summary = error_count_from_summary.max(inline_error_count);
742
+
743
+ if summary.errors.is_empty() {
744
+ for idx in 0..error_count_from_summary {
745
+ summary.errors.push(BinlogIssue {
746
+ code: String::new(),
747
+ file: String::new(),
748
+ line: 0,
749
+ column: 0,
750
+ message: format!("Build error #{} (details omitted)", idx + 1),
751
+ });
752
+ }
753
+ }
754
+
755
+ if summary.warnings.is_empty() {
756
+ for idx in 0..warning_count_from_summary {
757
+ summary.warnings.push(BinlogIssue {
758
+ code: String::new(),
759
+ file: String::new(),
760
+ line: 0,
761
+ column: 0,
762
+ message: format!("Build warning #{} (details omitted)", idx + 1),
763
+ });
764
+ }
765
+ }
766
+
767
+ if summary.errors.is_empty() {
768
+ let fallback_error_lines = FALLBACK_ERROR_LINE_RE.captures_iter(&scrubbed).count();
769
+ for idx in 0..fallback_error_lines {
770
+ summary.errors.push(BinlogIssue {
771
+ code: String::new(),
772
+ file: String::new(),
773
+ line: 0,
774
+ column: 0,
775
+ message: format!("Build error #{} (details omitted)", idx + 1),
776
+ });
777
+ }
778
+ }
779
+
780
+ if summary.warnings.is_empty() {
781
+ let fallback_warning_lines = FALLBACK_WARNING_LINE_RE.captures_iter(&scrubbed).count();
782
+ for idx in 0..fallback_warning_lines {
783
+ summary.warnings.push(BinlogIssue {
784
+ code: String::new(),
785
+ file: String::new(),
786
+ line: 0,
787
+ column: 0,
788
+ message: format!("Build warning #{} (details omitted)", idx + 1),
789
+ });
790
+ }
791
+ }
792
+ }
793
+
794
+ let has_error_signal = scrubbed.contains("Build FAILED")
795
+ || scrubbed.contains(": error ")
796
+ || BUILD_SUMMARY_RE.captures_iter(&scrubbed).any(|captures| {
797
+ let is_error = matches!(
798
+ captures
799
+ .name("kind")
800
+ .map(|m| m.as_str().to_ascii_lowercase())
801
+ .as_deref(),
802
+ Some("error")
803
+ );
804
+ let count = captures
805
+ .name("count")
806
+ .and_then(|m| m.as_str().parse::<usize>().ok())
807
+ .unwrap_or(0);
808
+ is_error && count > 0
809
+ });
810
+
811
+ if summary.errors.is_empty() || summary.warnings.is_empty() {
812
+ let (diagnostic_errors, diagnostic_warnings) = parse_restore_issues_from_text(&scrubbed);
813
+
814
+ if summary.errors.is_empty() {
815
+ summary.errors = diagnostic_errors;
816
+ }
817
+
818
+ if summary.warnings.is_empty() {
819
+ summary.warnings = diagnostic_warnings;
820
+ }
821
+ }
822
+
823
+ if summary.errors.is_empty() && !summary.succeeded && has_error_signal {
824
+ summary.errors = extract_binary_like_issues(&scrubbed);
825
+ }
826
+
827
+ if summary.project_count == 0
828
+ && (scrubbed.contains("Build succeeded")
829
+ || scrubbed.contains("Build FAILED")
830
+ || scrubbed.contains(" -> "))
831
+ {
832
+ summary.project_count = 1;
833
+ }
834
+
835
+ summary
836
+ }
837
+
838
+ pub fn parse_test_from_text(text: &str) -> TestSummary {
839
+ let clean = strip_ansi(text);
840
+ let scrubbed = scrub_sensitive_env_vars(&clean);
841
+ let mut summary = TestSummary {
842
+ passed: 0,
843
+ failed: 0,
844
+ skipped: 0,
845
+ total: 0,
846
+ project_count: count_projects(&scrubbed).max(1),
847
+ failed_tests: Vec::new(),
848
+ duration_text: extract_duration(&scrubbed),
849
+ };
850
+
851
+ let mut found_summary_line = false;
852
+ let mut fallback_duration = None;
853
+ for captures in TEST_RESULT_RE.captures_iter(&scrubbed) {
854
+ found_summary_line = true;
855
+ summary.passed += captures
856
+ .name("passed")
857
+ .and_then(|m| m.as_str().parse::<usize>().ok())
858
+ .unwrap_or(0);
859
+ summary.failed += captures
860
+ .name("failed")
861
+ .and_then(|m| m.as_str().parse::<usize>().ok())
862
+ .unwrap_or(0);
863
+ summary.skipped += captures
864
+ .name("skipped")
865
+ .and_then(|m| m.as_str().parse::<usize>().ok())
866
+ .unwrap_or(0);
867
+ summary.total += captures
868
+ .name("total")
869
+ .and_then(|m| m.as_str().parse::<usize>().ok())
870
+ .unwrap_or(0);
871
+
872
+ if let Some(duration) = captures.name("duration") {
873
+ fallback_duration = Some(duration.as_str().trim().to_string());
874
+ }
875
+ }
876
+
877
+ if found_summary_line && summary.duration_text.is_none() {
878
+ summary.duration_text = fallback_duration;
879
+ }
880
+
881
+ if let Some(captures) = TEST_SUMMARY_RE.captures_iter(&scrubbed).last() {
882
+ summary.passed = captures
883
+ .name("passed")
884
+ .and_then(|m| m.as_str().parse::<usize>().ok())
885
+ .unwrap_or(summary.passed);
886
+ summary.failed = captures
887
+ .name("failed")
888
+ .and_then(|m| m.as_str().parse::<usize>().ok())
889
+ .unwrap_or(summary.failed);
890
+ summary.skipped = captures
891
+ .name("skipped")
892
+ .and_then(|m| m.as_str().parse::<usize>().ok())
893
+ .unwrap_or(summary.skipped);
894
+ summary.total = captures
895
+ .name("total")
896
+ .and_then(|m| m.as_str().parse::<usize>().ok())
897
+ .unwrap_or(summary.total);
898
+
899
+ if let Some(duration) = captures.name("duration") {
900
+ summary.duration_text = Some(duration.as_str().trim().to_string());
901
+ }
902
+ }
903
+
904
+ let lines: Vec<&str> = scrubbed.lines().collect();
905
+ let mut idx = 0;
906
+ while idx < lines.len() {
907
+ let line = lines[idx];
908
+ if let Some(captures) = FAILED_TEST_HEAD_RE.captures(line) {
909
+ let name = captures
910
+ .name("name")
911
+ .map(|m| m.as_str().trim().to_string())
912
+ .unwrap_or_else(|| "unknown".to_string());
913
+ let mut details = Vec::new();
914
+ idx += 1;
915
+ while idx < lines.len() {
916
+ let detail_line = lines[idx].trim_end();
917
+ if FAILED_TEST_HEAD_RE.is_match(detail_line) {
918
+ idx = idx.saturating_sub(1);
919
+ break;
920
+ }
921
+ let detail_trimmed = detail_line.trim_start();
922
+ if detail_trimmed.starts_with("Failed! -")
923
+ || detail_trimmed.starts_with("Passed! -")
924
+ || detail_trimmed.starts_with("Test summary:")
925
+ || detail_trimmed.starts_with("Build ")
926
+ {
927
+ idx = idx.saturating_sub(1);
928
+ break;
929
+ }
930
+
931
+ if detail_line.trim().is_empty() {
932
+ if !details.is_empty() {
933
+ details.push(String::new());
934
+ }
935
+ } else {
936
+ details.push(detail_line.trim().to_string());
937
+ }
938
+ if details.len() >= 20 {
939
+ break;
940
+ }
941
+ idx += 1;
942
+ }
943
+ summary.failed_tests.push(FailedTest { name, details });
944
+ }
945
+ idx += 1;
946
+ }
947
+
948
+ if summary.failed == 0 {
949
+ summary.failed = summary.failed_tests.len();
950
+ }
951
+ if summary.total == 0 {
952
+ summary.total = summary.passed + summary.failed + summary.skipped;
953
+ }
954
+
955
+ summary
956
+ }
957
+
958
+ pub fn parse_restore_from_text(text: &str) -> RestoreSummary {
959
+ let (errors, warnings) = parse_restore_issues_from_text(text);
960
+ let clean = strip_ansi(text);
961
+ let scrubbed = scrub_sensitive_env_vars(&clean);
962
+
963
+ RestoreSummary {
964
+ restored_projects: RESTORE_PROJECT_RE.captures_iter(&scrubbed).count(),
965
+ warnings: warnings.len(),
966
+ errors: errors.len(),
967
+ duration_text: extract_duration(&scrubbed),
968
+ }
969
+ }
970
+
971
+ pub fn parse_restore_issues_from_text(text: &str) -> (Vec<BinlogIssue>, Vec<BinlogIssue>) {
972
+ let clean = strip_ansi(text);
973
+ let scrubbed = scrub_sensitive_env_vars(&clean);
974
+ let mut errors = Vec::new();
975
+ let mut warnings = Vec::new();
976
+ let mut seen_errors: HashSet<(String, String, u32, u32, String)> = HashSet::new();
977
+ let mut seen_warnings: HashSet<(String, String, u32, u32, String)> = HashSet::new();
978
+
979
+ for captures in RESTORE_DIAGNOSTIC_RE.captures_iter(&scrubbed) {
980
+ let issue = BinlogIssue {
981
+ code: captures
982
+ .name("code")
983
+ .map(|m| m.as_str().trim().to_string())
984
+ .unwrap_or_default(),
985
+ file: captures
986
+ .name("file")
987
+ .map(|m| m.as_str().trim().to_string())
988
+ .unwrap_or_default(),
989
+ line: 0,
990
+ column: 0,
991
+ message: captures
992
+ .name("msg")
993
+ .map(|m| m.as_str().trim().to_string())
994
+ .unwrap_or_default(),
995
+ };
996
+
997
+ let key = (
998
+ issue.code.clone(),
999
+ issue.file.clone(),
1000
+ issue.line,
1001
+ issue.column,
1002
+ issue.message.clone(),
1003
+ );
1004
+
1005
+ match captures
1006
+ .name("kind")
1007
+ .map(|m| m.as_str().to_ascii_lowercase())
1008
+ {
1009
+ Some(kind) if kind == "error" => {
1010
+ if seen_errors.insert(key) {
1011
+ errors.push(issue);
1012
+ }
1013
+ }
1014
+ Some(kind) if kind == "warning" => {
1015
+ if seen_warnings.insert(key) {
1016
+ warnings.push(issue);
1017
+ }
1018
+ }
1019
+ _ => {}
1020
+ }
1021
+ }
1022
+
1023
+ (errors, warnings)
1024
+ }
1025
+
1026
+ fn count_projects(text: &str) -> usize {
1027
+ PROJECT_PATH_RE.captures_iter(text).count()
1028
+ }
1029
+
1030
+ fn extract_duration(text: &str) -> Option<String> {
1031
+ DURATION_RE
1032
+ .captures(text)
1033
+ .and_then(|c| c.name("duration"))
1034
+ .map(|m| m.as_str().trim().to_string())
1035
+ }
1036
+
1037
+ fn extract_printable_runs(text: &str) -> Vec<String> {
1038
+ let mut runs = Vec::new();
1039
+ for captures in PRINTABLE_RUN_RE.captures_iter(text) {
1040
+ let Some(matched) = captures.get(0) else {
1041
+ continue;
1042
+ };
1043
+
1044
+ let run = matched.as_str().trim();
1045
+ if run.len() < 5 {
1046
+ continue;
1047
+ }
1048
+ runs.push(run.to_string());
1049
+ }
1050
+ runs
1051
+ }
1052
+
1053
+ fn extract_binary_like_issues(text: &str) -> Vec<BinlogIssue> {
1054
+ let runs = extract_printable_runs(text);
1055
+ if runs.is_empty() {
1056
+ return Vec::new();
1057
+ }
1058
+
1059
+ let mut issues = Vec::new();
1060
+ let mut seen: HashSet<(String, String, String)> = HashSet::new();
1061
+
1062
+ for idx in 0..runs.len() {
1063
+ let code = runs[idx].trim();
1064
+ if !DIAGNOSTIC_CODE_RE.is_match(code) || !is_likely_diagnostic_code(code) {
1065
+ continue;
1066
+ }
1067
+
1068
+ let message = (1..=4)
1069
+ .filter_map(|delta| idx.checked_sub(delta))
1070
+ .map(|j| runs[j].trim())
1071
+ .find(|candidate| {
1072
+ !DIAGNOSTIC_CODE_RE.is_match(candidate)
1073
+ && !SOURCE_FILE_RE.is_match(candidate)
1074
+ && candidate.chars().any(|c| c.is_ascii_alphabetic())
1075
+ && candidate.contains(' ')
1076
+ && !candidate.contains("Copyright")
1077
+ && !candidate.contains("Compiler version")
1078
+ })
1079
+ .unwrap_or("Build issue")
1080
+ .to_string();
1081
+
1082
+ let file = (1..=4)
1083
+ .filter_map(|delta| runs.get(idx + delta))
1084
+ .find_map(|candidate| {
1085
+ SOURCE_FILE_RE
1086
+ .captures(candidate)
1087
+ .and_then(|caps| caps.get(0))
1088
+ .map(|m| m.as_str().to_string())
1089
+ })
1090
+ .unwrap_or_default();
1091
+
1092
+ if file.is_empty() && message == "Build issue" {
1093
+ continue;
1094
+ }
1095
+
1096
+ let key = (code.to_string(), file.clone(), message.clone());
1097
+ if !seen.insert(key) {
1098
+ continue;
1099
+ }
1100
+
1101
+ issues.push(BinlogIssue {
1102
+ code: code.to_string(),
1103
+ file,
1104
+ line: 0,
1105
+ column: 0,
1106
+ message,
1107
+ });
1108
+ }
1109
+
1110
+ issues
1111
+ }
1112
+
1113
+ fn is_likely_diagnostic_code(code: &str) -> bool {
1114
+ const ALLOWED_PREFIXES: &[&str] = &[
1115
+ "CS", "MSB", "NU", "FS", "BC", "CA", "SA", "IDE", "IL", "VB", "AD", "TS", "C", "LNK",
1116
+ ];
1117
+
1118
+ ALLOWED_PREFIXES
1119
+ .iter()
1120
+ .any(|prefix| code.starts_with(prefix))
1121
+ }
1122
+
1123
+ #[cfg(test)]
1124
+ mod tests {
1125
+ use super::*;
1126
+ use flate2::write::GzEncoder;
1127
+ use flate2::Compression;
1128
+ use std::io::Write;
1129
+
1130
+ fn write_7bit_i32(buf: &mut Vec<u8>, value: i32) {
1131
+ let mut v = value as u32;
1132
+ while v >= 0x80 {
1133
+ buf.push(((v as u8) & 0x7F) | 0x80);
1134
+ v >>= 7;
1135
+ }
1136
+ buf.push(v as u8);
1137
+ }
1138
+
1139
+ fn write_dotnet_string(buf: &mut Vec<u8>, value: &str) {
1140
+ write_7bit_i32(buf, value.len() as i32);
1141
+ buf.extend_from_slice(value.as_bytes());
1142
+ }
1143
+
1144
+ fn write_event_record(target: &mut Vec<u8>, kind: i32, payload: &[u8]) {
1145
+ write_7bit_i32(target, kind);
1146
+ write_7bit_i32(target, payload.len() as i32);
1147
+ target.extend_from_slice(payload);
1148
+ }
1149
+
1150
+ fn build_minimal_binlog(records: &[u8]) -> Vec<u8> {
1151
+ let mut plain = Vec::new();
1152
+ plain.extend_from_slice(&25_i32.to_le_bytes());
1153
+ plain.extend_from_slice(&18_i32.to_le_bytes());
1154
+ plain.extend_from_slice(records);
1155
+
1156
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
1157
+ encoder.write_all(&plain).expect("write plain payload");
1158
+ encoder.finish().expect("finish gzip")
1159
+ }
1160
+
1161
+ #[test]
1162
+ fn test_scrub_sensitive_env_vars_masks_values() {
1163
+ let input = "PATH=/usr/local/bin HOME: /Users/daniel GITHUB_TOKEN=ghp_123";
1164
+ let scrubbed = scrub_sensitive_env_vars(input);
1165
+
1166
+ assert!(scrubbed.contains("PATH=[REDACTED]"));
1167
+ assert!(scrubbed.contains("HOME: [REDACTED]"));
1168
+ assert!(scrubbed.contains("GITHUB_TOKEN=[REDACTED]"));
1169
+ assert!(!scrubbed.contains("/usr/local/bin"));
1170
+ assert!(!scrubbed.contains("ghp_123"));
1171
+ }
1172
+
1173
+ #[test]
1174
+ fn test_scrub_sensitive_env_vars_masks_token_and_connection_values() {
1175
+ let input = "GH_TOKEN=ghs_abc AWS_SESSION_TOKEN=aws_xyz CONNECTION_STRING=Server=localhost";
1176
+ let scrubbed = scrub_sensitive_env_vars(input);
1177
+
1178
+ assert!(scrubbed.contains("GH_TOKEN=[REDACTED]"));
1179
+ assert!(scrubbed.contains("AWS_SESSION_TOKEN=[REDACTED]"));
1180
+ assert!(scrubbed.contains("CONNECTION_STRING=[REDACTED]"));
1181
+ assert!(!scrubbed.contains("ghs_abc"));
1182
+ assert!(!scrubbed.contains("aws_xyz"));
1183
+ assert!(!scrubbed.contains("Server=localhost"));
1184
+ }
1185
+
1186
+ #[test]
1187
+ fn test_parse_build_from_text_extracts_issues() {
1188
+ let input = r#"
1189
+ Build FAILED.
1190
+ src/Program.cs(42,15): error CS0103: The name 'foo' does not exist
1191
+ src/Program.cs(25,10): warning CS0219: Variable 'x' is assigned but never used
1192
+ 1 Warning(s)
1193
+ 1 Error(s)
1194
+ Time Elapsed 00:00:03.45
1195
+ "#;
1196
+
1197
+ let summary = parse_build_from_text(input);
1198
+ assert!(!summary.succeeded);
1199
+ assert_eq!(summary.errors.len(), 1);
1200
+ assert_eq!(summary.warnings.len(), 1);
1201
+ assert_eq!(summary.errors[0].code, "CS0103");
1202
+ assert_eq!(summary.warnings[0].code, "CS0219");
1203
+ assert_eq!(summary.duration_text.as_deref(), Some("00:00:03.45"));
1204
+ }
1205
+
1206
+ #[test]
1207
+ fn test_parse_build_from_text_extracts_warning_without_code() {
1208
+ let input = r#"
1209
+ /Users/dev/sdk/Microsoft.TestPlatform.targets(48,5): warning
1210
+ Build succeeded with 1 warning(s) in 0.5s
1211
+ "#;
1212
+
1213
+ let summary = parse_build_from_text(input);
1214
+ assert_eq!(summary.warnings.len(), 1);
1215
+ assert_eq!(
1216
+ summary.warnings[0].file,
1217
+ "/Users/dev/sdk/Microsoft.TestPlatform.targets"
1218
+ );
1219
+ assert_eq!(summary.warnings[0].code, "");
1220
+ }
1221
+
1222
+ #[test]
1223
+ fn test_parse_build_from_text_extracts_inline_warning_counts() {
1224
+ let input = r#"
1225
+ Build failed with 1 error(s) and 4 warning(s) in 4.7s
1226
+ "#;
1227
+
1228
+ let summary = parse_build_from_text(input);
1229
+ assert_eq!(summary.errors.len(), 1);
1230
+ assert_eq!(summary.warnings.len(), 4);
1231
+ }
1232
+
1233
+ #[test]
1234
+ fn test_parse_build_from_text_extracts_msbuild_global_error() {
1235
+ let input = r#"
1236
+ MSBUILD : error MSB1009: Project file does not exist.
1237
+ Switch: /tmp/nonexistent.csproj
1238
+ "#;
1239
+
1240
+ let summary = parse_build_from_text(input);
1241
+ assert_eq!(summary.errors.len(), 1);
1242
+ assert_eq!(summary.errors[0].code, "MSB1009");
1243
+ assert_eq!(summary.errors[0].file, "MSBUILD");
1244
+ assert!(summary.errors[0]
1245
+ .message
1246
+ .contains("Project file does not exist"));
1247
+ }
1248
+
1249
+ #[test]
1250
+ fn test_parse_test_from_text_extracts_failure_summary() {
1251
+ let input = r#"
1252
+ Failed! - Failed: 2, Passed: 245, Skipped: 0, Total: 247, Duration: 1 s
1253
+ Failed MyApp.Tests.UnitTests.CalculatorTests.Add_ShouldReturnSum [5 ms]
1254
+ Error Message:
1255
+ Assert.Equal() Failure: Expected 5, Actual 4
1256
+
1257
+ Failed MyApp.Tests.IntegrationTests.DatabaseTests.CanConnect [20 ms]
1258
+ Error Message:
1259
+ System.InvalidOperationException: Connection refused
1260
+ "#;
1261
+
1262
+ let summary = parse_test_from_text(input);
1263
+ assert_eq!(summary.passed, 245);
1264
+ assert_eq!(summary.failed, 2);
1265
+ assert_eq!(summary.total, 247);
1266
+ assert_eq!(summary.failed_tests.len(), 2);
1267
+ assert!(summary.failed_tests[0]
1268
+ .name
1269
+ .contains("CalculatorTests.Add_ShouldReturnSum"));
1270
+ }
1271
+
1272
+ #[test]
1273
+ fn test_parse_test_from_text_keeps_multiline_failure_details() {
1274
+ let input = r#"
1275
+ Failed! - Failed: 1, Passed: 10, Skipped: 0, Total: 11, Duration: 1 s
1276
+ Failed MyApp.Tests.SampleTests.ShouldFail [5 ms]
1277
+ Error Message:
1278
+ Assert.That(messageInstance, Is.Null)
1279
+ Expected: null
1280
+ But was: <MyApp.Tests.SampleTests+Impl>
1281
+
1282
+ Stack Trace:
1283
+ at MyApp.Tests.SampleTests.ShouldFail() in /repo/SampleTests.cs:line 42
1284
+ "#;
1285
+
1286
+ let summary = parse_test_from_text(input);
1287
+ assert_eq!(summary.failed, 1);
1288
+ assert_eq!(summary.failed_tests.len(), 1);
1289
+ let details = summary.failed_tests[0].details.join("\n");
1290
+ assert!(details.contains("Expected: null"));
1291
+ assert!(details.contains("But was:"));
1292
+ assert!(details.contains("Stack Trace:"));
1293
+ }
1294
+
1295
+ #[test]
1296
+ fn test_parse_test_from_text_ignores_non_test_failed_prefix_lines() {
1297
+ let input = r#"
1298
+ Passed! - Failed: 0, Passed: 940, Skipped: 7, Total: 947, Duration: 1 s
1299
+ Failed to load prune package data from PrunePackageData folder, loading from targeting packs instead
1300
+ "#;
1301
+
1302
+ let summary = parse_test_from_text(input);
1303
+ assert_eq!(summary.failed, 0);
1304
+ assert!(summary.failed_tests.is_empty());
1305
+ }
1306
+
1307
+ #[test]
1308
+ fn test_parse_test_from_text_aggregates_multiple_project_summaries() {
1309
+ let input = r#"
1310
+ Passed! - Failed: 0, Passed: 914, Skipped: 7, Total: 921, Duration: 00:00:08.20
1311
+ Failed! - Failed: 1, Passed: 26, Skipped: 0, Total: 27, Duration: 00:00:00.54
1312
+ Time Elapsed 00:00:12.34
1313
+ "#;
1314
+
1315
+ let summary = parse_test_from_text(input);
1316
+ assert_eq!(summary.passed, 940);
1317
+ assert_eq!(summary.failed, 1);
1318
+ assert_eq!(summary.skipped, 7);
1319
+ assert_eq!(summary.total, 948);
1320
+ assert_eq!(summary.duration_text.as_deref(), Some("00:00:12.34"));
1321
+ }
1322
+
1323
+ #[test]
1324
+ fn test_parse_test_from_text_prefers_test_summary_duration_and_counts() {
1325
+ let input = r#"
1326
+ Failed! - Failed: 1, Passed: 940, Skipped: 7, Total: 948, Duration: 1 s
1327
+ Test summary: total: 949, failed: 1, succeeded: 940, skipped: 7, duration: 2.7s
1328
+ Build failed with 1 error(s) and 4 warning(s) in 6.0s
1329
+ "#;
1330
+
1331
+ let summary = parse_test_from_text(input);
1332
+ assert_eq!(summary.passed, 940);
1333
+ assert_eq!(summary.failed, 1);
1334
+ assert_eq!(summary.skipped, 7);
1335
+ assert_eq!(summary.total, 949);
1336
+ assert_eq!(summary.duration_text.as_deref(), Some("2.7s"));
1337
+ }
1338
+
1339
+ #[test]
1340
+ fn test_parse_restore_from_text_extracts_project_count() {
1341
+ let input = r#"
1342
+ Restored /tmp/App/App.csproj (in 1.1 sec).
1343
+ Restored /tmp/App.Tests/App.Tests.csproj (in 1.2 sec).
1344
+ "#;
1345
+
1346
+ let summary = parse_restore_from_text(input);
1347
+ assert_eq!(summary.restored_projects, 2);
1348
+ assert_eq!(summary.errors, 0);
1349
+ }
1350
+
1351
+ #[test]
1352
+ fn test_parse_restore_from_text_extracts_nuget_error_diagnostic() {
1353
+ let input = r#"
1354
+ /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
1355
+
1356
+ Restore failed with 1 error(s) in 1.0s
1357
+ "#;
1358
+
1359
+ let summary = parse_restore_from_text(input);
1360
+ assert_eq!(summary.errors, 1);
1361
+ assert_eq!(summary.warnings, 0);
1362
+ }
1363
+
1364
+ #[test]
1365
+ fn test_parse_restore_issues_ignores_summary_warning_error_counts() {
1366
+ let input = r#"
1367
+ 0 Warning(s)
1368
+ 1 Error(s)
1369
+
1370
+ Time Elapsed 00:00:01.23
1371
+ "#;
1372
+
1373
+ let (errors, warnings) = parse_restore_issues_from_text(input);
1374
+ assert_eq!(errors.len(), 0);
1375
+ assert_eq!(warnings.len(), 0);
1376
+ }
1377
+
1378
+ #[test]
1379
+ fn test_parse_build_fails_when_binlog_is_unparseable() {
1380
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1381
+ let binlog_path = temp_dir.path().join("build.binlog");
1382
+ std::fs::write(&binlog_path, [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00])
1383
+ .expect("write binary file");
1384
+
1385
+ let err = parse_build(&binlog_path).expect_err("parse should fail");
1386
+ assert!(
1387
+ err.to_string().contains("Failed to parse binlog"),
1388
+ "unexpected error: {}",
1389
+ err
1390
+ );
1391
+ }
1392
+
1393
+ #[test]
1394
+ fn test_parse_build_fails_when_binlog_missing() {
1395
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1396
+ let binlog_path = temp_dir.path().join("build.binlog");
1397
+
1398
+ let err = parse_build(&binlog_path).expect_err("parse should fail");
1399
+ assert!(
1400
+ err.to_string().contains("Failed to parse binlog"),
1401
+ "unexpected error: {}",
1402
+ err
1403
+ );
1404
+ }
1405
+
1406
+ #[test]
1407
+ fn test_parse_build_reads_structured_events() {
1408
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1409
+ let binlog_path = temp_dir.path().join("build.binlog");
1410
+
1411
+ let mut records = Vec::new();
1412
+
1413
+ // String records (index starts at 10)
1414
+ write_7bit_i32(&mut records, RECORD_STRING);
1415
+ write_dotnet_string(&mut records, "Build started"); // 10
1416
+ write_7bit_i32(&mut records, RECORD_STRING);
1417
+ write_dotnet_string(&mut records, "Build finished"); // 11
1418
+ write_7bit_i32(&mut records, RECORD_STRING);
1419
+ write_dotnet_string(&mut records, "src/App.csproj"); // 12
1420
+ write_7bit_i32(&mut records, RECORD_STRING);
1421
+ write_dotnet_string(&mut records, "The name 'foo' does not exist"); // 13
1422
+ write_7bit_i32(&mut records, RECORD_STRING);
1423
+ write_dotnet_string(&mut records, "CS0103"); // 14
1424
+ write_7bit_i32(&mut records, RECORD_STRING);
1425
+ write_dotnet_string(&mut records, "src/Program.cs"); // 15
1426
+
1427
+ // BuildStarted (message + timestamp)
1428
+ let mut build_started = Vec::new();
1429
+ write_7bit_i32(&mut build_started, FLAG_MESSAGE | FLAG_TIMESTAMP);
1430
+ write_7bit_i32(&mut build_started, 10);
1431
+ build_started.extend_from_slice(&1_000_000_000_i64.to_le_bytes());
1432
+ write_7bit_i32(&mut build_started, 1);
1433
+ write_event_record(&mut records, RECORD_BUILD_STARTED, &build_started);
1434
+
1435
+ // ProjectFinished
1436
+ let mut project_finished = Vec::new();
1437
+ write_7bit_i32(&mut project_finished, 0);
1438
+ write_7bit_i32(&mut project_finished, 12);
1439
+ project_finished.push(1);
1440
+ write_event_record(&mut records, RECORD_PROJECT_FINISHED, &project_finished);
1441
+
1442
+ // Error event
1443
+ let mut error_event = Vec::new();
1444
+ write_7bit_i32(&mut error_event, FLAG_MESSAGE);
1445
+ write_7bit_i32(&mut error_event, 13);
1446
+ write_7bit_i32(&mut error_event, 0); // subcategory
1447
+ write_7bit_i32(&mut error_event, 14); // code
1448
+ write_7bit_i32(&mut error_event, 15); // file
1449
+ write_7bit_i32(&mut error_event, 0); // project file
1450
+ write_7bit_i32(&mut error_event, 42);
1451
+ write_7bit_i32(&mut error_event, 10);
1452
+ write_7bit_i32(&mut error_event, 42);
1453
+ write_7bit_i32(&mut error_event, 10);
1454
+ write_event_record(&mut records, RECORD_ERROR, &error_event);
1455
+
1456
+ // BuildFinished (message + timestamp + succeeded)
1457
+ let mut build_finished = Vec::new();
1458
+ write_7bit_i32(&mut build_finished, FLAG_MESSAGE | FLAG_TIMESTAMP);
1459
+ write_7bit_i32(&mut build_finished, 11);
1460
+ build_finished.extend_from_slice(&1_010_000_000_i64.to_le_bytes());
1461
+ write_7bit_i32(&mut build_finished, 1);
1462
+ build_finished.push(1);
1463
+ write_event_record(&mut records, RECORD_BUILD_FINISHED, &build_finished);
1464
+
1465
+ write_7bit_i32(&mut records, RECORD_END_OF_FILE);
1466
+
1467
+ let binlog_bytes = build_minimal_binlog(&records);
1468
+ std::fs::write(&binlog_path, binlog_bytes).expect("write binlog");
1469
+
1470
+ let summary = parse_build(&binlog_path).expect("parse should succeed");
1471
+ assert!(summary.succeeded);
1472
+ assert_eq!(summary.project_count, 1);
1473
+ assert_eq!(summary.errors.len(), 1);
1474
+ assert_eq!(summary.errors[0].code, "CS0103");
1475
+ assert_eq!(summary.duration_text.as_deref(), Some("00:00:01.00"));
1476
+ }
1477
+
1478
+ #[test]
1479
+ fn test_parse_test_reads_message_events() {
1480
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1481
+ let binlog_path = temp_dir.path().join("test.binlog");
1482
+
1483
+ let mut records = Vec::new();
1484
+ write_7bit_i32(&mut records, RECORD_STRING);
1485
+ write_dotnet_string(
1486
+ &mut records,
1487
+ "Failed! - Failed: 1, Passed: 2, Skipped: 0, Total: 3, Duration: 1 s",
1488
+ ); // 10
1489
+
1490
+ let mut message_event = Vec::new();
1491
+ write_7bit_i32(&mut message_event, FLAG_MESSAGE | FLAG_IMPORTANCE);
1492
+ write_7bit_i32(&mut message_event, 10);
1493
+ write_7bit_i32(&mut message_event, 1);
1494
+ write_event_record(&mut records, RECORD_MESSAGE, &message_event);
1495
+
1496
+ write_7bit_i32(&mut records, RECORD_END_OF_FILE);
1497
+ let binlog_bytes = build_minimal_binlog(&records);
1498
+ std::fs::write(&binlog_path, binlog_bytes).expect("write binlog");
1499
+
1500
+ let summary = parse_test(&binlog_path).expect("parse should succeed");
1501
+ assert_eq!(summary.failed, 1);
1502
+ assert_eq!(summary.passed, 2);
1503
+ assert_eq!(summary.total, 3);
1504
+ }
1505
+
1506
+ #[test]
1507
+ fn test_parse_test_fails_when_binlog_missing() {
1508
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1509
+ let binlog_path = temp_dir.path().join("test.binlog");
1510
+
1511
+ let err = parse_test(&binlog_path).expect_err("parse should fail");
1512
+ assert!(
1513
+ err.to_string().contains("Failed to parse binlog"),
1514
+ "unexpected error: {}",
1515
+ err
1516
+ );
1517
+ }
1518
+
1519
+ #[test]
1520
+ fn test_parse_restore_fails_when_binlog_missing() {
1521
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
1522
+ let binlog_path = temp_dir.path().join("restore.binlog");
1523
+
1524
+ let err = parse_restore(&binlog_path).expect_err("parse should fail");
1525
+ assert!(
1526
+ err.to_string().contains("Failed to parse binlog"),
1527
+ "unexpected error: {}",
1528
+ err
1529
+ );
1530
+ }
1531
+
1532
+ #[test]
1533
+ fn test_parse_build_from_fixture_text() {
1534
+ let input = include_str!("../tests/fixtures/dotnet/build_failed.txt");
1535
+ let summary = parse_build_from_text(input);
1536
+
1537
+ assert_eq!(summary.errors.len(), 1);
1538
+ assert_eq!(summary.errors[0].code, "CS1525");
1539
+ assert_eq!(summary.duration_text.as_deref(), Some("00:00:00.76"));
1540
+ }
1541
+
1542
+ #[test]
1543
+ fn test_parse_build_sets_project_count_floor() {
1544
+ let input = r#"
1545
+ RtkDotnetSmoke -> /tmp/RtkDotnetSmoke.dll
1546
+
1547
+ Build succeeded.
1548
+ 0 Warning(s)
1549
+ 0 Error(s)
1550
+
1551
+ Time Elapsed 00:00:00.12
1552
+ "#;
1553
+
1554
+ let summary = parse_build_from_text(input);
1555
+ assert_eq!(summary.project_count, 1);
1556
+ assert!(summary.succeeded);
1557
+ }
1558
+
1559
+ #[test]
1560
+ fn test_parse_build_does_not_infer_binary_errors_on_successful_build() {
1561
+ let input = "\x0bInvalid expression term ';'\x18\x06CS1525\x18%/tmp/App/Broken.cs\x09\nBuild succeeded.\n 0 Warning(s)\n 0 Error(s)\n";
1562
+
1563
+ let summary = parse_build_from_text(input);
1564
+ assert!(summary.succeeded);
1565
+ assert!(summary.errors.is_empty());
1566
+ }
1567
+
1568
+ #[test]
1569
+ fn test_parse_test_from_fixture_text() {
1570
+ let input = include_str!("../tests/fixtures/dotnet/test_failed.txt");
1571
+ let summary = parse_test_from_text(input);
1572
+
1573
+ assert_eq!(summary.failed, 1);
1574
+ assert_eq!(summary.passed, 0);
1575
+ assert_eq!(summary.total, 1);
1576
+ assert_eq!(summary.failed_tests.len(), 1);
1577
+ assert!(summary.failed_tests[0]
1578
+ .name
1579
+ .contains("RtkDotnetSmoke.UnitTest1.Test1"));
1580
+ }
1581
+
1582
+ #[test]
1583
+ fn test_extract_binary_like_issues_recovers_code_message_and_path() {
1584
+ let noisy =
1585
+ "\x0bInvalid expression term ';'\x18\x06CS1525\x18%/tmp/RtkDotnetSmoke/Broken.cs\x09";
1586
+ let issues = extract_binary_like_issues(noisy);
1587
+
1588
+ assert_eq!(issues.len(), 1);
1589
+ assert_eq!(issues[0].code, "CS1525");
1590
+ assert_eq!(issues[0].file, "/tmp/RtkDotnetSmoke/Broken.cs");
1591
+ assert!(issues[0].message.contains("Invalid expression term"));
1592
+ }
1593
+
1594
+ #[test]
1595
+ fn test_is_likely_diagnostic_code_filters_framework_monikers() {
1596
+ assert!(is_likely_diagnostic_code("CS1525"));
1597
+ assert!(is_likely_diagnostic_code("MSB4018"));
1598
+ assert!(!is_likely_diagnostic_code("NET451"));
1599
+ assert!(!is_likely_diagnostic_code("NET10"));
1600
+ }
1601
+
1602
+ #[test]
1603
+ fn test_select_best_issues_prefers_fallback_when_primary_loses_context() {
1604
+ let primary = vec![BinlogIssue {
1605
+ code: String::new(),
1606
+ file: "CS1525".to_string(),
1607
+ line: 51,
1608
+ column: 1,
1609
+ message: "Invalid expression term ';'".to_string(),
1610
+ }];
1611
+
1612
+ let fallback = vec![BinlogIssue {
1613
+ code: "CS1525".to_string(),
1614
+ file: "/Users/dev/project/src/NServiceBus.Core/Class1.cs".to_string(),
1615
+ line: 1,
1616
+ column: 9,
1617
+ message: "Invalid expression term ';'".to_string(),
1618
+ }];
1619
+
1620
+ let selected = select_best_issues(primary, fallback.clone());
1621
+ assert_eq!(selected, fallback);
1622
+ }
1623
+
1624
+ #[test]
1625
+ fn test_select_best_issues_keeps_primary_when_context_is_good() {
1626
+ let primary = vec![BinlogIssue {
1627
+ code: "CS0103".to_string(),
1628
+ file: "src/Program.cs".to_string(),
1629
+ line: 42,
1630
+ column: 15,
1631
+ message: "The name 'foo' does not exist".to_string(),
1632
+ }];
1633
+
1634
+ let fallback = vec![BinlogIssue {
1635
+ code: "CS0103".to_string(),
1636
+ file: String::new(),
1637
+ line: 0,
1638
+ column: 0,
1639
+ message: "Build error #1 (details omitted)".to_string(),
1640
+ }];
1641
+
1642
+ let selected = select_best_issues(primary.clone(), fallback);
1643
+ assert_eq!(selected, primary);
1644
+ }
1645
+ }