1use super::Color;
37use crate::terminal::{Env, ProcessEnv};
38
39#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum Profile {
47 Disabled,
53 Ascii,
58 Ansi,
63 Ansi256,
69 #[default]
73 TrueColor,
74}
75
76impl Profile {
77 pub fn convert(self, color: Color) -> Option<Color> {
84 use super::convert::*;
85 match self {
86 Profile::Disabled | Profile::Ascii => None,
87 Profile::Ansi => Some(rgb_to_ansi16(color.to_rgb())),
88 Profile::Ansi256 => Some(rgb_to_ansi256(color.to_rgb())),
89 Profile::TrueColor => Some(color),
90 }
91 }
92
93 pub fn detect() -> Self {
98 Self::detect_from(&ProcessEnv, true)
99 }
100
101 pub fn detect_from(env: &dyn Env, is_tty: bool) -> Self {
109 let is_tty = is_tty || is_truthy(env, "TTY_FORCE");
110 let term = env.get("TERM").unwrap_or_default();
111
112 let envp = env_color_profile(env, &term);
119 let mut p = if !is_tty { Profile::Disabled } else { envp };
120
121 if env.has("NO_COLOR") && is_tty {
125 if p > Profile::Ascii {
126 p = Profile::Ascii;
127 }
128 return p;
129 }
130
131 if is_truthy(env, "CLICOLOR_FORCE") {
133 if p < Profile::Ansi {
134 p = Profile::Ansi;
135 }
136 if envp > p {
137 p = envp;
138 }
139 return p;
140 }
141
142 let is_dumb = term.is_empty() || term == DUMB_TERM;
143 if is_truthy(env, "CLICOLOR") && is_tty && !is_dumb && p < Profile::Ansi {
145 p = Profile::Ansi;
146 }
147
148 p
149 }
150}
151
152const DUMB_TERM: &str = "dumb";
153
154fn is_truthy(env: &dyn Env, key: &str) -> bool {
160 matches!(
161 env.get(key).as_deref().unwrap_or_default(),
162 "1" | "t" | "T" | "TRUE" | "true" | "True"
163 )
164}
165
166fn env_color_profile(env: &dyn Env, term: &str) -> Profile {
168 let mut p = if term == DUMB_TERM {
169 Profile::Disabled
171 } else if term.is_empty() {
172 #[cfg(windows)]
176 {
177 windows_color_profile(env).unwrap_or(Profile::Disabled)
178 }
179 #[cfg(not(windows))]
180 {
181 let _ = env;
182 Profile::Disabled
183 }
184 } else {
185 Profile::Ansi
186 };
187
188 if KNOWN_TRUECOLOR_TERMS.iter().any(|t| term.contains(t)) {
190 return Profile::TrueColor;
191 }
192
193 if term.starts_with("tmux") || term.starts_with("screen") {
194 if p < Profile::Ansi256 {
195 p = Profile::Ansi256;
196 }
197 } else if term.starts_with("xterm") && p < Profile::Ansi {
198 p = Profile::Ansi;
199 }
200
201 if env.has("WT_SESSION") {
203 return Profile::TrueColor;
204 }
205
206 if is_truthy(env, "GOOGLE_CLOUD_SHELL") {
207 return Profile::TrueColor;
208 }
209
210 if is_truthy(env, "CI") {
213 return Profile::TrueColor;
214 }
215
216 if colorterm_says_truecolor(env) && !term.starts_with("screen") {
220 return Profile::TrueColor;
221 }
222
223 if term.ends_with("256color") && p < Profile::Ansi256 {
224 p = Profile::Ansi256;
225 }
226
227 if term.ends_with("direct") {
228 return Profile::TrueColor;
229 }
230
231 p
232}
233
234const KNOWN_TRUECOLOR_TERMS: &[&str] = &[
236 "alacritty",
237 "contour",
238 "foot",
239 "ghostty",
240 "kitty",
241 "rio",
242 "st",
243 "wezterm",
244];
245
246fn colorterm_says_truecolor(env: &dyn Env) -> bool {
247 let v = env
248 .get("COLORTERM")
249 .unwrap_or_default()
250 .to_ascii_lowercase();
251 matches!(v.as_str(), "truecolor" | "24bit" | "yes" | "true")
252}
253
254#[cfg(windows)]
255fn windows_color_profile(env: &dyn Env) -> Option<Profile> {
256 if env.has("WT_SESSION") {
262 return Some(Profile::TrueColor);
263 }
264 Some(Profile::Ansi256)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::terminal::EnvList;
271
272 fn env(pairs: &[(&str, &str)]) -> EnvList {
273 EnvList::from_pairs(pairs.iter().map(|(k, v)| (*k, *v)))
274 }
275
276 #[test]
277 fn default_is_truecolor() {
278 assert_eq!(Profile::default(), Profile::TrueColor);
279 }
280
281 #[test]
282 fn ordering_is_capability_ascending() {
283 assert!(Profile::Disabled < Profile::Ascii);
284 assert!(Profile::Ascii < Profile::Ansi);
285 assert!(Profile::Ansi < Profile::Ansi256);
286 assert!(Profile::Ansi256 < Profile::TrueColor);
287 }
288
289 #[test]
290 fn no_tty_clamps_to_notty() {
291 let e = env(&[("TERM", "xterm-256color")]);
292 assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
293 }
294
295 #[test]
296 fn dumb_term_is_notty() {
297 let e = env(&[("TERM", "dumb")]);
298 assert_eq!(Profile::detect_from(&e, true), Profile::Disabled);
299 }
300
301 #[test]
302 fn no_color_clamps_to_ascii() {
303 let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
304 assert_eq!(Profile::detect_from(&e, true), Profile::Ascii);
305 }
306
307 #[test]
311 fn no_color_clamps_for_any_non_empty_value() {
312 for v in ["1", "0", "false", "no", "yes", "please", " "] {
313 let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", v)]);
314 assert_eq!(
315 Profile::detect_from(&e, true),
316 Profile::Ascii,
317 "NO_COLOR={v:?} should disable color"
318 );
319 }
320 for e in [
321 env(&[("TERM", "xterm-256color"), ("NO_COLOR", "")]),
322 env(&[("TERM", "xterm-256color")]),
323 ] {
324 assert!(Profile::detect_from(&e, true) > Profile::Ascii);
325 }
326 }
327
328 #[test]
329 fn no_color_does_not_apply_off_tty() {
330 let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
332 assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
333 }
334
335 #[test]
336 fn clicolor_force_overrides_notty() {
337 let e = env(&[("TERM", "dumb"), ("CLICOLOR_FORCE", "1")]);
338 assert!(Profile::detect_from(&e, true) >= Profile::Ansi);
341 }
342
343 #[test]
344 fn clicolor_bumps_to_ansi_on_tty() {
345 let e = env(&[("CLICOLOR", "1"), ("TERM", "screen")]);
347 let p = Profile::detect_from(&e, true);
348 assert!(p >= Profile::Ansi);
349 }
350
351 #[test]
352 fn colorterm_truecolor_upgrades() {
353 let e = env(&[("TERM", "xterm"), ("COLORTERM", "truecolor")]);
354 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
355 }
356
357 #[test]
358 fn colorterm_24bit_upgrades() {
359 let e = env(&[("TERM", "xterm"), ("COLORTERM", "24bit")]);
360 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
361 }
362
363 #[test]
364 fn colorterm_does_not_upgrade_inside_screen() {
365 let e = env(&[("TERM", "screen-256color"), ("COLORTERM", "truecolor")]);
366 let p = Profile::detect_from(&e, true);
368 assert!(p < Profile::TrueColor);
369 }
370
371 #[test]
372 fn colorterm_upgrades_inside_tmux() {
373 let e = env(&[("TERM", "tmux-256color"), ("COLORTERM", "truecolor")]);
374 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
376 }
377
378 #[test]
379 fn known_terminal_is_truecolor() {
380 for name in [
381 "alacritty",
382 "wezterm",
383 "ghostty",
384 "kitty-direct",
385 "xterm-kitty",
386 ] {
387 let e = env(&[("TERM", name)]);
388 assert_eq!(
389 Profile::detect_from(&e, true),
390 Profile::TrueColor,
391 "TERM={name} should detect as TrueColor",
392 );
393 }
394 }
395
396 #[test]
397 fn term_256color_suffix_is_ansi256() {
398 let e = env(&[("TERM", "xterm-256color")]);
399 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
400 }
401
402 #[test]
403 fn term_direct_suffix_is_truecolor() {
404 let e = env(&[("TERM", "tmux-direct")]);
405 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
406 }
407
408 #[test]
409 fn screen_floor_is_ansi256() {
410 let e = env(&[("TERM", "screen")]);
411 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
412 }
413
414 #[test]
415 fn tmux_floor_is_ansi256() {
416 let e = env(&[("TERM", "tmux")]);
417 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
418 }
419
420 #[test]
421 fn wt_session_implies_truecolor() {
422 let e = env(&[("TERM", "xterm"), ("WT_SESSION", "1234")]);
423 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
424 }
425
426 #[cfg(windows)]
427 #[test]
428 fn wt_session_without_term_is_truecolor_on_windows() {
429 let e = env(&[("WT_SESSION", "1234")]);
432 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
433 }
434
435 #[cfg(windows)]
436 #[test]
437 fn empty_term_falls_back_to_ansi256_on_windows() {
438 let e = env(&[]);
441 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
442 }
443
444 #[test]
445 fn google_cloud_shell_implies_truecolor() {
446 let e = env(&[("GOOGLE_CLOUD_SHELL", "true"), ("TERM", "xterm")]);
447 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
448 }
449
450 #[test]
451 fn ci_implies_truecolor() {
452 let e = env(&[("CI", "true"), ("TERM", "dumb")]);
453 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
454 }
455
456 #[test]
457 fn xterm_plain_is_ansi() {
458 let e = env(&[("TERM", "xterm")]);
459 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi);
460 }
461
462 #[test]
463 fn env_bool_parses_truthy_values() {
464 let cases = [
465 ("1", true),
466 ("0", false),
467 ("true", true),
468 ("True", true),
469 ("TRUE", true),
470 ("t", true),
471 ("T", true),
472 ("false", false),
473 ("False", false),
474 ("FALSE", false),
475 ("", false),
476 ("yes", false),
477 ("garbage", false),
478 ];
479 for (v, want) in cases {
480 let e = env(&[("X", v)]);
481 assert_eq!(is_truthy(&e, "X"), want, "bool({v:?})");
482 }
483 }
484
485 #[test]
486 fn convert_to_each_profile() {
487 let red = Color::Rgb(255, 0, 0);
488 assert!(Profile::Disabled.convert(red).is_none());
489 assert!(Profile::Ascii.convert(red).is_none());
490 assert!(Profile::Ansi.convert(red).is_some());
491 assert!(Profile::Ansi256.convert(red).is_some());
492 assert_eq!(Profile::TrueColor.convert(red), Some(red));
493 }
494}