calepin 0.0.51

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::env;
use std::time::Duration;

use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};

const PROGRESS_ENV_VAR: &str = "CALEPIN_PROGRESS";

#[derive(Clone)]
pub struct ProgressManager {
    quiet: bool,
    multi: Option<MultiProgress>,
}

impl ProgressManager {
    pub fn new(quiet: bool) -> Self {
        Self::with_draw_target(
            quiet,
            ProgressPreference::from_env(),
            ProgressDrawTarget::stderr_with_hz(12),
        )
    }

    fn with_draw_target(
        quiet: bool,
        preference: ProgressPreference,
        draw_target: ProgressDrawTarget,
    ) -> Self {
        if quiet {
            return Self { quiet, multi: None };
        }

        let multi = match preference {
            ProgressPreference::Auto => {
                let multi = MultiProgress::with_draw_target(draw_target);
                (!multi.is_hidden()).then_some(multi)
            }
            ProgressPreference::Plain => None,
        };

        Self { quiet, multi }
    }

    pub fn spinner(&self, message: impl Into<String>) -> Progress {
        Progress::from_manager(self, ProgressKind::Spinner, message.into(), 0)
    }

    pub fn bar(&self, message: impl Into<String>, len: u64) -> Progress {
        Progress::from_manager(self, ProgressKind::Bar, message.into(), len)
    }
}

pub struct Progress {
    quiet: bool,
    finished: bool,
    bar: Option<ProgressBar>,
    _multi: Option<MultiProgress>,
}

enum ProgressKind {
    Spinner,
    Bar,
}

impl Progress {
    pub fn spinner(message: impl Into<String>, quiet: bool) -> Self {
        ProgressManager::new(quiet).spinner(message)
    }

    pub fn bar(message: impl Into<String>, len: u64, quiet: bool) -> Self {
        ProgressManager::new(quiet).bar(message, len)
    }

    fn from_manager(
        manager: &ProgressManager,
        kind: ProgressKind,
        message: String,
        len: u64,
    ) -> Self {
        if manager.quiet {
            return Self::hidden(manager.quiet);
        }

        let Some(multi) = manager.multi.clone() else {
            eprintln!("{message}");
            return Self::hidden(manager.quiet);
        };
        let bar = match kind {
            ProgressKind::Spinner => {
                let bar = multi.add(ProgressBar::new_spinner());
                let style = ProgressStyle::with_template("{spinner} {msg}")
                    .unwrap_or_else(|_| ProgressStyle::default_spinner())
                    .tick_chars("|/-\\");
                bar.set_style(style);
                bar
            }
            ProgressKind::Bar => {
                let bar = multi.add(ProgressBar::new(len));
                let style = ProgressStyle::with_template(
                    "{spinner} [{elapsed_precise}] [{bar:32.cyan/blue}] {pos}/{len} {msg}",
                )
                .unwrap_or_else(|_| ProgressStyle::default_bar())
                .progress_chars("#>-")
                .tick_chars("|/-\\");
                bar.set_style(style);
                bar
            }
        };
        bar.set_message(message);
        bar.enable_steady_tick(Duration::from_millis(120));

        Self {
            quiet: manager.quiet,
            finished: false,
            bar: Some(bar),
            _multi: Some(multi),
        }
    }

    pub fn set_message(&self, message: impl Into<String>) {
        if let Some(bar) = &self.bar {
            bar.set_message(message.into());
        }
    }

    pub fn inc(&self, delta: u64) {
        if let Some(bar) = &self.bar {
            bar.inc(delta);
        }
    }

    pub fn finish(mut self, message: impl AsRef<str>) {
        if let Some(bar) = &self.bar {
            bar.finish_and_clear();
        }
        self.finished = true;
        if !self.quiet && self.bar.is_none() {
            eprintln!("{}", message.as_ref());
        }
    }

    fn hidden(quiet: bool) -> Self {
        Self {
            quiet,
            finished: false,
            bar: None,
            _multi: None,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProgressPreference {
    Auto,
    Plain,
}

impl ProgressPreference {
    fn from_env() -> Self {
        Self::from_env_value(env::var(PROGRESS_ENV_VAR).ok().as_deref())
    }

    fn from_env_value(value: Option<&str>) -> Self {
        match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
            Some("plain") => Self::Plain,
            _ => Self::Auto,
        }
    }
}

impl Drop for Progress {
    fn drop(&mut self) {
        if !self.finished {
            if let Some(bar) = &self.bar {
                bar.finish_and_clear();
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use indicatif::ProgressDrawTarget;

    use super::*;

    #[test]
    fn progress_preference_defaults_to_auto() {
        assert_eq!(
            ProgressPreference::from_env_value(None),
            ProgressPreference::Auto
        );
        assert_eq!(
            ProgressPreference::from_env_value(Some("")),
            ProgressPreference::Auto
        );
    }

    #[test]
    fn progress_preference_accepts_plain_escape_hatch() {
        for value in ["plain", "PLAIN", " plain "] {
            assert_eq!(
                ProgressPreference::from_env_value(Some(value)),
                ProgressPreference::Plain
            );
        }
    }

    #[test]
    fn auto_progress_uses_indicatif_hidden_detection() {
        let manager = ProgressManager::with_draw_target(
            false,
            ProgressPreference::Auto,
            ProgressDrawTarget::hidden(),
        );

        assert!(manager.multi.is_none());
    }

    #[test]
    fn plain_progress_skips_dynamic_draw_target() {
        let manager = ProgressManager::with_draw_target(
            false,
            ProgressPreference::Plain,
            ProgressDrawTarget::stderr_with_hz(12),
        );

        assert!(manager.multi.is_none());
    }
}