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 was_skipped(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "skipped" || diagnostic.severity == "error")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScanOptions {
pub(crate) dedupe: bool,
}
pub(crate) fn reported_path(path: &std::path::Path) -> String {
let text = path.to_string_lossy().into_owned();
if cfg!(windows) {
return text.replace('\\', "/");
}
text
}
pub(crate) fn scan_file(target: &Target, options: ScanOptions) -> FileReport {
let file = reported_path(&target.path);
let skipped = |reason: String| FileReport {
file: file.clone(),
format: target.language_id.to_string(),
urls: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "warning".to_string(),
code: "skipped".to_string(),
message: reason,
}],
summary: Summary { urls: 0 },
};
if let Some(reason) = &target.unreadable {
return skipped(reason.clone());
}
let bytes = match std::fs::read(&target.path) {
Ok(bytes) => bytes,
Err(error) => return skipped(error.to_string()),
};
let Ok(content) = String::from_utf8(bytes) else {
return skipped("not UTF-8 text".to_string());
};
scan_content(without_bom(&content), file, target.language_id, options)
}
pub(crate) fn without_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
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], strict: bool) -> u8 {
if strict && reports.iter().any(FileReport::was_skipped) {
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,
unreadable: None,
}
}
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], false), 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], false), 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], false), 0);
}
#[test]
fn an_unreadable_file_is_reported_and_does_not_end_the_run() {
let tree = TempTree::new("scan-unreadable");
let report = scan_file(&target(&tree, "gone.md", "markdown"), options());
assert!(report.was_skipped());
assert_eq!(report.diagnostics[0].code, "skipped");
assert_eq!(report.diagnostics[0].severity, "warning");
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2);
}
#[test]
fn nothing_to_examine_reports_none_found() {
assert_eq!(exit_code(&[], false), 1);
}
#[test]
fn a_path_the_walk_could_not_examine_is_reported_not_fatal() {
let tree = TempTree::new("scan-walk-unreadable");
let report = scan_file(
&Target {
path: tree.path().join("locked"),
language_id: "plaintext",
unreadable: Some("Permission denied (os error 13)".to_string()),
},
options(),
);
assert!(report.was_skipped());
assert!(report.diagnostics[0].message.contains("Permission denied"));
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2);
}
#[test]
fn a_reported_path_uses_forward_slashes() {
let mut path = std::path::PathBuf::from("docs");
path.push("guide.md");
assert_eq!(reported_path(&path), "docs/guide.md");
}
#[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"
);
}
}
#[cfg(test)]
mod hazards {
use super::*;
#[test]
fn a_byte_order_mark_is_not_part_of_the_document() {
assert_eq!(
without_bom("\u{feff}https://a.example"),
"https://a.example"
);
assert_eq!(without_bom("https://a.example"), "https://a.example");
assert_eq!(without_bom("a\u{feff}b"), "a\u{feff}b");
}
#[test]
fn a_byte_order_mark_does_not_move_the_first_column() {
let options = ScanOptions { dedupe: false };
let plain = scan_content("https://a.example", "x".to_string(), "markdown", options);
let marked = scan_content(
without_bom("\u{feff}https://a.example"),
"x".to_string(),
"markdown",
options,
);
assert!(!plain.urls.is_empty(), "the baseline must find something");
assert_eq!(marked.urls.len(), plain.urls.len());
assert_eq!(
marked.urls[0].position.map(|p| p.column),
plain.urls[0].position.map(|p| p.column)
);
}
#[test]
fn a_skipped_file_does_not_fail_the_run() {
let skipped = FileReport {
file: "logo.png".to_string(),
format: "markdown".to_string(),
urls: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "warning".to_string(),
code: "skipped".to_string(),
message: "not UTF-8 text".to_string(),
}],
summary: Summary { urls: 0 },
};
assert!(skipped.was_skipped());
assert_eq!(exit_code(std::slice::from_ref(&skipped), false), 1);
assert_eq!(exit_code(&[skipped], true), 2, "--strict is opt-in");
}
}