reserve 0.2.0

Check domain name availability across grouped extensions, straight from the registry
//! A progress line on stderr for the waits a person would otherwise sit through blind.

use std::io::{self, Write};
use std::time::{Duration, Instant};

use crossterm::{cursor, queue, terminal};
use unicode_width::UnicodeWidthChar;

use crate::context::Context;
use crate::output::Palette;

const BRAILLE: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
const PLAIN: [&str; 4] = ["|", "/", "-", "\\"];
const REDRAW_EVERY: Duration = Duration::from_millis(80);

/// @docgen A run that finishes in a blink must not flash a spinner, so nothing is painted until the wait is real.
const HOLD_BEFORE_FIRST_PAINT: Duration = Duration::from_millis(150);

const FALLBACK_COLUMNS: usize = 80;

#[derive(Debug)]
pub(crate) struct Progress {
    line: Option<Line>,
}

#[derive(Debug)]
struct Line {
    label: &'static str,
    total: Option<usize>,
    done: usize,
    /// @docgen The name a count alone cannot give: on a long sweep this is the only sign the run is moving through real work.
    note: String,
    frame: usize,
    started: Instant,
    last_paint: Option<Instant>,
    painted: bool,
    palette: Palette,
    frames: &'static [&'static str],
    columns: usize,
}

impl Progress {
    pub(crate) const fn hidden() -> Self {
        Self { line: None }
    }

    pub(crate) fn start(label: &'static str, total: Option<usize>, context: &Context) -> Self {
        if !shows(context) {
            return Self::hidden();
        }

        Self {
            line: Some(Line {
                label,
                total,
                done: 0,
                note: String::new(),
                frame: 0,
                started: Instant::now(),
                last_paint: None,
                painted: false,
                palette: Palette::new(context.color.stderr),
                frames: if context.wide_glyphs {
                    &BRAILLE
                } else {
                    &PLAIN
                },
                columns: match context.terminal.width {
                    Some(width) if width > 0 => usize::from(width),
                    _ => FALLBACK_COLUMNS,
                },
            }),
        }
    }

    pub(crate) fn tick(&mut self, done: usize, note: &str) {
        let Some(line) = self.line.as_mut() else {
            return;
        };
        line.done = done;
        if line.note != note {
            line.note.clear();
            line.note.push_str(note);
        }

        let now = Instant::now();
        if now.duration_since(line.started) < HOLD_BEFORE_FIRST_PAINT {
            return;
        }
        if line
            .last_paint
            .is_some_and(|last| now.duration_since(last) < REDRAW_EVERY)
        {
            return;
        }

        line.last_paint = Some(now);
        line.frame = line.frame.wrapping_add(1);
        let _ = line.paint();
    }

    /// @docgen Two writers sharing one screen would interleave, so the line is taken down before anything else prints and put back after.
    pub(crate) fn suspend<T>(&mut self, write: impl FnOnce() -> T) -> T {
        if let Some(line) = self.line.as_mut() {
            let _ = line.erase();
        }
        let outcome = write();
        if let Some(line) = self.line.as_mut()
            && line.painted
        {
            line.last_paint = Some(Instant::now());
            let _ = line.paint();
        }
        outcome
    }

    pub(crate) fn finish(&mut self) {
        if let Some(mut line) = self.line.take() {
            let _ = line.erase();
        }
    }
}

impl Drop for Progress {
    /// @docgen The line is erased by the destructor so an early return, a `?`, or a panic cannot leave half a spinner on the screen.
    fn drop(&mut self) {
        self.finish();
    }
}

impl Line {
    fn compose(&self, seconds: u64) -> String {
        let spinner = self
            .frames
            .get(self.frame % self.frames.len())
            .copied()
            .unwrap_or("");

        let mut text = format!("{spinner} {}", self.label);
        if let Some(total) = self.total {
            text.push_str(&format!(" {} of {total}", self.done));
        }
        if !self.note.is_empty() {
            text.push_str(&format!("  {}", self.note));
        }
        if seconds >= 1 {
            text.push_str(&format!(" {seconds}s"));
        }
        text
    }

    fn paint(&mut self) -> io::Result<()> {
        let text = self.compose(self.started.elapsed().as_secs());
        let body = fit(&text, self.columns.saturating_sub(1));
        let styled = self.palette.dim(&body);

        let mut stderr = io::stderr();
        queue!(
            stderr,
            cursor::MoveToColumn(0),
            terminal::Clear(terminal::ClearType::CurrentLine)
        )?;
        write!(stderr, "{styled}")?;
        stderr.flush()?;
        self.painted = true;
        Ok(())
    }

    fn erase(&mut self) -> io::Result<()> {
        if !self.painted {
            return Ok(());
        }
        let mut stderr = io::stderr();
        queue!(
            stderr,
            cursor::MoveToColumn(0),
            terminal::Clear(terminal::ClearType::CurrentLine)
        )?;
        stderr.flush()?;
        self.painted = false;
        Ok(())
    }
}

