#![allow(dead_code)]
use std::borrow::Cow;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformInfo {
pub os: &'static str,
pub arch: &'static str,
pub is_windows: bool,
pub is_macos: bool,
pub is_linux: bool,
pub unicode_likely: bool,
}
impl PlatformInfo {
#[must_use]
pub fn current() -> Self {
Self {
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
is_windows: cfg!(target_os = "windows"),
is_macos: cfg!(target_os = "macos"),
is_linux: cfg!(target_os = "linux"),
unicode_likely: !cfg!(target_os = "windows")
|| std::env::var("WT_SESSION").is_ok()
|| std::env::var("TERM_PROGRAM").is_ok_and(|v| v.contains("vscode")),
}
}
#[must_use]
pub fn is_ci() -> bool {
std::env::var("CI").is_ok()
|| std::env::var("GITHUB_ACTIONS").is_ok()
|| std::env::var("TRAVIS").is_ok()
|| std::env::var("CIRCLECI").is_ok()
|| std::env::var("GITLAB_CI").is_ok()
}
#[must_use]
pub fn snapshot_suffix(&self) -> &'static str {
if self.is_windows {
"windows"
} else if self.is_macos {
"macos"
} else {
"linux"
}
}
}
impl Default for PlatformInfo {
fn default() -> Self {
Self::current()
}
}
#[must_use]
pub fn normalize_line_endings(s: &str) -> Cow<'_, str> {
if !s.contains('\r') {
return Cow::Borrowed(s);
}
Cow::Owned(s.replace("\r\n", "\n").replace('\r', "\n"))
}
#[must_use]
pub fn to_native_line_endings(s: &str) -> Cow<'_, str> {
#[cfg(target_os = "windows")]
{
if !s.contains('\n') || s.contains("\r\n") {
return Cow::Borrowed(s);
}
let mut result = String::with_capacity(s.len() + s.matches('\n').count());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\n' {
result.push_str("\r\n");
} else {
result.push(c);
}
}
Cow::Owned(result)
}
#[cfg(not(target_os = "windows"))]
{
Cow::Borrowed(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BoxCharSet {
#[default]
Unicode,
Ascii,
}
impl BoxCharSet {
#[must_use]
pub fn for_platform() -> Self {
let info = PlatformInfo::current();
if info.unicode_likely {
Self::Unicode
} else {
Self::Ascii
}
}
#[must_use]
pub fn horizontal(self) -> char {
match self {
Self::Unicode => '─',
Self::Ascii => '-',
}
}
#[must_use]
pub fn vertical(self) -> char {
match self {
Self::Unicode => '│',
Self::Ascii => '|',
}
}
#[must_use]
pub fn top_left(self) -> char {
match self {
Self::Unicode => '┌',
Self::Ascii => '+',
}
}
#[must_use]
pub fn top_right(self) -> char {
match self {
Self::Unicode => '┐',
Self::Ascii => '+',
}
}
#[must_use]
pub fn bottom_left(self) -> char {
match self {
Self::Unicode => '└',
Self::Ascii => '+',
}
}
#[must_use]
pub fn bottom_right(self) -> char {
match self {
Self::Unicode => '┘',
Self::Ascii => '+',
}
}
}
#[must_use]
pub fn unicode_to_ascii_boxes(s: &str) -> String {
s.chars()
.map(|c| match c {
'─' | '━' | '═' => '-',
'│' | '┃' | '║' => '|',
'┌' | '┏' | '╔' | '╭' => '+',
'┐' | '┓' | '╗' | '╮' => '+',
'└' | '┗' | '╚' | '╰' => '+',
'┘' | '┛' | '╝' | '╯' => '+',
'├' | '┣' | '╠' => '+',
'┤' | '┫' | '╣' => '+',
'┬' | '┳' | '╦' => '+',
'┴' | '┻' | '╩' => '+',
'┼' | '╋' | '╬' => '+',
_ => c,
})
.collect()
}
#[track_caller]
pub fn assert_eq_normalized(context: &str, actual: &str, expected: &str) {
let actual_norm = normalize_line_endings(actual);
let expected_norm = normalize_line_endings(expected);
if actual_norm != expected_norm {
panic!(
"{context}: strings differ after line ending normalization.\n\
Expected:\n{expected_norm:?}\n\
Actual:\n{actual_norm:?}"
);
}
}
#[track_caller]
pub fn assert_eq_platform_agnostic(context: &str, actual: &str, expected: &str) {
let actual_norm = unicode_to_ascii_boxes(&normalize_line_endings(actual));
let expected_norm = unicode_to_ascii_boxes(&normalize_line_endings(expected));
if actual_norm != expected_norm {
panic!(
"{context}: strings differ after platform normalization.\n\
Expected:\n{expected_norm:?}\n\
Actual:\n{actual_norm:?}"
);
}
}
#[track_caller]
pub fn skip_unless_windows() {
if !cfg!(target_os = "windows") {
eprintln!("Skipping test: Windows-only");
}
}
#[track_caller]
pub fn skip_unless_unix() {
if cfg!(target_os = "windows") {
eprintln!("Skipping test: Unix-only");
}
}
#[track_caller]
pub fn skip_unless_ci() {
if !PlatformInfo::is_ci() {
eprintln!("Skipping test: CI-only");
}
}
pub fn with_env_var<F, R>(key: &str, value: &str, f: F) -> R
where
F: FnOnce() -> R,
{
let original = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
let result = f();
match original {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
result
}
pub fn without_env_var<F, R>(key: &str, f: F) -> R
where
F: FnOnce() -> R,
{
let original = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
let result = f();
if let Some(v) = original {
unsafe { std::env::set_var(key, v) };
}
result
}
#[derive(Debug, Clone)]
pub struct TerminalEnv {
pub term: Option<String>,
pub colorterm: Option<String>,
pub no_color: bool,
pub force_color: bool,
pub columns: Option<u16>,
pub lines: Option<u16>,
}
impl TerminalEnv {
#[must_use]
pub fn new() -> Self {
Self {
term: Some("xterm-256color".to_string()),
colorterm: Some("truecolor".to_string()),
no_color: false,
force_color: false,
columns: Some(80),
lines: Some(24),
}
}
#[must_use]
pub fn dumb() -> Self {
Self {
term: Some("dumb".to_string()),
colorterm: None,
no_color: false,
force_color: false,
columns: Some(80),
lines: Some(24),
}
}
#[must_use]
pub fn no_color() -> Self {
Self {
term: Some("xterm-256color".to_string()),
colorterm: None,
no_color: true,
force_color: false,
columns: Some(80),
lines: Some(24),
}
}
pub fn apply<F, R>(&self, f: F) -> R
where
F: FnOnce() -> R,
{
let orig_term = std::env::var("TERM").ok();
let orig_colorterm = std::env::var("COLORTERM").ok();
let orig_no_color = std::env::var("NO_COLOR").ok();
let orig_force_color = std::env::var("FORCE_COLOR").ok();
let orig_columns = std::env::var("COLUMNS").ok();
let orig_lines = std::env::var("LINES").ok();
unsafe {
match &self.term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
match &self.colorterm {
Some(v) => std::env::set_var("COLORTERM", v),
None => std::env::remove_var("COLORTERM"),
}
if self.no_color {
std::env::set_var("NO_COLOR", "1");
} else {
std::env::remove_var("NO_COLOR");
}
if self.force_color {
std::env::set_var("FORCE_COLOR", "1");
} else {
std::env::remove_var("FORCE_COLOR");
}
if let Some(cols) = self.columns {
std::env::set_var("COLUMNS", cols.to_string());
} else {
std::env::remove_var("COLUMNS");
}
if let Some(lines) = self.lines {
std::env::set_var("LINES", lines.to_string());
} else {
std::env::remove_var("LINES");
}
}
let result = f();
unsafe {
match orig_term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
match orig_colorterm {
Some(v) => std::env::set_var("COLORTERM", v),
None => std::env::remove_var("COLORTERM"),
}
match orig_no_color {
Some(v) => std::env::set_var("NO_COLOR", v),
None => std::env::remove_var("NO_COLOR"),
}
match orig_force_color {
Some(v) => std::env::set_var("FORCE_COLOR", v),
None => std::env::remove_var("FORCE_COLOR"),
}
match orig_columns {
Some(v) => std::env::set_var("COLUMNS", v),
None => std::env::remove_var("COLUMNS"),
}
match orig_lines {
Some(v) => std::env::set_var("LINES", v),
None => std::env::remove_var("LINES"),
}
}
result
}
}
impl Default for TerminalEnv {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_platform_info() {
let info = PlatformInfo::current();
assert!(!info.os.is_empty());
assert!(!info.arch.is_empty());
assert!(info.is_windows || info.is_macos || info.is_linux);
}
#[test]
fn test_normalize_line_endings() {
assert_eq!(normalize_line_endings("hello\nworld"), "hello\nworld");
assert_eq!(normalize_line_endings("hello\r\nworld"), "hello\nworld");
assert_eq!(normalize_line_endings("hello\rworld"), "hello\nworld");
assert_eq!(normalize_line_endings("a\r\nb\rc\n"), "a\nb\nc\n");
}
#[test]
fn test_unicode_to_ascii_boxes() {
assert_eq!(unicode_to_ascii_boxes("┌─┐"), "+-+");
assert_eq!(unicode_to_ascii_boxes("│x│"), "|x|");
assert_eq!(unicode_to_ascii_boxes("└─┘"), "+-+");
assert_eq!(unicode_to_ascii_boxes("Hello"), "Hello");
assert_eq!(unicode_to_ascii_boxes("┌──┐"), "+--+");
}
#[test]
fn test_box_char_set() {
let unicode = BoxCharSet::Unicode;
assert_eq!(unicode.horizontal(), '─');
assert_eq!(unicode.vertical(), '│');
let ascii = BoxCharSet::Ascii;
assert_eq!(ascii.horizontal(), '-');
assert_eq!(ascii.vertical(), '|');
}
#[test]
#[serial]
fn test_with_env_var() {
let original = std::env::var("TEST_PLATFORM_VAR").ok();
with_env_var("TEST_PLATFORM_VAR", "test_value", || {
assert_eq!(std::env::var("TEST_PLATFORM_VAR").unwrap(), "test_value");
});
assert_eq!(std::env::var("TEST_PLATFORM_VAR").ok(), original);
}
#[test]
#[serial]
fn test_terminal_env_apply() {
let env = TerminalEnv::dumb();
env.apply(|| {
assert_eq!(std::env::var("TERM").unwrap(), "dumb");
});
}
#[test]
#[serial]
fn test_terminal_env_no_color() {
let env = TerminalEnv::no_color();
env.apply(|| {
assert!(std::env::var("NO_COLOR").is_ok());
});
}
#[test]
fn test_assert_eq_normalized() {
assert_eq_normalized("same content", "hello\nworld", "hello\nworld");
assert_eq_normalized("crlf vs lf", "hello\nworld", "hello\r\nworld");
}
#[test]
fn test_assert_eq_platform_agnostic() {
assert_eq_platform_agnostic("same content", "hello", "hello");
assert_eq_platform_agnostic("box chars", "┌─┐", "+-+");
assert_eq_platform_agnostic("box chars multi", "┌──┐", "+--+");
}
}