@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,1727 @@
1
+ use crate::tracking;
2
+ use crate::utils::truncate;
3
+ use anyhow::{Context, Result};
4
+ use std::collections::HashMap;
5
+ use std::ffi::OsString;
6
+ use std::process::Command;
7
+ use std::sync::OnceLock;
8
+
9
+ #[derive(Debug, Clone)]
10
+ pub enum CargoCommand {
11
+ Build,
12
+ Test,
13
+ Clippy,
14
+ Check,
15
+ Install,
16
+ Nextest,
17
+ }
18
+
19
+ pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<()> {
20
+ match cmd {
21
+ CargoCommand::Build => run_build(args, verbose),
22
+ CargoCommand::Test => run_test(args, verbose),
23
+ CargoCommand::Clippy => run_clippy(args, verbose),
24
+ CargoCommand::Check => run_check(args, verbose),
25
+ CargoCommand::Install => run_install(args, verbose),
26
+ CargoCommand::Nextest => run_nextest(args, verbose),
27
+ }
28
+ }
29
+
30
+ /// Reconstruct args with `--` separator preserved from the original command line.
31
+ /// Clap strips `--` from parsed args, but cargo subcommands need it to separate
32
+ /// their own flags from test runner flags (e.g. `cargo test -- --nocapture`).
33
+ fn restore_double_dash(args: &[String]) -> Vec<String> {
34
+ let raw_args: Vec<String> = std::env::args().collect();
35
+ restore_double_dash_with_raw(args, &raw_args)
36
+ }
37
+
38
+ /// Testable version that takes raw_args explicitly.
39
+ fn restore_double_dash_with_raw(args: &[String], raw_args: &[String]) -> Vec<String> {
40
+ if args.is_empty() {
41
+ return args.to_vec();
42
+ }
43
+
44
+ // Find `--` in the original command line
45
+ let sep_pos = match raw_args.iter().position(|a| a == "--") {
46
+ Some(pos) => pos,
47
+ None => return args.to_vec(),
48
+ };
49
+
50
+ // Count how many of our parsed args appeared before `--` in the original.
51
+ // Args before `--` are positional (e.g. test name), args after are flags.
52
+ let args_before_sep = raw_args[..sep_pos]
53
+ .iter()
54
+ .filter(|a| args.contains(a))
55
+ .count();
56
+
57
+ let mut result = Vec::with_capacity(args.len() + 1);
58
+ result.extend_from_slice(&args[..args_before_sep]);
59
+ result.push("--".to_string());
60
+ result.extend_from_slice(&args[args_before_sep..]);
61
+ result
62
+ }
63
+
64
+ /// Generic cargo command runner with filtering
65
+ fn run_cargo_filtered<F>(subcommand: &str, args: &[String], verbose: u8, filter_fn: F) -> Result<()>
66
+ where
67
+ F: Fn(&str) -> String,
68
+ {
69
+ let timer = tracking::TimedExecution::start();
70
+
71
+ let mut cmd = Command::new("cargo");
72
+ cmd.arg(subcommand);
73
+
74
+ let restored_args = restore_double_dash(args);
75
+ for arg in &restored_args {
76
+ cmd.arg(arg);
77
+ }
78
+
79
+ if verbose > 0 {
80
+ eprintln!("Running: cargo {} {}", subcommand, restored_args.join(" "));
81
+ }
82
+
83
+ let output = cmd
84
+ .output()
85
+ .with_context(|| format!("Failed to run cargo {}", subcommand))?;
86
+ let stdout = String::from_utf8_lossy(&output.stdout);
87
+ let stderr = String::from_utf8_lossy(&output.stderr);
88
+ let raw = format!("{}\n{}", stdout, stderr);
89
+
90
+ let exit_code = output
91
+ .status
92
+ .code()
93
+ .unwrap_or(if output.status.success() { 0 } else { 1 });
94
+ let filtered = filter_fn(&raw);
95
+
96
+ if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("cargo_{}", subcommand), exit_code)
97
+ {
98
+ println!("{}\n{}", filtered, hint);
99
+ } else {
100
+ println!("{}", filtered);
101
+ }
102
+
103
+ timer.track(
104
+ &format!("cargo {} {}", subcommand, restored_args.join(" ")),
105
+ &format!("rtk cargo {} {}", subcommand, restored_args.join(" ")),
106
+ &raw,
107
+ &filtered,
108
+ );
109
+
110
+ if !output.status.success() {
111
+ std::process::exit(exit_code);
112
+ }
113
+
114
+ Ok(())
115
+ }
116
+
117
+ fn run_build(args: &[String], verbose: u8) -> Result<()> {
118
+ run_cargo_filtered("build", args, verbose, filter_cargo_build)
119
+ }
120
+
121
+ fn run_test(args: &[String], verbose: u8) -> Result<()> {
122
+ run_cargo_filtered("test", args, verbose, filter_cargo_test)
123
+ }
124
+
125
+ fn run_clippy(args: &[String], verbose: u8) -> Result<()> {
126
+ run_cargo_filtered("clippy", args, verbose, filter_cargo_clippy)
127
+ }
128
+
129
+ fn run_check(args: &[String], verbose: u8) -> Result<()> {
130
+ run_cargo_filtered("check", args, verbose, filter_cargo_build)
131
+ }
132
+
133
+ fn run_install(args: &[String], verbose: u8) -> Result<()> {
134
+ run_cargo_filtered("install", args, verbose, filter_cargo_install)
135
+ }
136
+
137
+ fn run_nextest(args: &[String], verbose: u8) -> Result<()> {
138
+ run_cargo_filtered("nextest", args, verbose, filter_cargo_nextest)
139
+ }
140
+
141
+ /// Format crate name + version into a display string
142
+ fn format_crate_info(name: &str, version: &str, fallback: &str) -> String {
143
+ if name.is_empty() {
144
+ fallback.to_string()
145
+ } else if version.is_empty() {
146
+ name.to_string()
147
+ } else {
148
+ format!("{} {}", name, version)
149
+ }
150
+ }
151
+
152
+ /// Filter cargo install output - strip dep compilation, keep installed/replaced/errors
153
+ fn filter_cargo_install(output: &str) -> String {
154
+ let mut errors: Vec<String> = Vec::new();
155
+ let mut error_count = 0;
156
+ let mut compiled = 0;
157
+ let mut in_error = false;
158
+ let mut current_error = Vec::new();
159
+ let mut installed_crate = String::new();
160
+ let mut installed_version = String::new();
161
+ let mut replaced_lines: Vec<String> = Vec::new();
162
+ let mut already_installed = false;
163
+ let mut ignored_line = String::new();
164
+
165
+ for line in output.lines() {
166
+ let trimmed = line.trim_start();
167
+
168
+ // Strip noise: dep compilation, downloading, locking, etc.
169
+ if trimmed.starts_with("Compiling") {
170
+ compiled += 1;
171
+ continue;
172
+ }
173
+ if trimmed.starts_with("Downloading")
174
+ || trimmed.starts_with("Downloaded")
175
+ || trimmed.starts_with("Locking")
176
+ || trimmed.starts_with("Updating")
177
+ || trimmed.starts_with("Adding")
178
+ || trimmed.starts_with("Finished")
179
+ || trimmed.starts_with("Blocking waiting for file lock")
180
+ {
181
+ continue;
182
+ }
183
+
184
+ // Keep: Installing line (extract crate name + version)
185
+ if trimmed.starts_with("Installing") {
186
+ let rest = trimmed.strip_prefix("Installing").unwrap_or("").trim();
187
+ if !rest.is_empty() && !rest.starts_with('/') {
188
+ if let Some((name, version)) = rest.split_once(' ') {
189
+ installed_crate = name.to_string();
190
+ installed_version = version.to_string();
191
+ } else {
192
+ installed_crate = rest.to_string();
193
+ }
194
+ }
195
+ continue;
196
+ }
197
+
198
+ // Keep: Installed line (extract crate + version if not already set)
199
+ if trimmed.starts_with("Installed") {
200
+ let rest = trimmed.strip_prefix("Installed").unwrap_or("").trim();
201
+ if !rest.is_empty() && installed_crate.is_empty() {
202
+ let mut parts = rest.split_whitespace();
203
+ if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
204
+ installed_crate = name.to_string();
205
+ installed_version = version.to_string();
206
+ }
207
+ }
208
+ continue;
209
+ }
210
+
211
+ // Keep: Replacing/Replaced lines
212
+ if trimmed.starts_with("Replacing") || trimmed.starts_with("Replaced") {
213
+ replaced_lines.push(trimmed.to_string());
214
+ continue;
215
+ }
216
+
217
+ // Keep: "Ignored package" (already up to date)
218
+ if trimmed.starts_with("Ignored package") {
219
+ already_installed = true;
220
+ ignored_line = trimmed.to_string();
221
+ continue;
222
+ }
223
+
224
+ // Keep: actionable warnings (e.g., "be sure to add `/path` to your PATH")
225
+ // Skip summary lines like "warning: `crate` generated N warnings"
226
+ if line.starts_with("warning:") {
227
+ if !(line.contains("generated") && line.contains("warning")) {
228
+ replaced_lines.push(line.to_string());
229
+ }
230
+ continue;
231
+ }
232
+
233
+ // Detect error blocks
234
+ if line.starts_with("error[") || line.starts_with("error:") {
235
+ if line.contains("aborting due to") || line.contains("could not compile") {
236
+ continue;
237
+ }
238
+ if in_error && !current_error.is_empty() {
239
+ errors.push(current_error.join("\n"));
240
+ current_error.clear();
241
+ }
242
+ error_count += 1;
243
+ in_error = true;
244
+ current_error.push(line.to_string());
245
+ } else if in_error {
246
+ if line.trim().is_empty() && current_error.len() > 3 {
247
+ errors.push(current_error.join("\n"));
248
+ current_error.clear();
249
+ in_error = false;
250
+ } else {
251
+ current_error.push(line.to_string());
252
+ }
253
+ }
254
+ }
255
+
256
+ if !current_error.is_empty() {
257
+ errors.push(current_error.join("\n"));
258
+ }
259
+
260
+ // Already installed / up to date
261
+ if already_installed {
262
+ let info = ignored_line.split('`').nth(1).unwrap_or(&ignored_line);
263
+ return format!("✓ cargo install: {} already installed", info);
264
+ }
265
+
266
+ // Errors
267
+ if error_count > 0 {
268
+ let crate_info = format_crate_info(&installed_crate, &installed_version, "");
269
+ let deps_info = if compiled > 0 {
270
+ format!(", {} deps compiled", compiled)
271
+ } else {
272
+ String::new()
273
+ };
274
+
275
+ let mut result = String::new();
276
+ if crate_info.is_empty() {
277
+ result.push_str(&format!(
278
+ "cargo install: {} error{}{}\n",
279
+ error_count,
280
+ if error_count > 1 { "s" } else { "" },
281
+ deps_info
282
+ ));
283
+ } else {
284
+ result.push_str(&format!(
285
+ "cargo install: {} error{} ({}{})\n",
286
+ error_count,
287
+ if error_count > 1 { "s" } else { "" },
288
+ crate_info,
289
+ deps_info
290
+ ));
291
+ }
292
+ result.push_str("═══════════════════════════════════════\n");
293
+
294
+ for (i, err) in errors.iter().enumerate().take(15) {
295
+ result.push_str(err);
296
+ result.push('\n');
297
+ if i < errors.len() - 1 {
298
+ result.push('\n');
299
+ }
300
+ }
301
+
302
+ if errors.len() > 15 {
303
+ result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15));
304
+ }
305
+
306
+ return result.trim().to_string();
307
+ }
308
+
309
+ // Success
310
+ let crate_info = format_crate_info(&installed_crate, &installed_version, "package");
311
+
312
+ let mut result = format!(
313
+ "✓ cargo install ({}, {} deps compiled)",
314
+ crate_info, compiled
315
+ );
316
+
317
+ for line in &replaced_lines {
318
+ result.push_str(&format!("\n {}", line));
319
+ }
320
+
321
+ result
322
+ }
323
+
324
+ /// Push a completed failure block (header + body) into the failures list, then clear the buffers.
325
+ fn flush_failure_block(header: &mut String, body: &mut Vec<String>, failures: &mut Vec<String>) {
326
+ if header.is_empty() {
327
+ return;
328
+ }
329
+ let mut block = header.clone();
330
+ if !body.is_empty() {
331
+ block.push('\n');
332
+ block.push_str(&body.join("\n"));
333
+ }
334
+ failures.push(block);
335
+ header.clear();
336
+ body.clear();
337
+ }
338
+
339
+ /// Filter cargo nextest output - show failures + compact summary
340
+ fn filter_cargo_nextest(output: &str) -> String {
341
+ static SUMMARY_RE: OnceLock<regex::Regex> = OnceLock::new();
342
+ let summary_re = SUMMARY_RE.get_or_init(|| {
343
+ regex::Regex::new(
344
+ r"Summary \[\s*([\d.]+)s\]\s+(\d+) tests? run:\s+(\d+) passed(?:,\s+(\d+) failed)?(?:,\s+(\d+) skipped)?"
345
+ ).expect("invalid nextest summary regex")
346
+ });
347
+
348
+ static STARTING_RE: OnceLock<regex::Regex> = OnceLock::new();
349
+ let starting_re = STARTING_RE.get_or_init(|| {
350
+ regex::Regex::new(r"Starting \d+ tests? across (\d+) binar(?:y|ies)")
351
+ .expect("invalid nextest starting regex")
352
+ });
353
+
354
+ let mut failures: Vec<String> = Vec::new();
355
+ let mut in_failure_block = false;
356
+ let mut past_summary = false;
357
+ let mut current_failure_header = String::new();
358
+ let mut current_failure_body = Vec::new();
359
+ let mut summary_line = String::new();
360
+ let mut binaries: u32 = 0;
361
+ let mut has_cancel_line = false;
362
+
363
+ for line in output.lines() {
364
+ let trimmed = line.trim();
365
+
366
+ // Strip compilation noise
367
+ if trimmed.starts_with("Compiling")
368
+ || trimmed.starts_with("Downloading")
369
+ || trimmed.starts_with("Downloaded")
370
+ || trimmed.starts_with("Finished")
371
+ || trimmed.starts_with("Locking")
372
+ || trimmed.starts_with("Updating")
373
+ {
374
+ continue;
375
+ }
376
+
377
+ // Strip separator lines (────)
378
+ if trimmed.starts_with("────") {
379
+ continue;
380
+ }
381
+
382
+ // Skip post-summary recap lines (FAIL duplicates + "error: test run failed")
383
+ if past_summary {
384
+ continue;
385
+ }
386
+
387
+ // Parse binary count from Starting line
388
+ if trimmed.starts_with("Starting") {
389
+ if let Some(caps) = starting_re.captures(trimmed) {
390
+ if let Some(m) = caps.get(1) {
391
+ binaries = m.as_str().parse().unwrap_or(0);
392
+ }
393
+ }
394
+ continue;
395
+ }
396
+
397
+ // Strip PASS lines
398
+ if trimmed.starts_with("PASS") {
399
+ if in_failure_block {
400
+ flush_failure_block(
401
+ &mut current_failure_header,
402
+ &mut current_failure_body,
403
+ &mut failures,
404
+ );
405
+ in_failure_block = false;
406
+ }
407
+ continue;
408
+ }
409
+
410
+ // Detect FAIL lines
411
+ if trimmed.starts_with("FAIL") {
412
+ // Close previous failure block if any
413
+ if in_failure_block {
414
+ flush_failure_block(
415
+ &mut current_failure_header,
416
+ &mut current_failure_body,
417
+ &mut failures,
418
+ );
419
+ }
420
+ current_failure_header = trimmed.to_string();
421
+ in_failure_block = true;
422
+ continue;
423
+ }
424
+
425
+ // Cancellation notice
426
+ if trimmed.starts_with("Cancelling") || trimmed.starts_with("Canceling") {
427
+ has_cancel_line = true;
428
+ continue;
429
+ }
430
+
431
+ // Nextest run ID line
432
+ if trimmed.starts_with("Nextest run ID") {
433
+ continue;
434
+ }
435
+
436
+ // Parse summary
437
+ if trimmed.starts_with("Summary") {
438
+ summary_line = trimmed.to_string();
439
+ if in_failure_block {
440
+ flush_failure_block(
441
+ &mut current_failure_header,
442
+ &mut current_failure_body,
443
+ &mut failures,
444
+ );
445
+ in_failure_block = false;
446
+ }
447
+ past_summary = true;
448
+ continue;
449
+ }
450
+
451
+ // Collect failure body lines (stdout/stderr sections)
452
+ if in_failure_block {
453
+ current_failure_body.push(line.to_string());
454
+ }
455
+ }
456
+
457
+ // Close last failure block
458
+ if in_failure_block {
459
+ flush_failure_block(
460
+ &mut current_failure_header,
461
+ &mut current_failure_body,
462
+ &mut failures,
463
+ );
464
+ }
465
+
466
+ // Parse summary with regex
467
+ if let Some(caps) = summary_re.captures(&summary_line) {
468
+ let duration = caps.get(1).map_or("?", |m| m.as_str());
469
+ let passed: u32 = caps
470
+ .get(3)
471
+ .and_then(|m| m.as_str().parse().ok())
472
+ .unwrap_or(0);
473
+ let failed: u32 = caps
474
+ .get(4)
475
+ .and_then(|m| m.as_str().parse().ok())
476
+ .unwrap_or(0);
477
+ let skipped: u32 = caps
478
+ .get(5)
479
+ .and_then(|m| m.as_str().parse().ok())
480
+ .unwrap_or(0);
481
+
482
+ let binary_text = if binaries == 1 {
483
+ "1 binary".to_string()
484
+ } else if binaries > 1 {
485
+ format!("{} binaries", binaries)
486
+ } else {
487
+ String::new()
488
+ };
489
+
490
+ if failed == 0 {
491
+ // All pass - compact single line
492
+ let mut parts = vec![format!("{} passed", passed)];
493
+ if skipped > 0 {
494
+ parts.push(format!("{} skipped", skipped));
495
+ }
496
+ let meta = if binary_text.is_empty() {
497
+ format!("{}s", duration)
498
+ } else {
499
+ format!("{}, {}s", binary_text, duration)
500
+ };
501
+ return format!("✓ cargo nextest: {} ({})", parts.join(", "), meta);
502
+ }
503
+
504
+ // With failures - show failure details then summary
505
+ let mut result = String::new();
506
+
507
+ for failure in &failures {
508
+ result.push_str(failure);
509
+ result.push('\n');
510
+ }
511
+
512
+ if has_cancel_line {
513
+ result.push_str("Cancelling due to test failure\n");
514
+ }
515
+
516
+ let mut summary_parts = vec![format!("{} passed", passed)];
517
+ if failed > 0 {
518
+ summary_parts.push(format!("{} failed", failed));
519
+ }
520
+ if skipped > 0 {
521
+ summary_parts.push(format!("{} skipped", skipped));
522
+ }
523
+ let meta = if binary_text.is_empty() {
524
+ format!("{}s", duration)
525
+ } else {
526
+ format!("{}, {}s", binary_text, duration)
527
+ };
528
+ result.push_str(&format!(
529
+ "cargo nextest: {} ({})",
530
+ summary_parts.join(", "),
531
+ meta
532
+ ));
533
+
534
+ return result.trim().to_string();
535
+ }
536
+
537
+ // Fallback: if summary regex didn't match, show what we have
538
+ if !failures.is_empty() {
539
+ let mut result = String::new();
540
+ for failure in &failures {
541
+ result.push_str(failure);
542
+ result.push('\n');
543
+ }
544
+ if !summary_line.is_empty() {
545
+ result.push_str(&summary_line);
546
+ }
547
+ return result.trim().to_string();
548
+ }
549
+
550
+ if !summary_line.is_empty() {
551
+ return summary_line;
552
+ }
553
+
554
+ // Empty or unrecognized
555
+ String::new()
556
+ }
557
+
558
+ /// Filter cargo build/check output - strip "Compiling"/"Checking" lines, keep errors + summary
559
+ fn filter_cargo_build(output: &str) -> String {
560
+ let mut errors: Vec<String> = Vec::new();
561
+ let mut warnings = 0;
562
+ let mut error_count = 0;
563
+ let mut compiled = 0;
564
+ let mut in_error = false;
565
+ let mut current_error = Vec::new();
566
+
567
+ for line in output.lines() {
568
+ if line.trim_start().starts_with("Compiling") || line.trim_start().starts_with("Checking") {
569
+ compiled += 1;
570
+ continue;
571
+ }
572
+ if line.trim_start().starts_with("Downloading")
573
+ || line.trim_start().starts_with("Downloaded")
574
+ {
575
+ continue;
576
+ }
577
+ if line.trim_start().starts_with("Finished") {
578
+ continue;
579
+ }
580
+
581
+ // Detect error/warning blocks
582
+ if line.starts_with("error[") || line.starts_with("error:") {
583
+ // Skip "error: aborting due to" summary lines
584
+ if line.contains("aborting due to") || line.contains("could not compile") {
585
+ continue;
586
+ }
587
+ if in_error && !current_error.is_empty() {
588
+ errors.push(current_error.join("\n"));
589
+ current_error.clear();
590
+ }
591
+ error_count += 1;
592
+ in_error = true;
593
+ current_error.push(line.to_string());
594
+ } else if line.starts_with("warning:")
595
+ && line.contains("generated")
596
+ && line.contains("warning")
597
+ {
598
+ // "warning: `crate` generated N warnings" summary line
599
+ continue;
600
+ } else if line.starts_with("warning:") || line.starts_with("warning[") {
601
+ if in_error && !current_error.is_empty() {
602
+ errors.push(current_error.join("\n"));
603
+ current_error.clear();
604
+ }
605
+ warnings += 1;
606
+ in_error = true;
607
+ current_error.push(line.to_string());
608
+ } else if in_error {
609
+ if line.trim().is_empty() && current_error.len() > 3 {
610
+ errors.push(current_error.join("\n"));
611
+ current_error.clear();
612
+ in_error = false;
613
+ } else {
614
+ current_error.push(line.to_string());
615
+ }
616
+ }
617
+ }
618
+
619
+ if !current_error.is_empty() {
620
+ errors.push(current_error.join("\n"));
621
+ }
622
+
623
+ if error_count == 0 && warnings == 0 {
624
+ return format!("✓ cargo build ({} crates compiled)", compiled);
625
+ }
626
+
627
+ let mut result = String::new();
628
+ result.push_str(&format!(
629
+ "cargo build: {} errors, {} warnings ({} crates)\n",
630
+ error_count, warnings, compiled
631
+ ));
632
+ result.push_str("═══════════════════════════════════════\n");
633
+
634
+ for (i, err) in errors.iter().enumerate().take(15) {
635
+ result.push_str(err);
636
+ result.push('\n');
637
+ if i < errors.len() - 1 {
638
+ result.push('\n');
639
+ }
640
+ }
641
+
642
+ if errors.len() > 15 {
643
+ result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15));
644
+ }
645
+
646
+ result.trim().to_string()
647
+ }
648
+
649
+ /// Aggregated test results for compact display
650
+ #[derive(Debug, Default, Clone)]
651
+ struct AggregatedTestResult {
652
+ passed: usize,
653
+ failed: usize,
654
+ ignored: usize,
655
+ measured: usize,
656
+ filtered_out: usize,
657
+ suites: usize,
658
+ duration_secs: f64,
659
+ has_duration: bool,
660
+ }
661
+
662
+ impl AggregatedTestResult {
663
+ /// Parse a test result summary line
664
+ /// Format: "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s"
665
+ fn parse_line(line: &str) -> Option<Self> {
666
+ static RE: OnceLock<regex::Regex> = OnceLock::new();
667
+ let re = RE.get_or_init(|| {
668
+ regex::Regex::new(
669
+ r"test result: (\w+)\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored;\s+(\d+) measured;\s+(\d+) filtered out(?:;\s+finished in ([\d.]+)s)?"
670
+ ).unwrap()
671
+ });
672
+
673
+ let caps = re.captures(line)?;
674
+ let status = caps.get(1)?.as_str();
675
+
676
+ // Only aggregate if status is "ok" (all tests passed)
677
+ if status != "ok" {
678
+ return None;
679
+ }
680
+
681
+ let passed = caps.get(2)?.as_str().parse().ok()?;
682
+ let failed = caps.get(3)?.as_str().parse().ok()?;
683
+ let ignored = caps.get(4)?.as_str().parse().ok()?;
684
+ let measured = caps.get(5)?.as_str().parse().ok()?;
685
+ let filtered_out = caps.get(6)?.as_str().parse().ok()?;
686
+
687
+ let (duration_secs, has_duration) = if let Some(duration_match) = caps.get(7) {
688
+ (duration_match.as_str().parse().unwrap_or(0.0), true)
689
+ } else {
690
+ (0.0, false)
691
+ };
692
+
693
+ Some(Self {
694
+ passed,
695
+ failed,
696
+ ignored,
697
+ measured,
698
+ filtered_out,
699
+ suites: 1,
700
+ duration_secs,
701
+ has_duration,
702
+ })
703
+ }
704
+
705
+ /// Merge another test result into this one
706
+ fn merge(&mut self, other: &Self) {
707
+ self.passed += other.passed;
708
+ self.failed += other.failed;
709
+ self.ignored += other.ignored;
710
+ self.measured += other.measured;
711
+ self.filtered_out += other.filtered_out;
712
+ self.suites += other.suites;
713
+ self.duration_secs += other.duration_secs;
714
+ self.has_duration = self.has_duration && other.has_duration;
715
+ }
716
+
717
+ /// Format as compact single line
718
+ fn format_compact(&self) -> String {
719
+ let mut parts = vec![format!("{} passed", self.passed)];
720
+
721
+ if self.ignored > 0 {
722
+ parts.push(format!("{} ignored", self.ignored));
723
+ }
724
+ if self.filtered_out > 0 {
725
+ parts.push(format!("{} filtered out", self.filtered_out));
726
+ }
727
+
728
+ let counts = parts.join(", ");
729
+
730
+ let suite_text = if self.suites == 1 {
731
+ "1 suite".to_string()
732
+ } else {
733
+ format!("{} suites", self.suites)
734
+ };
735
+
736
+ if self.has_duration {
737
+ format!(
738
+ "✓ cargo test: {} ({}, {:.2}s)",
739
+ counts, suite_text, self.duration_secs
740
+ )
741
+ } else {
742
+ format!("✓ cargo test: {} ({})", counts, suite_text)
743
+ }
744
+ }
745
+ }
746
+
747
+ /// Filter cargo test output - show failures + summary only
748
+ fn filter_cargo_test(output: &str) -> String {
749
+ let mut failures: Vec<String> = Vec::new();
750
+ let mut summary_lines: Vec<String> = Vec::new();
751
+ let mut in_failure_section = false;
752
+ let mut current_failure = Vec::new();
753
+
754
+ for line in output.lines() {
755
+ // Skip compilation lines
756
+ if line.trim_start().starts_with("Compiling")
757
+ || line.trim_start().starts_with("Downloading")
758
+ || line.trim_start().starts_with("Downloaded")
759
+ || line.trim_start().starts_with("Finished")
760
+ {
761
+ continue;
762
+ }
763
+
764
+ // Skip "running N tests" and individual "test ... ok" lines
765
+ if line.starts_with("running ") || (line.starts_with("test ") && line.ends_with("... ok")) {
766
+ continue;
767
+ }
768
+
769
+ // Detect failures section
770
+ if line == "failures:" {
771
+ in_failure_section = true;
772
+ continue;
773
+ }
774
+
775
+ if in_failure_section {
776
+ if line.starts_with("test result:") {
777
+ in_failure_section = false;
778
+ summary_lines.push(line.to_string());
779
+ } else if line.starts_with(" ") || line.starts_with("---- ") {
780
+ current_failure.push(line.to_string());
781
+ } else if line.trim().is_empty() && !current_failure.is_empty() {
782
+ failures.push(current_failure.join("\n"));
783
+ current_failure.clear();
784
+ } else if !line.trim().is_empty() {
785
+ current_failure.push(line.to_string());
786
+ }
787
+ }
788
+
789
+ // Capture test result summary
790
+ if !in_failure_section && line.starts_with("test result:") {
791
+ summary_lines.push(line.to_string());
792
+ }
793
+ }
794
+
795
+ if !current_failure.is_empty() {
796
+ failures.push(current_failure.join("\n"));
797
+ }
798
+
799
+ let mut result = String::new();
800
+
801
+ if failures.is_empty() && !summary_lines.is_empty() {
802
+ // All passed - try to aggregate
803
+ let mut aggregated: Option<AggregatedTestResult> = None;
804
+ let mut all_parsed = true;
805
+
806
+ for line in &summary_lines {
807
+ if let Some(parsed) = AggregatedTestResult::parse_line(line) {
808
+ if let Some(ref mut agg) = aggregated {
809
+ agg.merge(&parsed);
810
+ } else {
811
+ aggregated = Some(parsed);
812
+ }
813
+ } else {
814
+ all_parsed = false;
815
+ break;
816
+ }
817
+ }
818
+
819
+ // If all lines parsed successfully and we have at least one suite, return compact format
820
+ if all_parsed {
821
+ if let Some(agg) = aggregated {
822
+ if agg.suites > 0 {
823
+ return agg.format_compact();
824
+ }
825
+ }
826
+ }
827
+
828
+ // Fallback: use original behavior if regex failed
829
+ for line in &summary_lines {
830
+ result.push_str(&format!("✓ {}\n", line));
831
+ }
832
+ return result.trim().to_string();
833
+ }
834
+
835
+ if !failures.is_empty() {
836
+ result.push_str(&format!("FAILURES ({}):\n", failures.len()));
837
+ result.push_str("═══════════════════════════════════════\n");
838
+ for (i, failure) in failures.iter().enumerate().take(10) {
839
+ result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200)));
840
+ }
841
+ if failures.len() > 10 {
842
+ result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10));
843
+ }
844
+ result.push('\n');
845
+ }
846
+
847
+ for line in &summary_lines {
848
+ result.push_str(&format!("{}\n", line));
849
+ }
850
+
851
+ if result.trim().is_empty() {
852
+ // Fallback: show last meaningful lines
853
+ let meaningful: Vec<&str> = output
854
+ .lines()
855
+ .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling"))
856
+ .collect();
857
+ for line in meaningful.iter().rev().take(5).rev() {
858
+ result.push_str(&format!("{}\n", line));
859
+ }
860
+ }
861
+
862
+ result.trim().to_string()
863
+ }
864
+
865
+ /// Filter cargo clippy output - group warnings by lint rule
866
+ fn filter_cargo_clippy(output: &str) -> String {
867
+ let mut by_rule: HashMap<String, Vec<String>> = HashMap::new();
868
+ let mut error_count = 0;
869
+ let mut warning_count = 0;
870
+
871
+ // Parse clippy output lines
872
+ // Format: "warning: description\n --> file:line:col\n |\n | code\n"
873
+ let mut current_rule = String::new();
874
+
875
+ for line in output.lines() {
876
+ // Skip compilation lines
877
+ if line.trim_start().starts_with("Compiling")
878
+ || line.trim_start().starts_with("Checking")
879
+ || line.trim_start().starts_with("Downloading")
880
+ || line.trim_start().starts_with("Downloaded")
881
+ || line.trim_start().starts_with("Finished")
882
+ {
883
+ continue;
884
+ }
885
+
886
+ // "warning: unused variable [unused_variables]" or "warning: description [clippy::rule_name]"
887
+ if (line.starts_with("warning:") || line.starts_with("warning["))
888
+ || (line.starts_with("error:") || line.starts_with("error["))
889
+ {
890
+ // Skip summary lines: "warning: `rtk` (bin) generated 5 warnings"
891
+ if line.contains("generated") && line.contains("warning") {
892
+ continue;
893
+ }
894
+ // Skip "error: aborting" / "error: could not compile"
895
+ if line.contains("aborting due to") || line.contains("could not compile") {
896
+ continue;
897
+ }
898
+
899
+ let is_error = line.starts_with("error");
900
+ if is_error {
901
+ error_count += 1;
902
+ } else {
903
+ warning_count += 1;
904
+ }
905
+
906
+ // Extract rule name from brackets
907
+ current_rule = if let Some(bracket_start) = line.rfind('[') {
908
+ if let Some(bracket_end) = line.rfind(']') {
909
+ line[bracket_start + 1..bracket_end].to_string()
910
+ } else {
911
+ line.to_string()
912
+ }
913
+ } else {
914
+ // No bracket: use the message itself as the rule
915
+ let prefix = if is_error { "error: " } else { "warning: " };
916
+ line.strip_prefix(prefix).unwrap_or(line).to_string()
917
+ };
918
+ } else if line.trim_start().starts_with("--> ") {
919
+ let location = line.trim_start().trim_start_matches("--> ").to_string();
920
+ if !current_rule.is_empty() {
921
+ by_rule
922
+ .entry(current_rule.clone())
923
+ .or_default()
924
+ .push(location);
925
+ }
926
+ }
927
+ }
928
+
929
+ if error_count == 0 && warning_count == 0 {
930
+ return "✓ cargo clippy: No issues found".to_string();
931
+ }
932
+
933
+ let mut result = String::new();
934
+ result.push_str(&format!(
935
+ "cargo clippy: {} errors, {} warnings\n",
936
+ error_count, warning_count
937
+ ));
938
+ result.push_str("═══════════════════════════════════════\n");
939
+
940
+ // Sort rules by frequency
941
+ let mut rule_counts: Vec<_> = by_rule.iter().collect();
942
+ rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
943
+
944
+ for (rule, locations) in rule_counts.iter().take(15) {
945
+ result.push_str(&format!(" {} ({}x)\n", rule, locations.len()));
946
+ for loc in locations.iter().take(3) {
947
+ result.push_str(&format!(" {}\n", loc));
948
+ }
949
+ if locations.len() > 3 {
950
+ result.push_str(&format!(" ... +{} more\n", locations.len() - 3));
951
+ }
952
+ }
953
+
954
+ if by_rule.len() > 15 {
955
+ result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15));
956
+ }
957
+
958
+ result.trim().to_string()
959
+ }
960
+
961
+ /// Runs an unsupported cargo subcommand by passing it through directly
962
+ pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> {
963
+ let timer = tracking::TimedExecution::start();
964
+
965
+ if verbose > 0 {
966
+ eprintln!("cargo passthrough: {:?}", args);
967
+ }
968
+ let status = Command::new("cargo")
969
+ .args(args)
970
+ .status()
971
+ .context("Failed to run cargo")?;
972
+
973
+ let args_str = tracking::args_display(args);
974
+ timer.track_passthrough(
975
+ &format!("cargo {}", args_str),
976
+ &format!("rtk cargo {} (passthrough)", args_str),
977
+ );
978
+
979
+ if !status.success() {
980
+ std::process::exit(status.code().unwrap_or(1));
981
+ }
982
+ Ok(())
983
+ }
984
+
985
+ #[cfg(test)]
986
+ mod tests {
987
+ use super::*;
988
+
989
+ #[test]
990
+ fn test_restore_double_dash_with_separator() {
991
+ // rtk cargo test -- --nocapture → clap gives ["--nocapture"]
992
+ let args: Vec<String> = vec!["--nocapture".into()];
993
+ let raw = vec![
994
+ "rtk".into(),
995
+ "cargo".into(),
996
+ "test".into(),
997
+ "--".into(),
998
+ "--nocapture".into(),
999
+ ];
1000
+ let result = restore_double_dash_with_raw(&args, &raw);
1001
+ assert_eq!(result, vec!["--", "--nocapture"]);
1002
+ }
1003
+
1004
+ #[test]
1005
+ fn test_restore_double_dash_with_test_name() {
1006
+ // rtk cargo test my_test -- --nocapture → clap gives ["my_test", "--nocapture"]
1007
+ let args: Vec<String> = vec!["my_test".into(), "--nocapture".into()];
1008
+ let raw = vec![
1009
+ "rtk".into(),
1010
+ "cargo".into(),
1011
+ "test".into(),
1012
+ "my_test".into(),
1013
+ "--".into(),
1014
+ "--nocapture".into(),
1015
+ ];
1016
+ let result = restore_double_dash_with_raw(&args, &raw);
1017
+ assert_eq!(result, vec!["my_test", "--", "--nocapture"]);
1018
+ }
1019
+
1020
+ #[test]
1021
+ fn test_restore_double_dash_without_separator() {
1022
+ // rtk cargo test my_test → no --, args unchanged
1023
+ let args: Vec<String> = vec!["my_test".into()];
1024
+ let raw = vec![
1025
+ "rtk".into(),
1026
+ "cargo".into(),
1027
+ "test".into(),
1028
+ "my_test".into(),
1029
+ ];
1030
+ let result = restore_double_dash_with_raw(&args, &raw);
1031
+ assert_eq!(result, vec!["my_test"]);
1032
+ }
1033
+
1034
+ #[test]
1035
+ fn test_restore_double_dash_empty_args() {
1036
+ let args: Vec<String> = vec![];
1037
+ let raw = vec!["rtk".into(), "cargo".into(), "test".into()];
1038
+ let result = restore_double_dash_with_raw(&args, &raw);
1039
+ assert!(result.is_empty());
1040
+ }
1041
+
1042
+ #[test]
1043
+ fn test_restore_double_dash_clippy() {
1044
+ // rtk cargo clippy -- -D warnings → clap gives ["-D", "warnings"]
1045
+ let args: Vec<String> = vec!["-D".into(), "warnings".into()];
1046
+ let raw = vec![
1047
+ "rtk".into(),
1048
+ "cargo".into(),
1049
+ "clippy".into(),
1050
+ "--".into(),
1051
+ "-D".into(),
1052
+ "warnings".into(),
1053
+ ];
1054
+ let result = restore_double_dash_with_raw(&args, &raw);
1055
+ assert_eq!(result, vec!["--", "-D", "warnings"]);
1056
+ }
1057
+
1058
+ #[test]
1059
+ fn test_filter_cargo_build_success() {
1060
+ let output = r#" Compiling libc v0.2.153
1061
+ Compiling cfg-if v1.0.0
1062
+ Compiling rtk v0.5.0
1063
+ Finished dev [unoptimized + debuginfo] target(s) in 15.23s
1064
+ "#;
1065
+ let result = filter_cargo_build(output);
1066
+ assert!(result.contains("✓ cargo build"));
1067
+ assert!(result.contains("3 crates compiled"));
1068
+ }
1069
+
1070
+ #[test]
1071
+ fn test_filter_cargo_build_errors() {
1072
+ let output = r#" Compiling rtk v0.5.0
1073
+ error[E0308]: mismatched types
1074
+ --> src/main.rs:10:5
1075
+ |
1076
+ 10| "hello"
1077
+ | ^^^^^^^ expected `i32`, found `&str`
1078
+
1079
+ error: aborting due to 1 previous error
1080
+ "#;
1081
+ let result = filter_cargo_build(output);
1082
+ assert!(result.contains("1 errors"));
1083
+ assert!(result.contains("E0308"));
1084
+ assert!(result.contains("mismatched types"));
1085
+ }
1086
+
1087
+ #[test]
1088
+ fn test_filter_cargo_test_all_pass() {
1089
+ let output = r#" Compiling rtk v0.5.0
1090
+ Finished test [unoptimized + debuginfo] target(s) in 2.53s
1091
+ Running target/debug/deps/rtk-abc123
1092
+
1093
+ running 15 tests
1094
+ test utils::tests::test_truncate_short_string ... ok
1095
+ test utils::tests::test_truncate_long_string ... ok
1096
+ test utils::tests::test_strip_ansi_simple ... ok
1097
+
1098
+ test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
1099
+ "#;
1100
+ let result = filter_cargo_test(output);
1101
+ assert!(
1102
+ result.contains("✓ cargo test: 15 passed (1 suite, 0.01s)"),
1103
+ "Expected compact format, got: {}",
1104
+ result
1105
+ );
1106
+ assert!(!result.contains("Compiling"));
1107
+ assert!(!result.contains("test utils"));
1108
+ }
1109
+
1110
+ #[test]
1111
+ fn test_filter_cargo_test_failures() {
1112
+ let output = r#"running 5 tests
1113
+ test foo::test_a ... ok
1114
+ test foo::test_b ... FAILED
1115
+ test foo::test_c ... ok
1116
+
1117
+ failures:
1118
+
1119
+ ---- foo::test_b stdout ----
1120
+ thread 'foo::test_b' panicked at 'assert_eq!(1, 2)'
1121
+
1122
+ failures:
1123
+ foo::test_b
1124
+
1125
+ test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
1126
+ "#;
1127
+ let result = filter_cargo_test(output);
1128
+ assert!(result.contains("FAILURES"));
1129
+ assert!(result.contains("test_b"));
1130
+ assert!(result.contains("test result:"));
1131
+ }
1132
+
1133
+ #[test]
1134
+ fn test_filter_cargo_test_multi_suite_all_pass() {
1135
+ let output = r#" Compiling rtk v0.5.0
1136
+ Finished test [unoptimized + debuginfo] target(s) in 2.53s
1137
+ Running unittests src/lib.rs (target/debug/deps/rtk-abc123)
1138
+
1139
+ running 50 tests
1140
+ test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s
1141
+
1142
+ Running unittests src/main.rs (target/debug/deps/rtk-def456)
1143
+
1144
+ running 30 tests
1145
+ test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s
1146
+
1147
+ Running tests/integration.rs (target/debug/deps/integration-ghi789)
1148
+
1149
+ running 25 tests
1150
+ test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
1151
+
1152
+ Doc-tests rtk
1153
+
1154
+ running 32 tests
1155
+ test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s
1156
+ "#;
1157
+ let result = filter_cargo_test(output);
1158
+ assert!(
1159
+ result.contains("✓ cargo test: 137 passed (4 suites, 1.45s)"),
1160
+ "Expected aggregated format, got: {}",
1161
+ result
1162
+ );
1163
+ assert!(!result.contains("running"));
1164
+ }
1165
+
1166
+ #[test]
1167
+ fn test_filter_cargo_test_multi_suite_with_failures() {
1168
+ let output = r#" Running unittests src/lib.rs
1169
+
1170
+ running 20 tests
1171
+ test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
1172
+
1173
+ Running unittests src/main.rs
1174
+
1175
+ running 15 tests
1176
+ test foo::test_bad ... FAILED
1177
+
1178
+ failures:
1179
+
1180
+ ---- foo::test_bad stdout ----
1181
+ thread panicked at 'assertion failed'
1182
+
1183
+ test result: FAILED. 14 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
1184
+
1185
+ Running tests/integration.rs
1186
+
1187
+ running 10 tests
1188
+ test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
1189
+ "#;
1190
+ let result = filter_cargo_test(output);
1191
+ // Should NOT aggregate when there are failures
1192
+ assert!(result.contains("FAILURES"), "got: {}", result);
1193
+ assert!(result.contains("test_bad"), "got: {}", result);
1194
+ assert!(result.contains("test result:"), "got: {}", result);
1195
+ // Should show individual summaries
1196
+ assert!(result.contains("20 passed"), "got: {}", result);
1197
+ assert!(result.contains("14 passed"), "got: {}", result);
1198
+ assert!(result.contains("10 passed"), "got: {}", result);
1199
+ }
1200
+
1201
+ #[test]
1202
+ fn test_filter_cargo_test_all_suites_zero_tests() {
1203
+ let output = r#" Running unittests src/empty1.rs
1204
+
1205
+ running 0 tests
1206
+
1207
+ test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
1208
+
1209
+ Running unittests src/empty2.rs
1210
+
1211
+ running 0 tests
1212
+
1213
+ test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
1214
+
1215
+ Running tests/empty3.rs
1216
+
1217
+ running 0 tests
1218
+
1219
+ test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
1220
+ "#;
1221
+ let result = filter_cargo_test(output);
1222
+ assert!(
1223
+ result.contains("✓ cargo test: 0 passed (3 suites, 0.00s)"),
1224
+ "Expected compact format for zero tests, got: {}",
1225
+ result
1226
+ );
1227
+ }
1228
+
1229
+ #[test]
1230
+ fn test_filter_cargo_test_with_ignored_and_filtered() {
1231
+ let output = r#" Running unittests src/lib.rs
1232
+
1233
+ running 50 tests
1234
+ test result: ok. 45 passed; 0 failed; 3 ignored; 0 measured; 2 filtered out; finished in 0.50s
1235
+
1236
+ Running tests/integration.rs
1237
+
1238
+ running 20 tests
1239
+ test result: ok. 18 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.20s
1240
+ "#;
1241
+ let result = filter_cargo_test(output);
1242
+ assert!(
1243
+ result.contains("✓ cargo test: 63 passed, 5 ignored, 2 filtered out (2 suites, 0.70s)"),
1244
+ "Expected compact format with ignored and filtered, got: {}",
1245
+ result
1246
+ );
1247
+ }
1248
+
1249
+ #[test]
1250
+ fn test_filter_cargo_test_single_suite_compact() {
1251
+ let output = r#" Running unittests src/main.rs
1252
+
1253
+ running 15 tests
1254
+ test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
1255
+ "#;
1256
+ let result = filter_cargo_test(output);
1257
+ assert!(
1258
+ result.contains("✓ cargo test: 15 passed (1 suite, 0.01s)"),
1259
+ "Expected singular 'suite', got: {}",
1260
+ result
1261
+ );
1262
+ }
1263
+
1264
+ #[test]
1265
+ fn test_filter_cargo_test_regex_fallback() {
1266
+ let output = r#" Running unittests src/main.rs
1267
+
1268
+ running 15 tests
1269
+ test result: MALFORMED LINE WITHOUT PROPER FORMAT
1270
+ "#;
1271
+ let result = filter_cargo_test(output);
1272
+ // Should fallback to original behavior (show line with checkmark)
1273
+ assert!(
1274
+ result.contains("✓ test result: MALFORMED"),
1275
+ "Expected fallback format, got: {}",
1276
+ result
1277
+ );
1278
+ }
1279
+
1280
+ #[test]
1281
+ fn test_filter_cargo_clippy_clean() {
1282
+ let output = r#" Checking rtk v0.5.0
1283
+ Finished dev [unoptimized + debuginfo] target(s) in 1.53s
1284
+ "#;
1285
+ let result = filter_cargo_clippy(output);
1286
+ assert!(result.contains("✓ cargo clippy: No issues found"));
1287
+ }
1288
+
1289
+ #[test]
1290
+ fn test_filter_cargo_clippy_warnings() {
1291
+ let output = r#" Checking rtk v0.5.0
1292
+ warning: unused variable: `x` [unused_variables]
1293
+ --> src/main.rs:10:9
1294
+ |
1295
+ 10| let x = 5;
1296
+ | ^ help: if this is intentional, prefix it with an underscore: `_x`
1297
+
1298
+ warning: this function has too many arguments [clippy::too_many_arguments]
1299
+ --> src/git.rs:16:1
1300
+ |
1301
+ 16| pub fn run(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) {}
1302
+ |
1303
+
1304
+ warning: `rtk` (bin) generated 2 warnings
1305
+ Finished dev [unoptimized + debuginfo] target(s) in 1.53s
1306
+ "#;
1307
+ let result = filter_cargo_clippy(output);
1308
+ assert!(result.contains("0 errors, 2 warnings"));
1309
+ assert!(result.contains("unused_variables"));
1310
+ assert!(result.contains("clippy::too_many_arguments"));
1311
+ }
1312
+
1313
+ #[test]
1314
+ fn test_filter_cargo_install_success() {
1315
+ let output = r#" Installing rtk v0.11.0
1316
+ Downloading crates ...
1317
+ Downloaded anyhow v1.0.80
1318
+ Downloaded clap v4.5.0
1319
+ Compiling libc v0.2.153
1320
+ Compiling cfg-if v1.0.0
1321
+ Compiling anyhow v1.0.80
1322
+ Compiling clap v4.5.0
1323
+ Compiling rtk v0.11.0
1324
+ Finished `release` profile [optimized] target(s) in 45.23s
1325
+ Replacing /Users/user/.cargo/bin/rtk
1326
+ Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
1327
+ "#;
1328
+ let result = filter_cargo_install(output);
1329
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1330
+ assert!(result.contains("rtk v0.11.0"), "got: {}", result);
1331
+ assert!(result.contains("5 deps compiled"), "got: {}", result);
1332
+ assert!(result.contains("Replaced"), "got: {}", result);
1333
+ assert!(!result.contains("Compiling"), "got: {}", result);
1334
+ assert!(!result.contains("Downloading"), "got: {}", result);
1335
+ }
1336
+
1337
+ #[test]
1338
+ fn test_filter_cargo_install_replace() {
1339
+ let output = r#" Installing rtk v0.11.0
1340
+ Compiling rtk v0.11.0
1341
+ Finished `release` profile [optimized] target(s) in 10.0s
1342
+ Replacing /Users/user/.cargo/bin/rtk
1343
+ Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
1344
+ "#;
1345
+ let result = filter_cargo_install(output);
1346
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1347
+ assert!(result.contains("Replacing"), "got: {}", result);
1348
+ assert!(result.contains("Replaced"), "got: {}", result);
1349
+ }
1350
+
1351
+ #[test]
1352
+ fn test_filter_cargo_install_error() {
1353
+ let output = r#" Installing rtk v0.11.0
1354
+ Compiling rtk v0.11.0
1355
+ error[E0308]: mismatched types
1356
+ --> src/main.rs:10:5
1357
+ |
1358
+ 10| "hello"
1359
+ | ^^^^^^^ expected `i32`, found `&str`
1360
+
1361
+ error: aborting due to 1 previous error
1362
+ "#;
1363
+ let result = filter_cargo_install(output);
1364
+ assert!(result.contains("cargo install: 1 error"), "got: {}", result);
1365
+ assert!(result.contains("E0308"), "got: {}", result);
1366
+ assert!(result.contains("mismatched types"), "got: {}", result);
1367
+ assert!(!result.contains("aborting"), "got: {}", result);
1368
+ }
1369
+
1370
+ #[test]
1371
+ fn test_filter_cargo_install_already_installed() {
1372
+ let output = r#" Ignored package `rtk v0.11.0`, is already installed
1373
+ "#;
1374
+ let result = filter_cargo_install(output);
1375
+ assert!(result.contains("already installed"), "got: {}", result);
1376
+ assert!(result.contains("rtk v0.11.0"), "got: {}", result);
1377
+ }
1378
+
1379
+ #[test]
1380
+ fn test_filter_cargo_install_up_to_date() {
1381
+ let output = r#" Ignored package `cargo-deb v2.1.0 (/Users/user/cargo-deb)`, is already installed
1382
+ "#;
1383
+ let result = filter_cargo_install(output);
1384
+ assert!(result.contains("already installed"), "got: {}", result);
1385
+ assert!(result.contains("cargo-deb v2.1.0"), "got: {}", result);
1386
+ }
1387
+
1388
+ #[test]
1389
+ fn test_filter_cargo_install_empty_output() {
1390
+ let result = filter_cargo_install("");
1391
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1392
+ assert!(result.contains("0 deps compiled"), "got: {}", result);
1393
+ }
1394
+
1395
+ #[test]
1396
+ fn test_filter_cargo_install_path_warning() {
1397
+ let output = r#" Installing rtk v0.11.0
1398
+ Compiling rtk v0.11.0
1399
+ Finished `release` profile [optimized] target(s) in 10.0s
1400
+ Replacing /Users/user/.cargo/bin/rtk
1401
+ Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
1402
+ warning: be sure to add `/Users/user/.cargo/bin` to your PATH
1403
+ "#;
1404
+ let result = filter_cargo_install(output);
1405
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1406
+ assert!(
1407
+ result.contains("be sure to add"),
1408
+ "PATH warning should be kept: {}",
1409
+ result
1410
+ );
1411
+ assert!(result.contains("Replaced"), "got: {}", result);
1412
+ }
1413
+
1414
+ #[test]
1415
+ fn test_filter_cargo_install_multiple_errors() {
1416
+ let output = r#" Installing rtk v0.11.0
1417
+ Compiling rtk v0.11.0
1418
+ error[E0308]: mismatched types
1419
+ --> src/main.rs:10:5
1420
+ |
1421
+ 10| "hello"
1422
+ | ^^^^^^^ expected `i32`, found `&str`
1423
+
1424
+ error[E0425]: cannot find value `foo`
1425
+ --> src/lib.rs:20:9
1426
+ |
1427
+ 20| foo
1428
+ | ^^^ not found in this scope
1429
+
1430
+ error: aborting due to 2 previous errors
1431
+ "#;
1432
+ let result = filter_cargo_install(output);
1433
+ assert!(
1434
+ result.contains("2 errors"),
1435
+ "should show 2 errors: {}",
1436
+ result
1437
+ );
1438
+ assert!(result.contains("E0308"), "got: {}", result);
1439
+ assert!(result.contains("E0425"), "got: {}", result);
1440
+ assert!(!result.contains("aborting"), "got: {}", result);
1441
+ }
1442
+
1443
+ #[test]
1444
+ fn test_filter_cargo_install_locking_and_blocking() {
1445
+ let output = r#" Locking 45 packages to latest compatible versions
1446
+ Blocking waiting for file lock on package cache
1447
+ Downloading crates ...
1448
+ Downloaded serde v1.0.200
1449
+ Compiling serde v1.0.200
1450
+ Compiling rtk v0.11.0
1451
+ Finished `release` profile [optimized] target(s) in 30.0s
1452
+ Installing rtk v0.11.0
1453
+ "#;
1454
+ let result = filter_cargo_install(output);
1455
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1456
+ assert!(!result.contains("Locking"), "got: {}", result);
1457
+ assert!(!result.contains("Blocking"), "got: {}", result);
1458
+ assert!(!result.contains("Downloading"), "got: {}", result);
1459
+ }
1460
+
1461
+ #[test]
1462
+ fn test_filter_cargo_install_from_path() {
1463
+ let output = r#" Installing /Users/user/projects/rtk
1464
+ Compiling rtk v0.11.0
1465
+ Finished `release` profile [optimized] target(s) in 10.0s
1466
+ "#;
1467
+ let result = filter_cargo_install(output);
1468
+ // Path-based install: crate info not extracted from path
1469
+ assert!(result.contains("✓ cargo install"), "got: {}", result);
1470
+ assert!(result.contains("1 deps compiled"), "got: {}", result);
1471
+ }
1472
+
1473
+ #[test]
1474
+ fn test_format_crate_info() {
1475
+ assert_eq!(format_crate_info("rtk", "v0.11.0", ""), "rtk v0.11.0");
1476
+ assert_eq!(format_crate_info("rtk", "", ""), "rtk");
1477
+ assert_eq!(format_crate_info("", "", "package"), "package");
1478
+ assert_eq!(format_crate_info("", "v0.1.0", "fallback"), "fallback");
1479
+ }
1480
+
1481
+ #[test]
1482
+ fn test_filter_cargo_nextest_all_pass() {
1483
+ let output = r#" Compiling rtk v0.15.2
1484
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 0.04s
1485
+ ────────────────────────────
1486
+ Starting 301 tests across 1 binary
1487
+ PASS [ 0.009s] (1/301) rtk::bin/rtk cargo_cmd::tests::test_one
1488
+ PASS [ 0.008s] (2/301) rtk::bin/rtk cargo_cmd::tests::test_two
1489
+ PASS [ 0.007s] (301/301) rtk::bin/rtk cargo_cmd::tests::test_last
1490
+ ────────────────────────────
1491
+ Summary [ 0.192s] 301 tests run: 301 passed, 0 skipped
1492
+ "#;
1493
+ let result = filter_cargo_nextest(output);
1494
+ assert_eq!(
1495
+ result, "✓ cargo nextest: 301 passed (1 binary, 0.192s)",
1496
+ "got: {}",
1497
+ result
1498
+ );
1499
+ }
1500
+
1501
+ #[test]
1502
+ fn test_filter_cargo_nextest_with_failures() {
1503
+ let output = r#" Starting 4 tests across 1 binary (1 test skipped)
1504
+ PASS [ 0.006s] (1/4) test-proj tests::passing_test
1505
+ FAIL [ 0.006s] (2/4) test-proj tests::failing_test
1506
+
1507
+ stderr ───
1508
+
1509
+ thread 'tests::failing_test' panicked at src/lib.rs:15:9:
1510
+ assertion `left == right` failed
1511
+ left: 1
1512
+ right: 2
1513
+
1514
+ Cancelling due to test failure: 2 tests still running
1515
+ PASS [ 0.007s] (3/4) test-proj tests::another_passing
1516
+ FAIL [ 0.006s] (4/4) test-proj tests::another_failing
1517
+
1518
+ stderr ───
1519
+
1520
+ thread 'tests::another_failing' panicked at src/lib.rs:20:9:
1521
+ something went wrong
1522
+
1523
+ ────────────────────────────
1524
+ Summary [ 0.007s] 4 tests run: 2 passed, 2 failed, 1 skipped
1525
+ FAIL [ 0.006s] (2/4) test-proj tests::failing_test
1526
+ FAIL [ 0.006s] (4/4) test-proj tests::another_failing
1527
+ error: test run failed
1528
+ "#;
1529
+ let result = filter_cargo_nextest(output);
1530
+ assert!(
1531
+ result.contains("tests::failing_test"),
1532
+ "should contain first failure: {}",
1533
+ result
1534
+ );
1535
+ assert!(
1536
+ result.contains("tests::another_failing"),
1537
+ "should contain second failure: {}",
1538
+ result
1539
+ );
1540
+ assert!(
1541
+ result.contains("panicked"),
1542
+ "should contain stderr detail: {}",
1543
+ result
1544
+ );
1545
+ assert!(
1546
+ result.contains("2 passed, 2 failed, 1 skipped"),
1547
+ "should contain summary: {}",
1548
+ result
1549
+ );
1550
+ assert!(
1551
+ !result.contains("PASS"),
1552
+ "should not contain PASS lines: {}",
1553
+ result
1554
+ );
1555
+ // Post-summary FAIL recaps must not create duplicate FAIL header entries
1556
+ // (test names may appear in both header and stderr body naturally)
1557
+ assert_eq!(
1558
+ result.matches("FAIL [").count(),
1559
+ 2,
1560
+ "should have exactly 2 FAIL headers (no post-summary duplicates): {}",
1561
+ result
1562
+ );
1563
+ assert!(
1564
+ !result.contains("error: test run failed"),
1565
+ "should not contain post-summary error line: {}",
1566
+ result
1567
+ );
1568
+ }
1569
+
1570
+ #[test]
1571
+ fn test_filter_cargo_nextest_with_skipped() {
1572
+ let output = r#" Starting 50 tests across 2 binaries (3 tests skipped)
1573
+ PASS [ 0.010s] (1/50) rtk::bin/rtk test_one
1574
+ PASS [ 0.010s] (50/50) rtk::bin/rtk test_last
1575
+ ────────────────────────────
1576
+ Summary [ 0.500s] 50 tests run: 50 passed, 3 skipped
1577
+ "#;
1578
+ let result = filter_cargo_nextest(output);
1579
+ assert_eq!(
1580
+ result, "✓ cargo nextest: 50 passed, 3 skipped (2 binaries, 0.500s)",
1581
+ "got: {}",
1582
+ result
1583
+ );
1584
+ }
1585
+
1586
+ #[test]
1587
+ fn test_filter_cargo_nextest_single_failure_detail() {
1588
+ let output = r#" Starting 2 tests across 1 binary
1589
+ PASS [ 0.005s] (1/2) proj tests::good
1590
+ FAIL [ 0.005s] (2/2) proj tests::bad
1591
+
1592
+ stderr ───
1593
+
1594
+ thread 'tests::bad' panicked at src/lib.rs:5:9:
1595
+ assertion failed: false
1596
+
1597
+ ────────────────────────────
1598
+ Summary [ 0.010s] 2 tests run: 1 passed, 1 failed
1599
+ FAIL [ 0.005s] (2/2) proj tests::bad
1600
+ error: test run failed
1601
+ "#;
1602
+ let result = filter_cargo_nextest(output);
1603
+ assert!(
1604
+ result.contains("assertion failed: false"),
1605
+ "should show panic message: {}",
1606
+ result
1607
+ );
1608
+ assert!(
1609
+ result.contains("1 passed, 1 failed"),
1610
+ "should show summary: {}",
1611
+ result
1612
+ );
1613
+ // Post-summary recap must not duplicate FAIL headers
1614
+ assert_eq!(
1615
+ result.matches("FAIL [").count(),
1616
+ 1,
1617
+ "should have exactly 1 FAIL header (no post-summary duplicate): {}",
1618
+ result
1619
+ );
1620
+ }
1621
+
1622
+ #[test]
1623
+ fn test_filter_cargo_nextest_multiple_binaries() {
1624
+ let output = r#" Starting 100 tests across 5 binaries
1625
+ PASS [ 0.010s] (100/100) test_last
1626
+ ────────────────────────────
1627
+ Summary [ 1.234s] 100 tests run: 100 passed, 0 skipped
1628
+ "#;
1629
+ let result = filter_cargo_nextest(output);
1630
+ assert_eq!(
1631
+ result, "✓ cargo nextest: 100 passed (5 binaries, 1.234s)",
1632
+ "got: {}",
1633
+ result
1634
+ );
1635
+ }
1636
+
1637
+ #[test]
1638
+ fn test_filter_cargo_nextest_compilation_stripped() {
1639
+ let output = r#" Compiling serde v1.0.200
1640
+ Compiling rtk v0.15.2
1641
+ Downloading crates ...
1642
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 5.00s
1643
+ ────────────────────────────
1644
+ Starting 10 tests across 1 binary
1645
+ PASS [ 0.010s] (10/10) test_last
1646
+ ────────────────────────────
1647
+ Summary [ 0.050s] 10 tests run: 10 passed, 0 skipped
1648
+ "#;
1649
+ let result = filter_cargo_nextest(output);
1650
+ assert!(
1651
+ !result.contains("Compiling"),
1652
+ "should strip Compiling: {}",
1653
+ result
1654
+ );
1655
+ assert!(
1656
+ !result.contains("Downloading"),
1657
+ "should strip Downloading: {}",
1658
+ result
1659
+ );
1660
+ assert!(
1661
+ !result.contains("Finished"),
1662
+ "should strip Finished: {}",
1663
+ result
1664
+ );
1665
+ assert!(
1666
+ result.contains("✓ cargo nextest: 10 passed"),
1667
+ "got: {}",
1668
+ result
1669
+ );
1670
+ }
1671
+
1672
+ #[test]
1673
+ fn test_filter_cargo_nextest_empty() {
1674
+ let result = filter_cargo_nextest("");
1675
+ assert!(result.is_empty(), "got: {}", result);
1676
+ }
1677
+
1678
+ #[test]
1679
+ fn test_filter_cargo_nextest_cancellation_notice() {
1680
+ let output = r#" Starting 3 tests across 1 binary
1681
+ FAIL [ 0.005s] (1/3) proj tests::bad
1682
+
1683
+ stderr ───
1684
+
1685
+ thread panicked at 'oops'
1686
+
1687
+ Cancelling due to test failure: 2 tests still running
1688
+ ────────────────────────────
1689
+ Summary [ 0.010s] 3 tests run: 2 passed, 1 failed
1690
+ FAIL [ 0.005s] (1/3) proj tests::bad
1691
+ error: test run failed
1692
+ "#;
1693
+ let result = filter_cargo_nextest(output);
1694
+ assert!(
1695
+ result.contains("Cancelling due to test failure"),
1696
+ "should include cancel notice: {}",
1697
+ result
1698
+ );
1699
+ assert!(
1700
+ result.contains("1 failed"),
1701
+ "should show failure count: {}",
1702
+ result
1703
+ );
1704
+ // Post-summary recap must not duplicate FAIL headers
1705
+ assert_eq!(
1706
+ result.matches("FAIL [").count(),
1707
+ 1,
1708
+ "should have exactly 1 FAIL header (no post-summary duplicate): {}",
1709
+ result
1710
+ );
1711
+ }
1712
+
1713
+ #[test]
1714
+ fn test_filter_cargo_nextest_summary_regex_fallback() {
1715
+ let output = r#" Starting 5 tests across 1 binary
1716
+ PASS [ 0.005s] (5/5) test_last
1717
+ ────────────────────────────
1718
+ Summary MALFORMED LINE
1719
+ "#;
1720
+ let result = filter_cargo_nextest(output);
1721
+ assert!(
1722
+ result.contains("Summary MALFORMED"),
1723
+ "should fall back to raw summary: {}",
1724
+ result
1725
+ );
1726
+ }
1727
+ }