/// @docgen A progress line belongs to a person watching a terminal; every other reader gets a clean stream instead.
fn shows(context: &Context) -> bool {
    context.terminal.stderr_is_tty
        && !context.terminal.is_ci
        && !context.quiet_progress
        && context.decorate
}

fn fit(text: &str, columns: usize) -> String {
    if columns == 0 {
        return String::new();
    }

    let mut used = 0;
    let mut kept = String::new();
    for glyph in text.chars() {
        let width = glyph.width().unwrap_or(0);
        if used + width > columns {
            return kept;
        }
        used += width;
        kept.push(glyph);
    }
    kept
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::ColorArg;
    use crate::context::{ColorPolicy, TerminalInfo};

    fn context(stderr_is_tty: bool, is_ci: bool, quiet_progress: bool) -> Context {
        Context {
            color: ColorPolicy::resolve(ColorArg::Never),
            terminal: TerminalInfo {
                stdin_is_tty: true,
                stdout_is_tty: true,
                stderr_is_tty,
                width: Some(80),
                height: Some(24),
                is_ci,
            },
            quiet_progress,
            decorate: true,
            wide_glyphs: true,
        }
    }

    #[test]
    fn a_redirected_stderr_never_receives_a_progress_line() {
        assert!(
            !shows(&context(false, false, false)),
            "a progress line painted into a file is corruption, not progress"
        );
    }

    #[test]
    fn continuous_integration_gets_no_spinner() {
        assert!(
            !shows(&context(true, true, false)),
            "a build log must not collect thousands of redraw frames"
        );
    }

    #[test]
    fn a_machine_readable_or_unattended_run_stays_silent() {
        assert!(!shows(&context(true, false, true)));
    }

    #[test]
    fn a_person_at_a_terminal_sees_the_line() {
        assert!(shows(&context(true, false, false)));
    }

    #[test]
    fn a_hidden_progress_paints_nothing_and_tolerates_every_call() {
        let mut progress = Progress::hidden();
        progress.tick(5, "");
        let carried = progress.suspend(|| 42);
        progress.finish();
        assert_eq!(carried, 42, "suspend passes the closure's value back");
    }

    #[test]
    fn nothing_is_painted_before_the_wait_is_real() {
        let mut progress = Progress {
            line: Some(Line {
                label: "checking",
                total: Some(10),
                done: 0,
                note: String::new(),
                frame: 0,
                started: Instant::now(),
                last_paint: None,
                painted: false,
                palette: Palette::new(false),
                frames: &PLAIN,
                columns: 80,
            }),
        };
        progress.tick(1, "");
        let painted = progress.line.as_ref().is_some_and(|line| line.painted);
        assert!(!painted, "a run that finishes in a blink must not flash");
    }

    fn line(note: &str, total: Option<usize>, done: usize) -> Line {
        Line {
            label: "checking",
            total,
            done,
            note: note.to_owned(),
            frame: 1,
            started: Instant::now(),
            last_paint: None,
            painted: false,
            palette: Palette::new(false),
            frames: &PLAIN,
            columns: 80,
        }
    }

    #[test]
    fn the_line_names_what_the_run_is_working_on() {
        let shown = line("example.io", Some(40), 12).compose(3);
        assert!(
            shown.contains("12 of 40"),
            "the count is still there: {shown}"
        );
        assert!(
            shown.contains("example.io"),
            "a count alone never shows the run is moving through real work: {shown}"
        );
        assert!(shown.contains("3s"), "the elapsed time survives: {shown}");
    }

    #[test]
    fn a_stage_with_nothing_to_name_leaves_no_gap_behind() {
        let shown = line("", Some(40), 12).compose(0);
        assert!(!shown.contains("  "), "no empty slot is left: {shown:?}");
        assert!(!shown.ends_with(' '), "and no trailing space: {shown:?}");
    }

    #[test]
    fn a_stage_with_no_total_still_names_its_work() {
        let shown = line("example.io", None, 0).compose(0);
        assert!(!shown.contains(" of "), "nothing to count: {shown}");
        assert!(shown.contains("example.io"), "but still named: {shown}");
    }

    #[test]
    fn a_stage_with_nothing_to_name_still_paints_a_clean_line() {
        let mut progress = Progress::hidden();
        progress.tick(3, "");
        progress.tick(4, "example.com");
        progress.finish();
    }

    #[test]
    fn the_line_is_cut_to_the_terminal_width() {
        assert_eq!(fit("checking 1 of 2", 8), "checking");
        assert_eq!(fit("checking", 0), "");
        assert_eq!(fit("short", 40), "short");
    }

    #[test]
    fn a_wide_glyph_is_never_cut_in_half() {
        let text = "⠋ 世界";
        let kept = fit(text, 4);
        assert!(
            kept.chars().all(|glyph| text.contains(glyph)),
            "truncation keeps whole characters"
        );
        let width: usize = kept.chars().map(|g| g.width().unwrap_or(0)).sum();
        assert!(
            width <= 4,
            "a wide glyph that would overflow is dropped whole"
        );
    }
}