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 env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
return Self {
stdout: false,
stderr: false,
};
}
if env::var("TERM").is_ok_and(|term| term == "dumb") {
return Self {
stdout: false,
stderr: false,
};
}
let forced = env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty())
|| 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 size = crossterm::terminal::size().ok();
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: io::stdout().is_terminal(),
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)
}
}
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,
}
impl Context {
#[must_use]
pub(crate) fn fit_columns(&self) -> usize {
self.terminal.fit_columns()
}
pub(crate) fn new(color: ColorArg, width: Option<u16>) -> Self {
Self {
color: ColorPolicy::resolve(color),
terminal: TerminalInfo::detect(width),
}
}
}
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),
}
}
#[must_use]
pub(crate) fn cache_file(name: &str) -> std::path::PathBuf {
use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
let base = choose_app_strategy(AppStrategyArgs {
top_level_domain: "sh".to_owned(),
author: "sharkar".to_owned(),
app_name: "reserve".to_owned(),
})
.map_or_else(|_| std::env::temp_dir().join("reserve"), |s| s.cache_dir());
base.join(name)
}
#[must_use]
pub(crate) fn paths() -> Vec<(&'static str, String)> {
use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "sh".to_owned(),
author: "sharkar".to_owned(),
app_name: "reserve".to_owned(),
});
match strategy {
Ok(strategy) => vec![
("config", strategy.config_dir().display().to_string()),
("cache", strategy.cache_dir().display().to_string()),
("data", 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);
}
}