Skip to main content

rch_common/
toolchain.rs

1//! Rust toolchain information types.
2//!
3//! Types for representing Rust toolchain information used in the RCH protocol
4//! for toolchain synchronization between local and remote workers.
5
6use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8
9use crate::ssh_utils::is_valid_env_key;
10use crate::types::ColorMode;
11
12/// Detected Rust toolchain information.
13///
14/// Contains the channel, optional date, and full version string needed
15/// to ensure worker uses the same toolchain as the local project.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ToolchainInfo {
18    /// The channel: "stable", "beta", "nightly", or specific version like "1.75.0".
19    pub channel: String,
20    /// Optional date for nightly/beta: "2024-01-15".
21    pub date: Option<String>,
22    /// Full version string from rustc --version.
23    pub full_version: String,
24}
25
26impl ToolchainInfo {
27    /// Create a new ToolchainInfo.
28    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    /// Format for rustup run command.
41    ///
42    /// Returns the toolchain identifier suitable for `rustup run <toolchain>`.
43    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    /// Check if this is a nightly toolchain.
51    pub fn is_nightly(&self) -> bool {
52        self.channel == "nightly"
53    }
54
55    /// Check if this is a beta toolchain.
56    pub fn is_beta(&self) -> bool {
57        self.channel == "beta"
58    }
59
60    /// Check if this is a stable toolchain.
61    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
72/// Wrap a command to run with a specific toolchain.
73///
74/// If toolchain is provided, wraps the command with `rustup run <toolchain>`.
75/// Otherwise returns the original command unchanged.
76pub 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
266/// Wrap a command with environment variables to force color output.
267///
268/// Remote SSH connections typically don't allocate a PTY, which causes tools
269/// like cargo, rustc, and bun to detect a non-TTY and disable color output.
270/// This function adds environment variables that force color output regardless
271/// of terminal detection.
272///
273/// # Environment Variables Set
274///
275/// - `CARGO_TERM_COLOR=always` - Forces cargo to output ANSI colors
276/// - `RUST_LOG_STYLE=always` - Forces env_logger/tracing to use colors
277/// - `CLICOLOR_FORCE=1` - Standard env var for forcing color output
278/// - `FORCE_COLOR=1` - Used by many Node.js/JS tools including Bun
279///
280/// # Arguments
281///
282/// * `command` - The command to wrap
283/// * `color_mode` - The color mode configuration
284///
285/// # Returns
286///
287/// The command prefixed with color-forcing environment variables,
288/// or the original command if color mode is Auto or Never.
289pub fn wrap_command_with_color(command: &str, color_mode: ColorMode) -> String {
290    match color_mode {
291        ColorMode::Always => {
292            // Environment variables that force color output across different tools:
293            // - CARGO_TERM_COLOR: cargo's native color setting
294            // - RUST_LOG_STYLE: env_logger and tracing-subscriber color setting
295            // - CLICOLOR_FORCE: de facto standard for forcing colors (clicolors.org)
296            // - FORCE_COLOR: Node.js/JS ecosystem standard (used by Bun, chalk, etc.)
297            // - NO_COLOR: Ensure it's unset (some tools check this first)
298            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            // Let the remote command auto-detect (default behavior)
305            command.to_string()
306        }
307        ColorMode::Never => {
308            // Explicitly disable colors
309            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    // === Additional command wrapping tests for toolchain synchronization ===
467
468    #[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        // Specific version like "1.75.0" without date
486        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        // Only one of the is_* methods should return true for a given toolchain
626        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            // Should have at most one true (custom channels have none)
633            assert!(count <= 1, "Multiple channel types for: {}", channel);
634        }
635    }
636
637    // === Color wrapping tests ===
638
639    #[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        // Auto mode should not modify the command
654        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        // Verify Bun commands work with color wrapping (uses FORCE_COLOR)
695        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        // Default should be Always to preserve colors in remote SSH
720        assert_eq!(ColorMode::default(), ColorMode::Always);
721    }
722}