1use super::Color;
37use crate::terminal::Env;
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 let env = Env::from_process();
99 Self::detect_from(&env, true)
100 }
101
102 pub fn detect_from(env: &Env, is_tty: bool) -> Self {
110 let is_tty = is_tty || env.is_truthy("TTY_FORCE");
111 let term = env.get("TERM").unwrap_or_default();
112
113 let envp = env_color_profile(env, &term);
120 let mut p = if !is_tty { Profile::Disabled } else { envp };
121
122 if env.is_truthy("NO_COLOR") && is_tty {
124 if p > Profile::Ascii {
125 p = Profile::Ascii;
126 }
127 return p;
128 }
129
130 if env.is_truthy("CLICOLOR_FORCE") {
132 if p < Profile::Ansi {
133 p = Profile::Ansi;
134 }
135 if envp > p {
136 p = envp;
137 }
138 return p;
139 }
140
141 let is_dumb = term.is_empty() || term == DUMB_TERM;
142 if env.is_truthy("CLICOLOR") && is_tty && !is_dumb && p < Profile::Ansi {
144 p = Profile::Ansi;
145 }
146
147 p
148 }
149}
150
151const DUMB_TERM: &str = "dumb";
152
153fn env_color_profile(env: &Env, term: &str) -> Profile {
155 let mut p = if term == DUMB_TERM {
156 Profile::Disabled
158 } else if term.is_empty() {
159 #[cfg(windows)]
163 {
164 windows_color_profile(env).unwrap_or(Profile::Disabled)
165 }
166 #[cfg(not(windows))]
167 {
168 let _ = env;
169 Profile::Disabled
170 }
171 } else {
172 Profile::Ansi
173 };
174
175 if KNOWN_TRUECOLOR_TERMS.iter().any(|t| term.contains(t)) {
177 return Profile::TrueColor;
178 }
179
180 if term.starts_with("tmux") || term.starts_with("screen") {
181 if p < Profile::Ansi256 {
182 p = Profile::Ansi256;
183 }
184 } else if term.starts_with("xterm") && p < Profile::Ansi {
185 p = Profile::Ansi;
186 }
187
188 if env.has("WT_SESSION") {
190 return Profile::TrueColor;
191 }
192
193 if env.is_truthy("GOOGLE_CLOUD_SHELL") {
194 return Profile::TrueColor;
195 }
196
197 if env.is_truthy("CI") {
200 return Profile::TrueColor;
201 }
202
203 if colorterm_says_truecolor(env) && !term.starts_with("screen") {
207 return Profile::TrueColor;
208 }
209
210 if term.ends_with("256color") && p < Profile::Ansi256 {
211 p = Profile::Ansi256;
212 }
213
214 if term.ends_with("direct") {
215 return Profile::TrueColor;
216 }
217
218 p
219}
220
221const KNOWN_TRUECOLOR_TERMS: &[&str] = &[
223 "alacritty",
224 "contour",
225 "foot",
226 "ghostty",
227 "kitty",
228 "rio",
229 "st",
230 "wezterm",
231];
232
233fn colorterm_says_truecolor(env: &Env) -> bool {
234 let v = env
235 .get("COLORTERM")
236 .unwrap_or_default()
237 .to_ascii_lowercase();
238 matches!(v.as_str(), "truecolor" | "24bit" | "yes" | "true")
239}
240
241#[cfg(windows)]
242fn windows_color_profile(env: &Env) -> Option<Profile> {
243 if env.has("WT_SESSION") {
249 return Some(Profile::TrueColor);
250 }
251 Some(Profile::Ansi256)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn env(pairs: &[(&str, &str)]) -> Env {
259 Env::from_pairs(pairs.iter().map(|(k, v)| (*k, *v)))
260 }
261
262 #[test]
263 fn default_is_truecolor() {
264 assert_eq!(Profile::default(), Profile::TrueColor);
265 }
266
267 #[test]
268 fn ordering_is_capability_ascending() {
269 assert!(Profile::Disabled < Profile::Ascii);
270 assert!(Profile::Ascii < Profile::Ansi);
271 assert!(Profile::Ansi < Profile::Ansi256);
272 assert!(Profile::Ansi256 < Profile::TrueColor);
273 }
274
275 #[test]
276 fn no_tty_clamps_to_notty() {
277 let e = env(&[("TERM", "xterm-256color")]);
278 assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
279 }
280
281 #[test]
282 fn dumb_term_is_notty() {
283 let e = env(&[("TERM", "dumb")]);
284 assert_eq!(Profile::detect_from(&e, true), Profile::Disabled);
285 }
286
287 #[test]
288 fn no_color_clamps_to_ascii() {
289 let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
290 assert_eq!(Profile::detect_from(&e, true), Profile::Ascii);
291 }
292
293 #[test]
294 fn no_color_does_not_apply_off_tty() {
295 let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
297 assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
298 }
299
300 #[test]
301 fn clicolor_force_overrides_notty() {
302 let e = env(&[("TERM", "dumb"), ("CLICOLOR_FORCE", "1")]);
303 assert!(Profile::detect_from(&e, true) >= Profile::Ansi);
306 }
307
308 #[test]
309 fn clicolor_bumps_to_ansi_on_tty() {
310 let e = env(&[("CLICOLOR", "1"), ("TERM", "screen")]);
312 let p = Profile::detect_from(&e, true);
313 assert!(p >= Profile::Ansi);
314 }
315
316 #[test]
317 fn colorterm_truecolor_upgrades() {
318 let e = env(&[("TERM", "xterm"), ("COLORTERM", "truecolor")]);
319 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
320 }
321
322 #[test]
323 fn colorterm_24bit_upgrades() {
324 let e = env(&[("TERM", "xterm"), ("COLORTERM", "24bit")]);
325 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
326 }
327
328 #[test]
329 fn colorterm_does_not_upgrade_inside_screen() {
330 let e = env(&[("TERM", "screen-256color"), ("COLORTERM", "truecolor")]);
331 let p = Profile::detect_from(&e, true);
333 assert!(p < Profile::TrueColor);
334 }
335
336 #[test]
337 fn colorterm_upgrades_inside_tmux() {
338 let e = env(&[("TERM", "tmux-256color"), ("COLORTERM", "truecolor")]);
339 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
341 }
342
343 #[test]
344 fn known_terminal_is_truecolor() {
345 for name in [
346 "alacritty",
347 "wezterm",
348 "ghostty",
349 "kitty-direct",
350 "xterm-kitty",
351 ] {
352 let e = env(&[("TERM", name)]);
353 assert_eq!(
354 Profile::detect_from(&e, true),
355 Profile::TrueColor,
356 "TERM={name} should detect as TrueColor",
357 );
358 }
359 }
360
361 #[test]
362 fn term_256color_suffix_is_ansi256() {
363 let e = env(&[("TERM", "xterm-256color")]);
364 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
365 }
366
367 #[test]
368 fn term_direct_suffix_is_truecolor() {
369 let e = env(&[("TERM", "tmux-direct")]);
370 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
371 }
372
373 #[test]
374 fn screen_floor_is_ansi256() {
375 let e = env(&[("TERM", "screen")]);
376 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
377 }
378
379 #[test]
380 fn tmux_floor_is_ansi256() {
381 let e = env(&[("TERM", "tmux")]);
382 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
383 }
384
385 #[test]
386 fn wt_session_implies_truecolor() {
387 let e = env(&[("TERM", "xterm"), ("WT_SESSION", "1234")]);
388 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
389 }
390
391 #[cfg(windows)]
392 #[test]
393 fn wt_session_without_term_is_truecolor_on_windows() {
394 let e = env(&[("WT_SESSION", "1234")]);
397 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
398 }
399
400 #[cfg(windows)]
401 #[test]
402 fn empty_term_falls_back_to_ansi256_on_windows() {
403 let e = env(&[]);
406 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
407 }
408
409 #[test]
410 fn google_cloud_shell_implies_truecolor() {
411 let e = env(&[("GOOGLE_CLOUD_SHELL", "true"), ("TERM", "xterm")]);
412 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
413 }
414
415 #[test]
416 fn ci_implies_truecolor() {
417 let e = env(&[("CI", "true"), ("TERM", "dumb")]);
418 assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
419 }
420
421 #[test]
422 fn xterm_plain_is_ansi() {
423 let e = env(&[("TERM", "xterm")]);
424 assert_eq!(Profile::detect_from(&e, true), Profile::Ansi);
425 }
426
427 #[test]
428 fn env_bool_parses_truthy_values() {
429 let cases = [
430 ("1", true),
431 ("0", false),
432 ("true", true),
433 ("True", true),
434 ("TRUE", true),
435 ("t", true),
436 ("T", true),
437 ("false", false),
438 ("False", false),
439 ("FALSE", false),
440 ("", false),
441 ("yes", false),
442 ("garbage", false),
443 ];
444 for (v, want) in cases {
445 let e = env(&[("X", v)]);
446 assert_eq!(e.is_truthy("X"), want, "bool({v:?})");
447 }
448 }
449
450 #[test]
451 fn convert_to_each_profile() {
452 let red = Color::Rgb(255, 0, 0);
453 assert!(Profile::Disabled.convert(red).is_none());
454 assert!(Profile::Ascii.convert(red).is_none());
455 assert!(Profile::Ansi.convert(red).is_some());
456 assert!(Profile::Ansi256.convert(red).is_some());
457 assert_eq!(Profile::TrueColor.convert(red), Some(red));
458 }
459}