use std::path::Path;
use serde::Serialize;
use crate::extract::{self, Found, resolve_format, time};
#[derive(Debug, Clone)]
pub(crate) struct ScanOptions {
pub(crate) format: Option<String>,
pub(crate) dedupe: bool,
pub(crate) sort: bool,
pub(crate) iso: bool,
pub(crate) after: Option<i64>,
pub(crate) before: Option<i64>,
pub(crate) year: i64,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
format: None,
dedupe: false,
sort: false,
iso: false,
after: None,
before: None,
year: time::current_year(),
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct Date {
pub(crate) value: String,
pub(crate) format: &'static str,
pub(crate) timestamp: i64,
pub(crate) line: usize,
pub(crate) column: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) iso: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct FileReport {
pub(crate) file: String,
#[serde(rename = "fileType")]
pub(crate) file_type: String,
pub(crate) dates: Vec<Date>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) skipped: Option<String>,
}
impl FileReport {
fn skipped(file: &str, reason: String) -> Self {
Self {
file: file.to_string(),
file_type: String::new(),
dates: Vec::new(),
skipped: Some(reason),
}
}
}
pub(crate) fn scan_text(
label: &str,
content: &str,
language: &str,
options: &ScanOptions,
) -> FileReport {
let found = extract::extract(content, language, options.year);
FileReport {
file: label.to_string(),
file_type: language.to_string(),
dates: shape(found, options),
skipped: None,
}
}
pub(crate) fn scan_file(path: &Path, options: &ScanOptions) -> FileReport {
let label = path.display().to_string();
let name = path.file_name().and_then(|name| name.to_str());
let Some(language) = resolve_format(options.format.as_deref(), name) else {
return FileReport::skipped(&label, "no supported format for this file name".to_string());
};
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) => return FileReport::skipped(&label, error.to_string()),
};
let Ok(content) = String::from_utf8(bytes) else {
return FileReport::skipped(&label, "not UTF-8 text".to_string());
};
scan_text(&label, without_bom(&content), language, options)
}
pub(crate) fn without_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
fn shape(found: Vec<Found>, options: &ScanOptions) -> Vec<Date> {
let mut dates: Vec<Date> = found
.into_iter()
.filter(|date| options.after.is_none_or(|after| date.timestamp >= after))
.filter(|date| options.before.is_none_or(|before| date.timestamp < before))
.map(|date| Date {
iso: options.iso.then(|| time::to_iso(date.timestamp)).flatten(),
value: date.value,
format: date.notation.as_str(),
timestamp: date.timestamp,
line: date.line,
column: date.column,
})
.collect();
if options.dedupe {
let mut seen = std::collections::HashSet::new();
dates.retain(|date| seen.insert(date.value.clone()));
}
if options.sort {
dates.sort_by_key(|date| date.timestamp);
}
dates
}
pub(crate) fn exit_code(reports: &[FileReport], strict: bool) -> std::process::ExitCode {
if strict && reports.iter().any(|report| report.skipped.is_some()) {
return std::process::ExitCode::from(2);
}
if reports.iter().any(|report| !report.dates.is_empty()) {
return std::process::ExitCode::SUCCESS;
}
std::process::ExitCode::from(1)
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> ScanOptions {
ScanOptions {
year: 2026,
..ScanOptions::default()
}
}
const DOCUMENT: &str = "2024-03-01 then 2024-01-15 then 2024-01-15";
fn values(options: &ScanOptions) -> Vec<String> {
scan_text("x", DOCUMENT, "plaintext", options)
.dates
.into_iter()
.map(|date| date.value)
.collect()
}
#[test]
fn dates_come_back_in_document_order() {
assert_eq!(
values(&options()),
["2024-03-01", "2024-01-15", "2024-01-15"]
);
}
#[test]
fn dedupe_keeps_the_first_occurrence() {
let options = ScanOptions {
dedupe: true,
..options()
};
assert_eq!(values(&options), ["2024-03-01", "2024-01-15"]);
}
#[test]
fn sort_orders_by_instant() {
let options = ScanOptions {
sort: true,
..options()
};
assert_eq!(values(&options), ["2024-01-15", "2024-01-15", "2024-03-01"]);
}
#[test]
fn dedupe_and_sort_compose_one_way_only() {
let options = ScanOptions {
dedupe: true,
sort: true,
..options()
};
assert_eq!(values(&options), ["2024-01-15", "2024-03-01"]);
}
#[test]
fn after_is_inclusive_and_before_is_not() {
let boundary = 1_705_276_800_000; let after = ScanOptions {
after: Some(boundary),
..options()
};
assert_eq!(values(&after).len(), 3);
let before = ScanOptions {
before: Some(boundary),
..options()
};
assert!(values(&before).is_empty());
}
#[test]
fn iso_is_added_only_when_asked_for() {
let plain = scan_text("x", "2024-01-15", "plaintext", &options());
assert_eq!(plain.dates[0].iso, None);
let options = ScanOptions {
iso: true,
..options()
};
let annotated = scan_text("x", "2024-01-15", "plaintext", &options);
assert_eq!(
annotated.dates[0].iso.as_deref(),
Some("2024-01-15T00:00:00Z")
);
}
fn code(report: std::process::ExitCode) -> String {
format!("{report:?}")
}
#[test]
fn the_exit_code_follows_grep() {
let found = scan_text("x", "2024-01-15", "plaintext", &options());
let none = scan_text("x", "nothing", "plaintext", &options());
assert_eq!(
code(exit_code(&[found], false)),
code(std::process::ExitCode::SUCCESS)
);
assert_eq!(
code(exit_code(&[none], false)),
code(std::process::ExitCode::from(1))
);
}
#[test]
fn a_skipped_file_does_not_fail_the_run() {
let skipped = FileReport::skipped("logo.png", "not UTF-8 text".to_string());
assert_eq!(
code(exit_code(&[skipped], false)),
code(std::process::ExitCode::from(1)),
"a file that could not be read is not a malformed question"
);
}
#[test]
fn strict_turns_a_skipped_file_back_into_a_failure() {
let skipped = FileReport::skipped("logo.png", "not UTF-8 text".to_string());
assert_eq!(
code(exit_code(&[skipped], true)),
code(std::process::ExitCode::from(2))
);
}
#[test]
fn a_skip_does_not_hide_what_was_found() {
let found = scan_text("x", "2024-01-15", "plaintext", &options());
let skipped = FileReport::skipped("logo.png", "not UTF-8 text".to_string());
assert_eq!(
code(exit_code(&[found, skipped], false)),
code(std::process::ExitCode::SUCCESS)
);
}
#[test]
fn a_file_with_no_supported_format_is_named_rather_than_dropped() {
let report = scan_file(Path::new("/nonexistent/main.rs"), &options());
assert!(report.skipped.is_some());
}
#[test]
fn an_unreadable_file_says_so() {
let report = scan_file(Path::new("/nonexistent/a.json"), &options());
assert!(report.skipped.is_some());
assert!(report.dates.is_empty());
}
#[test]
fn a_byte_order_mark_is_not_part_of_the_document() {
assert_eq!(without_bom("\u{feff}{\"a\":1}"), "{\"a\":1}");
assert_eq!(without_bom("{\"a\":1}"), "{\"a\":1}");
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 plain = scan_text("x", "2024-01-15", "plaintext", &options());
let marked = scan_text(
"x",
without_bom("\u{feff}2024-01-15"),
"plaintext",
&options(),
);
assert_eq!(marked.dates.len(), plain.dates.len());
assert_eq!(marked.dates[0].column, plain.dates[0].column);
}
}