reserve 0.1.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 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();
        // @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: io::stdout().is_terminal(),
            stderr_is_tty: io::stderr().is_terminal(),
            width,
            height,
            is_ci: detect_ci(),
        }
    }

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

/// @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),
    }
}

#[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);
    }
}