use linesmith_core::theme::Capability;
use crate::config::ColorPolicy;
#[derive(Debug, Default, Clone)]
pub(super) struct EnvironmentSnapshot {
pub(super) no_color: bool,
pub(super) term_program: Option<String>,
pub(super) tmux_active: bool,
}
impl EnvironmentSnapshot {
pub(super) fn from_process() -> Self {
Self {
no_color: std::env::var_os("NO_COLOR").is_some(),
term_program: std::env::var("TERM_PROGRAM").ok(),
tmux_active: std::env::var_os("TMUX").is_some()
|| std::env::var("TERM")
.map(|v| v.starts_with("tmux"))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum LimitedTier {
Palette16,
Palette256,
}
impl LimitedTier {
fn from_capability(cap: Capability) -> Option<Self> {
match cap {
Capability::Palette16 => Some(Self::Palette16),
Capability::Palette256 => Some(Self::Palette256),
Capability::None | Capability::TrueColor => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum EnvironmentWarning {
NoColorSet,
NoColorSupport,
LimitedPalette(LimitedTier),
VsCodeContrastShim,
TmuxNoTrueColorPassthrough,
}
impl EnvironmentWarning {
#[must_use]
pub(super) fn message(&self) -> String {
match self {
Self::NoColorSet => {
"NO_COLOR is set; theme colors are disabled. Unset NO_COLOR to enable.".into()
}
Self::NoColorSupport => "terminal didn't advertise color support; check `$TERM` (e.g., `export TERM=xterm-256color`).".into(),
Self::LimitedPalette(tier) => format!(
"terminal supports only {} colors; truecolor themes will downgrade.",
match tier {
LimitedTier::Palette16 => "16",
LimitedTier::Palette256 => "256",
}
),
Self::VsCodeContrastShim => "VSCode terminal: `terminal.integrated.minimumContrastRatio` may distort theme colors. Set to 1 to disable.".into(),
Self::TmuxNoTrueColorPassthrough => "tmux active with reduced color tier; if your outer terminal supports truecolor, add `set -ga terminal-overrides ',*:Tc'` to tmux.conf.".into(),
}
}
}
pub(super) fn compute_environment_warnings(
capability: Capability,
color_policy: ColorPolicy,
env: &EnvironmentSnapshot,
) -> Vec<EnvironmentWarning> {
let mut warnings = Vec::new();
if matches!(color_policy, ColorPolicy::Never) {
return warnings;
}
if matches!(color_policy, ColorPolicy::Auto) {
if env.no_color {
warnings.push(EnvironmentWarning::NoColorSet);
} else if capability == Capability::None {
warnings.push(EnvironmentWarning::NoColorSupport);
} else if let Some(tier) = LimitedTier::from_capability(capability) {
warnings.push(EnvironmentWarning::LimitedPalette(tier));
}
}
if env
.term_program
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case("vscode"))
{
warnings.push(EnvironmentWarning::VsCodeContrastShim);
}
if env.tmux_active && LimitedTier::from_capability(capability).is_some() {
warnings.push(EnvironmentWarning::TmuxNoTrueColorPassthrough);
}
warnings
}
pub(super) fn prepend_env_warnings(
warnings: &mut Vec<String>,
capability: Capability,
color_policy: ColorPolicy,
env: &EnvironmentSnapshot,
) {
let env_warnings = compute_environment_warnings(capability, color_policy, env);
if env_warnings.is_empty() {
return;
}
let prefixed: Vec<String> = env_warnings
.iter()
.map(EnvironmentWarning::message)
.collect();
warnings.splice(0..0, prefixed);
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_env() -> EnvironmentSnapshot {
EnvironmentSnapshot::default()
}
#[test]
fn no_color_wins_over_palette_tier_under_auto_policy() {
let env = EnvironmentSnapshot {
no_color: true,
..empty_env()
};
let warnings = compute_environment_warnings(Capability::Palette16, ColorPolicy::Auto, &env);
assert_eq!(warnings, vec![EnvironmentWarning::NoColorSet]);
}
#[test]
fn no_color_support_fires_when_capability_is_none_under_auto_policy() {
let warnings =
compute_environment_warnings(Capability::None, ColorPolicy::Auto, &empty_env());
assert_eq!(warnings, vec![EnvironmentWarning::NoColorSupport]);
}
#[test]
fn limited_palette_fires_only_when_below_truecolor_under_auto() {
for (cap, tier) in [
(Capability::Palette16, LimitedTier::Palette16),
(Capability::Palette256, LimitedTier::Palette256),
] {
let warnings = compute_environment_warnings(cap, ColorPolicy::Auto, &empty_env());
assert_eq!(
warnings,
vec![EnvironmentWarning::LimitedPalette(tier)],
"expected LimitedPalette({tier:?}) for {cap:?}",
);
}
let warnings =
compute_environment_warnings(Capability::TrueColor, ColorPolicy::Auto, &empty_env());
assert!(
warnings.is_empty(),
"TrueColor capability with empty env emits no warnings",
);
}
#[test]
fn always_policy_suppresses_ladder_but_keeps_terminal_specific() {
let env = EnvironmentSnapshot {
no_color: true,
term_program: Some("vscode".to_string()),
tmux_active: true,
};
let warnings =
compute_environment_warnings(Capability::Palette16, ColorPolicy::Always, &env);
assert!(
!warnings.contains(&EnvironmentWarning::NoColorSet),
"Always policy must suppress NoColorSet (user override): {warnings:?}",
);
assert!(
!warnings.contains(&EnvironmentWarning::LimitedPalette(LimitedTier::Palette16)),
"Always policy must suppress LimitedPalette: {warnings:?}",
);
assert!(warnings.contains(&EnvironmentWarning::VsCodeContrastShim));
assert!(warnings.contains(&EnvironmentWarning::TmuxNoTrueColorPassthrough));
}
#[test]
fn never_policy_emits_no_warnings_at_all() {
let env = EnvironmentSnapshot {
no_color: true,
term_program: Some("vscode".to_string()),
tmux_active: true,
};
let warnings =
compute_environment_warnings(Capability::Palette16, ColorPolicy::Never, &env);
assert!(
warnings.is_empty(),
"Never policy must emit no warnings, got {warnings:?}",
);
}
#[test]
fn vscode_warning_is_additive_with_palette_warning() {
let env = EnvironmentSnapshot {
term_program: Some("vscode".to_string()),
..empty_env()
};
let warnings =
compute_environment_warnings(Capability::Palette256, ColorPolicy::Auto, &env);
assert_eq!(
warnings,
vec![
EnvironmentWarning::LimitedPalette(LimitedTier::Palette256),
EnvironmentWarning::VsCodeContrastShim,
],
);
}
#[test]
fn vscode_check_is_case_insensitive() {
for variant in ["vscode", "VSCode", "VSCODE"] {
let env = EnvironmentSnapshot {
term_program: Some(variant.to_string()),
..empty_env()
};
let warnings =
compute_environment_warnings(Capability::TrueColor, ColorPolicy::Auto, &env);
assert_eq!(
warnings,
vec![EnvironmentWarning::VsCodeContrastShim],
"term_program={variant:?}",
);
}
}
#[test]
fn tmux_warning_only_fires_when_colors_render_at_reduced_tier() {
let tmux_env = EnvironmentSnapshot {
tmux_active: true,
..empty_env()
};
for cap in [Capability::Palette16, Capability::Palette256] {
let warnings = compute_environment_warnings(cap, ColorPolicy::Auto, &tmux_env);
assert!(
warnings.contains(&EnvironmentWarning::TmuxNoTrueColorPassthrough),
"expected tmux warn for {cap:?}",
);
}
let warnings = compute_environment_warnings(Capability::None, ColorPolicy::Auto, &tmux_env);
assert!(
!warnings.contains(&EnvironmentWarning::TmuxNoTrueColorPassthrough),
"tmux warn must not fire when capability is None",
);
let warnings =
compute_environment_warnings(Capability::TrueColor, ColorPolicy::Auto, &tmux_env);
assert!(!warnings.contains(&EnvironmentWarning::TmuxNoTrueColorPassthrough));
}
#[test]
fn three_way_additive_palette_vscode_tmux() {
let env = EnvironmentSnapshot {
term_program: Some("vscode".to_string()),
tmux_active: true,
..empty_env()
};
let warnings =
compute_environment_warnings(Capability::Palette256, ColorPolicy::Auto, &env);
assert_eq!(
warnings,
vec![
EnvironmentWarning::LimitedPalette(LimitedTier::Palette256),
EnvironmentWarning::VsCodeContrastShim,
EnvironmentWarning::TmuxNoTrueColorPassthrough,
],
"all three additives must co-occur in declared order",
);
}
#[test]
fn truecolor_with_clean_env_emits_no_warnings() {
let warnings =
compute_environment_warnings(Capability::TrueColor, ColorPolicy::Auto, &empty_env());
assert!(
warnings.is_empty(),
"truecolor + empty env must emit no warnings, got {warnings:?}",
);
}
#[test]
fn message_renders_remediation_imperative_for_each_variant() {
let cases = [
(
EnvironmentWarning::NoColorSet,
vec!["NO_COLOR", "Unset NO_COLOR"],
),
(
EnvironmentWarning::NoColorSupport,
vec!["color support", "$TERM"],
),
(
EnvironmentWarning::LimitedPalette(LimitedTier::Palette16),
vec!["16 colors", "downgrade"],
),
(
EnvironmentWarning::LimitedPalette(LimitedTier::Palette256),
vec!["256 colors", "downgrade"],
),
(
EnvironmentWarning::VsCodeContrastShim,
vec!["minimumContrastRatio", "Set to 1"],
),
(
EnvironmentWarning::TmuxNoTrueColorPassthrough,
vec!["terminal-overrides", "outer terminal"],
),
];
for (warn, expected_phrases) in cases {
let msg = warn.message();
for phrase in expected_phrases {
assert!(
msg.contains(phrase),
"{warn:?}'s message must mention {phrase:?}: {msg}",
);
}
}
}
#[test]
fn prepend_env_warnings_lands_env_warnings_at_index_zero() {
let env = EnvironmentSnapshot {
no_color: true,
..empty_env()
};
let mut warnings = vec![
"runtime warning A".to_string(),
"runtime warning B".to_string(),
];
prepend_env_warnings(
&mut warnings,
Capability::Palette16,
ColorPolicy::Auto,
&env,
);
assert_eq!(warnings.len(), 3);
assert!(
warnings[0].contains("NO_COLOR"),
"env warning must come first, got: {warnings:?}",
);
assert_eq!(warnings[1], "runtime warning A");
assert_eq!(warnings[2], "runtime warning B");
}
#[test]
fn prepend_env_warnings_is_noop_when_compute_returns_empty() {
let mut warnings = vec!["runtime only".to_string()];
prepend_env_warnings(
&mut warnings,
Capability::TrueColor,
ColorPolicy::Auto,
&empty_env(),
);
assert_eq!(warnings, vec!["runtime only".to_string()]);
}
}