1use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8
9use crate::ssh_utils::is_valid_env_key;
10use crate::types::ColorMode;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ToolchainInfo {
18 pub channel: String,
20 pub date: Option<String>,
22 pub full_version: String,
24}
25
26impl ToolchainInfo {
27 pub fn new(
29 channel: impl Into<String>,
30 date: Option<String>,
31 full_version: impl Into<String>,
32 ) -> Self {
33 Self {
34 channel: channel.into(),
35 date,
36 full_version: full_version.into(),
37 }
38 }
39
40 pub fn rustup_toolchain(&self) -> String {
44 match &self.date {
45 Some(date) => format!("{}-{}", self.channel, date),
46 None => self.channel.clone(),
47 }
48 }
49
50 pub fn is_nightly(&self) -> bool {
52 self.channel == "nightly"
53 }
54
55 pub fn is_beta(&self) -> bool {
57 self.channel == "beta"
58 }
59
60 pub fn is_stable(&self) -> bool {
62 self.channel == "stable"
63 }
64}
65
66impl std::fmt::Display for ToolchainInfo {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(f, "{}", self.rustup_toolchain())
69 }
70}
71
72pub fn wrap_command_with_toolchain(command: &str, toolchain: Option<&ToolchainInfo>) -> String {
77 match toolchain {
78 Some(tc) => insert_toolchain_runner(
79 command,
80 &format!(
81 "rustup run {}",
82 shell_escape::escape(Cow::Owned(tc.rustup_toolchain()))
83 ),
84 ),
85 None => command.to_string(),
86 }
87}
88
89fn insert_toolchain_runner(command: &str, runner: &str) -> String {
90 if command.is_empty() {
91 return format!("{runner} ");
92 }
93
94 let insert_at = toolchain_runner_insert_index(command);
95 if insert_at == 0 {
96 format!("{runner} {command}")
97 } else if insert_at >= command.len() {
98 format!("{command} {runner} ")
99 } else {
100 let Some(prefix) = command.get(..insert_at) else {
101 return format!("{runner} {command}");
102 };
103 let Some(suffix) = command.get(insert_at..) else {
104 return format!("{command} {runner} ");
105 };
106 format!("{prefix}{runner} {suffix}")
107 }
108}
109
110fn toolchain_runner_insert_index(command: &str) -> usize {
111 let Some((start, end)) = next_shell_token_span(command, 0) else {
112 return command.len();
113 };
114
115 let Some(first) = command.get(start..end) else {
116 return start;
117 };
118 if matches!(first, "env") {
119 return env_command_insert_index(command, skip_shell_whitespace(command, end));
120 }
121
122 let mut index = start;
123 let mut cursor = start;
124 while let Some((token_start, token_end)) = next_shell_token_span(command, cursor) {
125 let Some(token) = command.get(token_start..token_end) else {
126 return token_start;
127 };
128 if !is_env_assignment_token(token) {
129 return token_start;
130 }
131 index = skip_shell_whitespace(command, token_end);
132 cursor = index;
133 }
134
135 index
136}
137
138fn env_command_insert_index(command: &str, mut cursor: usize) -> usize {
139 while let Some((token_start, token_end)) = next_shell_token_span(command, cursor) {
140 let Some(token) = command.get(token_start..token_end) else {
141 return token_start;
142 };
143 if matches!(token, "--") {
144 return skip_shell_whitespace(command, token_end);
145 }
146 if env_option_takes_next_token(token) {
147 cursor = skip_shell_whitespace(command, token_end);
148 if let Some((_, value_end)) = next_shell_token_span(command, cursor) {
149 cursor = skip_shell_whitespace(command, value_end);
150 }
151 continue;
152 }
153 match token {
154 "-u" | "--unset" => {
155 cursor = skip_shell_whitespace(command, token_end);
156 if let Some((_, unset_end)) = next_shell_token_span(command, cursor) {
157 cursor = skip_shell_whitespace(command, unset_end);
158 }
159 }
160 _ if token.starts_with("--unset=") => {
161 cursor = skip_shell_whitespace(command, token_end);
162 }
163 _ if env_option_has_inline_value(token) => {
164 cursor = skip_shell_whitespace(command, token_end);
165 }
166 _ if token.starts_with('-') && !token.contains('=') => {
167 cursor = skip_shell_whitespace(command, token_end);
168 }
169 _ if is_env_assignment_token(token) => {
170 cursor = skip_shell_whitespace(command, token_end);
171 }
172 _ => return token_start,
173 }
174 }
175
176 command.len()
177}
178
179fn env_option_takes_next_token(token: &str) -> bool {
180 matches!(
181 token,
182 "-C" | "--chdir"
183 | "-f"
184 | "--file"
185 | "-S"
186 | "--split-string"
187 | "-a"
188 | "--argv0"
189 | "--ignore-signal"
190 )
191}
192
193fn env_option_has_inline_value(token: &str) -> bool {
194 token.starts_with("--chdir=")
195 || token.starts_with("--file=")
196 || token.starts_with("--split-string=")
197 || token.starts_with("--argv0=")
198 || token.starts_with("--ignore-signal=")
199 || token.starts_with("-C")
200 || token.starts_with("-f")
201 || token.starts_with("-S")
202 || token.starts_with("-a")
203 || token.starts_with("-u")
204}
205
206fn is_env_assignment_token(token: &str) -> bool {
207 token
208 .split_once('=')
209 .is_some_and(|(key, _)| is_valid_env_key(key))
210}
211
212fn next_shell_token_span(command: &str, offset: usize) -> Option<(usize, usize)> {
213 let start = skip_shell_whitespace(command, offset);
214 if start >= command.len() {
215 return None;
216 }
217
218 let mut in_single = false;
219 let mut in_double = false;
220 let mut escaped = false;
221
222 let rest = command.get(start..)?;
223 for (relative_index, ch) in rest.char_indices() {
224 let index = start + relative_index;
225
226 if escaped {
227 escaped = false;
228 continue;
229 }
230 if ch == '\\' && !in_single {
231 escaped = true;
232 continue;
233 }
234 if ch == '\'' && !in_double {
235 in_single = !in_single;
236 continue;
237 }
238 if ch == '"' && !in_single {
239 in_double = !in_double;
240 continue;
241 }
242 if ch.is_whitespace() && !in_single && !in_double {
243 return Some((start, index));
244 }
245 }
246
247 Some((start, command.len()))
248}
249
250fn skip_shell_whitespace(command: &str, mut offset: usize) -> usize {
251 while offset < command.len() {
252 let Some(rest) = command.get(offset..) else {
253 break;
254 };
255 let Some(ch) = rest.chars().next() else {
256 break;
257 };
258 if !ch.is_whitespace() {
259 break;
260 }
261 offset += ch.len_utf8();
262 }
263 offset
264}
265
266pub fn wrap_command_with_color(command: &str, color_mode: ColorMode) -> String {
290 match color_mode {
291 ColorMode::Always => {
292 format!(
299 "env -u NO_COLOR CARGO_TERM_COLOR=always RUST_LOG_STYLE=always CLICOLOR_FORCE=1 FORCE_COLOR=1 {}",
300 command
301 )
302 }
303 ColorMode::Auto => {
304 command.to_string()
306 }
307 ColorMode::Never => {
308 format!("env NO_COLOR=1 CARGO_TERM_COLOR=never {}", command)
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_toolchain_info_new() {
320 let tc = ToolchainInfo::new(
321 "nightly",
322 Some("2024-01-15".to_string()),
323 "rustc 1.76.0-nightly",
324 );
325 assert_eq!(tc.channel, "nightly");
326 assert_eq!(tc.date, Some("2024-01-15".to_string()));
327 }
328
329 #[test]
330 fn test_rustup_toolchain_with_date() {
331 let tc = ToolchainInfo {
332 channel: "nightly".to_string(),
333 date: Some("2024-01-15".to_string()),
334 full_version: "".to_string(),
335 };
336 assert_eq!(tc.rustup_toolchain(), "nightly-2024-01-15");
337 }
338
339 #[test]
340 fn test_rustup_toolchain_without_date() {
341 let tc = ToolchainInfo {
342 channel: "stable".to_string(),
343 date: None,
344 full_version: "".to_string(),
345 };
346 assert_eq!(tc.rustup_toolchain(), "stable");
347 }
348
349 #[test]
350 fn test_toolchain_info_display() {
351 let tc = ToolchainInfo {
352 channel: "nightly".to_string(),
353 date: Some("2024-01-15".to_string()),
354 full_version: "".to_string(),
355 };
356 assert_eq!(tc.to_string(), "nightly-2024-01-15");
357 }
358
359 #[test]
360 fn test_is_nightly() {
361 let tc = ToolchainInfo::new("nightly", None, "");
362 assert!(tc.is_nightly());
363 assert!(!tc.is_stable());
364 assert!(!tc.is_beta());
365 }
366
367 #[test]
368 fn test_is_stable() {
369 let tc = ToolchainInfo::new("stable", None, "");
370 assert!(tc.is_stable());
371 assert!(!tc.is_nightly());
372 assert!(!tc.is_beta());
373 }
374
375 #[test]
376 fn test_serialization_roundtrip() {
377 let tc = ToolchainInfo {
378 channel: "nightly".to_string(),
379 date: Some("2024-01-15".to_string()),
380 full_version: "rustc 1.76.0-nightly (abc123 2024-01-15)".to_string(),
381 };
382 let json = serde_json::to_string(&tc).unwrap();
383 let parsed: ToolchainInfo = serde_json::from_str(&json).unwrap();
384 assert_eq!(tc, parsed);
385 }
386
387 #[test]
388 fn test_wrap_command_with_toolchain() {
389 let tc = ToolchainInfo::new("nightly", Some("2024-01-15".to_string()), "");
390 let wrapped = wrap_command_with_toolchain("cargo build", Some(&tc));
391 assert_eq!(wrapped, "rustup run nightly-2024-01-15 cargo build");
392 }
393
394 #[test]
395 fn test_wrap_command_with_toolchain_preserves_inline_env_assignment() {
396 let tc = ToolchainInfo::new("nightly", None, "");
397 let wrapped = wrap_command_with_toolchain(
398 "RUSTFLAGS=\"-C target-cpu=native\" cargo build",
399 Some(&tc),
400 );
401 assert_eq!(
402 wrapped,
403 "RUSTFLAGS=\"-C target-cpu=native\" rustup run nightly cargo build"
404 );
405 }
406
407 #[test]
408 fn test_wrap_command_with_toolchain_preserves_env_wrapper() {
409 let tc = ToolchainInfo::new("stable", None, "");
410 let wrapped = wrap_command_with_toolchain(
411 "env -u RUST_LOG RUSTFLAGS=\"-C target-cpu=native\" cargo test",
412 Some(&tc),
413 );
414 assert_eq!(
415 wrapped,
416 "env -u RUST_LOG RUSTFLAGS=\"-C target-cpu=native\" rustup run stable cargo test"
417 );
418 }
419
420 #[test]
421 fn test_wrap_command_with_toolchain_preserves_env_options_with_operands() {
422 let tc = ToolchainInfo::new("stable", None, "");
423 let wrapped = wrap_command_with_toolchain(
424 "env -C \"/tmp/project dir\" -f .env RUSTFLAGS=-Awarnings cargo test",
425 Some(&tc),
426 );
427 assert_eq!(
428 wrapped,
429 "env -C \"/tmp/project dir\" -f .env RUSTFLAGS=-Awarnings rustup run stable cargo test"
430 );
431 }
432
433 #[test]
434 fn test_wrap_command_with_toolchain_preserves_env_options_with_inline_operands() {
435 let tc = ToolchainInfo::new("stable", None, "");
436 let wrapped = wrap_command_with_toolchain(
437 "env --chdir=\"/tmp/project dir\" --file=.env RUSTFLAGS=-Awarnings cargo test",
438 Some(&tc),
439 );
440 assert_eq!(
441 wrapped,
442 "env --chdir=\"/tmp/project dir\" --file=.env RUSTFLAGS=-Awarnings rustup run stable cargo test"
443 );
444 }
445
446 #[test]
447 fn test_wrap_command_with_toolchain_quotes_toolchain_name() {
448 let tc = ToolchainInfo::new("nightly;echo injected", None, "");
449 let wrapped = wrap_command_with_toolchain("cargo build", Some(&tc));
450 assert_eq!(wrapped, "rustup run 'nightly;echo injected' cargo build");
451 }
452
453 #[test]
454 fn test_wrap_command_no_toolchain() {
455 let wrapped = wrap_command_with_toolchain("cargo build", None);
456 assert_eq!(wrapped, "cargo build");
457 }
458
459 #[test]
460 fn test_wrap_command_stable_toolchain() {
461 let tc = ToolchainInfo::new("stable", None, "");
462 let wrapped = wrap_command_with_toolchain("cargo test", Some(&tc));
463 assert_eq!(wrapped, "rustup run stable cargo test");
464 }
465
466 #[test]
469 fn test_is_beta() {
470 let tc = ToolchainInfo::new("beta", None, "");
471 assert!(tc.is_beta());
472 assert!(!tc.is_stable());
473 assert!(!tc.is_nightly());
474 }
475
476 #[test]
477 fn test_wrap_command_beta_with_date() {
478 let tc = ToolchainInfo::new("beta", Some("2024-02-01".to_string()), "");
479 let wrapped = wrap_command_with_toolchain("cargo check", Some(&tc));
480 assert_eq!(wrapped, "rustup run beta-2024-02-01 cargo check");
481 }
482
483 #[test]
484 fn test_wrap_command_specific_version() {
485 let tc = ToolchainInfo::new("1.75.0", None, "rustc 1.75.0");
487 let wrapped = wrap_command_with_toolchain("cargo build --release", Some(&tc));
488 assert_eq!(wrapped, "rustup run 1.75.0 cargo build --release");
489 }
490
491 #[test]
492 fn test_wrap_command_with_complex_args() {
493 let tc = ToolchainInfo::new("nightly", None, "");
494 let cmd = "cargo build --features \"feature1,feature2\" --target x86_64-unknown-linux-gnu";
495 let wrapped = wrap_command_with_toolchain(cmd, Some(&tc));
496 assert_eq!(
497 wrapped,
498 "rustup run nightly cargo build --features \"feature1,feature2\" --target x86_64-unknown-linux-gnu"
499 );
500 }
501
502 #[test]
503 fn test_wrap_command_with_path_spaces() {
504 let tc = ToolchainInfo::new("stable", None, "");
505 let cmd = "cargo build --manifest-path \"/path/with spaces/Cargo.toml\"";
506 let wrapped = wrap_command_with_toolchain(cmd, Some(&tc));
507 assert_eq!(
508 wrapped,
509 "rustup run stable cargo build --manifest-path \"/path/with spaces/Cargo.toml\""
510 );
511 }
512
513 #[test]
514 fn test_wrap_command_empty_string() {
515 let tc = ToolchainInfo::new("stable", None, "");
516 let wrapped = wrap_command_with_toolchain("", Some(&tc));
517 assert_eq!(wrapped, "rustup run stable ");
518 }
519
520 #[test]
521 fn test_wrap_command_empty_string_no_toolchain() {
522 let wrapped = wrap_command_with_toolchain("", None);
523 assert_eq!(wrapped, "");
524 }
525
526 #[test]
527 fn test_wrap_command_clippy() {
528 let tc = ToolchainInfo::new("nightly", Some("2024-03-15".to_string()), "");
529 let wrapped = wrap_command_with_toolchain("cargo clippy -- -D warnings", Some(&tc));
530 assert_eq!(
531 wrapped,
532 "rustup run nightly-2024-03-15 cargo clippy -- -D warnings"
533 );
534 }
535
536 #[test]
537 fn test_wrap_command_rustfmt() {
538 let tc = ToolchainInfo::new("nightly", None, "");
539 let wrapped = wrap_command_with_toolchain("cargo fmt --check", Some(&tc));
540 assert_eq!(wrapped, "rustup run nightly cargo fmt --check");
541 }
542
543 #[test]
544 fn test_wrap_command_test_with_filter() {
545 let tc = ToolchainInfo::new("stable", None, "");
546 let wrapped = wrap_command_with_toolchain("cargo test test_name -- --nocapture", Some(&tc));
547 assert_eq!(
548 wrapped,
549 "rustup run stable cargo test test_name -- --nocapture"
550 );
551 }
552
553 #[test]
554 fn test_rustup_toolchain_beta_with_date() {
555 let tc = ToolchainInfo {
556 channel: "beta".to_string(),
557 date: Some("2024-02-01".to_string()),
558 full_version: "".to_string(),
559 };
560 assert_eq!(tc.rustup_toolchain(), "beta-2024-02-01");
561 }
562
563 #[test]
564 fn test_rustup_toolchain_specific_version() {
565 let tc = ToolchainInfo {
566 channel: "1.75.0".to_string(),
567 date: None,
568 full_version: "rustc 1.75.0".to_string(),
569 };
570 assert_eq!(tc.rustup_toolchain(), "1.75.0");
571 }
572
573 #[test]
574 fn test_display_stable() {
575 let tc = ToolchainInfo::new("stable", None, "");
576 assert_eq!(tc.to_string(), "stable");
577 }
578
579 #[test]
580 fn test_display_beta_with_date() {
581 let tc = ToolchainInfo::new("beta", Some("2024-02-01".to_string()), "");
582 assert_eq!(tc.to_string(), "beta-2024-02-01");
583 }
584
585 #[test]
586 fn test_serialization_stable() {
587 let tc = ToolchainInfo::new("stable", None, "rustc 1.75.0 (hash 2024-01-01)");
588 let json = serde_json::to_string(&tc).unwrap();
589 assert!(json.contains("\"channel\":\"stable\""));
590 assert!(json.contains("\"date\":null"));
591 let parsed: ToolchainInfo = serde_json::from_str(&json).unwrap();
592 assert_eq!(tc, parsed);
593 }
594
595 #[test]
596 fn test_serialization_beta_with_date() {
597 let tc = ToolchainInfo::new("beta", Some("2024-02-01".to_string()), "rustc 1.76.0-beta");
598 let json = serde_json::to_string(&tc).unwrap();
599 let parsed: ToolchainInfo = serde_json::from_str(&json).unwrap();
600 assert_eq!(tc, parsed);
601 assert_eq!(parsed.date, Some("2024-02-01".to_string()));
602 }
603
604 #[test]
605 fn test_toolchain_info_clone() {
606 let tc = ToolchainInfo::new("nightly", Some("2024-01-15".to_string()), "rustc 1.76.0");
607 let cloned = tc.clone();
608 assert_eq!(tc, cloned);
609 assert_eq!(tc.channel, cloned.channel);
610 assert_eq!(tc.date, cloned.date);
611 assert_eq!(tc.full_version, cloned.full_version);
612 }
613
614 #[test]
615 fn test_toolchain_info_equality() {
616 let tc1 = ToolchainInfo::new("nightly", Some("2024-01-15".to_string()), "rustc 1.76.0");
617 let tc2 = ToolchainInfo::new("nightly", Some("2024-01-15".to_string()), "rustc 1.76.0");
618 let tc3 = ToolchainInfo::new("nightly", Some("2024-01-16".to_string()), "rustc 1.76.0");
619 assert_eq!(tc1, tc2);
620 assert_ne!(tc1, tc3);
621 }
622
623 #[test]
624 fn test_channel_detection_methods_consistency() {
625 for channel in &["stable", "beta", "nightly", "1.75.0", "custom"] {
627 let tc = ToolchainInfo::new(*channel, None, "");
628 let count = [tc.is_stable(), tc.is_beta(), tc.is_nightly()]
629 .iter()
630 .filter(|&&b| b)
631 .count();
632 assert!(count <= 1, "Multiple channel types for: {}", channel);
634 }
635 }
636
637 #[test]
640 fn test_wrap_command_with_color_always() {
641 let wrapped = wrap_command_with_color("cargo test", ColorMode::Always);
642 assert!(wrapped.contains("CARGO_TERM_COLOR=always"));
643 assert!(wrapped.contains("RUST_LOG_STYLE=always"));
644 assert!(wrapped.contains("CLICOLOR_FORCE=1"));
645 assert!(wrapped.contains("FORCE_COLOR=1"));
646 assert!(wrapped.contains("env -u NO_COLOR"));
647 assert!(wrapped.contains("cargo test"));
648 }
649
650 #[test]
651 fn test_wrap_command_with_color_auto() {
652 let wrapped = wrap_command_with_color("cargo test", ColorMode::Auto);
653 assert_eq!(wrapped, "cargo test");
655 }
656
657 #[test]
658 fn test_wrap_command_with_color_never() {
659 let wrapped = wrap_command_with_color("cargo test", ColorMode::Never);
660 assert!(wrapped.starts_with("env "));
661 assert!(wrapped.contains("NO_COLOR=1"));
662 assert!(wrapped.contains("CARGO_TERM_COLOR=never"));
663 assert!(wrapped.contains("cargo test"));
664 }
665
666 #[test]
667 fn test_wrap_command_with_color_never_is_timeout_safe() {
668 let wrapped = wrap_command_with_color("sh -c 'exit 43'", ColorMode::Never);
669 let status = std::process::Command::new("sh")
670 .arg("-c")
671 .arg(format!(
672 "timeout --foreground --preserve-status 5 {}",
673 wrapped
674 ))
675 .status()
676 .expect("timeout-wrapped color-disabled command should execute");
677
678 assert_eq!(
679 status.code(),
680 Some(43),
681 "timeout must execute env, not an environment assignment argv"
682 );
683 }
684
685 #[test]
686 fn test_wrap_command_with_color_preserves_complex_command() {
687 let cmd = "cargo test --release -- --test-threads=4";
688 let wrapped = wrap_command_with_color(cmd, ColorMode::Always);
689 assert!(wrapped.contains(cmd));
690 }
691
692 #[test]
693 fn test_wrap_command_with_color_bun_commands() {
694 let wrapped = wrap_command_with_color("bun test", ColorMode::Always);
696 assert!(wrapped.contains("FORCE_COLOR=1"));
697 assert!(wrapped.contains("bun test"));
698 }
699
700 #[test]
701 fn test_color_mode_display() {
702 assert_eq!(ColorMode::Always.to_string(), "always");
703 assert_eq!(ColorMode::Auto.to_string(), "auto");
704 assert_eq!(ColorMode::Never.to_string(), "never");
705 }
706
707 #[test]
708 fn test_color_mode_from_str() {
709 assert_eq!("always".parse::<ColorMode>(), Ok(ColorMode::Always));
710 assert_eq!("force".parse::<ColorMode>(), Ok(ColorMode::Always));
711 assert_eq!("auto".parse::<ColorMode>(), Ok(ColorMode::Auto));
712 assert_eq!("never".parse::<ColorMode>(), Ok(ColorMode::Never));
713 assert_eq!("no".parse::<ColorMode>(), Ok(ColorMode::Never));
714 assert!("invalid".parse::<ColorMode>().is_err());
715 }
716
717 #[test]
718 fn test_color_mode_default() {
719 assert_eq!(ColorMode::default(), ColorMode::Always);
721 }
722}