use std::path::Path as StdPath;
use serde::Serialize;
use crate::extract::{self, Clock, Found, Kind, Options, resolve_format};
const SCHEMA: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Severity {
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Code {
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Diagnostic {
pub(crate) severity: Severity,
pub(crate) code: Code,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct Summary {
pub(crate) ids: usize,
pub(crate) refused: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct FileReport {
pub(crate) schema: u8,
pub(crate) file: String,
pub(crate) format: String,
pub(crate) ids: 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 == Code::Skipped)
}
}
pub(crate) fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok())
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScanOptions {
pub(crate) extract: Options,
pub(crate) format: Option<&'static str>,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
extract: Options {
clock: Clock::at(now_ms()),
kind: None,
},
format: None,
}
}
}
impl ScanOptions {
pub(crate) fn with_kind(self, kind: Option<Kind>) -> Self {
Self {
extract: Options {
kind,
..self.extract
},
..self
}
}
}
#[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')
}
pub(crate) fn scan_file(path: &StdPath, 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()))
}
#[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 scan_content(
content: &str,
file: String,
format: &str,
options: ScanOptions,
) -> FileReport {
let ids = extract::extract(content, format, options.extract);
let (named, refused) = extract::counts(&ids);
FileReport {
schema: SCHEMA,
file,
format: format.to_string(),
ids,
diagnostics: Vec::new(),
summary: Summary {
ids: named,
refused,
},
}
}
pub(crate) fn exit_code(reports: &[FileReport], strict: bool) -> u8 {
if strict
&& reports
.iter()
.any(|report| report.was_skipped() || report.summary.refused > 0)
{
return 2;
}
u8::from(!reports.iter().any(|report| report.summary.ids > 0))
}
pub(crate) fn describe(report: &FileReport, found: &Found) -> String {
let where_it_is = format!(
"{}:{}:{}",
report.file, found.position.line, found.position.column
);
let Some(reason) = found.refused else {
let kind = found
.kind
.map_or_else(|| "id".to_string(), |kind| kind_name(kind).to_string());
let version = found
.version
.map_or_else(String::new, |version| format!(" v{version}"));
let when = found
.timestamp
.as_deref()
.map_or_else(String::new, |stamp| format!(" {stamp}"));
return format!("{where_it_is} {kind}{version} {}{when}", found.value);
};
format!(
"{where_it_is} refused ({}) {} — {}",
reason_name(reason),
found.value,
found.detail.as_deref().unwrap_or("no reason given")
)
}
fn kind_name(kind: Kind) -> &'static str {
match kind {
Kind::Uuid => "uuid",
Kind::Ulid => "ulid",
Kind::NanoId => "nanoid",
Kind::ObjectId => "objectid",
Kind::Snowflake => "snowflake",
}
}
fn reason_name(reason: crate::extract::Reason) -> &'static str {
use crate::extract::Reason;
match reason {
Reason::AmbiguousKind => "ambiguous_kind",
Reason::Malformed => "malformed",
Reason::NilOrMax => "nil_or_max",
Reason::VersionClaimMismatch => "version_claim_mismatch",
Reason::TimestampImplausible => "timestamp_implausible",
}
}
fn skipped(file: String, format: &'static str, reason: &str) -> FileReport {
FileReport {
schema: SCHEMA,
file,
format: format.to_string(),
ids: Vec::new(),
diagnostics: vec![Diagnostic {
severity: Severity::Warning,
code: Code::Skipped,
message: reason.to_string(),
}],
summary: Summary { ids: 0, refused: 0 },
}
}
pub(crate) fn without_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::Reason;
use crate::testing::TempTree;
const UUID: &str = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
const NIL: &str = "00000000-0000-0000-0000-000000000000";
fn plain() -> ScanOptions {
ScanOptions {
extract: Options {
clock: Clock::at(1_786_492_800_000),
kind: None,
},
format: None,
}
}
fn read(path: &StdPath, options: ScanOptions) -> FileReport {
scan_file(path, options)
.into_report()
.expect("the file was a text candidate")
}
#[test]
fn a_document_with_identifiers_exits_zero() {
let report = scan_content(
&format!("{{\"id\":\"{UUID}\"}}"),
"a.json".into(),
"json",
plain(),
);
assert_eq!(report.summary.ids, 1);
assert_eq!(report.schema, 1);
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.ids, 0);
assert_eq!(exit_code(&[report], false), 1);
}
#[test]
fn nothing_to_scan_exits_one() {
assert_eq!(exit_code(&[], false), 1);
}
#[test]
fn a_refusal_does_not_move_the_exit_code_unless_strict() {
let report = scan_content(
&format!("{{\"id\":\"{NIL}\"}}"),
"a.json".into(),
"json",
plain(),
);
assert_eq!(report.summary, Summary { ids: 0, refused: 1 });
assert_eq!(report.ids[0].refused, Some(Reason::NilOrMax));
assert_eq!(exit_code(std::slice::from_ref(&report), false), 1);
assert_eq!(exit_code(&[report], true), 2);
}
#[test]
fn a_refusal_beside_a_finding_still_exits_zero() {
let report = scan_content(
&format!("a: {UUID}\nb: {NIL}\n"),
"a.yaml".into(),
"yaml",
plain(),
);
assert_eq!(report.summary, Summary { ids: 1, refused: 1 });
assert_eq!(exit_code(std::slice::from_ref(&report), false), 0);
assert_eq!(exit_code(&[report], true), 2, "--strict is opt-in");
}
#[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, 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_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("ids.env", &format!("SERVICE_ID={UUID}\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");
assert_eq!(reports[0].diagnostics[0].message, "not UTF-8 text");
assert_eq!(exit_code(&reports, false), 0, "the .env file has an id");
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 every_diagnostic_variant_is_reachable_and_keeps_its_wire_name() {
let tree = TempTree::new("scan-severities");
let broken = tree.write_bytes("notes.txt", &[0x68, 0x69, 0xff, 0xfe]);
let diagnostics = read(&broken, plain()).diagnostics;
let severity_name = |severity: Severity| match severity {
Severity::Warning => "warning",
};
let code_name = |code: Code| match code {
Code::Skipped => "skipped",
};
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(severity_name(diagnostics[0].severity), "warning");
assert_eq!(code_name(diagnostics[0].code), "skipped");
assert_eq!(
serde_json::to_value(diagnostics[0].severity).expect("serializes"),
severity_name(diagnostics[0].severity)
);
assert_eq!(
serde_json::to_value(diagnostics[0].code).expect("serializes"),
code_name(diagnostics[0].code)
);
}
#[test]
fn binary_is_a_nul_byte_in_the_first_8_kib() {
let tree = TempTree::new("scan-sniff");
let mut late = vec![b'a'; 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", &format!("id = \"{UUID}\"\n"));
let report = read(&file, plain());
assert_eq!(report.format, "toml");
assert_eq!(report.ids[0].key.as_deref(), Some("id"));
}
#[test]
fn an_unrecognised_file_is_still_scanned() {
let tree = TempTree::new("scan-fallback");
let file = tree.write("NOTES.md", &format!("Released under {UUID}.\n"));
let report = read(&file, plain());
assert_eq!(report.format, "text");
assert_eq!(report.summary.ids, 1);
assert_eq!(report.ids[0].key, None);
}
#[test]
fn a_forced_format_overrides_the_file_name() {
let tree = TempTree::new("scan-forced");
let file = tree.write("data.json", &format!("id = \"{UUID}\"\n"));
let report = read(
&file,
ScanOptions {
format: Some("toml"),
..plain()
},
);
assert_eq!(report.format, "toml");
assert_eq!(report.ids[0].key.as_deref(), Some("id"));
}
#[test]
fn a_kind_filter_narrows_the_report() {
let text = format!("a: {UUID}\nb: 01KZSM9K00ABCDEFGH12345678\n");
let all = scan_content(&text, "a.yaml".into(), "yaml", plain());
assert_eq!(all.summary.ids, 2);
let only = scan_content(
&text,
"a.yaml".into(),
"yaml",
plain().with_kind(Some(Kind::Uuid)),
);
assert_eq!(only.summary.ids, 1);
}
#[test]
fn the_human_line_carries_the_kind_and_the_decode() {
let report = scan_content(
"id: 019ff344-cc00-7abc-8def-0123456789ab\n",
"a.yaml".into(),
"yaml",
plain(),
);
assert_eq!(
describe(&report, &report.ids[0]),
"a.yaml:1:5 uuid v7 019ff344-cc00-7abc-8def-0123456789ab 2026-08-12T00:00:00.000Z"
);
}
#[test]
fn the_human_line_names_the_refusal_and_its_reason() {
let report = scan_content(&format!("id: {NIL}\n"), "a.yaml".into(), "yaml", plain());
let line = describe(&report, &report.ids[0]);
assert!(line.contains("refused (nil_or_max)"), "{line}");
assert!(line.contains("RFC 9562"), "{line}");
}
#[test]
fn every_reason_has_a_name_matching_its_serialized_form() {
for reason in [
Reason::AmbiguousKind,
Reason::Malformed,
Reason::NilOrMax,
Reason::VersionClaimMismatch,
Reason::TimestampImplausible,
] {
assert_eq!(
serde_json::to_value(reason).expect("serializes"),
reason_name(reason)
);
}
}
#[cfg(windows)]
#[test]
fn a_reported_path_is_separated_by_forward_slashes() {
assert_eq!(
report_path(StdPath::new(r"C:\src\config.json")),
"C:/src/config.json"
);
}
#[cfg(not(windows))]
#[test]
fn a_backslash_in_a_unix_filename_is_left_alone() {
assert_eq!(
report_path(StdPath::new(r"/tmp/odd\name.json")),
r"/tmp/odd\name.json"
);
}
#[test]
fn every_kind_has_a_name_matching_its_serialized_form() {
for kind in [
Kind::Uuid,
Kind::Ulid,
Kind::NanoId,
Kind::ObjectId,
Kind::Snowflake,
] {
assert_eq!(
serde_json::to_value(kind).expect("serializes"),
kind_name(kind)
);
}
}
}
#[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");
}
#[test]
fn an_unreadable_clock_makes_the_tool_say_less() {
let closed = Clock::at(0);
assert!(!closed.is_plausible(crate::extract::policy::EARLIEST_PLAUSIBLE_MS));
}
}