reserve 0.2.0

Check domain name availability across grouped extensions, straight from the registry
//! Where output goes, whether it may be coloured, and whether anyone is there to answer.

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();
        // @docgen The size probe reads the controlling terminal, not stdout, so without this gate a pipe still gets rows cut to the window.
        let size = if stdout_is_tty {
            crossterm::terminal::size().ok()
        } else {
            None
        };
        // @docgen COLUMNS and LINES are what a shell exports and a test harness sets, so they win over the terminal's own answer.
        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(),
        }
    }

    /// @docgen An explicit width is honoured even when stdout is not a terminal, so `COLUMNS=60` still fits piped output on request.
    #[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)
    }
}

/// @docgen Both conventions mean the same thing to every decoration the tool draws, so colour and the progress line read one answer.
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;
    }

    // @docgen Windows leaves TERM unset on a console that renders colour perfectly well, so only a unix host reads absence as dumb.
    #[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")
    }
}

/// @docgen A terminal outside a UTF-8 locale draws a braille frame as a replacement box, so the plain frames are used instead.
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,
    /// @docgen A machine reading the results and an unattended run both want the stream quiet, whatever the terminal says.
    pub quiet_progress: bool,
    /// @docgen Decided once at startup, because code that reads the environment ad hoc behaves differently under a test than under a run.
    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(),
        }
    }
}

/// @docgen A closed pipe ends the run quietly rather than as an error, because a downstream `head` closing stdout is normal.
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),
    }
}

/// @docgen Grouped under the business domain so every tool from it shares one folder instead of each scattering an entry of its own.
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);
    }
}