Skip to main content

latexml/
identity.rs

1//! Per-conversion identity banner — executable name, version, git revision and
2//! exact start time, logged once at the head of every conversion so any log
3//! names the precise binary and moment that produced it.
4//!
5//! Faithful to Perl LaTeXML, which logs `Note("$LaTeXML::IDENTITY processing
6//! $source")` at each conversion start (`bin/latexml` L83). `$IDENTITY` is
7//! `"$FindBin::Script ($LaTeXML::FULLVERSION)"` (`LaTeXML.pm` L40) — the invoked
8//! script's basename plus `"LaTeXML version <v>; revision <sha>"`, the revision
9//! filled into `Version.pm` by `make`. We mirror that (revision embedded by
10//! `build.rs` instead of `make`) and additionally stamp the exact wall-clock
11//! start time, which Perl only emits under `--verbose` (`processing started …`).
12//!
13//! Generic across every front-end: [`Converter::convert`](crate::converter::Converter::convert)
14//! emits it for `latexml_oxide` and `cortex_worker`, and `latexmlmath_oxide` (a
15//! separate digest path) emits it directly. The executable name is read from
16//! `argv[0]` at runtime, so each binary self-identifies without per-binary wiring.
17
18use std::path::Path;
19
20use chrono::{DateTime, Local};
21
22/// This crate's version (`latexml_oxide`) — the emulated engine's own version,
23/// NOT the Perl LaTeXML version it targets. Full `CARGO_PKG_VERSION`, keeping any
24/// `-rc` pre-release suffix (a log should reveal an rc); the bare-`X.Y.Z` form
25/// for BookML's version gate is [`crate::core_interface::LATEXML_VERSION`].
26pub const VERSION: &str = env!("CARGO_PKG_VERSION");
27
28/// Short git revision of the source that built this binary, embedded by
29/// `build.rs` (`"unknown"` off a checkout with no `.git` and no
30/// `LATEXML_GIT_SHA` override). Perl's `$LaTeXML::Version::REVISION`.
31pub const GIT_REVISION: &str = env!("LATEXML_GIT_SHA");
32
33/// Basename of the invoked executable — Perl's `$FindBin::Script`
34/// (`latexml_oxide`, `cortex_worker`, `latexmlmath_oxide`, …). Read from
35/// `argv[0]` so each binary self-identifies; `"latexml-oxide"` when argv is
36/// empty or unreadable (e.g. an embedder driving `Converter` directly).
37pub fn executable_name() -> String {
38  std::env::args_os()
39    .next()
40    .as_deref()
41    .map(Path::new)
42    .and_then(Path::file_name)
43    .map(|s| s.to_string_lossy().into_owned())
44    .filter(|s| !s.is_empty())
45    .unwrap_or_else(|| "latexml-oxide".to_string())
46}
47
48/// Conversion start instant. Honours `SOURCE_DATE_EPOCH` (reproducible builds),
49/// exactly as the engine's `\today`/date registers do (`tex_job.rs`), so a
50/// pinned epoch yields a deterministic banner; otherwise the local wall clock.
51fn start_time() -> DateTime<Local> {
52  if let Some(epoch) = std::env::var("SOURCE_DATE_EPOCH")
53    .ok()
54    .and_then(|e| e.trim().parse::<i64>().ok())
55    && let Some(utc) = DateTime::from_timestamp(epoch, 0)
56  {
57    return utc.with_timezone(&Local);
58  }
59  Local::now()
60}
61
62/// The one-line identity banner, e.g.
63/// `latexml_oxide (latexml-oxide 0.9.0; revision a1b2c3d) started 2026-08-21 14:32:05 -0400`.
64///
65/// Emit it through [`Note!`](latexml_core::Note) so it reaches both stderr and
66/// the captured `.latexml.log`, and inherits the verbosity gate (`--quiet`
67/// suppresses it).
68pub fn identity_banner() -> String {
69  format!(
70    "{exe} (latexml-oxide {VERSION}; revision {GIT_REVISION}) started {when}",
71    exe = executable_name(),
72    when = start_time().format("%Y-%m-%d %H:%M:%S %z"),
73  )
74}
75
76#[cfg(test)]
77mod tests {
78  use super::*;
79
80  /// The banner carries all four requested fields: an executable name, the
81  /// crate version, the embedded revision, and a `started <timestamp>` stamp.
82  #[test]
83  fn banner_has_exe_version_revision_and_time() {
84    let banner = identity_banner();
85    assert!(
86      banner.contains(VERSION),
87      "banner {banner:?} missing version {VERSION:?}"
88    );
89    assert!(
90      banner.contains("revision "),
91      "banner {banner:?} missing revision field"
92    );
93    assert!(
94      banner.contains(GIT_REVISION),
95      "banner {banner:?} missing revision {GIT_REVISION:?}"
96    );
97    assert!(
98      banner.contains(" started "),
99      "banner {banner:?} missing start-time stamp"
100    );
101    assert!(
102      !executable_name().is_empty(),
103      "executable name must be non-empty"
104    );
105  }
106
107  /// `SOURCE_DATE_EPOCH` pins the timestamp for a deterministic (reproducible)
108  /// banner. Epoch 0 = 1970-01-01 UTC; the local-time render must land on the
109  /// 1969-12-31/1970-01-01 boundary depending on the tester's zone.
110  #[test]
111  fn source_date_epoch_pins_the_timestamp() {
112    // SAFETY: single-threaded test; no other thread reads the environment here.
113    unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "0") };
114    let when = start_time().format("%Y-%m-%d").to_string();
115    unsafe { std::env::remove_var("SOURCE_DATE_EPOCH") };
116    assert!(
117      when == "1970-01-01" || when == "1969-12-31",
118      "SOURCE_DATE_EPOCH=0 should render the epoch date, got {when:?}"
119    );
120  }
121}