lychee 0.24.2

A fast, async link checker
use std::{
    collections::{HashMap, HashSet},
    fmt::{self, Display},
};

use super::StatsFormatter;
use anyhow::Result;
use http::StatusCode;
use lychee_lib::{InputSource, ResponseBody, Status};
use std::fmt::Write;
use tabled::{
    Table, Tabled,
    settings::{Alignment, Modify, Style, object::Segment},
};

use crate::formatters::{
    host_stats::MarkdownHostStats,
    stats::{OutputStats, ResponseStats},
};

#[derive(Tabled)]
struct StatsTableEntry {
    #[tabled(rename = "Status")]
    status: &'static str,
    #[tabled(rename = "Count")]
    count: usize,
}

fn stats_table(stats: &ResponseStats) -> String {
    let stats = vec![
        StatsTableEntry {
            status: "🔍 Total",
            count: stats.total,
        },
        StatsTableEntry {
            status: "🔗 Unique",
            count: stats.unique,
        },
        StatsTableEntry {
            status: "✅ Successful",
            count: stats.successful,
        },
        StatsTableEntry {
            status: "⏳ Timeouts",
            count: stats.timeouts,
        },
        StatsTableEntry {
            status: "🔀 Redirected",
            count: stats.redirects,
        },
        StatsTableEntry {
            status: "👻 Excluded",
            count: stats.excludes,
        },
        StatsTableEntry {
            status: "❓ Unknown",
            count: stats.unknown,
        },
        StatsTableEntry {
            status: "🚫 Errors",
            count: stats.errors,
        },
        StatsTableEntry {
            status: "⛔ Unsupported",
            count: stats.unsupported,
        },
    ];
    let style = Style::markdown();

    Table::new(stats)
        .with(Modify::new(Segment::all()).with(Alignment::left()))
        .with(style)
        .to_string()
}

/// Helper function to format single response body as markdown
///
/// Optional details get added if available.
fn markdown_response(response: &ResponseBody) -> Result<String> {
    let mut formatted = format!(
        "* [{}] <{}>",
        response.status.code_as_string(),
        response.uri,
    );

    if let Some(span) = response.span {
        formatted = format!("{formatted} (at {span})");
    }

    if let Status::Ok(StatusCode::OK) = response.status {
        // Don't print anything else if the status code is 200.
        // The output gets too verbose then.
        return Ok(formatted);
    }

    // Add a separator between the URI and the additional details below.
    // Note: To make the links clickable in some terminals,
    // we add a space before the separator.
    let details = response.status.details();
    write!(formatted, " | {details}")?;

    Ok(formatted)
}

struct MarkdownResponseStats(ResponseStats);

impl Display for MarkdownResponseStats {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let stats = &self.0;

        writeln!(f, "# Summary")?;
        writeln!(f)?;
        writeln!(f, "{}", stats_table(&self.0))?;

        write_stats_per_input(f, "Errors", &stats.error_map, |response| {
            markdown_response(response).map_err(|_| fmt::Error)
        })?;

        write_stats_per_input(f, "Timeouts", &stats.timeout_map, |response| {
            markdown_response(response).map_err(|_| fmt::Error)
        })?;

        write_stats_per_input(f, "Redirects", &stats.redirect_map, |redirects| {
            Ok(format!("* {redirects}"))
        })?;

        write_stats_per_input(f, "Suggestions", &stats.suggestion_map, |suggestion| {
            Ok(format!("* {suggestion}"))
        })?;

        Ok(())
    }
}

fn write_stats_per_input<T, F>(
    f: &mut fmt::Formatter<'_>,
    name: &'static str,
    map: &HashMap<InputSource, HashSet<T>>,
    write_stat: F,
) -> fmt::Result
where
    T: Display,
    F: Fn(&T) -> Result<String, std::fmt::Error>,
{
    if !&map.is_empty() {
        writeln!(f, "\n## {name} per input")?;
        for (source, responses) in super::sort_stat_map(map) {
            writeln!(f, "\n### {name} in {source}\n")?;
            for response in responses {
                writeln!(f, "{}", write_stat(response)?)?;
            }
        }
    }

    Ok(())
}

pub(crate) struct Markdown;

impl Markdown {
    pub(crate) const fn new() -> Self {
        Self {}
    }
}

