@hasna/terminal 2.0.5 → 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 (263) hide show
  1. package/dist/cli.js +52 -21
  2. package/package.json +1 -1
  3. package/src/ai.ts +77 -130
  4. package/src/cli.tsx +51 -21
  5. package/src/command-validator.ts +11 -0
  6. package/src/context-hints.ts +291 -0
  7. package/src/discover.ts +238 -0
  8. package/src/economy.ts +53 -0
  9. package/src/output-processor.ts +7 -18
  10. package/src/output-store.ts +65 -0
  11. package/src/providers/base.ts +3 -1
  12. package/src/providers/groq.ts +108 -0
  13. package/src/providers/index.ts +26 -2
  14. package/src/providers/providers.test.ts +4 -2
  15. package/src/providers/xai.ts +108 -0
  16. package/src/sessions-db.ts +81 -0
  17. package/temp/rtk/.claude/agents/code-reviewer.md +221 -0
  18. package/temp/rtk/.claude/agents/debugger.md +519 -0
  19. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +461 -0
  20. package/temp/rtk/.claude/agents/rust-rtk.md +511 -0
  21. package/temp/rtk/.claude/agents/technical-writer.md +355 -0
  22. package/temp/rtk/.claude/commands/diagnose.md +352 -0
  23. package/temp/rtk/.claude/commands/test-routing.md +362 -0
  24. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +16 -0
  25. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +70 -0
  26. package/temp/rtk/.claude/hooks/rtk-suggest.sh +152 -0
  27. package/temp/rtk/.claude/rules/cli-testing.md +526 -0
  28. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +348 -0
  29. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +134 -0
  30. package/temp/rtk/.claude/skills/performance.md +435 -0
  31. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +315 -0
  32. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +71 -0
  33. package/temp/rtk/.claude/skills/repo-recap.md +206 -0
  34. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +78 -0
  35. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +124 -0
  36. package/temp/rtk/.claude/skills/security-guardian.md +503 -0
  37. package/temp/rtk/.claude/skills/ship.md +404 -0
  38. package/temp/rtk/.github/workflows/benchmark.yml +34 -0
  39. package/temp/rtk/.github/workflows/dco-check.yaml +12 -0
  40. package/temp/rtk/.github/workflows/release-please.yml +51 -0
  41. package/temp/rtk/.github/workflows/release.yml +343 -0
  42. package/temp/rtk/.github/workflows/security-check.yml +135 -0
  43. package/temp/rtk/.github/workflows/validate-docs.yml +78 -0
  44. package/temp/rtk/.release-please-manifest.json +3 -0
  45. package/temp/rtk/ARCHITECTURE.md +1491 -0
  46. package/temp/rtk/CHANGELOG.md +640 -0
  47. package/temp/rtk/CLAUDE.md +605 -0
  48. package/temp/rtk/CONTRIBUTING.md +199 -0
  49. package/temp/rtk/Cargo.lock +1668 -0
  50. package/temp/rtk/Cargo.toml +64 -0
  51. package/temp/rtk/Formula/rtk.rb +43 -0
  52. package/temp/rtk/INSTALL.md +390 -0
  53. package/temp/rtk/LICENSE +21 -0
  54. package/temp/rtk/README.md +386 -0
  55. package/temp/rtk/README_es.md +159 -0
  56. package/temp/rtk/README_fr.md +197 -0
  57. package/temp/rtk/README_ja.md +159 -0
  58. package/temp/rtk/README_ko.md +159 -0
  59. package/temp/rtk/README_zh.md +167 -0
  60. package/temp/rtk/ROADMAP.md +15 -0
  61. package/temp/rtk/SECURITY.md +217 -0
  62. package/temp/rtk/TEST_EXEC_TIME.md +102 -0
  63. package/temp/rtk/build.rs +57 -0
  64. package/temp/rtk/docs/AUDIT_GUIDE.md +432 -0
  65. package/temp/rtk/docs/FEATURES.md +1410 -0
  66. package/temp/rtk/docs/TROUBLESHOOTING.md +309 -0
  67. package/temp/rtk/docs/filter-workflow.md +102 -0
  68. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  69. package/temp/rtk/docs/tracking.md +583 -0
  70. package/temp/rtk/hooks/opencode-rtk.ts +39 -0
  71. package/temp/rtk/hooks/rtk-awareness.md +29 -0
  72. package/temp/rtk/hooks/rtk-rewrite.sh +61 -0
  73. package/temp/rtk/hooks/test-rtk-rewrite.sh +442 -0
  74. package/temp/rtk/install.sh +124 -0
  75. package/temp/rtk/release-please-config.json +10 -0
  76. package/temp/rtk/scripts/benchmark.sh +592 -0
  77. package/temp/rtk/scripts/check-installation.sh +162 -0
  78. package/temp/rtk/scripts/install-local.sh +37 -0
  79. package/temp/rtk/scripts/rtk-economics.sh +137 -0
  80. package/temp/rtk/scripts/test-all.sh +561 -0
  81. package/temp/rtk/scripts/test-aristote.sh +227 -0
  82. package/temp/rtk/scripts/test-tracking.sh +79 -0
  83. package/temp/rtk/scripts/update-readme-metrics.sh +32 -0
  84. package/temp/rtk/scripts/validate-docs.sh +73 -0
  85. package/temp/rtk/src/aws_cmd.rs +880 -0
  86. package/temp/rtk/src/binlog.rs +1645 -0
  87. package/temp/rtk/src/cargo_cmd.rs +1727 -0
  88. package/temp/rtk/src/cc_economics.rs +1157 -0
  89. package/temp/rtk/src/ccusage.rs +340 -0
  90. package/temp/rtk/src/config.rs +187 -0
  91. package/temp/rtk/src/container.rs +855 -0
  92. package/temp/rtk/src/curl_cmd.rs +134 -0
  93. package/temp/rtk/src/deps.rs +268 -0
  94. package/temp/rtk/src/diff_cmd.rs +367 -0
  95. package/temp/rtk/src/discover/mod.rs +274 -0
  96. package/temp/rtk/src/discover/provider.rs +388 -0
  97. package/temp/rtk/src/discover/registry.rs +2022 -0
  98. package/temp/rtk/src/discover/report.rs +202 -0
  99. package/temp/rtk/src/discover/rules.rs +667 -0
  100. package/temp/rtk/src/display_helpers.rs +402 -0
  101. package/temp/rtk/src/dotnet_cmd.rs +1771 -0
  102. package/temp/rtk/src/dotnet_format_report.rs +133 -0
  103. package/temp/rtk/src/dotnet_trx.rs +593 -0
  104. package/temp/rtk/src/env_cmd.rs +204 -0
  105. package/temp/rtk/src/filter.rs +462 -0
  106. package/temp/rtk/src/filters/README.md +52 -0
  107. package/temp/rtk/src/filters/ansible-playbook.toml +34 -0
  108. package/temp/rtk/src/filters/basedpyright.toml +47 -0
  109. package/temp/rtk/src/filters/biome.toml +45 -0
  110. package/temp/rtk/src/filters/brew-install.toml +37 -0
  111. package/temp/rtk/src/filters/composer-install.toml +40 -0
  112. package/temp/rtk/src/filters/df.toml +16 -0
  113. package/temp/rtk/src/filters/dotnet-build.toml +64 -0
  114. package/temp/rtk/src/filters/du.toml +16 -0
  115. package/temp/rtk/src/filters/fail2ban-client.toml +15 -0
  116. package/temp/rtk/src/filters/gcc.toml +49 -0
  117. package/temp/rtk/src/filters/gcloud.toml +22 -0
  118. package/temp/rtk/src/filters/hadolint.toml +24 -0
  119. package/temp/rtk/src/filters/helm.toml +29 -0
  120. package/temp/rtk/src/filters/iptables.toml +27 -0
  121. package/temp/rtk/src/filters/jj.toml +28 -0
  122. package/temp/rtk/src/filters/jq.toml +24 -0
  123. package/temp/rtk/src/filters/make.toml +41 -0
  124. package/temp/rtk/src/filters/markdownlint.toml +24 -0
  125. package/temp/rtk/src/filters/mix-compile.toml +27 -0
  126. package/temp/rtk/src/filters/mix-format.toml +15 -0
  127. package/temp/rtk/src/filters/mvn-build.toml +44 -0
  128. package/temp/rtk/src/filters/oxlint.toml +43 -0
  129. package/temp/rtk/src/filters/ping.toml +63 -0
  130. package/temp/rtk/src/filters/pio-run.toml +40 -0
  131. package/temp/rtk/src/filters/poetry-install.toml +50 -0
  132. package/temp/rtk/src/filters/pre-commit.toml +35 -0
  133. package/temp/rtk/src/filters/ps.toml +16 -0
  134. package/temp/rtk/src/filters/quarto-render.toml +41 -0
  135. package/temp/rtk/src/filters/rsync.toml +48 -0
  136. package/temp/rtk/src/filters/shellcheck.toml +27 -0
  137. package/temp/rtk/src/filters/shopify-theme.toml +29 -0
  138. package/temp/rtk/src/filters/skopeo.toml +45 -0
  139. package/temp/rtk/src/filters/sops.toml +16 -0
  140. package/temp/rtk/src/filters/ssh.toml +44 -0
  141. package/temp/rtk/src/filters/stat.toml +34 -0
  142. package/temp/rtk/src/filters/swift-build.toml +41 -0
  143. package/temp/rtk/src/filters/systemctl-status.toml +33 -0
  144. package/temp/rtk/src/filters/terraform-plan.toml +35 -0
  145. package/temp/rtk/src/filters/tofu-fmt.toml +16 -0
  146. package/temp/rtk/src/filters/tofu-init.toml +38 -0
  147. package/temp/rtk/src/filters/tofu-plan.toml +35 -0
  148. package/temp/rtk/src/filters/tofu-validate.toml +17 -0
  149. package/temp/rtk/src/filters/trunk-build.toml +39 -0
  150. package/temp/rtk/src/filters/ty.toml +50 -0
  151. package/temp/rtk/src/filters/uv-sync.toml +37 -0
  152. package/temp/rtk/src/filters/xcodebuild.toml +99 -0
  153. package/temp/rtk/src/filters/yamllint.toml +25 -0
  154. package/temp/rtk/src/find_cmd.rs +598 -0
  155. package/temp/rtk/src/format_cmd.rs +386 -0
  156. package/temp/rtk/src/gain.rs +723 -0
  157. package/temp/rtk/src/gh_cmd.rs +1651 -0
  158. package/temp/rtk/src/git.rs +2012 -0
  159. package/temp/rtk/src/go_cmd.rs +592 -0
  160. package/temp/rtk/src/golangci_cmd.rs +254 -0
  161. package/temp/rtk/src/grep_cmd.rs +288 -0
  162. package/temp/rtk/src/gt_cmd.rs +810 -0
  163. package/temp/rtk/src/hook_audit_cmd.rs +283 -0
  164. package/temp/rtk/src/hook_check.rs +171 -0
  165. package/temp/rtk/src/init.rs +1859 -0
  166. package/temp/rtk/src/integrity.rs +537 -0
  167. package/temp/rtk/src/json_cmd.rs +231 -0
  168. package/temp/rtk/src/learn/detector.rs +628 -0
  169. package/temp/rtk/src/learn/mod.rs +119 -0
  170. package/temp/rtk/src/learn/report.rs +184 -0
  171. package/temp/rtk/src/lint_cmd.rs +694 -0
  172. package/temp/rtk/src/local_llm.rs +316 -0
  173. package/temp/rtk/src/log_cmd.rs +248 -0
  174. package/temp/rtk/src/ls.rs +324 -0
  175. package/temp/rtk/src/main.rs +2482 -0
  176. package/temp/rtk/src/mypy_cmd.rs +389 -0
  177. package/temp/rtk/src/next_cmd.rs +241 -0
  178. package/temp/rtk/src/npm_cmd.rs +236 -0
  179. package/temp/rtk/src/parser/README.md +267 -0
  180. package/temp/rtk/src/parser/error.rs +46 -0
  181. package/temp/rtk/src/parser/formatter.rs +336 -0
  182. package/temp/rtk/src/parser/mod.rs +311 -0
  183. package/temp/rtk/src/parser/types.rs +119 -0
  184. package/temp/rtk/src/pip_cmd.rs +302 -0
  185. package/temp/rtk/src/playwright_cmd.rs +479 -0
  186. package/temp/rtk/src/pnpm_cmd.rs +573 -0
  187. package/temp/rtk/src/prettier_cmd.rs +221 -0
  188. package/temp/rtk/src/prisma_cmd.rs +482 -0
  189. package/temp/rtk/src/psql_cmd.rs +382 -0
  190. package/temp/rtk/src/pytest_cmd.rs +384 -0
  191. package/temp/rtk/src/read.rs +217 -0
  192. package/temp/rtk/src/rewrite_cmd.rs +50 -0
  193. package/temp/rtk/src/ruff_cmd.rs +402 -0
  194. package/temp/rtk/src/runner.rs +271 -0
  195. package/temp/rtk/src/summary.rs +297 -0
  196. package/temp/rtk/src/tee.rs +405 -0
  197. package/temp/rtk/src/telemetry.rs +248 -0
  198. package/temp/rtk/src/toml_filter.rs +1655 -0
  199. package/temp/rtk/src/tracking.rs +1416 -0
  200. package/temp/rtk/src/tree.rs +209 -0
  201. package/temp/rtk/src/tsc_cmd.rs +259 -0
  202. package/temp/rtk/src/utils.rs +432 -0
  203. package/temp/rtk/src/verify_cmd.rs +47 -0
  204. package/temp/rtk/src/vitest_cmd.rs +385 -0
  205. package/temp/rtk/src/wc_cmd.rs +401 -0
  206. package/temp/rtk/src/wget_cmd.rs +260 -0
  207. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +11 -0
  208. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +31 -0
  209. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +1 -0
  210. package/temp/rtk/tests/fixtures/dotnet/format_success.json +12 -0
  211. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +18 -0
  212. package/dist/App.js +0 -404
  213. package/dist/Browse.js +0 -79
  214. package/dist/FuzzyPicker.js +0 -47
  215. package/dist/Onboarding.js +0 -51
  216. package/dist/Spinner.js +0 -12
  217. package/dist/StatusBar.js +0 -49
  218. package/dist/ai.js +0 -368
  219. package/dist/cache.js +0 -41
  220. package/dist/command-rewriter.js +0 -64
  221. package/dist/command-validator.js +0 -77
  222. package/dist/compression.js +0 -107
  223. package/dist/diff-cache.js +0 -107
  224. package/dist/economy.js +0 -79
  225. package/dist/expand-store.js +0 -38
  226. package/dist/file-cache.js +0 -72
  227. package/dist/file-index.js +0 -62
  228. package/dist/history.js +0 -62
  229. package/dist/lazy-executor.js +0 -54
  230. package/dist/line-dedup.js +0 -59
  231. package/dist/loop-detector.js +0 -75
  232. package/dist/mcp/install.js +0 -98
  233. package/dist/mcp/server.js +0 -569
  234. package/dist/noise-filter.js +0 -86
  235. package/dist/output-processor.js +0 -136
  236. package/dist/output-router.js +0 -41
  237. package/dist/parsers/base.js +0 -2
  238. package/dist/parsers/build.js +0 -64
  239. package/dist/parsers/errors.js +0 -101
  240. package/dist/parsers/files.js +0 -78
  241. package/dist/parsers/git.js +0 -99
  242. package/dist/parsers/index.js +0 -48
  243. package/dist/parsers/tests.js +0 -89
  244. package/dist/providers/anthropic.js +0 -39
  245. package/dist/providers/base.js +0 -4
  246. package/dist/providers/cerebras.js +0 -95
  247. package/dist/providers/index.js +0 -49
  248. package/dist/recipes/model.js +0 -20
  249. package/dist/recipes/storage.js +0 -136
  250. package/dist/search/content-search.js +0 -68
  251. package/dist/search/file-search.js +0 -61
  252. package/dist/search/filters.js +0 -34
  253. package/dist/search/index.js +0 -5
  254. package/dist/search/semantic.js +0 -320
  255. package/dist/session-boot.js +0 -59
  256. package/dist/session-context.js +0 -55
  257. package/dist/sessions-db.js +0 -120
  258. package/dist/smart-display.js +0 -286
  259. package/dist/snapshots.js +0 -51
  260. package/dist/supervisor.js +0 -112
  261. package/dist/test-watchlist.js +0 -131
  262. package/dist/tree.js +0 -94
  263. package/dist/usage-cache.js +0 -65
