Skip to content

Commit 5068717

Browse files
authored
Sort compact/detailed/markdown error output by file path (#1622)
* Sort compact/detailed/markdown error output by file path * - Modify sort_stats_map to sort HashMap values - Add unit/integration tests for sort_stats_map - Add human-sort dependency for natural sorting * Fix warnings reported by GitHub checks * Fix clippy warning - Fix clippy warning - Make entry sorting case-insensitive in sort_stat_map * Fix clippy warning
1 parent 2aa22f8 commit 5068717

File tree

7 files changed

+179
-10
lines changed

7 files changed

+179
-10
lines changed

Cargo.lock

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lychee-bin/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ tokio = { version = "1.42.0", features = ["full"] }
5555
tokio-stream = "0.1.17"
5656
toml = "0.8.19"
5757
url = "2.5.4"
58+
human-sort = "0.2.2"
5859

5960
[dev-dependencies]
6061
assert_cmd = "2.0.16"

lychee-bin/src/formatters/stats/compact.rs

+10-3
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ impl Display for CompactResponseStats {
3737

3838
let response_formatter = get_response_formatter(&self.mode);
3939

40-
for (source, responses) in &stats.error_map {
40+
for (source, responses) in super::sort_stat_map(&stats.error_map) {
4141
color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
4242
for response in responses {
4343
writeln!(
@@ -47,9 +47,16 @@ impl Display for CompactResponseStats {
4747
)?;
4848
}
4949

50-
if let Some(suggestions) = &stats.suggestion_map.get(source) {
50+
if let Some(suggestions) = stats.suggestion_map.get(source) {
51+
// Sort suggestions
52+
let mut sorted_suggestions: Vec<_> = suggestions.iter().collect();
53+
sorted_suggestions.sort_by(|a, b| {
54+
let (a, b) = (a.to_string().to_lowercase(), b.to_string().to_lowercase());
55+
human_sort::compare(&a, &b)
56+
});
57+
5158
writeln!(f, "\n\u{2139} Suggestions")?;
52-
for suggestion in *suggestions {
59+
for suggestion in sorted_suggestions {
5360
writeln!(f, "{suggestion}")?;
5461
}
5562
}

lychee-bin/src/formatters/stats/detailed.rs

+13-6
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ impl Display for DetailedResponseStats {
4949

5050
let response_formatter = get_response_formatter(&self.mode);
5151

52-
for (source, responses) in &stats.error_map {
52+
for (source, responses) in super::sort_stat_map(&stats.error_map) {
5353
// Using leading newlines over trailing ones (e.g. `writeln!`)
5454
// lets us avoid extra newlines without any additional logic.
5555
write!(f, "\n\nErrors in {source}")?;
@@ -60,12 +60,19 @@ impl Display for DetailedResponseStats {
6060
"\n{}",
6161
response_formatter.format_detailed_response(response)
6262
)?;
63+
}
6364

64-
if let Some(suggestions) = &stats.suggestion_map.get(source) {
65-
writeln!(f, "\nSuggestions in {source}")?;
66-
for suggestion in *suggestions {
67-
writeln!(f, "{suggestion}")?;
68-
}
65+
if let Some(suggestions) = stats.suggestion_map.get(source) {
66+
// Sort suggestions
67+
let mut sorted_suggestions: Vec<_> = suggestions.iter().collect();
68+
sorted_suggestions.sort_by(|a, b| {
69+
let (a, b) = (a.to_string().to_lowercase(), b.to_string().to_lowercase());
70+
human_sort::compare(&a, &b)
71+
});
72+
73+
writeln!(f, "\nSuggestions in {source}")?;
74+
for suggestion in sorted_suggestions {
75+
writeln!(f, "{suggestion}")?;
6976
}
7077
}
7178
}

lychee-bin/src/formatters/stats/markdown.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ where
127127
{
128128
if !&map.is_empty() {
129129
writeln!(f, "\n## {name} per input")?;
130-
for (source, responses) in map {
130+
for (source, responses) in super::sort_stat_map(map) {
131131
writeln!(f, "\n### {name} in {source}\n")?;
132132
for response in responses {
133133
writeln!(f, "{}", write_stat(response)?)?;

lychee-bin/src/formatters/stats/mod.rs

+100
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,110 @@ pub(crate) use json::Json;
1010
pub(crate) use markdown::Markdown;
1111
pub(crate) use raw::Raw;
1212

13+
use std::{
14+
collections::{HashMap, HashSet},
15+
fmt::Display,
16+
};
17+
1318
use crate::stats::ResponseStats;
1419
use anyhow::Result;
20+
use lychee_lib::InputSource;
1521

1622
pub(crate) trait StatsFormatter {
1723
/// Format the stats of all responses and write them to stdout
1824
fn format(&self, stats: ResponseStats) -> Result<Option<String>>;
1925
}
26+
27+
/// Convert a `ResponseStats` `HashMap` to a sorted Vec of key-value pairs
28+
/// The returned keys and values are both sorted in natural, case-insensitive order
29+
fn sort_stat_map<T>(stat_map: &HashMap<InputSource, HashSet<T>>) -> Vec<(&InputSource, Vec<&T>)>
30+
where
31+
T: Display,
32+
{
33+
let mut entries: Vec<_> = stat_map
34+
.iter()
35+
.map(|(source, responses)| {
36+
let mut sorted_responses: Vec<&T> = responses.iter().collect();
37+
sorted_responses.sort_by(|a, b| {
38+
let (a, b) = (a.to_string().to_lowercase(), b.to_string().to_lowercase());
39+
human_sort::compare(&a, &b)
40+
});
41+
42+
(source, sorted_responses)
43+
})
44+
.collect();
45+
46+
entries.sort_by(|(a, _), (b, _)| {
47+
let (a, b) = (a.to_string().to_lowercase(), b.to_string().to_lowercase());
48+
human_sort::compare(&a, &b)
49+
});
50+
51+
entries
52+
}
53+
54+
#[cfg(test)]
55+
mod tests {
56+
use super::*;
57+
58+
use lychee_lib::{ErrorKind, Response, Status, Uri};
59+
use url::Url;
60+
61+
fn make_test_url(url: &str) -> Url {
62+
Url::parse(url).expect("Expected valid Website URI")
63+
}
64+
65+
fn make_test_response(url_str: &str, source: InputSource) -> Response {
66+
let uri = Uri::from(make_test_url(url_str));
67+
68+
Response::new(uri, Status::Error(ErrorKind::InvalidUrlHost), source)
69+
}
70+
71+
#[test]
72+
fn test_sorted_stat_map() {
73+
let mut test_stats = ResponseStats::default();
74+
75+
// Sorted list of test sources
76+
let test_sources = vec![
77+
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/404"))),
78+
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/home"))),
79+
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/1"))),
80+
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/10"))),
81+
];
82+
83+
// Sorted list of test responses
84+
let test_response_urls = vec![
85+
"https://example.com/",
86+
"https://github.com/",
87+
"https://itch.io/",
88+
"https://youtube.com/",
89+
];
90+
91+
// Add responses to stats
92+
// Responses are added to a HashMap, so the order is not preserved
93+
for source in &test_sources {
94+
for response in &test_response_urls {
95+
test_stats.add(make_test_response(response, source.clone()));
96+
}
97+
}
98+
99+
// Sort error map and extract the sources
100+
let sorted_errors = sort_stat_map(&test_stats.error_map);
101+
let sorted_sources: Vec<InputSource> = sorted_errors
102+
.iter()
103+
.map(|(source, _)| (*source).clone())
104+
.collect();
105+
106+
// Check that the input sources are sorted
107+
assert_eq!(test_sources, sorted_sources);
108+
109+
// Check that the responses are sorted
110+
for (_, response_bodies) in sorted_errors {
111+
let response_urls: Vec<&str> = response_bodies
112+
.into_iter()
113+
.map(|response| response.uri.as_str())
114+
.collect();
115+
116+
assert_eq!(test_response_urls, response_urls);
117+
}
118+
}
119+
}

lychee-bin/tests/cli.rs

+47
Original file line numberDiff line numberDiff line change
@@ -1943,4 +1943,51 @@ mod cli {
19431943

19441944
Ok(())
19451945
}
1946+
1947+
#[test]
1948+
fn test_sorted_error_output() -> Result<()> {
1949+
let test_files = ["TEST_GITHUB_404.md", "TEST_INVALID_URLS.html"];
1950+
1951+
let test_urls = [
1952+
"https://httpbin.org/status/404",
1953+
"https://httpbin.org/status/500",
1954+
"https://httpbin.org/status/502",
1955+
];
1956+
1957+
let cmd = &mut main_command()
1958+
.arg("--format")
1959+
.arg("compact")
1960+
.arg(fixtures_path().join(test_files[1]))
1961+
.arg(fixtures_path().join(test_files[0]))
1962+
.assert()
1963+
.failure()
1964+
.code(2);
1965+
1966+
let output = String::from_utf8_lossy(&cmd.get_output().stdout);
1967+
let mut position: usize = 0;
1968+
1969+
// Check that the input sources are sorted
1970+
for file in test_files {
1971+
assert!(output.contains(file));
1972+
1973+
let next_position = output.find(file).unwrap();
1974+
1975+
assert!(next_position > position);
1976+
position = next_position;
1977+
}
1978+
1979+
position = 0;
1980+
1981+
// Check that the responses are sorted
1982+
for url in test_urls {
1983+
assert!(output.contains(url));
1984+
1985+
let next_position = output.find(url).unwrap();
1986+
1987+
assert!(next_position > position);
1988+
position = next_position;
1989+
}
1990+
1991+
Ok(())
1992+
}
19461993
}

0 commit comments

Comments
 (0)