use serde::Serialize;
use crate::extract::{self, Url};
use crate::walk::Target;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Diagnostic {
pub(crate) severity: String,
pub(crate) code: String,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct Summary {
pub(crate) urls: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct FileReport {
pub(crate) file: String,
pub(crate) format: String,
pub(crate) urls: Vec<Url>,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) summary: Summary,
}
impl FileReport {
pub(crate) fn is_unexamined(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == "error")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScanOptions {
pub(crate) dedupe: bool,
}
pub(crate) fn scan_file(target: &Target, options: ScanOptions) -> FileReport {
let file = target.path.to_string_lossy().into_owned();
match std::fs::read_to_string(&target.path) {
Ok(content) => scan_content(&content, file, target.language_id, options),
Err(error) => FileReport {
file,
format: target.language_id.to_string(),
urls: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "error".to_string(),
code: "unreadable".to_string(),
message: format!("could not be read: {error}"),
}],
summary: Summary { urls: 0 },
},
}
}
pub(crate) fn scan_content(
content: &str,
file: String,
language_id: &str,
options: ScanOptions,
) -> FileReport {
let extraction = extract::extract(content, language_id);
let mut urls = extraction.urls;
if options.dedupe {
let mut seen: Vec<String> = Vec::new();
urls.retain(|url| {
if seen.contains(&url.value) {
return false;
}
seen.push(url.value.clone());
true
});
}
FileReport {
file,
format: language_id.to_string(),
summary: Summary { urls: urls.len() },
urls,
diagnostics: extraction
.errors
.iter()
.map(|error| Diagnostic {
severity: format!("{:?}", error.severity).to_lowercase(),
code: format!("{:?}", error.category).to_lowercase(),
message: error.message.clone(),
})
.collect(),
}
}
pub(crate) fn exit_code(reports: &[FileReport]) -> u8 {
if reports.iter().any(FileReport::is_unexamined) {
return 2;
}
u8::from(!reports.iter().any(|report| report.summary.urls > 0))
}
pub(crate) fn describe(report: &FileReport, url: &Url) -> String {
match url.position {
Some(position) => format!(
"{}:{}:{} {}",
report.file, position.line, position.column, url.value
),
None => format!("{}:-:- {}", report.file, url.value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn target(tree: &TempTree, relative: &str, language_id: &'static str) -> Target {
Target {
path: tree.path().join(relative),
language_id,
}
}
fn options() -> ScanOptions {
ScanOptions { dedupe: false }
}
#[test]
fn a_document_with_urls_exits_zero() {
let tree = TempTree::new("scan-found");
tree.write("docs/a.md", "see https://a.example/x\n");
let report = scan_file(&target(&tree, "docs/a.md", "markdown"), options());
assert_eq!(report.summary.urls, 1);
assert_eq!(exit_code(&[report]), 0);
}
#[test]
fn a_document_with_none_exits_one() {
let tree = TempTree::new("scan-none");
tree.write("docs/a.md", "nothing to see\n");
let report = scan_file(&target(&tree, "docs/a.md", "markdown"), options());
assert_eq!(report.summary.urls, 0);
assert_eq!(exit_code(&[report]), 1);
}
#[test]
fn one_file_with_urls_is_enough_for_zero() {
let empty = scan_content("nothing", "a".into(), "markdown", options());
let found = scan_content("https://a.example", "b".into(), "markdown", options());
assert_eq!(exit_code(&[empty, found]), 0);
}
#[test]
fn an_unreadable_file_ends_the_run_at_two() {
let tree = TempTree::new("scan-unreadable");
let report = scan_file(&target(&tree, "gone.md", "markdown"), options());
assert!(report.is_unexamined());
assert_eq!(report.diagnostics[0].code, "unreadable");
assert_eq!(exit_code(&[report]), 2);
}
#[test]
fn nothing_to_examine_reports_none_found() {
assert_eq!(exit_code(&[]), 1);
}
#[test]
fn dedupe_collapses_repeats_to_the_first_occurrence() {
let content = "https://a.example\nhttps://b.example\nhttps://a.example\n";
let kept = scan_content(content, "x".into(), "markdown", options());
assert_eq!(kept.summary.urls, 3, "every occurrence is real");
let deduped = scan_content(
content,
"x".into(),
"markdown",
ScanOptions { dedupe: true },
);
assert_eq!(deduped.summary.urls, 2);
assert_eq!(deduped.urls[0].position.expect("a position").line, 1);
}
#[test]
fn the_human_line_carries_the_url_and_its_position() {
let report = scan_content("x https://a.example", "a.md".into(), "markdown", options());
assert_eq!(
describe(&report, &report.urls[0]),
"a.md:1:3 https://a.example"
);
}
}