@@ -0,0 +1,593 @@
1
+ use crate::binlog::{FailedTest, TestSummary};
2
+ use chrono::{DateTime, FixedOffset};
3
+ use quick_xml::events::{BytesStart, Event};
4
+ use quick_xml::Reader;
5
+ use std::path::{Path, PathBuf};
6
+ use std::time::SystemTime;
7
+
8
+ fn local_name(name: &[u8]) -> &[u8] {
9
+ name.rsplit(|b| *b == b':').next().unwrap_or(name)
10
+ }
11
+
12
+ fn extract_attr_value(
13
+ reader: &Reader<&[u8]>,
14
+ start: &BytesStart<'_>,
15
+ key: &[u8],
16
+ ) -> Option<String> {
17
+ for attr in start.attributes().flatten() {
18
+ if local_name(attr.key.as_ref()) != key {
19
+ continue;
20
+ }
21
+
22
+ if let Ok(value) = attr.decode_and_unescape_value(reader.decoder()) {
23
+ return Some(value.into_owned());
24
+ }
25
+ }
26
+
27
+ None
28
+ }
29
+
30
+ fn parse_usize_attr(reader: &Reader<&[u8]>, start: &BytesStart<'_>, key: &[u8]) -> usize {
31
+ extract_attr_value(reader, start, key)
32
+ .and_then(|v| v.parse::<usize>().ok())
33
+ .unwrap_or(0)
34
+ }
35
+
36
+ fn parse_trx_duration(start: &str, finish: &str) -> Option<String> {
37
+ let start_dt = DateTime::parse_from_rfc3339(start).ok()?;
38
+ let finish_dt = DateTime::parse_from_rfc3339(finish).ok()?;
39
+ format_duration_between(start_dt, finish_dt)
40
+ }
41
+
42
+ fn format_duration_between(
43
+ start_dt: DateTime<FixedOffset>,
44
+ finish_dt: DateTime<FixedOffset>,
45
+ ) -> Option<String> {
46
+ let diff = finish_dt.signed_duration_since(start_dt);
47
+ let millis = diff.num_milliseconds();
48
+ if millis <= 0 {
49
+ return None;
50
+ }
51
+
52
+ if millis >= 1_000 {
53
+ let seconds = millis as f64 / 1_000.0;
54
+ return Some(format!("{seconds:.1} s"));
55
+ }
56
+
57
+ Some(format!("{millis} ms"))
58
+ }
59
+
60
+ fn parse_trx_time_bounds(content: &str) -> Option<(DateTime<FixedOffset>, DateTime<FixedOffset>)> {
61
+ let mut reader = Reader::from_str(content);
62
+ reader.config_mut().trim_text(true);
63
+ let mut buf = Vec::new();
64
+
65
+ loop {
66
+ match reader.read_event_into(&mut buf) {
67
+ Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
68
+ if local_name(e.name().as_ref()) != b"Times" {
69
+ buf.clear();
70
+ continue;
71
+ }
72
+
73
+ let start = extract_attr_value(&reader, &e, b"start")?;
74
+ let finish = extract_attr_value(&reader, &e, b"finish")?;
75
+ let start_dt = DateTime::parse_from_rfc3339(&start).ok()?;
76
+ let finish_dt = DateTime::parse_from_rfc3339(&finish).ok()?;
77
+ return Some((start_dt, finish_dt));
78
+ }
79
+ Ok(Event::Eof) => break,
80
+ Err(_) => return None,
81
+ _ => {}
82
+ }
83
+
84
+ buf.clear();
85
+ }
86
+
87
+ None
88
+ }
89
+
90
+ /// Parse TRX (Visual Studio Test Results) file to extract test summary.
91
+ /// Returns None if the file doesn't exist or isn't a valid TRX file.
92
+ pub fn parse_trx_file(path: &Path) -> Option<TestSummary> {
93
+ let content = std::fs::read_to_string(path).ok()?;
94
+ parse_trx_content(&content)
95
+ }
96
+
97
+ pub fn parse_trx_file_since(path: &Path, since: SystemTime) -> Option<TestSummary> {
98
+ let modified = std::fs::metadata(path).ok()?.modified().ok()?;
99
+ if modified < since {
100
+ return None;
101
+ }
102
+
103
+ parse_trx_file(path)
104
+ }
105
+
106
+ pub fn parse_trx_files_in_dir(dir: &Path) -> Option<TestSummary> {
107
+ parse_trx_files_in_dir_since(dir, None)
108
+ }
109
+
110
+ pub fn parse_trx_files_in_dir_since(dir: &Path, since: Option<SystemTime>) -> Option<TestSummary> {
111
+ if !dir.exists() || !dir.is_dir() {
112
+ return None;
113
+ }
114
+
115
+ let mut summaries = Vec::new();
116
+ let mut min_start: Option<DateTime<FixedOffset>> = None;
117
+ let mut max_finish: Option<DateTime<FixedOffset>> = None;
118
+ let entries = std::fs::read_dir(dir).ok()?;
119
+ for entry in entries.flatten() {
120
+ let path = entry.path();
121
+ if path
122
+ .extension()
123
+ .is_none_or(|e| !e.eq_ignore_ascii_case("trx"))
124
+ {
125
+ continue;
126
+ }
127
+
128
+ if let Some(since) = since {
129
+ let modified = match entry.metadata().ok().and_then(|m| m.modified().ok()) {
130
+ Some(modified) => modified,
131
+ None => continue,
132
+ };
133
+ if modified < since {
134
+ continue;
135
+ }
136
+ }
137
+
138
+ let content = match std::fs::read_to_string(&path) {
139
+ Ok(content) => content,
140
+ Err(_) => continue,
141
+ };
142
+
143
+ if let Some((start, finish)) = parse_trx_time_bounds(&content) {
144
+ min_start = Some(min_start.map_or(start, |prev| prev.min(start)));
145
+ max_finish = Some(max_finish.map_or(finish, |prev| prev.max(finish)));
146
+ }
147
+
148
+ if let Some(summary) = parse_trx_content(&content) {
149
+ summaries.push(summary);
150
+ }
151
+ }
152
+
153
+ if summaries.is_empty() {
154
+ return None;
155
+ }
156
+
157
+ let mut merged = TestSummary::default();
158
+ for summary in summaries {
159
+ merged.passed += summary.passed;
160
+ merged.failed += summary.failed;
161
+ merged.skipped += summary.skipped;
162
+ merged.total += summary.total;
163
+ merged.failed_tests.extend(summary.failed_tests);
164
+ merged.project_count += summary.project_count.max(1);
165
+ if merged.duration_text.is_none() {
166
+ merged.duration_text = summary.duration_text;
167
+ }
168
+ }
169
+
170
+ if let (Some(start), Some(finish)) = (min_start, max_finish) {
171
+ merged.duration_text = format_duration_between(start, finish);
172
+ }
173
+
174
+ Some(merged)
175
+ }
176
+
177
+ pub fn find_recent_trx_in_testresults() -> Option<PathBuf> {
178
+ find_recent_trx_in_dir(Path::new("./TestResults"))
179
+ }
180
+
181
+ fn find_recent_trx_in_dir(dir: &Path) -> Option<PathBuf> {
182
+ if !dir.exists() {
183
+ return None;
184
+ }
185
+
186
+ std::fs::read_dir(dir)
187
+ .ok()?
188
+ .filter_map(|entry| entry.ok())
189
+ .filter_map(|entry| {
190
+ let path = entry.path();
191
+ let is_trx = path
192
+ .extension()
193
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("trx"));
194
+ if !is_trx {
195
+ return None;
196
+ }
197
+
198
+ let modified = entry.metadata().ok()?.modified().ok()?;
199
+ Some((modified, path))
200
+ })
201
+ .max_by_key(|(modified, _)| *modified)
202
+ .map(|(_, path)| path)
203
+ }
204
+
205
+ fn parse_trx_content(content: &str) -> Option<TestSummary> {
206
+ #[derive(Clone, Copy)]
207
+ enum CaptureField {
208
+ Message,
209
+ StackTrace,
210
+ }
211
+
212
+ let mut reader = Reader::from_str(content);
213
+ reader.config_mut().trim_text(true);
214
+ let mut buf = Vec::new();
215
+ let mut summary = TestSummary::default();
216
+ let mut saw_test_run = false;
217
+ let mut in_failed_result = false;
218
+ let mut in_error_info = false;
219
+ let mut failed_test_name = String::new();
220
+ let mut message_buf = String::new();
221
+ let mut stack_buf = String::new();
222
+ let mut capture_field: Option<CaptureField> = None;
223
+
224
+ loop {
225
+ match reader.read_event_into(&mut buf) {
226
+ Ok(Event::Start(e)) => match local_name(e.name().as_ref()) {
227
+ b"TestRun" => saw_test_run = true,
228
+ b"Times" => {
229
+ let start = extract_attr_value(&reader, &e, b"start");
230
+ let finish = extract_attr_value(&reader, &e, b"finish");
231
+ if let (Some(start), Some(finish)) = (start, finish) {
232
+ summary.duration_text = parse_trx_duration(&start, &finish);
233
+ }
234
+ }
235
+ b"Counters" => {
236
+ summary.total = parse_usize_attr(&reader, &e, b"total");
237
+ summary.passed = parse_usize_attr(&reader, &e, b"passed");
238
+ summary.failed = parse_usize_attr(&reader, &e, b"failed");
239
+ }
240
+ b"UnitTestResult" => {
241
+ let outcome = extract_attr_value(&reader, &e, b"outcome")
242
+ .unwrap_or_else(|| "Unknown".to_string());
243
+
244
+ if outcome == "Failed" {
245
+ in_failed_result = true;
246
+ in_error_info = false;
247
+ capture_field = None;
248
+ message_buf.clear();
249
+ stack_buf.clear();
250
+ failed_test_name = extract_attr_value(&reader, &e, b"testName")
251
+ .unwrap_or_else(|| "unknown".to_string());
252
+ }
253
+ }
254
+ b"ErrorInfo" => {
255
+ if in_failed_result {
256
+ in_error_info = true;
257
+ }
258
+ }
259
+ b"Message" => {
260
+ if in_failed_result && in_error_info {
261
+ capture_field = Some(CaptureField::Message);
262
+ message_buf.clear();
263
+ }
264
+ }
265
+ b"StackTrace" => {
266
+ if in_failed_result && in_error_info {
267
+ capture_field = Some(CaptureField::StackTrace);
268
+ stack_buf.clear();
269
+ }
270
+ }
271
+ _ => {}
272
+ },
273
+ Ok(Event::Empty(e)) => match local_name(e.name().as_ref()) {
274
+ b"Times" => {
275
+ let start = extract_attr_value(&reader, &e, b"start");
276
+ let finish = extract_attr_value(&reader, &e, b"finish");
277
+ if let (Some(start), Some(finish)) = (start, finish) {
278
+ summary.duration_text = parse_trx_duration(&start, &finish);
279
+ }
280
+ }
281
+ b"Counters" => {
282
+ summary.total = parse_usize_attr(&reader, &e, b"total");
283
+ summary.passed = parse_usize_attr(&reader, &e, b"passed");
284
+ summary.failed = parse_usize_attr(&reader, &e, b"failed");
285
+ }
286
+ b"UnitTestResult" => {
287
+ let outcome = extract_attr_value(&reader, &e, b"outcome")
288
+ .unwrap_or_else(|| "Unknown".to_string());
289
+ if outcome == "Failed" {
290
+ let name = extract_attr_value(&reader, &e, b"testName")
291
+ .unwrap_or_else(|| "unknown".to_string());
292
+ summary.failed_tests.push(FailedTest {
293
+ name,
294
+ details: Vec::new(),
295
+ });
296
+ }
297
+ }
298
+ _ => {}
299
+ },
300
+ Ok(Event::Text(e)) => {
301
+ if !in_failed_result {
302
+ buf.clear();
303
+ continue;
304
+ }
305
+
306
+ let text = String::from_utf8_lossy(e.as_ref());
307
+ match capture_field {
308
+ Some(CaptureField::Message) => message_buf.push_str(&text),
309
+ Some(CaptureField::StackTrace) => stack_buf.push_str(&text),
310
+ None => {}
311
+ }
312
+ }
313
+ Ok(Event::CData(e)) => {
314
+ if !in_failed_result {
315
+ buf.clear();
316
+ continue;
317
+ }
318
+
319
+ let text = String::from_utf8_lossy(e.as_ref());
320
+ match capture_field {
321
+ Some(CaptureField::Message) => message_buf.push_str(&text),
322
+ Some(CaptureField::StackTrace) => stack_buf.push_str(&text),
323
+ None => {}
324
+ }
325
+ }
326
+ Ok(Event::End(e)) => match local_name(e.name().as_ref()) {
327
+ b"Message" | b"StackTrace" => {
328
+ capture_field = None;
329
+ }
330
+ b"ErrorInfo" => {
331
+ in_error_info = false;
332
+ }
333
+ b"UnitTestResult" => {
334
+ if in_failed_result {
335
+ let mut details = Vec::new();
336
+
337
+ let message = message_buf.trim();
338
+ if !message.is_empty() {
339
+ details.push(message.to_string());
340
+ }
341
+
342
+ let stack = stack_buf.trim();
343
+ if !stack.is_empty() {
344
+ let stack_lines: Vec<&str> = stack.lines().take(3).collect();
345
+ if !stack_lines.is_empty() {
346
+ details.push(stack_lines.join("\n"));
347
+ }
348
+ }
349
+
350
+ summary.failed_tests.push(FailedTest {
351
+ name: failed_test_name.clone(),
352
+ details,
353
+ });
354
+
355
+ in_failed_result = false;
356
+ in_error_info = false;
357
+ capture_field = None;
358
+ message_buf.clear();
359
+ stack_buf.clear();
360
+ }
361
+ }
362
+ _ => {}
363
+ },
364
+ Ok(Event::Eof) => break,
365
+ Err(_) => return None,
366
+ _ => {}
367
+ }
368
+
369
+ buf.clear();
370
+ }
371
+
372
+ if !saw_test_run {
373
+ return None;
374
+ }
375
+
376
+ // Calculate skipped from counters if available
377
+ if summary.total > 0 {
378
+ summary.skipped = summary
379
+ .total
380
+ .saturating_sub(summary.passed + summary.failed);
381
+ }
382
+
383
+ // Set project count to at least 1 if there were any tests
384
+ if summary.total > 0 {
385
+ summary.project_count = 1;
386
+ }
387
+
388
+ Some(summary)
389
+ }
390
+
391
+ #[cfg(test)]
392
+ mod tests {
393
+ use super::*;
394
+ use std::time::Duration;
395
+
396
+ #[test]
397
+ fn test_parse_trx_content_extracts_passed_counts() {
398
+ let trx = r#"<?xml version="1.0" encoding="utf-8"?>
399
+ <TestRun xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
400
+ <Times creation="2026-02-21T12:57:28.3323710+01:00" queuing="2026-02-21T12:57:28.3323710+01:00" start="2026-02-21T12:57:27.7149650+01:00" finish="2026-02-21T12:57:30.2214710+01:00" />
401
+ <ResultSummary outcome="Completed">
402
+ <Counters total="42" executed="42" passed="40" failed="2" error="0" timeout="0" aborted="0" inconclusive="0" />
403
+ </ResultSummary>
404
+ </TestRun>"#;
405
+
406
+ let summary = parse_trx_content(trx).expect("valid TRX");
407
+ assert_eq!(summary.total, 42);
408
+ assert_eq!(summary.passed, 40);
409
+ assert_eq!(summary.failed, 2);
410
+ assert_eq!(summary.skipped, 0);
411
+ assert_eq!(summary.duration_text.as_deref(), Some("2.5 s"));
412
+ }
413
+
414
+ #[test]
415
+ fn test_parse_trx_content_extracts_failed_tests_with_details() {
416
+ let trx = r#"<?xml version="1.0" encoding="utf-8"?>
417
+ <TestRun>
418
+ <Results>
419
+ <UnitTestResult testName="MyTests.Calculator.Add_ShouldFail" outcome="Failed">
420
+ <Output>
421
+ <ErrorInfo>
422
+ <Message>Expected: 5, Actual: 4</Message>
423
+ <StackTrace>at MyTests.Calculator.Add_ShouldFail()\nat line 42</StackTrace>
424
+ </ErrorInfo>
425
+ </Output>
426
+ </UnitTestResult>
427
+ </Results>
428
+ <ResultSummary><Counters total="1" executed="1" passed="0" failed="1" /></ResultSummary>
429
+ </TestRun>"#;
430
+
431
+ let summary = parse_trx_content(trx).expect("valid TRX");
432
+ assert_eq!(summary.failed_tests.len(), 1);
433
+ assert_eq!(
434
+ summary.failed_tests[0].name,
435
+ "MyTests.Calculator.Add_ShouldFail"
436
+ );
437
+ assert!(summary.failed_tests[0].details[0].contains("Expected: 5, Actual: 4"));
438
+ }
439
+
440
+ #[test]
441
+ fn test_parse_trx_content_extracts_counters_when_attribute_order_varies() {
442
+ let trx = r#"<?xml version="1.0" encoding="utf-8"?>
443
+ <TestRun>
444
+ <ResultSummary outcome="Completed">
445
+ <Counters failed="3" passed="7" executed="10" total="10" />
446
+ </ResultSummary>
447
+ </TestRun>"#;
448
+
449
+ let summary = parse_trx_content(trx).expect("valid TRX");
450
+ assert_eq!(summary.total, 10);
451
+ assert_eq!(summary.passed, 7);
452
+ assert_eq!(summary.failed, 3);
453
+ }
454
+
455
+ #[test]
456
+ fn test_parse_trx_content_extracts_failed_tests_when_attribute_order_varies() {
457
+ let trx = r#"<?xml version="1.0" encoding="utf-8"?>
458
+ <TestRun>
459
+ <Results>
460
+ <UnitTestResult outcome="Failed" testName="MyTests.Ordering.ShouldStillParse">
461
+ <Output>
462
+ <ErrorInfo>
463
+ <Message>Boom</Message>
464
+ <StackTrace>at MyTests.Ordering.ShouldStillParse()</StackTrace>
465
+ </ErrorInfo>
466
+ </Output>
467
+ </UnitTestResult>
468
+ </Results>
469
+ <ResultSummary><Counters failed="1" passed="0" executed="1" total="1" /></ResultSummary>
470
+ </TestRun>"#;
471
+
472
+ let summary = parse_trx_content(trx).expect("valid TRX");
473
+ assert_eq!(summary.failed, 1);
474
+ assert_eq!(summary.failed_tests.len(), 1);
475
+ assert_eq!(
476
+ summary.failed_tests[0].name,
477
+ "MyTests.Ordering.ShouldStillParse"
478
+ );
479
+ }
480
+
481
+ #[test]
482
+ fn test_parse_trx_content_returns_none_for_invalid_xml() {
483
+ let not_trx = "This is not a TRX file";
484
+ assert!(parse_trx_content(not_trx).is_none());
485
+ }
486
+
487
+ #[test]
488
+ fn test_find_recent_trx_in_dir_returns_none_when_missing() {
489
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
490
+ let missing_dir = temp_dir.path().join("TestResults");
491
+
492
+ let found = find_recent_trx_in_dir(&missing_dir);
493
+ assert!(found.is_none());
494
+ }
495
+
496
+ #[test]
497
+ fn test_find_recent_trx_in_dir_picks_newest_trx() {
498
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
499
+ let testresults_dir = temp_dir.path().join("TestResults");
500
+ std::fs::create_dir_all(&testresults_dir).expect("create TestResults");
501
+
502
+ let old_trx = testresults_dir.join("old.trx");
503
+ let new_trx = testresults_dir.join("new.trx");
504
+ std::fs::write(&old_trx, "old").expect("write old");
505
+ std::thread::sleep(Duration::from_millis(5));
506
+ std::fs::write(&new_trx, "new").expect("write new");
507
+
508
+ let found = find_recent_trx_in_dir(&testresults_dir).expect("should find newest trx");
509
+ assert_eq!(found, new_trx);
510
+ }
511
+
512
+ #[test]
513
+ fn test_find_recent_trx_in_dir_ignores_non_trx_files() {
514
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
515
+ let testresults_dir = temp_dir.path().join("TestResults");
516
+ std::fs::create_dir_all(&testresults_dir).expect("create TestResults");
517
+
518
+ let txt = testresults_dir.join("notes.txt");
519
+ std::fs::write(&txt, "noop").expect("write txt");
520
+
521
+ let found = find_recent_trx_in_dir(&testresults_dir);
522
+ assert!(found.is_none());
523
+ }
524
+
525
+ #[test]
526
+ fn test_parse_trx_files_in_dir_aggregates_counts_and_wall_clock_duration() {
527
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
528
+ let trx_dir = temp_dir.path().join("TestResults");
529
+ std::fs::create_dir_all(&trx_dir).expect("create TestResults");
530
+
531
+ let trx_one = r#"<?xml version="1.0" encoding="utf-8"?>
532
+ <TestRun>
533
+ <Times start="2026-02-21T12:57:27.0000000+01:00" finish="2026-02-21T12:57:30.0000000+01:00" />
534
+ <ResultSummary outcome="Completed">
535
+ <Counters total="10" executed="10" passed="9" failed="1" />
536
+ </ResultSummary>
537
+ </TestRun>"#;
538
+
539
+ let trx_two = r#"<?xml version="1.0" encoding="utf-8"?>
540
+ <TestRun>
541
+ <Times start="2026-02-21T12:57:28.0000000+01:00" finish="2026-02-21T12:57:29.0000000+01:00" />
542
+ <ResultSummary outcome="Completed">
543
+ <Counters total="20" executed="20" passed="20" failed="0" />
544
+ </ResultSummary>
545
+ </TestRun>"#;
546
+
547
+ std::fs::write(trx_dir.join("a.trx"), trx_one).expect("write first trx");
548
+ std::fs::write(trx_dir.join("b.trx"), trx_two).expect("write second trx");
549
+
550
+ let summary = parse_trx_files_in_dir(&trx_dir).expect("merged summary");
551
+ assert_eq!(summary.total, 30);
552
+ assert_eq!(summary.passed, 29);
553
+ assert_eq!(summary.failed, 1);
554
+ assert_eq!(summary.duration_text.as_deref(), Some("3.0 s"));
555
+ }
556
+
557
+ #[test]
558
+ fn test_parse_trx_files_in_dir_since_ignores_older_files() {
559
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
560
+ let trx_dir = temp_dir.path().join("TestResults");
561
+ std::fs::create_dir_all(&trx_dir).expect("create TestResults");
562
+
563
+ let trx_old = r#"<?xml version="1.0" encoding="utf-8"?>
564
+ <TestRun><ResultSummary><Counters total="2" executed="2" passed="2" failed="0" /></ResultSummary></TestRun>"#;
565
+ std::fs::write(trx_dir.join("old.trx"), trx_old).expect("write old trx");
566
+ std::thread::sleep(Duration::from_millis(5));
567
+ let since = SystemTime::now();
568
+ std::thread::sleep(Duration::from_millis(5));
569
+
570
+ let trx_new = r#"<?xml version="1.0" encoding="utf-8"?>
571
+ <TestRun><ResultSummary><Counters total="3" executed="3" passed="2" failed="1" /></ResultSummary></TestRun>"#;
572
+ std::fs::write(trx_dir.join("new.trx"), trx_new).expect("write new trx");
573
+
574
+ let summary = parse_trx_files_in_dir_since(&trx_dir, Some(since)).expect("merged summary");
575
+ assert_eq!(summary.total, 3);
576
+ assert_eq!(summary.failed, 1);
577
+ }
578
+
579
+ #[test]
580
+ fn test_parse_trx_files_in_dir_since_handles_uppercase_extension() {
581
+ let temp_dir = tempfile::tempdir().expect("create temp dir");
582
+ let trx_dir = temp_dir.path().join("TestResults");
583
+ std::fs::create_dir_all(&trx_dir).expect("create TestResults");
584
+
585
+ let trx = r#"<?xml version="1.0" encoding="utf-8"?>
586
+ <TestRun><ResultSummary><Counters total="3" executed="3" passed="2" failed="1" /></ResultSummary></TestRun>"#;
587
+ std::fs::write(trx_dir.join("UPPER.TRX"), trx).expect("write trx");
588
+
589
+ let summary = parse_trx_files_in_dir_since(&trx_dir, None).expect("summary");
590
+ assert_eq!(summary.total, 3);
591
+ assert_eq!(summary.failed, 1);
592
+ }
593
+ }