microsandbox_cli/
styles.rs

1use clap::builder::styling::{AnsiColor, Effects, Style, Styles};
2use std::fmt::Write;
3
4//--------------------------------------------------------------------------------------------------
5// Constants
6//--------------------------------------------------------------------------------------------------
7
8#[cfg(not(test))]
9/// Global flag indicating whether we're in an ANSI-capable interactive terminal
10static IS_ANSI_TERMINAL: std::sync::LazyLock<bool> =
11    std::sync::LazyLock::new(|| microsandbox_utils::term::is_ansi_interactive_terminal());
12
13//--------------------------------------------------------------------------------------------------
14// Functions
15//--------------------------------------------------------------------------------------------------
16
17/// Returns a `Styles` object with the default styles for the CLI.
18pub fn styles() -> Styles {
19    Styles::styled()
20        .header(AnsiColor::Yellow.on_default() | Effects::BOLD)
21        .usage(AnsiColor::Yellow.on_default() | Effects::BOLD)
22        .literal(AnsiColor::Blue.on_default() | Effects::BOLD)
23        .placeholder(AnsiColor::Green.on_default())
24        .error(AnsiColor::Red.on_default() | Effects::BOLD)
25        .valid(AnsiColor::Green.on_default() | Effects::BOLD)
26        .invalid(AnsiColor::Red.on_default() | Effects::BOLD)
27}
28
29/// Helper function to apply a style to text
30fn apply_style(text: String, style: &Style) -> String {
31    #[cfg(not(test))]
32    if !*IS_ANSI_TERMINAL {
33        return text;
34    }
35
36    #[cfg(test)]
37    {
38        if std::env::var("TERM").unwrap_or_default() == "dumb" {
39            return text;
40        }
41    }
42
43    let mut styled = String::with_capacity(text.len() + 20); // Reserve extra space for ANSI codes
44    let _ = write!(styled, "{}", style);
45    styled.push_str(&text);
46    let _ = write!(styled, "{}", style.render_reset());
47    styled
48}
49
50//--------------------------------------------------------------------------------------------------
51// Traits
52//--------------------------------------------------------------------------------------------------
53
54/// A trait for applying Styles defined in [`styles`] to text.
55pub trait AnsiStyles {
56    /// Apply header style to text
57    fn header(&self) -> String;
58
59    /// Apply usage style to text
60    fn usage(&self) -> String;
61
62    /// Apply literal style to text
63    fn literal(&self) -> String;
64
65    /// Apply placeholder style to text
66    fn placeholder(&self) -> String;
67
68    /// Apply error style to text
69    fn error(&self) -> String;
70
71    /// Apply valid style to text
72    fn valid(&self) -> String;
73
74    /// Apply invalid style to text
75    fn invalid(&self) -> String;
76}
77
78//--------------------------------------------------------------------------------------------------
79// Trait Implementations
80//--------------------------------------------------------------------------------------------------
81
82impl AnsiStyles for String {
83    fn header(&self) -> String {
84        apply_style(self.clone(), styles().get_header())
85    }
86
87    fn usage(&self) -> String {
88        apply_style(self.clone(), styles().get_usage())
89    }
90
91    fn literal(&self) -> String {
92        apply_style(self.clone(), styles().get_literal())
93    }
94
95    fn placeholder(&self) -> String {
96        apply_style(self.clone(), styles().get_placeholder())
97    }
98
99    fn error(&self) -> String {
100        apply_style(self.clone(), styles().get_error())
101    }
102
103    fn valid(&self) -> String {
104        apply_style(self.clone(), styles().get_valid())
105    }
106
107    fn invalid(&self) -> String {
108        apply_style(self.clone(), styles().get_invalid())
109    }
110}
111
112impl AnsiStyles for &str {
113    fn header(&self) -> String {
114        self.to_string().header()
115    }
116
117    fn usage(&self) -> String {
118        self.to_string().usage()
119    }
120
121    fn literal(&self) -> String {
122        self.to_string().literal()
123    }
124
125    fn placeholder(&self) -> String {
126        self.to_string().placeholder()
127    }
128
129    fn error(&self) -> String {
130        self.to_string().error()
131    }
132
133    fn valid(&self) -> String {
134        self.to_string().valid()
135    }
136
137    fn invalid(&self) -> String {
138        self.to_string().invalid()
139    }
140}
141
142//--------------------------------------------------------------------------------------------------
143// Tests
144//--------------------------------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use serial_test::serial;
149
150    use super::*;
151
152    #[test]
153    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
154    #[serial]
155    fn test_ansi_styles_string_non_interactive() {
156        helper::setup_non_interactive();
157
158        let text = String::from("test");
159        assert_eq!(text.header(), "test");
160        assert_eq!(text.usage(), "test");
161        assert_eq!(text.literal(), "test");
162        assert_eq!(text.placeholder(), "test");
163        assert_eq!(text.error(), "test");
164        assert_eq!(text.valid(), "test");
165        assert_eq!(text.invalid(), "test");
166    }
167
168    #[test]
169    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
170    #[serial]
171    fn test_ansi_styles_str_non_interactive() {
172        helper::setup_non_interactive();
173
174        let text = "test";
175        assert_eq!(text.header(), "test");
176        assert_eq!(text.usage(), "test");
177        assert_eq!(text.literal(), "test");
178        assert_eq!(text.placeholder(), "test");
179        assert_eq!(text.error(), "test");
180        assert_eq!(text.valid(), "test");
181        assert_eq!(text.invalid(), "test");
182    }
183
184    #[test]
185    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
186    #[serial]
187    fn test_ansi_styles_string_interactive() {
188        helper::setup_interactive();
189
190        let text = String::from("test");
191
192        let header = text.header();
193        println!("header: {}", header);
194        // Check for bold and yellow separately
195        assert!(header.contains("\x1b[1m"));
196        assert!(header.contains("\x1b[33m"));
197        assert!(header.contains("test"));
198        assert!(header.contains("\x1b[0m"));
199
200        let usage = text.usage();
201        println!("usage: {}", usage);
202        assert!(usage.contains("\x1b[1m"));
203        assert!(usage.contains("\x1b[33m"));
204        assert!(usage.contains("test"));
205        assert!(usage.contains("\x1b[0m"));
206
207        let literal = text.literal();
208        println!("literal: {}", literal);
209        assert!(literal.contains("\x1b[1m"));
210        assert!(literal.contains("\x1b[34m"));
211        assert!(literal.contains("test"));
212        assert!(literal.contains("\x1b[0m"));
213
214        let placeholder = text.placeholder();
215        println!("placeholder: {}", placeholder);
216        // For placeholder, no bold is expected
217        assert!(placeholder.contains("\x1b[32m"));
218        assert!(placeholder.contains("test"));
219        assert!(placeholder.contains("\x1b[0m"));
220
221        let error = text.error();
222        println!("error: {}", error);
223        assert!(error.contains("\x1b[1m"));
224        assert!(error.contains("\x1b[31m"));
225        assert!(error.contains("test"));
226        assert!(error.contains("\x1b[0m"));
227
228        let valid = text.valid();
229        println!("valid: {}", valid);
230        assert!(valid.contains("\x1b[1m"));
231        assert!(valid.contains("\x1b[32m"));
232        assert!(valid.contains("test"));
233        assert!(valid.contains("\x1b[0m"));
234
235        let invalid = text.invalid();
236        println!("invalid: {}", invalid);
237        assert!(invalid.contains("\x1b[1m"));
238        assert!(invalid.contains("\x1b[31m"));
239        assert!(invalid.contains("test"));
240        assert!(invalid.contains("\x1b[0m"));
241    }
242
243    #[test]
244    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
245    #[serial]
246    fn test_ansi_styles_str_interactive() {
247        helper::setup_interactive();
248
249        let text = "test";
250
251        let header = text.header();
252        assert!(header.contains("\x1b[1m"));
253        assert!(header.contains("\x1b[33m"));
254        assert!(header.contains("test"));
255        assert!(header.contains("\x1b[0m"));
256
257        let usage = text.usage();
258        assert!(usage.contains("\x1b[1m"));
259        assert!(usage.contains("\x1b[33m"));
260        assert!(usage.contains("test"));
261        assert!(usage.contains("\x1b[0m"));
262
263        let literal = text.literal();
264        assert!(literal.contains("\x1b[1m"));
265        assert!(literal.contains("\x1b[34m"));
266        assert!(literal.contains("test"));
267        assert!(literal.contains("\x1b[0m"));
268
269        let placeholder = text.placeholder();
270        assert!(placeholder.contains("\x1b[32m"));
271        assert!(placeholder.contains("test"));
272        assert!(placeholder.contains("\x1b[0m"));
273
274        let error = text.error();
275        assert!(error.contains("\x1b[1m"));
276        assert!(error.contains("\x1b[31m"));
277        assert!(error.contains("test"));
278        assert!(error.contains("\x1b[0m"));
279
280        let valid = text.valid();
281        assert!(valid.contains("\x1b[1m"));
282        assert!(valid.contains("\x1b[32m"));
283        assert!(valid.contains("test"));
284        assert!(valid.contains("\x1b[0m"));
285
286        let invalid = text.invalid();
287        assert!(invalid.contains("\x1b[1m"));
288        assert!(invalid.contains("\x1b[31m"));
289        assert!(invalid.contains("test"));
290        assert!(invalid.contains("\x1b[0m"));
291    }
292
293    #[test]
294    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
295    #[serial]
296    fn test_ansi_styles_empty_string() {
297        helper::setup_interactive();
298
299        let empty = String::new();
300        assert!(empty.header().ends_with("\x1b[0m"));
301        assert!(empty.usage().ends_with("\x1b[0m"));
302        assert!(empty.literal().ends_with("\x1b[0m"));
303        assert!(empty.placeholder().ends_with("\x1b[0m"));
304        assert!(empty.error().ends_with("\x1b[0m"));
305        assert!(empty.valid().ends_with("\x1b[0m"));
306        assert!(empty.invalid().ends_with("\x1b[0m"));
307
308        helper::setup_non_interactive();
309        assert_eq!(empty.header(), "");
310        assert_eq!(empty.usage(), "");
311        assert_eq!(empty.literal(), "");
312        assert_eq!(empty.placeholder(), "");
313        assert_eq!(empty.error(), "");
314        assert_eq!(empty.valid(), "");
315        assert_eq!(empty.invalid(), "");
316    }
317
318    #[test]
319    #[ignore = "this test won't work correctly in cargo-nextest. run with `cargo test -- --ignored`"]
320    #[serial]
321    fn test_ansi_styles_unicode_string() {
322        helper::setup_interactive();
323
324        let text = "测试";
325        let header = text.header();
326        assert!(header.contains("测试"));
327        assert!(header.starts_with("\x1b["));
328        assert!(header.ends_with("\x1b[0m"));
329    }
330}
331
332#[cfg(test)]
333mod helper {
334    use std::env;
335
336    /// Helper function to set up a non-interactive terminal environment
337    pub(super) fn setup_non_interactive() {
338        env::set_var("TERM", "dumb");
339    }
340
341    /// Helper function to set up an interactive terminal environment
342    pub(super) fn setup_interactive() {
343        env::set_var("TERM", "xterm-256color");
344    }
345}