@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,2022 @@
1
+ use lazy_static::lazy_static;
2
+ use regex::{Regex, RegexSet};
3
+
4
+ use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, PATTERNS, RULES};
5
+
6
+ /// Result of classifying a command.
7
+ #[derive(Debug, PartialEq)]
8
+ pub enum Classification {
9
+ Supported {
10
+ rtk_equivalent: &'static str,
11
+ category: &'static str,
12
+ estimated_savings_pct: f64,
13
+ status: super::report::RtkStatus,
14
+ },
15
+ Unsupported {
16
+ base_command: String,
17
+ },
18
+ Ignored,
19
+ }
20
+
21
+ /// Average token counts per category for estimation when no output_len available.
22
+ pub fn category_avg_tokens(category: &str, subcmd: &str) -> usize {
23
+ match category {
24
+ "Git" => match subcmd {
25
+ "log" | "diff" | "show" => 200,
26
+ _ => 40,
27
+ },
28
+ "Cargo" => match subcmd {
29
+ "test" => 500,
30
+ _ => 150,
31
+ },
32
+ "Tests" => 800,
33
+ "Files" => 100,
34
+ "Build" => 300,
35
+ "Infra" => 120,
36
+ "Network" => 150,
37
+ "GitHub" => 200,
38
+ "PackageManager" => 150,
39
+ _ => 150,
40
+ }
41
+ }
42
+
43
+ lazy_static! {
44
+ static ref REGEX_SET: RegexSet = RegexSet::new(PATTERNS).expect("invalid regex patterns");
45
+ static ref COMPILED: Vec<Regex> = PATTERNS
46
+ .iter()
47
+ .map(|p| Regex::new(p).expect("invalid regex"))
48
+ .collect();
49
+ static ref ENV_PREFIX: Regex =
50
+ Regex::new(r"^(?:sudo\s+|env\s+|[A-Z_][A-Z0-9_]*=[^\s]*\s+)+").unwrap();
51
+ }
52
+
53
+ /// Classify a single (already-split) command.
54
+ pub fn classify_command(cmd: &str) -> Classification {
55
+ let trimmed = cmd.trim();
56
+ if trimmed.is_empty() {
57
+ return Classification::Ignored;
58
+ }
59
+
60
+ // Check ignored
61
+ for exact in IGNORED_EXACT {
62
+ if trimmed == *exact {
63
+ return Classification::Ignored;
64
+ }
65
+ }
66
+ for prefix in IGNORED_PREFIXES {
67
+ if trimmed.starts_with(prefix) {
68
+ return Classification::Ignored;
69
+ }
70
+ }
71
+
72
+ // Strip env prefixes (sudo, env VAR=val, VAR=val)
73
+ let stripped = ENV_PREFIX.replace(trimmed, "");
74
+ let cmd_clean = stripped.trim();
75
+ if cmd_clean.is_empty() {
76
+ return Classification::Ignored;
77
+ }
78
+
79
+ // Exclude cat/head/tail with redirect operators — these are writes, not reads (#315)
80
+ if cmd_clean.starts_with("cat ")
81
+ || cmd_clean.starts_with("head ")
82
+ || cmd_clean.starts_with("tail ")
83
+ {
84
+ let has_redirect = cmd_clean
85
+ .split_whitespace()
86
+ .skip(1)
87
+ .any(|t| t.starts_with('>') || t == "<" || t.starts_with(">>"));
88
+ if has_redirect {
89
+ return Classification::Unsupported {
90
+ base_command: cmd_clean
91
+ .split_whitespace()
92
+ .next()
93
+ .unwrap_or("cat")
94
+ .to_string(),
95
+ };
96
+ }
97
+ }
98
+
99
+ // Fast check with RegexSet — take the last (most specific) match
100
+ let matches: Vec<usize> = REGEX_SET.matches(cmd_clean).into_iter().collect();
101
+ if let Some(&idx) = matches.last() {
102
+ let rule = &RULES[idx];
103
+
104
+ // Extract subcommand for savings override and status detection
105
+ let (savings, status) = if let Some(caps) = COMPILED[idx].captures(cmd_clean) {
106
+ if let Some(sub) = caps.get(1) {
107
+ let subcmd = sub.as_str();
108
+ // Check if this subcommand has a special status
109
+ let status = rule
110
+ .subcmd_status
111
+ .iter()
112
+ .find(|(s, _)| *s == subcmd)
113
+ .map(|(_, st)| *st)
114
+ .unwrap_or(super::report::RtkStatus::Existing);
115
+
116
+ // Check if this subcommand has custom savings
117
+ let savings = rule
118
+ .subcmd_savings
119
+ .iter()
120
+ .find(|(s, _)| *s == subcmd)
121
+ .map(|(_, pct)| *pct)
122
+ .unwrap_or(rule.savings_pct);
123
+
124
+ (savings, status)
125
+ } else {
126
+ (rule.savings_pct, super::report::RtkStatus::Existing)
127
+ }
128
+ } else {
129
+ (rule.savings_pct, super::report::RtkStatus::Existing)
130
+ };
131
+
132
+ Classification::Supported {
133
+ rtk_equivalent: rule.rtk_cmd,
134
+ category: rule.category,
135
+ estimated_savings_pct: savings,
136
+ status,
137
+ }
138
+ } else {
139
+ // Extract base command for unsupported
140
+ let base = extract_base_command(cmd_clean);
141
+ if base.is_empty() {
142
+ Classification::Ignored
143
+ } else {
144
+ Classification::Unsupported {
145
+ base_command: base.to_string(),
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ /// Extract the base command (first word, or first two if it looks like a subcommand pattern).
152
+ fn extract_base_command(cmd: &str) -> &str {
153
+ let parts: Vec<&str> = cmd.splitn(3, char::is_whitespace).collect();
154
+ match parts.len() {
155
+ 0 => "",
156
+ 1 => parts[0],
157
+ _ => {
158
+ let second = parts[1];
159
+ // If the second token looks like a subcommand (no leading -)
160
+ if !second.starts_with('-') && !second.contains('/') && !second.contains('.') {
161
+ // Return "cmd subcmd"
162
+ let end = cmd
163
+ .find(char::is_whitespace)
164
+ .and_then(|i| {
165
+ let rest = &cmd[i..];
166
+ let trimmed = rest.trim_start();
167
+ trimmed
168
+ .find(char::is_whitespace)
169
+ .map(|j| i + (rest.len() - trimmed.len()) + j)
170
+ })
171
+ .unwrap_or(cmd.len());
172
+ &cmd[..end]
173
+ } else {
174
+ parts[0]
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ /// Split a command chain on `&&`, `||`, `;` outside quotes.
181
+ /// For pipes `|`, only keep the first command.
182
+ /// Lines with `<<` (heredoc) or `$((` are returned whole.
183
+ pub fn split_command_chain(cmd: &str) -> Vec<&str> {
184
+ let trimmed = cmd.trim();
185
+ if trimmed.is_empty() {
186
+ return vec![];
187
+ }
188
+
189
+ // Heredoc or arithmetic expansion: treat as single command
190
+ if trimmed.contains("<<") || trimmed.contains("$((") {
191
+ return vec![trimmed];
192
+ }
193
+
194
+ let mut results = Vec::new();
195
+ let mut start = 0;
196
+ let bytes = trimmed.as_bytes();
197
+ let len = bytes.len();
198
+ let mut i = 0;
199
+ let mut in_single = false;
200
+ let mut in_double = false;
201
+ let mut pipe_seen = false;
202
+
203
+ while i < len {
204
+ let b = bytes[i];
205
+ match b {
206
+ b'\'' if !in_double => {
207
+ in_single = !in_single;
208
+ i += 1;
209
+ }
210
+ b'"' if !in_single => {
211
+ in_double = !in_double;
212
+ i += 1;
213
+ }
214
+ b'|' if !in_single && !in_double => {
215
+ if i + 1 < len && bytes[i + 1] == b'|' {
216
+ // ||
217
+ let segment = trimmed[start..i].trim();
218
+ if !segment.is_empty() {
219
+ results.push(segment);
220
+ }
221
+ i += 2;
222
+ start = i;
223
+ } else {
224
+ // pipe: keep only first command
225
+ let segment = trimmed[start..i].trim();
226
+ if !segment.is_empty() {
227
+ results.push(segment);
228
+ }
229
+ pipe_seen = true;
230
+ break;
231
+ }
232
+ }
233
+ b'&' if !in_single && !in_double && i + 1 < len && bytes[i + 1] == b'&' => {
234
+ let segment = trimmed[start..i].trim();
235
+ if !segment.is_empty() {
236
+ results.push(segment);
237
+ }
238
+ i += 2;
239
+ start = i;
240
+ }
241
+ b';' if !in_single && !in_double => {
242
+ let segment = trimmed[start..i].trim();
243
+ if !segment.is_empty() {
244
+ results.push(segment);
245
+ }
246
+ i += 1;
247
+ start = i;
248
+ }
249
+ _ => {
250
+ i += 1;
251
+ }
252
+ }
253
+ }
254
+
255
+ if !pipe_seen && start < len {
256
+ let segment = trimmed[start..].trim();
257
+ if !segment.is_empty() {
258
+ results.push(segment);
259
+ }
260
+ }
261
+
262
+ results
263
+ }
264
+
265
+ /// Check if a command has RTK_DISABLED= prefix in its env prefix portion.
266
+ pub fn has_rtk_disabled_prefix(cmd: &str) -> bool {
267
+ let trimmed = cmd.trim();
268
+ let stripped = ENV_PREFIX.replace(trimmed, "");
269
+ let prefix_len = trimmed.len() - stripped.len();
270
+ let prefix_part = &trimmed[..prefix_len];
271
+ prefix_part.contains("RTK_DISABLED=")
272
+ }
273
+
274
+ /// Strip RTK_DISABLED=X and other env prefixes, return the actual command.
275
+ pub fn strip_disabled_prefix(cmd: &str) -> &str {
276
+ let trimmed = cmd.trim();
277
+ let stripped = ENV_PREFIX.replace(trimmed, "");
278
+ // stripped is a Cow<str> that borrows from trimmed when no replacement happens.
279
+ // We need to return a &str into the original, so compute the offset.
280
+ let prefix_len = trimmed.len() - stripped.len();
281
+ trimmed[prefix_len..].trim_start()
282
+ }
283
+
284
+ /// Rewrite a raw command to its RTK equivalent.
285
+ ///
286
+ /// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK.
287
+ /// Returns `None` if the command is unsupported or ignored (hook should pass through).
288
+ ///
289
+ /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
290
+ /// For pipes (`|`), only rewrites the first command (the filter stays raw).
291
+ pub fn rewrite_command(cmd: &str, excluded: &[String]) -> Option<String> {
292
+ let trimmed = cmd.trim();
293
+ if trimmed.is_empty() {
294
+ return None;
295
+ }
296
+
297
+ // Heredoc or arithmetic expansion — unsafe to split/rewrite
298
+ if trimmed.contains("<<") || trimmed.contains("$((") {
299
+ return None;
300
+ }
301
+
302
+ // Simple (non-compound) already-RTK command — return as-is.
303
+ // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"),
304
+ // fall through to rewrite_compound so the remaining segments get rewritten.
305
+ let has_compound = trimmed.contains("&&")
306
+ || trimmed.contains("||")
307
+ || trimmed.contains(';')
308
+ || trimmed.contains('|')
309
+ || trimmed.contains(" & ");
310
+ if !has_compound && (trimmed.starts_with("rtk ") || trimmed == "rtk") {
311
+ return Some(trimmed.to_string());
312
+ }
313
+
314
+ rewrite_compound(trimmed, excluded)
315
+ }
316
+
317
+ /// Rewrite a compound command (with `&&`, `||`, `;`, `|`) by rewriting each segment.
318
+ fn rewrite_compound(cmd: &str, excluded: &[String]) -> Option<String> {
319
+ let bytes = cmd.as_bytes();
320
+ let len = bytes.len();
321
+ let mut result = String::with_capacity(len + 32);
322
+ let mut any_changed = false;
323
+ let mut seg_start = 0;
324
+ let mut i = 0;
325
+ let mut in_single = false;
326
+ let mut in_double = false;
327
+
328
+ while i < len {
329
+ let b = bytes[i];
330
+ match b {
331
+ b'\'' if !in_double => {
332
+ in_single = !in_single;
333
+ i += 1;
334
+ }
335
+ b'"' if !in_single => {
336
+ in_double = !in_double;
337
+ i += 1;
338
+ }
339
+ b'|' if !in_single && !in_double => {
340
+ if i + 1 < len && bytes[i + 1] == b'|' {
341
+ // `||` operator — rewrite left, continue
342
+ let seg = cmd[seg_start..i].trim();
343
+ let rewritten =
344
+ rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
345
+ if rewritten != seg {
346
+ any_changed = true;
347
+ }
348
+ result.push_str(&rewritten);
349
+ result.push_str(" || ");
350
+ i += 2;
351
+ while i < len && bytes[i] == b' ' {
352
+ i += 1;
353
+ }
354
+ seg_start = i;
355
+ } else {
356
+ // `|` pipe — rewrite first segment only, pass through the rest unchanged
357
+ let seg = cmd[seg_start..i].trim();
358
+ let rewritten =
359
+ rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
360
+ if rewritten != seg {
361
+ any_changed = true;
362
+ }
363
+ result.push_str(&rewritten);
364
+ // Preserve the space before the pipe that was lost by trim()
365
+ result.push(' ');
366
+ result.push_str(cmd[i..].trim_start());
367
+ return if any_changed { Some(result) } else { None };
368
+ }
369
+ }
370
+ b'&' if !in_single && !in_double && i + 1 < len && bytes[i + 1] == b'&' => {
371
+ // `&&` operator — rewrite left, continue
372
+ let seg = cmd[seg_start..i].trim();
373
+ let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
374
+ if rewritten != seg {
375
+ any_changed = true;
376
+ }
377
+ result.push_str(&rewritten);
378
+ result.push_str(" && ");
379
+ i += 2;
380
+ while i < len && bytes[i] == b' ' {
381
+ i += 1;
382
+ }
383
+ seg_start = i;
384
+ }
385
+ b'&' if !in_single && !in_double => {
386
+ // #346: redirect detection — 2>&1 / >&2 (> before &) or &>file / &>>file (> after &)
387
+ let is_redirect =
388
+ (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>');
389
+ if is_redirect {
390
+ i += 1;
391
+ } else {
392
+ // single `&` background execution operator
393
+ let seg = cmd[seg_start..i].trim();
394
+ let rewritten =
395
+ rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
396
+ if rewritten != seg {
397
+ any_changed = true;
398
+ }
399
+ result.push_str(&rewritten);
400
+ result.push_str(" & ");
401
+ i += 1;
402
+ while i < len && bytes[i] == b' ' {
403
+ i += 1;
404
+ }
405
+ seg_start = i;
406
+ }
407
+ }
408
+ b';' if !in_single && !in_double => {
409
+ // `;` separator
410
+ let seg = cmd[seg_start..i].trim();
411
+ let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
412
+ if rewritten != seg {
413
+ any_changed = true;
414
+ }
415
+ result.push_str(&rewritten);
416
+ result.push(';');
417
+ i += 1;
418
+ while i < len && bytes[i] == b' ' {
419
+ i += 1;
420
+ }
421
+ if i < len {
422
+ result.push(' ');
423
+ }
424
+ seg_start = i;
425
+ }
426
+ _ => {
427
+ i += 1;
428
+ }
429
+ }
430
+ }
431
+
432
+ // Last (or only) segment
433
+ let seg = cmd[seg_start..len].trim();
434
+ let rewritten = rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string());
435
+ if rewritten != seg {
436
+ any_changed = true;
437
+ }
438
+ result.push_str(&rewritten);
439
+
440
+ if any_changed {
441
+ Some(result)
442
+ } else {
443
+ None
444
+ }
445
+ }
446
+
447
+ /// Rewrite `head -N file` → `rtk read file --max-lines N`.
448
+ /// Returns `None` if the command doesn't match this pattern (fall through to generic logic).
449
+ fn rewrite_head_numeric(cmd: &str) -> Option<String> {
450
+ // Match: head -<digits> <file> (with optional env prefix)
451
+ lazy_static! {
452
+ static ref HEAD_N: Regex = Regex::new(r"^head\s+-(\d+)\s+(.+)$").expect("valid regex");
453
+ static ref HEAD_LINES: Regex =
454
+ Regex::new(r"^head\s+--lines=(\d+)\s+(.+)$").expect("valid regex");
455
+ }
456
+ if let Some(caps) = HEAD_N.captures(cmd) {
457
+ let n = caps.get(1)?.as_str();
458
+ let file = caps.get(2)?.as_str();
459
+ return Some(format!("rtk read {} --max-lines {}", file, n));
460
+ }
461
+ if let Some(caps) = HEAD_LINES.captures(cmd) {
462
+ let n = caps.get(1)?.as_str();
463
+ let file = caps.get(2)?.as_str();
464
+ return Some(format!("rtk read {} --max-lines {}", file, n));
465
+ }
466
+ // head with any other flag (e.g. -c, -q): skip rewriting to avoid clap errors
467
+ if cmd.starts_with("head -") {
468
+ return None;
469
+ }
470
+ None
471
+ }
472
+
473
+ /// Rewrite `tail` numeric line forms to `rtk read ... --tail-lines N`.
474
+ /// Returns `None` when the pattern is unsupported (caller falls through / skips rewrite).
475
+ fn rewrite_tail_lines(cmd: &str) -> Option<String> {
476
+ lazy_static! {
477
+ static ref TAIL_N: Regex = Regex::new(r"^tail\s+-(\d+)\s+(.+)$").expect("valid regex");
478
+ static ref TAIL_N_SPACE: Regex =
479
+ Regex::new(r"^tail\s+-n\s+(\d+)\s+(.+)$").expect("valid regex");
480
+ static ref TAIL_LINES_EQ: Regex =
481
+ Regex::new(r"^tail\s+--lines=(\d+)\s+(.+)$").expect("valid regex");
482
+ static ref TAIL_LINES_SPACE: Regex =
483
+ Regex::new(r"^tail\s+--lines\s+(\d+)\s+(.+)$").expect("valid regex");
484
+ }
485
+
486
+ for re in [
487
+ &*TAIL_N,
488
+ &*TAIL_N_SPACE,
489
+ &*TAIL_LINES_EQ,
490
+ &*TAIL_LINES_SPACE,
491
+ ] {
492
+ if let Some(caps) = re.captures(cmd) {
493
+ let n = caps.get(1)?.as_str();
494
+ let file = caps.get(2)?.as_str();
495
+ return Some(format!("rtk read {} --tail-lines {}", file, n));
496
+ }
497
+ }
498
+
499
+ // Unknown tail form: skip rewrite to preserve native behavior.
500
+ None
501
+ }
502
+
503
+ /// Rewrite a single (non-compound) command segment.
504
+ /// Returns `Some(rewritten)` if matched (including already-RTK pass-through).
505
+ /// Returns `None` if no match (caller uses original segment).
506
+ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
507
+ let trimmed = seg.trim();
508
+ if trimmed.is_empty() {
509
+ return None;
510
+ }
511
+
512
+ // Already RTK — pass through unchanged
513
+ if trimmed.starts_with("rtk ") || trimmed == "rtk" {
514
+ return Some(trimmed.to_string());
515
+ }
516
+
517
+ // Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N`
518
+ // Must intercept before generic prefix replacement, which would produce `rtk read -20 file`.
519
+ // Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls
520
+ // through to the generic rewrite below and produces `rtk read file` as expected.
521
+ if trimmed.starts_with("head -") {
522
+ return rewrite_head_numeric(trimmed);
523
+ }
524
+
525
+ // tail has several forms that are not compatible with generic prefix replacement.
526
+ // Only rewrite recognized numeric line forms; otherwise skip rewrite.
527
+ if trimmed.starts_with("tail ") {
528
+ return rewrite_tail_lines(trimmed);
529
+ }
530
+
531
+ // Use classify_command for correct ignore/prefix handling
532
+ let rtk_equivalent = match classify_command(trimmed) {
533
+ Classification::Supported { rtk_equivalent, .. } => {
534
+ // Check if the base command is excluded from rewriting (#243)
535
+ let base = trimmed.split_whitespace().next().unwrap_or("");
536
+ if excluded.iter().any(|e| e == base) {
537
+ return None;
538
+ }
539
+ rtk_equivalent
540
+ }
541
+ _ => return None,
542
+ };
543
+
544
+ // Find the matching rule (rtk_cmd values are unique across all rules)
545
+ let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?;
546
+
547
+ // Extract env prefix (sudo, env VAR=val, etc.)
548
+ let stripped_cow = ENV_PREFIX.replace(trimmed, "");
549
+ let env_prefix_len = trimmed.len() - stripped_cow.len();
550
+ let env_prefix = &trimmed[..env_prefix_len];
551
+ let cmd_clean = stripped_cow.trim();
552
+
553
+ // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
554
+ if has_rtk_disabled_prefix(trimmed) {
555
+ return None;
556
+ }
557
+
558
+ // #196: gh with --json/--jq/--template produces structured output that
559
+ // rtk gh would corrupt — skip rewrite so the caller gets raw JSON.
560
+ if rule.rtk_cmd == "rtk gh" {
561
+ let args_lower = cmd_clean.to_lowercase();
562
+ if args_lower.contains("--json")
563
+ || args_lower.contains("--jq")
564
+ || args_lower.contains("--template")
565
+ {
566
+ return None;
567
+ }
568
+ }
569
+
570
+ // Try each rewrite prefix (longest first) with word-boundary check
571
+ for &prefix in rule.rewrite_prefixes {
572
+ if let Some(rest) = strip_word_prefix(cmd_clean, prefix) {
573
+ let rewritten = if rest.is_empty() {
574
+ format!("{}{}", env_prefix, rule.rtk_cmd)
575
+ } else {
576
+ format!("{}{} {}", env_prefix, rule.rtk_cmd, rest)
577
+ };
578
+ return Some(rewritten);
579
+ }
580
+ }
581
+
582
+ None
583
+ }
584
+
585
+ /// Strip a command prefix with word-boundary check.
586
+ /// Returns the remainder of the command after the prefix, or `None` if no match.
587
+ fn strip_word_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> {
588
+ if cmd == prefix {
589
+ Some("")
590
+ } else if cmd.len() > prefix.len()
591
+ && cmd.starts_with(prefix)
592
+ && cmd.as_bytes()[prefix.len()] == b' '
593
+ {
594
+ Some(cmd[prefix.len() + 1..].trim_start())
595
+ } else {
596
+ None
597
+ }
598
+ }
599
+
600
+ #[cfg(test)]
601
+ mod tests {
602
+ use super::super::report::RtkStatus;
603
+ use super::*;
604
+
605
+ #[test]
606
+ fn test_classify_git_status() {
607
+ assert_eq!(
608
+ classify_command("git status"),
609
+ Classification::Supported {
610
+ rtk_equivalent: "rtk git",
611
+ category: "Git",
612
+ estimated_savings_pct: 70.0,
613
+ status: RtkStatus::Existing,
614
+ }
615
+ );
616
+ }
617
+
618
+ #[test]
619
+ fn test_classify_git_diff_cached() {
620
+ assert_eq!(
621
+ classify_command("git diff --cached"),
622
+ Classification::Supported {
623
+ rtk_equivalent: "rtk git",
624
+ category: "Git",
625
+ estimated_savings_pct: 80.0,
626
+ status: RtkStatus::Existing,
627
+ }
628
+ );
629
+ }
630
+
631
+ #[test]
632
+ fn test_classify_cargo_test_filter() {
633
+ assert_eq!(
634
+ classify_command("cargo test filter::"),
635
+ Classification::Supported {
636
+ rtk_equivalent: "rtk cargo",
637
+ category: "Cargo",
638
+ estimated_savings_pct: 90.0,
639
+ status: RtkStatus::Existing,
640
+ }
641
+ );
642
+ }
643
+
644
+ #[test]
645
+ fn test_classify_npx_tsc() {
646
+ assert_eq!(
647
+ classify_command("npx tsc --noEmit"),
648
+ Classification::Supported {
649
+ rtk_equivalent: "rtk tsc",
650
+ category: "Build",
651
+ estimated_savings_pct: 83.0,
652
+ status: RtkStatus::Existing,
653
+ }
654
+ );
655
+ }
656
+
657
+ #[test]
658
+ fn test_classify_cat_file() {
659
+ assert_eq!(
660
+ classify_command("cat src/main.rs"),
661
+ Classification::Supported {
662
+ rtk_equivalent: "rtk read",
663
+ category: "Files",
664
+ estimated_savings_pct: 60.0,
665
+ status: RtkStatus::Existing,
666
+ }
667
+ );
668
+ }
669
+
670
+ #[test]
671
+ fn test_classify_cat_redirect_not_supported() {
672
+ // cat > file and cat >> file are writes, not reads — should not be classified as supported
673
+ let write_commands = [
674
+ "cat > /tmp/output.txt",
675
+ "cat >> /tmp/output.txt",
676
+ "cat file.txt > output.txt",
677
+ "cat -n file.txt >> log.txt",
678
+ "head -10 README.md > output.txt",
679
+ "tail -f app.log > /dev/null",
680
+ ];
681
+ for cmd in &write_commands {
682
+ match classify_command(cmd) {
683
+ Classification::Supported { .. } => {
684
+ panic!("{} should NOT be classified as Supported", cmd)
685
+ }
686
+ _ => {} // Unsupported or Ignored is fine
687
+ }
688
+ }
689
+ }
690
+
691
+ #[test]
692
+ fn test_classify_cd_ignored() {
693
+ assert_eq!(classify_command("cd /tmp"), Classification::Ignored);
694
+ }
695
+
696
+ #[test]
697
+ fn test_classify_rtk_already() {
698
+ assert_eq!(classify_command("rtk git status"), Classification::Ignored);
699
+ }
700
+
701
+ #[test]
702
+ fn test_classify_echo_ignored() {
703
+ assert_eq!(
704
+ classify_command("echo hello world"),
705
+ Classification::Ignored
706
+ );
707
+ }
708
+
709
+ #[test]
710
+ fn test_classify_htop_unsupported() {
711
+ match classify_command("htop -d 10") {
712
+ Classification::Unsupported { base_command } => {
713
+ assert_eq!(base_command, "htop");
714
+ }
715
+ other => panic!("expected Unsupported, got {:?}", other),
716
+ }
717
+ }
718
+
719
+ #[test]
720
+ fn test_classify_env_prefix_stripped() {
721
+ assert_eq!(
722
+ classify_command("GIT_SSH_COMMAND=ssh git push"),
723
+ Classification::Supported {
724
+ rtk_equivalent: "rtk git",
725
+ category: "Git",
726
+ estimated_savings_pct: 70.0,
727
+ status: RtkStatus::Existing,
728
+ }
729
+ );
730
+ }
731
+
732
+ #[test]
733
+ fn test_classify_sudo_stripped() {
734
+ assert_eq!(
735
+ classify_command("sudo docker ps"),
736
+ Classification::Supported {
737
+ rtk_equivalent: "rtk docker",
738
+ category: "Infra",
739
+ estimated_savings_pct: 85.0,
740
+ status: RtkStatus::Existing,
741
+ }
742
+ );
743
+ }
744
+
745
+ #[test]
746
+ fn test_classify_cargo_check() {
747
+ assert_eq!(
748
+ classify_command("cargo check"),
749
+ Classification::Supported {
750
+ rtk_equivalent: "rtk cargo",
751
+ category: "Cargo",
752
+ estimated_savings_pct: 80.0,
753
+ status: RtkStatus::Existing,
754
+ }
755
+ );
756
+ }
757
+
758
+ #[test]
759
+ fn test_classify_cargo_check_all_targets() {
760
+ assert_eq!(
761
+ classify_command("cargo check --all-targets"),
762
+ Classification::Supported {
763
+ rtk_equivalent: "rtk cargo",
764
+ category: "Cargo",
765
+ estimated_savings_pct: 80.0,
766
+ status: RtkStatus::Existing,
767
+ }
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn test_classify_cargo_fmt_passthrough() {
773
+ assert_eq!(
774
+ classify_command("cargo fmt"),
775
+ Classification::Supported {
776
+ rtk_equivalent: "rtk cargo",
777
+ category: "Cargo",
778
+ estimated_savings_pct: 80.0,
779
+ status: RtkStatus::Passthrough,
780
+ }
781
+ );
782
+ }
783
+
784
+ #[test]
785
+ fn test_classify_cargo_clippy_savings() {
786
+ assert_eq!(
787
+ classify_command("cargo clippy --all-targets"),
788
+ Classification::Supported {
789
+ rtk_equivalent: "rtk cargo",
790
+ category: "Cargo",
791
+ estimated_savings_pct: 80.0,
792
+ status: RtkStatus::Existing,
793
+ }
794
+ );
795
+ }
796
+
797
+ #[test]
798
+ fn test_patterns_rules_length_match() {
799
+ assert_eq!(
800
+ PATTERNS.len(),
801
+ RULES.len(),
802
+ "PATTERNS and RULES must be aligned"
803
+ );
804
+ }
805
+
806
+ #[test]
807
+ fn test_registry_covers_all_cargo_subcommands() {
808
+ // Verify that every CargoCommand variant (Build, Test, Clippy, Check, Fmt)
809
+ // except Other has a matching pattern in the registry
810
+ for subcmd in ["build", "test", "clippy", "check", "fmt"] {
811
+ let cmd = format!("cargo {subcmd}");
812
+ match classify_command(&cmd) {
813
+ Classification::Supported { .. } => {}
814
+ other => panic!("cargo {subcmd} should be Supported, got {other:?}"),
815
+ }
816
+ }
817
+ }
818
+
819
+ #[test]
820
+ fn test_registry_covers_all_git_subcommands() {
821
+ // Verify that every GitCommand subcommand has a matching pattern
822
+ for subcmd in [
823
+ "status", "log", "diff", "show", "add", "commit", "push", "pull", "branch", "fetch",
824
+ "stash", "worktree",
825
+ ] {
826
+ let cmd = format!("git {subcmd}");
827
+ match classify_command(&cmd) {
828
+ Classification::Supported { .. } => {}
829
+ other => panic!("git {subcmd} should be Supported, got {other:?}"),
830
+ }
831
+ }
832
+ }
833
+
834
+ #[test]
835
+ fn test_classify_find_not_blocked_by_fi() {
836
+ // Regression: "fi" in IGNORED_PREFIXES used to shadow "find" commands
837
+ // because "find".starts_with("fi") is true. "fi" should only match exactly.
838
+ assert_eq!(
839
+ classify_command("find . -name foo"),
840
+ Classification::Supported {
841
+ rtk_equivalent: "rtk find",
842
+ category: "Files",
843
+ estimated_savings_pct: 70.0,
844
+ status: RtkStatus::Existing,
845
+ }
846
+ );
847
+ }
848
+
849
+ #[test]
850
+ fn test_fi_still_ignored_exact() {
851
+ // Bare "fi" (shell keyword) should still be ignored
852
+ assert_eq!(classify_command("fi"), Classification::Ignored);
853
+ }
854
+
855
+ #[test]
856
+ fn test_done_still_ignored_exact() {
857
+ // Bare "done" (shell keyword) should still be ignored
858
+ assert_eq!(classify_command("done"), Classification::Ignored);
859
+ }
860
+
861
+ #[test]
862
+ fn test_split_chain_and() {
863
+ assert_eq!(split_command_chain("a && b"), vec!["a", "b"]);
864
+ }
865
+
866
+ #[test]
867
+ fn test_split_chain_semicolon() {
868
+ assert_eq!(split_command_chain("a ; b"), vec!["a", "b"]);
869
+ }
870
+
871
+ #[test]
872
+ fn test_split_pipe_first_only() {
873
+ assert_eq!(split_command_chain("a | b"), vec!["a"]);
874
+ }
875
+
876
+ #[test]
877
+ fn test_split_single() {
878
+ assert_eq!(split_command_chain("git status"), vec!["git status"]);
879
+ }
880
+
881
+ #[test]
882
+ fn test_split_quoted_and() {
883
+ assert_eq!(
884
+ split_command_chain(r#"echo "a && b""#),
885
+ vec![r#"echo "a && b""#]
886
+ );
887
+ }
888
+
889
+ #[test]
890
+ fn test_split_heredoc_no_split() {
891
+ let cmd = "cat <<'EOF'\nhello && world\nEOF";
892
+ assert_eq!(split_command_chain(cmd), vec![cmd]);
893
+ }
894
+
895
+ #[test]
896
+ fn test_classify_mypy() {
897
+ assert_eq!(
898
+ classify_command("mypy src/"),
899
+ Classification::Supported {
900
+ rtk_equivalent: "rtk mypy",
901
+ category: "Build",
902
+ estimated_savings_pct: 80.0,
903
+ status: RtkStatus::Existing,
904
+ }
905
+ );
906
+ }
907
+
908
+ #[test]
909
+ fn test_classify_python_m_mypy() {
910
+ assert_eq!(
911
+ classify_command("python3 -m mypy --strict"),
912
+ Classification::Supported {
913
+ rtk_equivalent: "rtk mypy",
914
+ category: "Build",
915
+ estimated_savings_pct: 80.0,
916
+ status: RtkStatus::Existing,
917
+ }
918
+ );
919
+ }
920
+
921
+ // --- rewrite_command tests ---
922
+
923
+ #[test]
924
+ fn test_rewrite_git_status() {
925
+ assert_eq!(
926
+ rewrite_command("git status", &[]),
927
+ Some("rtk git status".into())
928
+ );
929
+ }
930
+
931
+ #[test]
932
+ fn test_rewrite_git_log() {
933
+ assert_eq!(
934
+ rewrite_command("git log -10", &[]),
935
+ Some("rtk git log -10".into())
936
+ );
937
+ }
938
+
939
+ #[test]
940
+ fn test_rewrite_cargo_test() {
941
+ assert_eq!(
942
+ rewrite_command("cargo test", &[]),
943
+ Some("rtk cargo test".into())
944
+ );
945
+ }
946
+
947
+ #[test]
948
+ fn test_rewrite_compound_and() {
949
+ assert_eq!(
950
+ rewrite_command("git add . && cargo test", &[]),
951
+ Some("rtk git add . && rtk cargo test".into())
952
+ );
953
+ }
954
+
955
+ #[test]
956
+ fn test_rewrite_compound_three_segments() {
957
+ assert_eq!(
958
+ rewrite_command(
959
+ "cargo fmt --all && cargo clippy --all-targets && cargo test",
960
+ &[]
961
+ ),
962
+ Some("rtk cargo fmt --all && rtk cargo clippy --all-targets && rtk cargo test".into())
963
+ );
964
+ }
965
+
966
+ #[test]
967
+ fn test_rewrite_already_rtk() {
968
+ assert_eq!(
969
+ rewrite_command("rtk git status", &[]),
970
+ Some("rtk git status".into())
971
+ );
972
+ }
973
+
974
+ #[test]
975
+ fn test_rewrite_background_single_amp() {
976
+ assert_eq!(
977
+ rewrite_command("cargo test & git status", &[]),
978
+ Some("rtk cargo test & rtk git status".into())
979
+ );
980
+ }
981
+
982
+ #[test]
983
+ fn test_rewrite_background_unsupported_right() {
984
+ assert_eq!(
985
+ rewrite_command("cargo test & htop", &[]),
986
+ Some("rtk cargo test & htop".into())
987
+ );
988
+ }
989
+
990
+ #[test]
991
+ fn test_rewrite_background_does_not_affect_double_amp() {
992
+ // `&&` must still work after adding `&` support
993
+ assert_eq!(
994
+ rewrite_command("cargo test && git status", &[]),
995
+ Some("rtk cargo test && rtk git status".into())
996
+ );
997
+ }
998
+
999
+ #[test]
1000
+ fn test_rewrite_unsupported_returns_none() {
1001
+ assert_eq!(rewrite_command("htop", &[]), None);
1002
+ }
1003
+
1004
+ #[test]
1005
+ fn test_rewrite_ignored_cd() {
1006
+ assert_eq!(rewrite_command("cd /tmp", &[]), None);
1007
+ }
1008
+
1009
+ #[test]
1010
+ fn test_rewrite_with_env_prefix() {
1011
+ assert_eq!(
1012
+ rewrite_command("GIT_SSH_COMMAND=ssh git push", &[]),
1013
+ Some("GIT_SSH_COMMAND=ssh rtk git push".into())
1014
+ );
1015
+ }
1016
+
1017
+ #[test]
1018
+ fn test_rewrite_npx_tsc() {
1019
+ assert_eq!(
1020
+ rewrite_command("npx tsc --noEmit", &[]),
1021
+ Some("rtk tsc --noEmit".into())
1022
+ );
1023
+ }
1024
+
1025
+ #[test]
1026
+ fn test_rewrite_pnpm_tsc() {
1027
+ assert_eq!(
1028
+ rewrite_command("pnpm tsc --noEmit", &[]),
1029
+ Some("rtk tsc --noEmit".into())
1030
+ );
1031
+ }
1032
+
1033
+ #[test]
1034
+ fn test_rewrite_cat_file() {
1035
+ assert_eq!(
1036
+ rewrite_command("cat src/main.rs", &[]),
1037
+ Some("rtk read src/main.rs".into())
1038
+ );
1039
+ }
1040
+
1041
+ #[test]
1042
+ fn test_rewrite_rg_pattern() {
1043
+ assert_eq!(
1044
+ rewrite_command("rg \"fn main\"", &[]),
1045
+ Some("rtk grep \"fn main\"".into())
1046
+ );
1047
+ }
1048
+
1049
+ #[test]
1050
+ fn test_rewrite_npx_playwright() {
1051
+ assert_eq!(
1052
+ rewrite_command("npx playwright test", &[]),
1053
+ Some("rtk playwright test".into())
1054
+ );
1055
+ }
1056
+
1057
+ #[test]
1058
+ fn test_rewrite_next_build() {
1059
+ assert_eq!(
1060
+ rewrite_command("next build --turbo", &[]),
1061
+ Some("rtk next --turbo".into())
1062
+ );
1063
+ }
1064
+
1065
+ #[test]
1066
+ fn test_rewrite_pipe_first_only() {
1067
+ // After a pipe, the filter command stays raw
1068
+ assert_eq!(
1069
+ rewrite_command("git log -10 | grep feat", &[]),
1070
+ Some("rtk git log -10 | grep feat".into())
1071
+ );
1072
+ }
1073
+
1074
+ #[test]
1075
+ fn test_rewrite_heredoc_returns_none() {
1076
+ assert_eq!(rewrite_command("cat <<'EOF'\nfoo\nEOF", &[]), None);
1077
+ }
1078
+
1079
+ #[test]
1080
+ fn test_rewrite_empty_returns_none() {
1081
+ assert_eq!(rewrite_command("", &[]), None);
1082
+ assert_eq!(rewrite_command(" ", &[]), None);
1083
+ }
1084
+
1085
+ #[test]
1086
+ fn test_rewrite_mixed_compound_partial() {
1087
+ // First segment already RTK, second gets rewritten
1088
+ assert_eq!(
1089
+ rewrite_command("rtk git add . && cargo test", &[]),
1090
+ Some("rtk git add . && rtk cargo test".into())
1091
+ );
1092
+ }
1093
+
1094
+ // --- #345: RTK_DISABLED ---
1095
+
1096
+ #[test]
1097
+ fn test_rewrite_rtk_disabled_curl() {
1098
+ assert_eq!(
1099
+ rewrite_command("RTK_DISABLED=1 curl https://example.com", &[]),
1100
+ None
1101
+ );
1102
+ }
1103
+
1104
+ #[test]
1105
+ fn test_rewrite_rtk_disabled_git_status() {
1106
+ assert_eq!(rewrite_command("RTK_DISABLED=1 git status", &[]), None);
1107
+ }
1108
+
1109
+ #[test]
1110
+ fn test_rewrite_rtk_disabled_multi_env() {
1111
+ assert_eq!(
1112
+ rewrite_command("FOO=1 RTK_DISABLED=1 git status", &[]),
1113
+ None
1114
+ );
1115
+ }
1116
+
1117
+ #[test]
1118
+ fn test_rewrite_non_rtk_disabled_env_still_rewrites() {
1119
+ assert_eq!(
1120
+ rewrite_command("SOME_VAR=1 git status", &[]),
1121
+ Some("SOME_VAR=1 rtk git status".into())
1122
+ );
1123
+ }
1124
+
1125
+ // --- #346: 2>&1 and &> redirect detection ---
1126
+
1127
+ #[test]
1128
+ fn test_rewrite_redirect_2_gt_amp_1_with_pipe() {
1129
+ assert_eq!(
1130
+ rewrite_command("cargo test 2>&1 | head", &[]),
1131
+ Some("rtk cargo test 2>&1 | head".into())
1132
+ );
1133
+ }
1134
+
1135
+ #[test]
1136
+ fn test_rewrite_redirect_2_gt_amp_1_trailing() {
1137
+ assert_eq!(
1138
+ rewrite_command("cargo test 2>&1", &[]),
1139
+ Some("rtk cargo test 2>&1".into())
1140
+ );
1141
+ }
1142
+
1143
+ #[test]
1144
+ fn test_rewrite_redirect_plain_2_devnull() {
1145
+ // 2>/dev/null has no `&`, never broken — non-regression
1146
+ assert_eq!(
1147
+ rewrite_command("git status 2>/dev/null", &[]),
1148
+ Some("rtk git status 2>/dev/null".into())
1149
+ );
1150
+ }
1151
+
1152
+ #[test]
1153
+ fn test_rewrite_redirect_2_gt_amp_1_with_and() {
1154
+ assert_eq!(
1155
+ rewrite_command("cargo test 2>&1 && echo done", &[]),
1156
+ Some("rtk cargo test 2>&1 && echo done".into())
1157
+ );
1158
+ }
1159
+
1160
+ #[test]
1161
+ fn test_rewrite_redirect_amp_gt_devnull() {
1162
+ assert_eq!(
1163
+ rewrite_command("cargo test &>/dev/null", &[]),
1164
+ Some("rtk cargo test &>/dev/null".into())
1165
+ );
1166
+ }
1167
+
1168
+ #[test]
1169
+ fn test_rewrite_background_amp_non_regression() {
1170
+ // background `&` must still work after redirect fix
1171
+ assert_eq!(
1172
+ rewrite_command("cargo test & git status", &[]),
1173
+ Some("rtk cargo test & rtk git status".into())
1174
+ );
1175
+ }
1176
+
1177
+ // --- P0.2: head -N rewrite ---
1178
+
1179
+ #[test]
1180
+ fn test_rewrite_head_numeric_flag() {
1181
+ // head -20 file → rtk read file --max-lines 20 (not rtk read -20 file)
1182
+ assert_eq!(
1183
+ rewrite_command("head -20 src/main.rs", &[]),
1184
+ Some("rtk read src/main.rs --max-lines 20".into())
1185
+ );
1186
+ }
1187
+
1188
+ #[test]
1189
+ fn test_rewrite_head_lines_long_flag() {
1190
+ assert_eq!(
1191
+ rewrite_command("head --lines=50 src/lib.rs", &[]),
1192
+ Some("rtk read src/lib.rs --max-lines 50".into())
1193
+ );
1194
+ }
1195
+
1196
+ #[test]
1197
+ fn test_rewrite_head_no_flag_still_rewrites() {
1198
+ // plain `head file` → `rtk read file` (no numeric flag)
1199
+ assert_eq!(
1200
+ rewrite_command("head src/main.rs", &[]),
1201
+ Some("rtk read src/main.rs".into())
1202
+ );
1203
+ }
1204
+
1205
+ #[test]
1206
+ fn test_rewrite_head_other_flag_skipped() {
1207
+ // head -c 100 file: unsupported flag, skip rewriting
1208
+ assert_eq!(rewrite_command("head -c 100 src/main.rs", &[]), None);
1209
+ }
1210
+
1211
+ #[test]
1212
+ fn test_rewrite_tail_numeric_flag() {
1213
+ assert_eq!(
1214
+ rewrite_command("tail -20 src/main.rs", &[]),
1215
+ Some("rtk read src/main.rs --tail-lines 20".into())
1216
+ );
1217
+ }
1218
+
1219
+ #[test]
1220
+ fn test_rewrite_tail_n_space_flag() {
1221
+ assert_eq!(
1222
+ rewrite_command("tail -n 12 src/lib.rs", &[]),
1223
+ Some("rtk read src/lib.rs --tail-lines 12".into())
1224
+ );
1225
+ }
1226
+
1227
+ #[test]
1228
+ fn test_rewrite_tail_lines_long_flag() {
1229
+ assert_eq!(
1230
+ rewrite_command("tail --lines=7 src/lib.rs", &[]),
1231
+ Some("rtk read src/lib.rs --tail-lines 7".into())
1232
+ );
1233
+ }
1234
+
1235
+ #[test]
1236
+ fn test_rewrite_tail_lines_space_flag() {
1237
+ assert_eq!(
1238
+ rewrite_command("tail --lines 7 src/lib.rs", &[]),
1239
+ Some("rtk read src/lib.rs --tail-lines 7".into())
1240
+ );
1241
+ }
1242
+
1243
+ #[test]
1244
+ fn test_rewrite_tail_other_flag_skipped() {
1245
+ assert_eq!(rewrite_command("tail -c 100 src/main.rs", &[]), None);
1246
+ }
1247
+
1248
+ #[test]
1249
+ fn test_rewrite_tail_plain_file_skipped() {
1250
+ assert_eq!(rewrite_command("tail src/main.rs", &[]), None);
1251
+ }
1252
+
1253
+ // --- New registry entries ---
1254
+
1255
+ #[test]
1256
+ fn test_classify_gh_release() {
1257
+ assert!(matches!(
1258
+ classify_command("gh release list"),
1259
+ Classification::Supported {
1260
+ rtk_equivalent: "rtk gh",
1261
+ ..
1262
+ }
1263
+ ));
1264
+ }
1265
+
1266
+ #[test]
1267
+ fn test_classify_cargo_install() {
1268
+ assert!(matches!(
1269
+ classify_command("cargo install rtk"),
1270
+ Classification::Supported {
1271
+ rtk_equivalent: "rtk cargo",
1272
+ ..
1273
+ }
1274
+ ));
1275
+ }
1276
+
1277
+ #[test]
1278
+ fn test_classify_docker_run() {
1279
+ assert!(matches!(
1280
+ classify_command("docker run --rm ubuntu bash"),
1281
+ Classification::Supported {
1282
+ rtk_equivalent: "rtk docker",
1283
+ ..
1284
+ }
1285
+ ));
1286
+ }
1287
+
1288
+ #[test]
1289
+ fn test_classify_docker_exec() {
1290
+ assert!(matches!(
1291
+ classify_command("docker exec -it mycontainer bash"),
1292
+ Classification::Supported {
1293
+ rtk_equivalent: "rtk docker",
1294
+ ..
1295
+ }
1296
+ ));
1297
+ }
1298
+
1299
+ #[test]
1300
+ fn test_classify_docker_build() {
1301
+ assert!(matches!(
1302
+ classify_command("docker build -t myimage ."),
1303
+ Classification::Supported {
1304
+ rtk_equivalent: "rtk docker",
1305
+ ..
1306
+ }
1307
+ ));
1308
+ }
1309
+
1310
+ #[test]
1311
+ fn test_classify_kubectl_describe() {
1312
+ assert!(matches!(
1313
+ classify_command("kubectl describe pod mypod"),
1314
+ Classification::Supported {
1315
+ rtk_equivalent: "rtk kubectl",
1316
+ ..
1317
+ }
1318
+ ));
1319
+ }
1320
+
1321
+ #[test]
1322
+ fn test_classify_kubectl_apply() {
1323
+ assert!(matches!(
1324
+ classify_command("kubectl apply -f deploy.yaml"),
1325
+ Classification::Supported {
1326
+ rtk_equivalent: "rtk kubectl",
1327
+ ..
1328
+ }
1329
+ ));
1330
+ }
1331
+
1332
+ #[test]
1333
+ fn test_classify_tree() {
1334
+ assert!(matches!(
1335
+ classify_command("tree src/"),
1336
+ Classification::Supported {
1337
+ rtk_equivalent: "rtk tree",
1338
+ ..
1339
+ }
1340
+ ));
1341
+ }
1342
+
1343
+ #[test]
1344
+ fn test_classify_diff() {
1345
+ assert!(matches!(
1346
+ classify_command("diff file1.txt file2.txt"),
1347
+ Classification::Supported {
1348
+ rtk_equivalent: "rtk diff",
1349
+ ..
1350
+ }
1351
+ ));
1352
+ }
1353
+
1354
+ #[test]
1355
+ fn test_rewrite_tree() {
1356
+ assert_eq!(
1357
+ rewrite_command("tree src/", &[]),
1358
+ Some("rtk tree src/".into())
1359
+ );
1360
+ }
1361
+
1362
+ #[test]
1363
+ fn test_rewrite_diff() {
1364
+ assert_eq!(
1365
+ rewrite_command("diff file1.txt file2.txt", &[]),
1366
+ Some("rtk diff file1.txt file2.txt".into())
1367
+ );
1368
+ }
1369
+
1370
+ #[test]
1371
+ fn test_rewrite_gh_release() {
1372
+ assert_eq!(
1373
+ rewrite_command("gh release list", &[]),
1374
+ Some("rtk gh release list".into())
1375
+ );
1376
+ }
1377
+
1378
+ #[test]
1379
+ fn test_rewrite_cargo_install() {
1380
+ assert_eq!(
1381
+ rewrite_command("cargo install rtk", &[]),
1382
+ Some("rtk cargo install rtk".into())
1383
+ );
1384
+ }
1385
+
1386
+ #[test]
1387
+ fn test_rewrite_kubectl_describe() {
1388
+ assert_eq!(
1389
+ rewrite_command("kubectl describe pod mypod", &[]),
1390
+ Some("rtk kubectl describe pod mypod".into())
1391
+ );
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn test_rewrite_docker_run() {
1396
+ assert_eq!(
1397
+ rewrite_command("docker run --rm ubuntu bash", &[]),
1398
+ Some("rtk docker run --rm ubuntu bash".into())
1399
+ );
1400
+ }
1401
+
1402
+ // --- #336: docker compose supported subcommands rewritten, unsupported skipped ---
1403
+
1404
+ #[test]
1405
+ fn test_rewrite_docker_compose_ps() {
1406
+ assert_eq!(
1407
+ rewrite_command("docker compose ps", &[]),
1408
+ Some("rtk docker compose ps".into())
1409
+ );
1410
+ }
1411
+
1412
+ #[test]
1413
+ fn test_rewrite_docker_compose_logs() {
1414
+ assert_eq!(
1415
+ rewrite_command("docker compose logs web", &[]),
1416
+ Some("rtk docker compose logs web".into())
1417
+ );
1418
+ }
1419
+
1420
+ #[test]
1421
+ fn test_rewrite_docker_compose_build() {
1422
+ assert_eq!(
1423
+ rewrite_command("docker compose build", &[]),
1424
+ Some("rtk docker compose build".into())
1425
+ );
1426
+ }
1427
+
1428
+ #[test]
1429
+ fn test_rewrite_docker_compose_up_skipped() {
1430
+ assert_eq!(rewrite_command("docker compose up -d", &[]), None);
1431
+ }
1432
+
1433
+ #[test]
1434
+ fn test_rewrite_docker_compose_down_skipped() {
1435
+ assert_eq!(rewrite_command("docker compose down", &[]), None);
1436
+ }
1437
+
1438
+ #[test]
1439
+ fn test_rewrite_docker_compose_config_skipped() {
1440
+ assert_eq!(
1441
+ rewrite_command("docker compose -f foo.yaml config --services", &[]),
1442
+ None
1443
+ );
1444
+ }
1445
+
1446
+ // --- AWS / psql (PR #216) ---
1447
+
1448
+ #[test]
1449
+ fn test_classify_aws() {
1450
+ assert!(matches!(
1451
+ classify_command("aws s3 ls"),
1452
+ Classification::Supported {
1453
+ rtk_equivalent: "rtk aws",
1454
+ ..
1455
+ }
1456
+ ));
1457
+ }
1458
+
1459
+ #[test]
1460
+ fn test_classify_aws_ec2() {
1461
+ assert!(matches!(
1462
+ classify_command("aws ec2 describe-instances"),
1463
+ Classification::Supported {
1464
+ rtk_equivalent: "rtk aws",
1465
+ ..
1466
+ }
1467
+ ));
1468
+ }
1469
+
1470
+ #[test]
1471
+ fn test_classify_psql() {
1472
+ assert!(matches!(
1473
+ classify_command("psql -U postgres"),
1474
+ Classification::Supported {
1475
+ rtk_equivalent: "rtk psql",
1476
+ ..
1477
+ }
1478
+ ));
1479
+ }
1480
+
1481
+ #[test]
1482
+ fn test_classify_psql_url() {
1483
+ assert!(matches!(
1484
+ classify_command("psql postgres://localhost/mydb"),
1485
+ Classification::Supported {
1486
+ rtk_equivalent: "rtk psql",
1487
+ ..
1488
+ }
1489
+ ));
1490
+ }
1491
+
1492
+ #[test]
1493
+ fn test_rewrite_aws() {
1494
+ assert_eq!(
1495
+ rewrite_command("aws s3 ls", &[]),
1496
+ Some("rtk aws s3 ls".into())
1497
+ );
1498
+ }
1499
+
1500
+ #[test]
1501
+ fn test_rewrite_aws_ec2() {
1502
+ assert_eq!(
1503
+ rewrite_command("aws ec2 describe-instances --region us-east-1", &[]),
1504
+ Some("rtk aws ec2 describe-instances --region us-east-1".into())
1505
+ );
1506
+ }
1507
+
1508
+ #[test]
1509
+ fn test_rewrite_psql() {
1510
+ assert_eq!(
1511
+ rewrite_command("psql -U postgres -d mydb", &[]),
1512
+ Some("rtk psql -U postgres -d mydb".into())
1513
+ );
1514
+ }
1515
+
1516
+ // --- Python tooling ---
1517
+
1518
+ #[test]
1519
+ fn test_classify_ruff_check() {
1520
+ assert!(matches!(
1521
+ classify_command("ruff check ."),
1522
+ Classification::Supported {
1523
+ rtk_equivalent: "rtk ruff",
1524
+ ..
1525
+ }
1526
+ ));
1527
+ }
1528
+
1529
+ #[test]
1530
+ fn test_classify_ruff_format() {
1531
+ assert!(matches!(
1532
+ classify_command("ruff format src/"),
1533
+ Classification::Supported {
1534
+ rtk_equivalent: "rtk ruff",
1535
+ ..
1536
+ }
1537
+ ));
1538
+ }
1539
+
1540
+ #[test]
1541
+ fn test_classify_pytest() {
1542
+ assert!(matches!(
1543
+ classify_command("pytest tests/"),
1544
+ Classification::Supported {
1545
+ rtk_equivalent: "rtk pytest",
1546
+ ..
1547
+ }
1548
+ ));
1549
+ }
1550
+
1551
+ #[test]
1552
+ fn test_classify_python_m_pytest() {
1553
+ assert!(matches!(
1554
+ classify_command("python -m pytest tests/"),
1555
+ Classification::Supported {
1556
+ rtk_equivalent: "rtk pytest",
1557
+ ..
1558
+ }
1559
+ ));
1560
+ }
1561
+
1562
+ #[test]
1563
+ fn test_classify_pip_list() {
1564
+ assert!(matches!(
1565
+ classify_command("pip list"),
1566
+ Classification::Supported {
1567
+ rtk_equivalent: "rtk pip",
1568
+ ..
1569
+ }
1570
+ ));
1571
+ }
1572
+
1573
+ #[test]
1574
+ fn test_classify_uv_pip_list() {
1575
+ assert!(matches!(
1576
+ classify_command("uv pip list"),
1577
+ Classification::Supported {
1578
+ rtk_equivalent: "rtk pip",
1579
+ ..
1580
+ }
1581
+ ));
1582
+ }
1583
+
1584
+ #[test]
1585
+ fn test_rewrite_ruff_check() {
1586
+ assert_eq!(
1587
+ rewrite_command("ruff check .", &[]),
1588
+ Some("rtk ruff check .".into())
1589
+ );
1590
+ }
1591
+
1592
+ #[test]
1593
+ fn test_rewrite_ruff_format() {
1594
+ assert_eq!(
1595
+ rewrite_command("ruff format src/", &[]),
1596
+ Some("rtk ruff format src/".into())
1597
+ );
1598
+ }
1599
+
1600
+ #[test]
1601
+ fn test_rewrite_pytest() {
1602
+ assert_eq!(
1603
+ rewrite_command("pytest tests/", &[]),
1604
+ Some("rtk pytest tests/".into())
1605
+ );
1606
+ }
1607
+
1608
+ #[test]
1609
+ fn test_rewrite_python_m_pytest() {
1610
+ assert_eq!(
1611
+ rewrite_command("python -m pytest -x tests/", &[]),
1612
+ Some("rtk pytest -x tests/".into())
1613
+ );
1614
+ }
1615
+
1616
+ #[test]
1617
+ fn test_rewrite_pip_list() {
1618
+ assert_eq!(
1619
+ rewrite_command("pip list", &[]),
1620
+ Some("rtk pip list".into())
1621
+ );
1622
+ }
1623
+
1624
+ #[test]
1625
+ fn test_rewrite_pip_outdated() {
1626
+ assert_eq!(
1627
+ rewrite_command("pip outdated", &[]),
1628
+ Some("rtk pip outdated".into())
1629
+ );
1630
+ }
1631
+
1632
+ #[test]
1633
+ fn test_rewrite_uv_pip_list() {
1634
+ assert_eq!(
1635
+ rewrite_command("uv pip list", &[]),
1636
+ Some("rtk pip list".into())
1637
+ );
1638
+ }
1639
+
1640
+ // --- Go tooling ---
1641
+
1642
+ #[test]
1643
+ fn test_classify_go_test() {
1644
+ assert!(matches!(
1645
+ classify_command("go test ./..."),
1646
+ Classification::Supported {
1647
+ rtk_equivalent: "rtk go",
1648
+ ..
1649
+ }
1650
+ ));
1651
+ }
1652
+
1653
+ #[test]
1654
+ fn test_classify_go_build() {
1655
+ assert!(matches!(
1656
+ classify_command("go build ./..."),
1657
+ Classification::Supported {
1658
+ rtk_equivalent: "rtk go",
1659
+ ..
1660
+ }
1661
+ ));
1662
+ }
1663
+
1664
+ #[test]
1665
+ fn test_classify_go_vet() {
1666
+ assert!(matches!(
1667
+ classify_command("go vet ./..."),
1668
+ Classification::Supported {
1669
+ rtk_equivalent: "rtk go",
1670
+ ..
1671
+ }
1672
+ ));
1673
+ }
1674
+
1675
+ #[test]
1676
+ fn test_classify_golangci_lint() {
1677
+ assert!(matches!(
1678
+ classify_command("golangci-lint run"),
1679
+ Classification::Supported {
1680
+ rtk_equivalent: "rtk golangci-lint",
1681
+ ..
1682
+ }
1683
+ ));
1684
+ }
1685
+
1686
+ #[test]
1687
+ fn test_rewrite_go_test() {
1688
+ assert_eq!(
1689
+ rewrite_command("go test ./...", &[]),
1690
+ Some("rtk go test ./...".into())
1691
+ );
1692
+ }
1693
+
1694
+ #[test]
1695
+ fn test_rewrite_go_build() {
1696
+ assert_eq!(
1697
+ rewrite_command("go build ./...", &[]),
1698
+ Some("rtk go build ./...".into())
1699
+ );
1700
+ }
1701
+
1702
+ #[test]
1703
+ fn test_rewrite_go_vet() {
1704
+ assert_eq!(
1705
+ rewrite_command("go vet ./...", &[]),
1706
+ Some("rtk go vet ./...".into())
1707
+ );
1708
+ }
1709
+
1710
+ #[test]
1711
+ fn test_rewrite_golangci_lint() {
1712
+ assert_eq!(
1713
+ rewrite_command("golangci-lint run ./...", &[]),
1714
+ Some("rtk golangci-lint run ./...".into())
1715
+ );
1716
+ }
1717
+
1718
+ // --- JS/TS tooling ---
1719
+
1720
+ #[test]
1721
+ fn test_classify_vitest() {
1722
+ assert!(matches!(
1723
+ classify_command("vitest run"),
1724
+ Classification::Supported {
1725
+ rtk_equivalent: "rtk vitest",
1726
+ ..
1727
+ }
1728
+ ));
1729
+ }
1730
+
1731
+ #[test]
1732
+ fn test_rewrite_vitest() {
1733
+ assert_eq!(
1734
+ rewrite_command("vitest run", &[]),
1735
+ Some("rtk vitest run".into())
1736
+ );
1737
+ }
1738
+
1739
+ #[test]
1740
+ fn test_rewrite_pnpm_vitest() {
1741
+ assert_eq!(
1742
+ rewrite_command("pnpm vitest run", &[]),
1743
+ Some("rtk vitest run".into())
1744
+ );
1745
+ }
1746
+
1747
+ #[test]
1748
+ fn test_classify_prisma() {
1749
+ assert!(matches!(
1750
+ classify_command("npx prisma migrate dev"),
1751
+ Classification::Supported {
1752
+ rtk_equivalent: "rtk prisma",
1753
+ ..
1754
+ }
1755
+ ));
1756
+ }
1757
+
1758
+ #[test]
1759
+ fn test_rewrite_prisma() {
1760
+ assert_eq!(
1761
+ rewrite_command("npx prisma migrate dev", &[]),
1762
+ Some("rtk prisma migrate dev".into())
1763
+ );
1764
+ }
1765
+
1766
+ #[test]
1767
+ fn test_rewrite_prettier() {
1768
+ assert_eq!(
1769
+ rewrite_command("npx prettier --check src/", &[]),
1770
+ Some("rtk prettier --check src/".into())
1771
+ );
1772
+ }
1773
+
1774
+ #[test]
1775
+ fn test_rewrite_pnpm_list() {
1776
+ assert_eq!(
1777
+ rewrite_command("pnpm list", &[]),
1778
+ Some("rtk pnpm list".into())
1779
+ );
1780
+ }
1781
+
1782
+ // --- Compound operator edge cases ---
1783
+
1784
+ #[test]
1785
+ fn test_rewrite_compound_or() {
1786
+ // `||` fallback: left rewritten, right rewritten
1787
+ assert_eq!(
1788
+ rewrite_command("cargo test || cargo build", &[]),
1789
+ Some("rtk cargo test || rtk cargo build".into())
1790
+ );
1791
+ }
1792
+
1793
+ #[test]
1794
+ fn test_rewrite_compound_semicolon() {
1795
+ assert_eq!(
1796
+ rewrite_command("git status; cargo test", &[]),
1797
+ Some("rtk git status; rtk cargo test".into())
1798
+ );
1799
+ }
1800
+
1801
+ #[test]
1802
+ fn test_rewrite_compound_pipe_raw_filter() {
1803
+ // Pipe: rewrite first segment only, pass through rest unchanged
1804
+ assert_eq!(
1805
+ rewrite_command("cargo test | grep FAILED", &[]),
1806
+ Some("rtk cargo test | grep FAILED".into())
1807
+ );
1808
+ }
1809
+
1810
+ #[test]
1811
+ fn test_rewrite_compound_pipe_git_grep() {
1812
+ assert_eq!(
1813
+ rewrite_command("git log -10 | grep feat", &[]),
1814
+ Some("rtk git log -10 | grep feat".into())
1815
+ );
1816
+ }
1817
+
1818
+ #[test]
1819
+ fn test_rewrite_compound_four_segments() {
1820
+ assert_eq!(
1821
+ rewrite_command(
1822
+ "cargo fmt --all && cargo clippy && cargo test && git status",
1823
+ &[]
1824
+ ),
1825
+ Some(
1826
+ "rtk cargo fmt --all && rtk cargo clippy && rtk cargo test && rtk git status"
1827
+ .into()
1828
+ )
1829
+ );
1830
+ }
1831
+
1832
+ #[test]
1833
+ fn test_rewrite_compound_mixed_supported_unsupported() {
1834
+ // unsupported segments stay raw
1835
+ assert_eq!(
1836
+ rewrite_command("cargo test && htop", &[]),
1837
+ Some("rtk cargo test && htop".into())
1838
+ );
1839
+ }
1840
+
1841
+ #[test]
1842
+ fn test_rewrite_compound_all_unsupported_returns_none() {
1843
+ // No rewrite at all: returns None
1844
+ assert_eq!(rewrite_command("htop && top", &[]), None);
1845
+ }
1846
+
1847
+ // --- sudo / env prefix + rewrite ---
1848
+
1849
+ #[test]
1850
+ fn test_rewrite_sudo_docker() {
1851
+ assert_eq!(
1852
+ rewrite_command("sudo docker ps", &[]),
1853
+ Some("sudo rtk docker ps".into())
1854
+ );
1855
+ }
1856
+
1857
+ #[test]
1858
+ fn test_rewrite_env_var_prefix() {
1859
+ assert_eq!(
1860
+ rewrite_command("GIT_SSH_COMMAND=ssh git push origin main", &[]),
1861
+ Some("GIT_SSH_COMMAND=ssh rtk git push origin main".into())
1862
+ );
1863
+ }
1864
+
1865
+ // --- find with native flags ---
1866
+
1867
+ #[test]
1868
+ fn test_rewrite_find_with_flags() {
1869
+ assert_eq!(
1870
+ rewrite_command("find . -name '*.rs' -type f", &[]),
1871
+ Some("rtk find . -name '*.rs' -type f".into())
1872
+ );
1873
+ }
1874
+
1875
+ // --- Ensure PATTERNS and RULES stay aligned after modifications ---
1876
+
1877
+ #[test]
1878
+ fn test_patterns_rules_aligned_after_aws_psql() {
1879
+ // If this fails, someone added a PATTERN without a matching RULE (or vice versa)
1880
+ assert_eq!(
1881
+ PATTERNS.len(),
1882
+ RULES.len(),
1883
+ "PATTERNS[{}] != RULES[{}] — they must stay 1:1",
1884
+ PATTERNS.len(),
1885
+ RULES.len()
1886
+ );
1887
+ }
1888
+
1889
+ // --- All RULES have non-empty rtk_cmd and at least one rewrite_prefix ---
1890
+
1891
+ #[test]
1892
+ fn test_all_rules_have_valid_rtk_cmd() {
1893
+ for rule in RULES {
1894
+ assert!(!rule.rtk_cmd.is_empty(), "Rule with empty rtk_cmd found");
1895
+ assert!(
1896
+ rule.rtk_cmd.starts_with("rtk "),
1897
+ "rtk_cmd '{}' must start with 'rtk '",
1898
+ rule.rtk_cmd
1899
+ );
1900
+ assert!(
1901
+ !rule.rewrite_prefixes.is_empty(),
1902
+ "Rule '{}' has no rewrite_prefixes",
1903
+ rule.rtk_cmd
1904
+ );
1905
+ }
1906
+ }
1907
+
1908
+ // --- exclude_commands (#243) ---
1909
+
1910
+ #[test]
1911
+ fn test_rewrite_excludes_curl() {
1912
+ let excluded = vec!["curl".to_string()];
1913
+ assert_eq!(
1914
+ rewrite_command("curl https://api.example.com/health", &excluded),
1915
+ None
1916
+ );
1917
+ }
1918
+
1919
+ #[test]
1920
+ fn test_rewrite_exclude_does_not_affect_other_commands() {
1921
+ let excluded = vec!["curl".to_string()];
1922
+ assert_eq!(
1923
+ rewrite_command("git status", &excluded),
1924
+ Some("rtk git status".into())
1925
+ );
1926
+ }
1927
+
1928
+ #[test]
1929
+ fn test_rewrite_empty_excludes_rewrites_curl() {
1930
+ let excluded: Vec<String> = vec![];
1931
+ assert!(rewrite_command("curl https://api.example.com", &excluded).is_some());
1932
+ }
1933
+
1934
+ #[test]
1935
+ fn test_rewrite_compound_partial_exclude() {
1936
+ // curl excluded but git still rewrites
1937
+ let excluded = vec!["curl".to_string()];
1938
+ assert_eq!(
1939
+ rewrite_command("git status && curl https://api.example.com", &excluded),
1940
+ Some("rtk git status && curl https://api.example.com".into())
1941
+ );
1942
+ }
1943
+
1944
+ // --- Every PATTERN compiles to a valid Regex ---
1945
+
1946
+ #[test]
1947
+ fn test_all_patterns_are_valid_regex() {
1948
+ use regex::Regex;
1949
+ for (i, pattern) in PATTERNS.iter().enumerate() {
1950
+ assert!(
1951
+ Regex::new(pattern).is_ok(),
1952
+ "PATTERNS[{i}] = '{pattern}' is not a valid regex"
1953
+ );
1954
+ }
1955
+ }
1956
+
1957
+ // --- #196: gh --json/--jq/--template passthrough ---
1958
+
1959
+ #[test]
1960
+ fn test_rewrite_gh_json_skipped() {
1961
+ assert_eq!(rewrite_command("gh pr list --json number,title", &[]), None);
1962
+ }
1963
+
1964
+ #[test]
1965
+ fn test_rewrite_gh_jq_skipped() {
1966
+ assert_eq!(
1967
+ rewrite_command("gh pr list --json number --jq '.[].number'", &[]),
1968
+ None
1969
+ );
1970
+ }
1971
+
1972
+ #[test]
1973
+ fn test_rewrite_gh_template_skipped() {
1974
+ assert_eq!(
1975
+ rewrite_command("gh pr view 42 --template '{{.title}}'", &[]),
1976
+ None
1977
+ );
1978
+ }
1979
+
1980
+ #[test]
1981
+ fn test_rewrite_gh_api_json_skipped() {
1982
+ assert_eq!(
1983
+ rewrite_command("gh api repos/owner/repo --jq '.name'", &[]),
1984
+ None
1985
+ );
1986
+ }
1987
+
1988
+ #[test]
1989
+ fn test_rewrite_gh_without_json_still_works() {
1990
+ assert_eq!(
1991
+ rewrite_command("gh pr list", &[]),
1992
+ Some("rtk gh pr list".into())
1993
+ );
1994
+ }
1995
+
1996
+ // --- #508: RTK_DISABLED detection helpers ---
1997
+
1998
+ #[test]
1999
+ fn test_has_rtk_disabled_prefix() {
2000
+ assert!(has_rtk_disabled_prefix("RTK_DISABLED=1 git status"));
2001
+ assert!(has_rtk_disabled_prefix("FOO=1 RTK_DISABLED=1 cargo test"));
2002
+ assert!(has_rtk_disabled_prefix(
2003
+ "RTK_DISABLED=true git log --oneline"
2004
+ ));
2005
+ assert!(!has_rtk_disabled_prefix("git status"));
2006
+ assert!(!has_rtk_disabled_prefix("rtk git status"));
2007
+ assert!(!has_rtk_disabled_prefix("SOME_VAR=1 git status"));
2008
+ }
2009
+
2010
+ #[test]
2011
+ fn test_strip_disabled_prefix() {
2012
+ assert_eq!(
2013
+ strip_disabled_prefix("RTK_DISABLED=1 git status"),
2014
+ "git status"
2015
+ );
2016
+ assert_eq!(
2017
+ strip_disabled_prefix("FOO=1 RTK_DISABLED=1 cargo test"),
2018
+ "cargo test"
2019
+ );
2020
+ assert_eq!(strip_disabled_prefix("git status"), "git status");
2021
+ }
2022
+ }