use std::path::{Path as StdPath, PathBuf};
use serde::Serialize;
use crate::extract::{self, Found, Options, resolve_format};
#[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) numbers: usize,
pub(crate) unlocated: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct FileReport {
pub(crate) file: String,
pub(crate) format: String,
pub(crate) numbers: Vec<Found>,
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")
}
pub(crate) fn is_incomplete(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == "error")
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ScanOptions {
pub(crate) dedupe: bool,
pub(crate) extract: Options,
pub(crate) format: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Scanned {
Read(Box<FileReport>),
Binary,
}
impl Scanned {
pub(crate) fn into_report(self) -> Option<FileReport> {
match self {
Self::Read(report) => Some(*report),
Self::Binary => None,
}
}
}
pub(crate) fn partition(scanned: Vec<Scanned>) -> (Vec<FileReport>, usize) {
let binary = scanned
.iter()
.filter(|one| **one == Scanned::Binary)
.count();
let reports = scanned
.into_iter()
.filter_map(Scanned::into_report)
.collect();
(reports, binary)
}
const BINARY_SNIFF_BYTES: usize = 8192;
fn is_binary(bytes: &[u8]) -> bool {
bytes
.iter()
.take(BINARY_SNIFF_BYTES)
.any(|byte| *byte == b'\0')
}
#[cfg(windows)]
fn report_path(path: &StdPath) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
fn report_path(path: &StdPath) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn unreadable(path: &StdPath, reason: &str) -> FileReport {
skipped(report_path(path), format_of(path), reason)
}
pub(crate) fn scan_file(path: &PathBuf, options: ScanOptions) -> Scanned {
let file = report_path(path);
let format = options.format.unwrap_or_else(|| format_of(path));
match std::fs::read(path) {
Ok(bytes) if is_binary(&bytes) => Scanned::Binary,
Ok(bytes) => Scanned::Read(Box::new(match String::from_utf8(bytes) {
Ok(content) => scan_content(without_bom(&content), file, format, options),
Err(_) => skipped(file, format, "not UTF-8 text"),
})),
Err(error) => Scanned::Read(Box::new(skipped(file, format, &error.to_string()))),
}
}
fn format_of(path: &StdPath) -> &'static str {
resolve_format(None, path.file_name().and_then(|name| name.to_str()))
}
pub(crate) fn scan_content(
content: &str,
file: String,
format: &str,
options: ScanOptions,
) -> FileReport {
let mut numbers = extract::extract_located(content, format, options.extract);
if options.dedupe {
let mut seen = std::collections::HashSet::new();
numbers.retain(|found| seen.insert(found.value.clone()));
}
let mut diagnostics = Vec::new();
if let Some(message) = extract::parse_error(content, format) {
diagnostics.push(Diagnostic {
severity: "warning".to_string(),
code: "unparsed".to_string(),
message,
});
}
let unlocated = numbers
.iter()
.filter(|found| found.position.is_none())
.count();
FileReport {
file,
format: format.to_string(),
summary: Summary {
numbers: numbers.len(),
unlocated,
},
numbers,
diagnostics,
}
}
pub(crate) fn exit_code(reports: &[FileReport], strict: bool) -> u8 {
if reports.iter().any(FileReport::is_incomplete) {
return 2;
}
if strict && reports.iter().any(FileReport::was_skipped) {
return 2;
}
u8::from(!reports.iter().any(|report| report.summary.numbers > 0))
}
pub(crate) fn describe(report: &FileReport, found: &Found) -> String {
match found.position {
Some(position) => format!(
"{}:{}:{} {}",
report.file, position.line, position.column, found.value
),
None => format!("{}:- {}", report.file, found.value),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn plain() -> ScanOptions {
ScanOptions::default()
}
fn read(path: &PathBuf, options: ScanOptions) -> FileReport {
scan_file(path, options)
.into_report()
.expect("the file was a text candidate")
}
fn values(report: &FileReport) -> Vec<&str> {
report.numbers.iter().map(|f| f.value.as_str()).collect()
}
#[test]
fn a_document_with_numbers_exits_zero() {
let report = scan_content(r#"{"port":8080}"#, "a.json".into(), "json", plain());
assert_eq!(values(&report), ["8080"]);
assert_eq!(exit_code(&[report], false), 0);
}
#[test]
fn a_document_with_none_exits_one() {
let report = scan_content(r#"{"a":"text"}"#, "a.json".into(), "json", plain());
assert_eq!(report.summary.numbers, 0);
assert_eq!(exit_code(&[report], false), 1);
}
#[test]
fn nothing_to_scan_exits_one() {
assert_eq!(exit_code(&[], false), 1);
}
#[test]
fn a_parse_failure_is_a_warning_not_an_exit_two() {
let report = scan_content("{not json", "a.json".into(), "json", plain());
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, "warning");
assert!(!report.was_skipped());
assert_eq!(exit_code(&[report], false), 1);
}
#[test]
fn an_unreadable_file_is_reported_and_does_not_end_the_run() {
let tree = TempTree::new("scan-unreadable");
let report = read(&tree.path().join("gone.json"), plain());
assert!(report.was_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, "--strict is opt-in");
}
#[test]
fn a_binary_file_is_not_a_report() {
let tree = TempTree::new("scan-binary");
let file = tree.write_bytes("logo.png", &[0x89, 0x50, 0x4e, 0x47, 0x00, 0x1a]);
assert_eq!(scan_file(&file, plain()), Scanned::Binary);
}
#[test]
fn a_text_file_that_cannot_be_read_still_fails_strict_and_a_binary_one_does_not() {
let tree = TempTree::new("scan-strict");
let binary = tree.write_bytes("logo.png", &[0x89, 0x50, 0x00, 0xff]);
let broken = tree.write_bytes("notes.txt", &[0x68, 0x69, 0xff, 0xfe]);
let good = tree.write("rates.env", "VAT=0.2\n");
let (reports, binaries) = partition(vec![
scan_file(&binary, plain()),
scan_file(&broken, plain()),
scan_file(&good, plain()),
]);
assert_eq!(binaries, 1);
assert_eq!(reports.len(), 2, "the PNG produced no report line");
let named: Vec<&str> = reports.iter().map(|r| r.file.as_str()).collect();
assert!(named.iter().any(|file| file.ends_with("notes.txt")));
assert!(named.iter().any(|file| file.ends_with("rates.env")));
assert_eq!(reports[0].diagnostics[0].message, "not UTF-8 text");
assert_eq!(exit_code(&reports, false), 0, "the .env file has a number");
assert_eq!(exit_code(&reports, true), 2, "the unreadable text file");
let binary_only = partition(vec![scan_file(&binary, plain())]).0;
assert_eq!(
exit_code(&binary_only, true),
1,
"a binary file never fails --strict"
);
}
#[test]
fn binary_is_a_nul_byte_in_the_first_8_kib() {
let tree = TempTree::new("scan-sniff");
let mut late = vec![b'1'; BINARY_SNIFF_BYTES + 16];
late[BINARY_SNIFF_BYTES + 8] = 0;
let file = tree.write_bytes("late.txt", &late);
assert_ne!(scan_file(&file, plain()), Scanned::Binary);
}
#[test]
fn the_format_comes_from_the_file_name() {
let tree = TempTree::new("scan-format");
let file = tree.write(
"config.toml",
"port = 8080
",
);
let report = read(&file, plain());
assert_eq!(report.format, "toml");
assert_eq!(values(&report), ["8080"]);
}
#[test]
fn a_source_file_is_read_by_its_language() {
let tree = TempTree::new("scan-source");
let file = tree.write(
"rates.ts",
"const VAT: number = 0.2;
",
);
let report = read(&file, plain());
assert_eq!(report.format, "typescript");
assert_eq!(values(&report), ["0.2"]);
}
#[test]
fn prose_falls_back_to_a_text_scan() {
let tree = TempTree::new("scan-fallback");
let file = tree.write(
"NOTES.md",
"Released v1.2.3 at a rate of 0.2.
",
);
let report = read(&file, plain());
assert_eq!(report.format, "unknown");
assert_eq!(values(&report), ["1.2", "0.3", "0.2"]);
}
#[test]
fn a_forced_format_overrides_the_file_name() {
let tree = TempTree::new("scan-forced");
let file = tree.write(
"data.json",
"port = 8080
",
);
let report = read(
&file,
ScanOptions {
format: Some("toml"),
..plain()
},
);
assert_eq!(report.format, "toml");
assert_eq!(values(&report), ["8080"]);
}
#[test]
fn coercion_follows_the_format_not_the_caller() {
let typed = scan_content(r#"{"a":"42"}"#, "a.json".into(), "json", plain());
assert_eq!(typed.summary.numbers, 0);
let untyped = scan_content("A=42", "a.env".into(), "env", plain());
assert_eq!(values(&untyped), ["42"]);
}
#[test]
fn dedupe_collapses_repeats_to_the_first() {
let content = r#"{"a":5,"b":9,"c":5}"#;
let kept = scan_content(content, "a.json".into(), "json", plain());
assert_eq!(kept.summary.numbers, 3);
let deduped = scan_content(
content,
"a.json".into(),
"json",
ScanOptions {
dedupe: true,
..plain()
},
);
assert_eq!(values(&deduped), ["5", "9"]);
assert_eq!(
deduped.numbers[0].position.expect("a position").column,
6,
"the first occurrence keeps its own position"
);
}
#[test]
fn values_the_scanner_cannot_see_are_counted() {
let report = scan_content(
"a = 1
b = 0x1A
",
"a.toml".into(),
"toml",
plain(),
);
assert_eq!(values(&report), ["1", "26"]);
assert_eq!(report.summary.unlocated, 1);
assert!(report.numbers[1].position.is_none());
}
#[test]
fn json_locates_a_value_the_source_spells_differently() {
let report = scan_content(r#"{"a":1e21}"#, "a.json".into(), "json", plain());
assert_eq!(values(&report), ["1e+21"]);
assert_eq!(report.summary.unlocated, 0);
}
#[test]
fn the_human_line_carries_the_position_when_there_is_one() {
let report = scan_content(r#"{"a":8080}"#, "a.json".into(), "json", plain());
assert_eq!(describe(&report, &report.numbers[0]), "a.json:1:6 8080");
}
#[test]
fn a_path_the_walk_could_not_open_is_a_report_not_a_failure() {
let report = unreadable(StdPath::new("locked/config.toml"), "Permission denied");
assert!(report.was_skipped());
assert_eq!(report.format, "toml", "the name still resolves a format");
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2, "--strict is opt-in");
}
#[cfg(windows)]
#[test]
fn a_reported_path_is_separated_by_forward_slashes() {
assert_eq!(
report_path(StdPath::new(r"C:\src\pricing.toml")),
"C:/src/pricing.toml"
);
}
#[test]
fn the_human_line_says_so_when_there_is_no_position() {
let report = scan_content(
"a = 0x1A
",
"a.toml".into(),
"toml",
plain(),
);
assert!(describe(&report, &report.numbers[0]).starts_with("a.toml:-"));
}
}
fn skipped(file: String, format: &'static str, reason: &str) -> FileReport {
FileReport {
file,
format: format.to_string(),
numbers: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "warning".to_string(),
code: "skipped".to_string(),
message: reason.to_string(),
}],
summary: Summary {
numbers: 0,
unlocated: 0,
},
}
}
pub(crate) fn without_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
#[cfg(test)]
mod hazards {
use super::*;
#[test]
fn a_byte_order_mark_is_not_part_of_the_document() {
assert_eq!(without_bom("\u{feff}abc"), "abc");
assert_eq!(without_bom("abc"), "abc");
assert_eq!(without_bom("a\u{feff}b"), "a\u{feff}b");
}
}