impl StatsFormatter for Markdown {
    fn format(&self, stats: OutputStats) -> Result<String> {
        let response_stats = MarkdownResponseStats(stats.response_stats);
        let host_stats = MarkdownHostStats {
            host_stats: stats.host_stats,
        };

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

#[cfg(test)]
mod tests {
    use std::num::NonZeroUsize;
    use std::time::Duration;

    use http::StatusCode;
    use lychee_lib::RawUriSpan;
    use lychee_lib::{CacheStatus, ResponseBody, Status, Uri};
    use pretty_assertions::assert_eq;

    use crate::formatters::stats::get_dummy_stats;

    use super::*;

    const SPAN: Option<RawUriSpan> = Some(RawUriSpan {
        line: NonZeroUsize::MIN,
        column: Some(NonZeroUsize::MIN),
    });
    const DURATION: Option<Duration> = Some(Duration::from_secs(1));

    #[test]
    fn test_markdown_response_ok() {
        let response = ResponseBody {
            uri: Uri::try_from("http://example.com").unwrap(),
            status: Status::Ok(StatusCode::OK),
            redirects: None,
            remap: None,
            span: SPAN,
            duration: DURATION,
        };
        let markdown = markdown_response(&response).unwrap();
        assert_eq!(markdown, "* [200] <http://example.com/> (at 1:1)");
    }

    #[test]
    fn test_markdown_response_cached_ok() {
        let response = ResponseBody {
            uri: Uri::try_from("http://example.com").unwrap(),
            status: Status::Cached(CacheStatus::Ok(StatusCode::OK)),
            redirects: None,
            remap: None,
            span: SPAN,
            duration: DURATION,
        };
        let markdown = markdown_response(&response).unwrap();
        assert_eq!(
            markdown,
            "* [200] <http://example.com/> (at 1:1) | OK (cached)"
        );
    }

    #[test]
    fn test_markdown_response_cached_err() {
        let response = ResponseBody {
            uri: Uri::try_from("http://example.com").unwrap(),
            status: Status::Cached(CacheStatus::Error(Some(StatusCode::BAD_REQUEST))),
            redirects: None,
            remap: None,
            span: SPAN,
            duration: DURATION,
        };
        let markdown = markdown_response(&response).unwrap();
        assert_eq!(
            markdown,
            "* [400] <http://example.com/> (at 1:1) | Error (cached)"
        );
    }

    #[test]
    fn test_render_stats() {
        let stats = ResponseStats::default();
        let table = stats_table(&stats);
        let expected = "| Status         | Count |
|----------------|-------|
| 🔍 Total       | 0     |
| 🔗 Unique      | 0     |
| ✅ Successful  | 0     |
| ⏳ Timeouts    | 0     |
| 🔀 Redirected  | 0     |
| 👻 Excluded    | 0     |
| ❓ Unknown     | 0     |
| 🚫 Errors      | 0     |
| ⛔ Unsupported | 0     |";
        assert_eq!(table, expected.to_string());
    }

    #[test]
    fn test_render_summary() {
        let summary = MarkdownResponseStats(get_dummy_stats().response_stats);
        let expected = "# Summary

| Status         | Count |
|----------------|-------|
| 🔍 Total       | 2     |
| 🔗 Unique      | 2     |
| ✅ Successful  | 0     |
| ⏳ Timeouts    | 1     |
| 🔀 Redirected  | 1     |
| 👻 Excluded    | 0     |
| ❓ Unknown     | 0     |
| 🚫 Errors      | 1     |
| ⛔ Unsupported | 0     |

## Errors per input

### Errors in https://example.com/

* [404] <https://github.com/mre/idiomatic-rust-doesnt-exist-man> (at 1:1) | 404 Not Found

## Timeouts per input

### Timeouts in https://example.com/

* [TIMEOUT] <https://httpbin.org/delay/2> (at 1:1) | Request timed out

## Redirects per input

### Redirects in https://example.com/

* https://1.dev/ --[308]--> https://2.dev/ --[308]--> http://redirected.dev/

## Suggestions per input

### Suggestions in https://example.com/

* https://original.dev/ --> https://suggestion.dev/
";
        assert_eq!(summary.to_string(), expected.to_string());
    }
}