lychee 0.24.0

A fast, async link checker
use anyhow::Result;
use console::Style;
use humantime::FormattedDuration;
use std::{
    fmt::{self, Display, Formatter},
    sync::LazyLock,
    time::Duration,
};

use crate::config;
use crate::formatters::{
    color::{BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, DIM, NORMAL, color},
    get_response_formatter,
    host_stats::CompactHostStats,
    response::ResponseFormatter,
    stats::{OutputStats, ResponseStats},
};

use super::StatsFormatter;

struct CompactResponseStats {
    stats: ResponseStats,
    mode: config::OutputMode,
}

impl Display for CompactResponseStats {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let stats = &self.stats;
        let issues = stats
            .error_map
            .iter()
            .chain(stats.timeout_map.iter())
            .count();

        if issues > 0 {
            let input = if issues == 1 { "input" } else { "inputs" };

            color!(
                f,
                BOLD_PINK,
                "Issues found in {issues} {input}. Find details below.\n\n",
            )?;
        }

        let response_formatter = get_response_formatter(&self.mode);

        for (source, responses) in
            super::sort_stats_iter(stats.error_map.iter().chain(stats.timeout_map.iter()))
        {
            color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
            write_responses(f, &*response_formatter, responses)?;
            write_suggestions(f, stats, source)?;
            writeln!(f)?;
        }

        color!(f, NORMAL, "🔍 {} Total", stats.total)?;
        color!(f, DIM, " (in {})", format_duration(stats.duration))?;
        color!(f, NORMAL, " 🔗 {} Unique", stats.unique)?;
        color!(f, BOLD_GREEN, " ✅ {} OK", stats.successful)?;

        let total_errors = stats.errors;

        let err_str = if total_errors == 1 { "Error" } else { "Errors" };
        color!(f, BOLD_PINK, " 🚫 {} {}", total_errors, err_str)?;

        write_if_any(stats.unknown, "", "Unknown", &BOLD_PINK, f)?;
        write_if_any(stats.excludes, "👻", "Excluded", &BOLD_YELLOW, f)?;
        write_if_any(stats.timeouts, "", "Timeouts", &BOLD_YELLOW, f)?;
        write_if_any(stats.unsupported, "", "Unsupported", &BOLD_YELLOW, f)?;
        write_if_any(stats.redirects, "🔀", "Redirects", &BOLD_YELLOW, f)?;

        Ok(())
    }
}

/// Format duration in a human readable format with
/// millisecond precision. (e.g. `2m 30s 155ms`)
fn format_duration(d: Duration) -> FormattedDuration {
    #[allow(clippy::cast_possible_truncation)]
    let truncated = Duration::from_millis(d.as_millis() as u64);
    humantime::format_duration(truncated)
}

fn write_responses(
    f: &mut Formatter<'_>,
    response_formatter: &dyn ResponseFormatter,
    responses: Vec<&lychee_lib::ResponseBody>,
) -> Result<(), fmt::Error> {
    for response in responses {
        writeln!(f, "{}", response_formatter.format_response(response))?;
    }

    Ok(())
}

fn write_suggestions(
    f: &mut Formatter<'_>,
    stats: &ResponseStats,
    source: &lychee_lib::InputSource,
) -> Result<(), fmt::Error> {
    if let Some(suggestions) = stats.suggestion_map.get(source) {
        // Sort suggestions
        let mut sorted_suggestions: Vec<_> = suggestions.iter().collect();
        sorted_suggestions.sort_by(|a, b| {
            let (a, b) = (a.to_string().to_lowercase(), b.to_string().to_lowercase());
            numeric_sort::cmp(&a, &b)
        });

        writeln!(f, "\nℹ Suggestions")?;
        for suggestion in sorted_suggestions {
            writeln!(f, "{suggestion}")?;
        }
    }

    Ok(())
}

fn write_if_any(
    value: usize,
    symbol: &str,
    text: &str,
    style: &LazyLock<Style>,
    f: &mut fmt::Formatter<'_>,
) -> Result<(), fmt::Error> {
    if value > 0 {
        color!(f, style, " {} {} {}", symbol, value, text)?;
    }
    Ok(())
}

pub(crate) struct Compact {
    mode: config::OutputMode,
}

impl Compact {
    pub(crate) const fn new(mode: config::OutputMode) -> Self {
        Self { mode }
    }
}

impl StatsFormatter for Compact {
    fn format(&self, stats: OutputStats) -> Result<String> {
        let response_stats = CompactResponseStats {
            stats: stats.response_stats,
            mode: self.mode.clone(),
        };
        let host_stats = CompactHostStats {
            host_stats: stats.host_stats,
        };

        Ok(format!("{response_stats}\n{host_stats}"))
    }
}

#[cfg(test)]
mod tests {
    use crate::config::OutputMode;
    use crate::formatters::stats::{StatsFormatter, get_dummy_stats};
    use regex::Regex;

    use super::*;

    #[test]
    fn test_formatter() {
        let formatter = Compact::new(OutputMode::Plain);
        let result = formatter.format(get_dummy_stats()).unwrap();

        // Remove color codes for better readability of the expected result
        let without_color_codes = Regex::new(r"\u{1b}\[[0-9;]*m")
            .unwrap()
            .replace_all(&result, "")
            .to_string();

        assert_eq!(
            without_color_codes,
            "Issues found in 2 inputs. Find details below.

[https://example.com/]:
[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man (at 1:1) | 404 Not Found
[TIMEOUT] https://httpbin.org/delay/2 (at 1:1) | Request timed out

ℹ Suggestions
https://original.dev/ --> https://suggestion.dev/

🔍 2 Total (in 0s) 🔗 2 Unique ✅ 0 OK 🚫 1 Error ⏳ 1 Timeouts 🔀 1 Redirects

📊 Per-host Statistics
────────────────────────────────────────────────────────────
example.com   │      5 reqs │   60.0% success │      N/A median │   20.0% cached
"
        );
    }
}