use std::env;
use std::io::{self, IsTerminal, Write};
use crate::cli::ColorArg;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ColorPolicy {
pub stdout: bool,
pub stderr: bool,
}
impl ColorPolicy {
#[must_use]
pub(crate) fn resolve(choice: ColorArg) -> Self {
match choice {
ColorArg::Always => {
return Self {
stdout: true,
stderr: true,
};
}
ColorArg::Never => {
return Self {
stdout: false,
stderr: false,
};
}
ColorArg::Auto => {}
}
if decoration_suppressed() {
return Self {
stdout: false,
stderr: false,
};
}
let forced = env::var_os("FORCE_COLOR")
.is_some_and(|value| !value.is_empty() && value != "0")
|| env::var_os("CLICOLOR_FORCE").is_some_and(|value| value != "0");
if forced {
return Self {
stdout: true,
stderr: true,
};
}
Self {
stdout: io::stdout().is_terminal(),
stderr: io::stderr().is_terminal(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TerminalInfo {
pub stdin_is_tty: bool,
pub stdout_is_tty: bool,
pub stderr_is_tty: bool,
pub width: Option<u16>,
pub height: Option<u16>,
pub is_ci: bool,
}
impl TerminalInfo {
#[must_use]
pub(crate) fn detect(forced_width: Option<u16>) -> Self {
let stdout_is_tty = io::stdout().is_terminal();
let size = if stdout_is_tty {
crossterm::terminal::size().ok()
} else {
None
};
let width = forced_width
.or_else(|| env_size("COLUMNS"))
.or_else(|| size.map(|(cols, _)| cols));
let height = env_size("LINES").or_else(|| size.map(|(_, rows)| rows));
Self {
stdin_is_tty: io::stdin().is_terminal(),
stdout_is_tty,
stderr_is_tty: io::stderr().is_terminal(),
width,
height,
is_ci: detect_ci(),
}
}
#[must_use]
pub(crate) fn fit_columns(&self) -> usize {
self.width.map_or(0, usize::from)
}
#[must_use]
pub(crate) fn page_rows(&self) -> usize {
const RESERVED_ROWS: usize = 6;
const DEFAULT_ROWS: usize = 25;
self.height
.map(usize::from)
.and_then(|rows| rows.checked_sub(RESERVED_ROWS))
.filter(|rows| *rows >= 5)
.unwrap_or(DEFAULT_ROWS)
}
}
pub(crate) fn decoration_suppressed() -> bool {
if env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty())
|| env::var_os("CLICOLOR").is_some_and(|value| value == "0")
{
return true;
}
#[cfg(unix)]
{
match env::var("TERM") {
Ok(term) => term.is_empty() || term == "dumb",
Err(_) => true,
}
}
#[cfg(not(unix))]
{
env::var("TERM").is_ok_and(|term| term == "dumb")
}
}
pub(crate) fn supports_wide_glyphs() -> bool {
["LC_ALL", "LC_CTYPE", "LANG"]
.iter()
.find_map(|key| env::var(key).ok().filter(|value| !value.is_empty()))
.is_some_and(|value| {
let value = value.to_ascii_uppercase();
value.contains("UTF-8") || value.contains("UTF8")
})
}
fn env_size(key: &str) -> Option<u16> {
env::var(key)
.ok()?
.trim()
.parse::<u16>()
.ok()
.filter(|n| *n > 0)
}
fn detect_ci() -> bool {
const MARKERS: [&str; 7] = [
"CI",
"CONTINUOUS_INTEGRATION",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"TEAMCITY_VERSION",
"TF_BUILD",
];
MARKERS.iter().any(|key| {
env::var_os(key).is_some_and(|value| !value.is_empty() && value != "0" && value != "false")
})
}
#[derive(Debug)]
pub(crate) struct Context {
pub color: ColorPolicy,
pub terminal: TerminalInfo,
pub quiet_progress: bool,
pub decorate: bool,
pub wide_glyphs: bool,
}
impl Context {
#[must_use]
pub(crate) fn fit_columns(&self) -> usize {
self.terminal.fit_columns()
}
pub(crate) fn new(color: ColorArg, width: Option<u16>, quiet_progress: bool) -> Self {
Self {
color: ColorPolicy::resolve(color),
terminal: TerminalInfo::detect(width),
quiet_progress,
decorate: !decoration_suppressed(),
wide_glyphs: supports_wide_glyphs(),
}
}
}
pub(crate) fn emit<F>(render: F) -> io::Result<()>
where
F: FnOnce(&mut dyn Write) -> io::Result<()>,
{
let stdout = io::stdout();
let mut handle = stdout.lock();
match render(&mut handle).and_then(|()| handle.flush()) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(error) => Err(error),
}
}
const VENDOR_DIR: &str = "devops.bd";
const APP_DIR: &str = "reserve";
fn branded(base: std::path::PathBuf) -> std::path::PathBuf {
base.join(VENDOR_DIR).join(APP_DIR)
}
#[must_use]
pub(crate) fn cache_file(name: &str) -> std::path::PathBuf {
use etcetera::{BaseStrategy, choose_base_strategy};
let base = choose_base_strategy()
.map_or_else(|_| std::env::temp_dir(), |strategy| strategy.cache_dir());
branded(base).join(name)
}
#[must_use]
pub(crate) fn paths() -> Vec<(&'static str, String)> {
use etcetera::{BaseStrategy, choose_base_strategy};
match choose_base_strategy() {
Ok(strategy) => vec![
(
"config",
branded(strategy.config_dir()).display().to_string(),
),
("cache", branded(strategy.cache_dir()).display().to_string()),
("data", branded(strategy.data_dir()).display().to_string()),
],
Err(_) => vec![("config", "unavailable".to_owned())],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_explicit_choice_beats_every_environment_variable() {
assert_eq!(
ColorPolicy::resolve(ColorArg::Always),
ColorPolicy {
stdout: true,
stderr: true
}
);
assert_eq!(
ColorPolicy::resolve(ColorArg::Never),
ColorPolicy {
stdout: false,
stderr: false
}
);
}
#[test]
fn a_closed_pipe_ends_the_run_quietly() {
let result = emit(|_: &mut dyn Write| Err(io::Error::from(io::ErrorKind::BrokenPipe)));
assert!(
result.is_ok(),
"a downstream `head` closing stdout is normal"
);
}
#[test]
fn any_other_write_failure_is_reported_rather_than_swallowed() {
let result =
emit(|_: &mut dyn Write| Err(io::Error::from(io::ErrorKind::PermissionDenied)));
assert_eq!(
result.unwrap_err().kind(),
io::ErrorKind::PermissionDenied,
"only a broken pipe may be turned into success"
);
}
#[test]
fn the_renderer_is_handed_the_stream_and_its_success_is_passed_on() {
let mut rendered = false;
let result = emit(|_: &mut dyn Write| {
rendered = true;
Ok(())
});
assert!(rendered);
assert!(result.is_ok());
}
#[test]
fn a_page_leaves_room_for_the_header_and_summary() {
let terminal = TerminalInfo {
stdin_is_tty: true,
stdout_is_tty: true,
stderr_is_tty: true,
width: Some(80),
height: Some(30),
is_ci: false,
};
assert_eq!(terminal.page_rows(), 24);
assert_eq!(terminal.fit_columns(), 80);
}
#[test]
fn a_tiny_or_unknown_terminal_falls_back_to_a_sane_page() {
let tiny = TerminalInfo {
stdin_is_tty: true,
stdout_is_tty: true,
stderr_is_tty: true,
width: Some(80),
height: Some(4),
is_ci: false,
};
assert_eq!(tiny.page_rows(), 25);
let unknown = TerminalInfo {
height: None,
..tiny
};
assert_eq!(unknown.page_rows(), 25);
}
}