#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand, ValueEnum};
#[cfg(feature = "csv")]
use timeglyph::csv_enrich::{Conversion, EnrichOptions};
use timeglyph::interpret::{self, Candidate};
use timeglyph::{DateStyle, RenderZone};
const EXIT_OK: u8 = 0;
const EXIT_ERR: u8 = 1;
const EXIT_AMBIGUOUS: u8 = 2;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum FormatArg {
Iso8601,
Space,
Rfc2822,
Us,
}
impl From<FormatArg> for DateStyle {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Iso8601 => DateStyle::Iso8601,
FormatArg::Space => DateStyle::SpaceSeparated,
FormatArg::Rfc2822 => DateStyle::Rfc2822,
FormatArg::Us => DateStyle::UsStyle,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum AsArg {
Auto,
Int,
Hex,
String,
}
#[derive(Parser, Debug)]
#[command(name = "timeglyph", version, about = "Forensic timestamp decipherment")]
struct Cli {
value: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, global = true, value_name = "ZONE")]
tz: Option<String>,
#[command(flatten)]
ident: IdentifyOpts,
#[command(flatten)]
style: StyleOpt,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Args, Debug, Clone, Copy)]
struct StyleOpt {
#[arg(long = "style", id = "date_style", value_enum, default_value_t = FormatArg::Iso8601)]
style: FormatArg,
}
#[derive(Args, Debug)]
struct IdentifyOpts {
#[arg(long, value_name = "HINT")]
artifact: Option<String>,
#[arg(long = "as", value_enum, default_value_t = AsArg::Auto)]
as_mode: AsArg,
#[arg(long, value_name = "N")]
top: Option<usize>,
#[arg(long = "min-score", value_name = "S")]
min_score: Option<f64>,
#[arg(long = "ambiguity-gap", value_name = "GAP", default_value_t = 1e-9)]
ambiguity_gap: f64,
#[arg(long)]
provenance: bool,
}
impl StyleOpt {
fn date_style(self) -> DateStyle {
self.style.into()
}
}
impl IdentifyOpts {
fn rank(&self) -> RankOpts {
RankOpts {
top: self.top,
min_score: self.min_score,
gap: self.ambiguity_gap,
}
}
}
#[derive(Clone, Copy)]
struct RankOpts {
top: Option<usize>,
min_score: Option<f64>,
gap: f64,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[command(visible_alias = "id")]
Identify {
value: String,
#[arg(long)]
json: bool,
#[command(flatten)]
ident: IdentifyOpts,
#[command(flatten)]
style: StyleOpt,
},
Decode {
format: String,
value: String,
#[command(flatten)]
style: StyleOpt,
},
Encode {
format: String,
datetime: String,
},
Scan {
text: Option<String>,
#[arg(long, default_value_t = 8)]
min_digits: usize,
#[arg(long, value_name = "N")]
top: Option<usize>,
#[arg(long)]
json: bool,
#[command(flatten)]
style: StyleOpt,
},
Carve {
hex: Option<String>,
#[arg(long, default_value_t = 0.5)]
min_score: f64,
#[arg(long)]
from: Option<i16>,
#[arg(long)]
to: Option<i16>,
#[arg(long)]
json: bool,
#[arg(long)]
imhex: bool,
},
Explain {
format: String,
},
Mcp,
List,
#[cfg(feature = "csv")]
Csv {
path: String,
#[arg(long = "convert", value_name = "COL:FMT")]
convert: Vec<String>,
#[arg(long)]
auto: bool,
#[arg(long)]
replace: bool,
#[arg(short, long, value_name = "FILE")]
output: Option<String>,
},
#[cfg(feature = "lunisolar")]
Lunisolar {
datetime: String,
#[arg(long, allow_hyphen_values = true)]
longitude: Option<f64>,
},
Cal {
when: Option<String>,
#[arg(long, value_name = "DAY", default_value = "monday")]
week_start: String,
#[arg(long, value_name = "WHEN", default_value = "auto")]
color: String,
#[arg(long, value_name = "KEYS")]
calendars: Option<String>,
#[arg(long)]
json: bool,
},
}
fn init_tracing() {
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.with_span_events(FmtSpan::CLOSE)
.try_init();
}
fn main() -> ExitCode {
init_tracing();
let cli = Cli::parse();
let zone = match RenderZone::parse(cli.tz.as_deref().unwrap_or("")) {
Ok(z) => z,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(EXIT_ERR);
}
};
let is_identify = matches!(cli.command, None | Some(Commands::Identify { .. }));
if !is_identify && cli.ident.as_mode != AsArg::Auto {
eprintln!("error: --as applies only to `identify` / the bare value; other commands set their own interpretation");
return ExitCode::from(EXIT_ERR);
}
if !is_identify && cli.ident.artifact.is_some() {
eprintln!("error: --artifact applies only to `identify` / the bare value");
return ExitCode::from(EXIT_ERR);
}
ExitCode::from(dispatch(cli, &zone))
}
fn dispatch(cli: Cli, zone: &RenderZone) -> u8 {
match cli.command {
Some(Commands::Identify {
value,
json,
ident,
style,
}) => run_identify(
&value,
json,
ident.provenance,
zone,
style.date_style(),
ident.artifact.as_deref(),
ident.as_mode,
ident.rank(),
),
Some(Commands::Decode {
format,
value,
style,
}) => run_decode(&format, &value, zone, style.date_style()),
Some(Commands::Encode { format, datetime }) => run_encode(&format, &datetime),
Some(Commands::Scan {
text,
min_digits,
top,
json,
style,
}) => run_scan(
text.as_deref(),
min_digits,
top,
json,
zone,
style.date_style(),
),
Some(Commands::Carve {
hex,
min_score,
from,
to,
json,
imhex,
}) => run_carve(hex.as_deref(), min_score, from, to, json, imhex),
Some(Commands::Explain { format }) => run_explain(&format),
Some(Commands::Cal {
when,
week_start,
color,
calendars,
json,
}) => run_cal(
when.as_deref(),
&week_start,
&color,
json,
calendars.as_deref(),
zone,
),
Some(Commands::Mcp) => run_mcp(),
Some(Commands::List) => run_list(),
#[cfg(feature = "csv")]
Some(Commands::Csv {
path,
convert,
auto,
replace,
output,
}) => run_csv(&path, &convert, auto, replace, output.as_deref(), zone),
#[cfg(feature = "lunisolar")]
Some(Commands::Lunisolar {
datetime,
longitude,
}) => run_lunisolar(&datetime, longitude, zone, cli.tz.is_some()),
None => {
if let Some(v) = cli.value {
run_identify(
&v,
cli.json,
cli.ident.provenance,
zone,
cli.style.date_style(),
cli.ident.artifact.as_deref(),
cli.ident.as_mode,
cli.ident.rank(),
)
} else {
eprintln!("error: give a VALUE or a subcommand (see --help)");
EXIT_ERR
}
}
}
}
fn ambiguity_code(cands: &[Candidate], gap: f64) -> u8 {
let Some(top) = cands.first() else {
return EXIT_AMBIGUOUS;
};
if top.sentinel {
return EXIT_AMBIGUOUS;
}
if cands.len() >= 2 && (top.score - cands[1].score).abs() <= gap {
return EXIT_AMBIGUOUS;
}
EXIT_OK
}
fn looks_like_hex_bytes(s: &str) -> bool {
s.len() >= 2
&& s.len().is_multiple_of(2)
&& s.bytes().all(|b| b.is_ascii_hexdigit())
&& s.bytes().any(|b| b.is_ascii_alphabetic())
}
#[allow(clippy::too_many_arguments)]
fn run_identify(
input: &str,
json: bool,
provenance: bool,
zone: &RenderZone,
style: DateStyle,
artifact: Option<&str>,
mode: AsArg,
opts: RankOpts,
) -> u8 {
let s = input.trim();
match mode {
AsArg::Hex => return run_hex(s, zone, style, opts.gap),
AsArg::String => return run_string(s, zone, style),
AsArg::Auto | AsArg::Int => {}
}
if mode == AsArg::Auto
&& (s.starts_with("0x") || s.starts_with("0X") || looks_like_hex_bytes(s))
{
return run_hex(s, zone, style, opts.gap);
}
let ctx = interpret::InterpretContext {
artifact,
..Default::default()
};
let mut cands = Vec::new();
if let Ok(v) = s.parse::<i64>() {
cands.extend(interpret::interpret_int_with_context(v, &ctx));
} else if mode == AsArg::Auto {
if let Ok(v) = s.parse::<f64>() {
cands.extend(interpret::interpret_float(v));
}
}
if mode == AsArg::Auto {
cands.extend(interpret::interpret_string(s));
}
cands.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
render_candidates_in_zone(&mut cands, zone, style);
if cands.is_empty() {
let tried = if mode == AsArg::Int {
"an integer (--as int)"
} else {
"an integer, hex, or datetime string"
};
eprintln!("error: could not interpret {s:?} as {tried}");
return EXIT_ERR;
}
if let Some(min) = opts.min_score {
cands.retain(|c| c.score >= min);
}
if let Some(n) = opts.top {
cands.truncate(n);
}
if json {
let serialized = if provenance {
serde_json::to_string_pretty(&ProvenanceEnvelope {
schema_version: SCHEMA_VERSION,
engine: "timeglyph",
engine_version: env!("CARGO_PKG_VERSION"),
registry_digest: timeglyph::registry_digest(),
input: s,
readings: &cands,
})
} else {
serde_json::to_string_pretty(&cands)
};
match serialized {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("error: serializing candidates: {e}");
return EXIT_ERR;
}
}
return ambiguity_code(&cands, opts.gap);
}
println!(
"# readings consistent with {s} (ranked; a raw value is usually \
underdetermined — not a single verdict):"
);
print_candidates(&cands, zone, style);
ambiguity_code(&cands, opts.gap)
}
fn render_candidate(c: &Candidate, zone: &RenderZone, style: DateStyle) -> Option<String> {
let tz = timeglyph::format(c.format_id).map_or(timeglyph::TzSemantics::Utc, |f| f.tz);
match tz {
timeglyph::TzSemantics::LocalNaive => {
Some(timeglyph::datefmt::format_naive(c.instant, style))
}
_ => c
.instant
.render(zone)
.map(|_| timeglyph::datefmt::format_instant(c.instant, zone, style))
.or_else(|| c.rendered.clone()),
}
}
fn render_candidates_in_zone(cands: &mut [Candidate], zone: &RenderZone, style: DateStyle) {
for c in cands {
c.rendered = render_candidate(c, zone, style);
if matches!(zone, RenderZone::Named(_))
&& timeglyph::format(c.format_id)
.is_ok_and(|f| matches!(f.tz, timeglyph::TzSemantics::LocalNaive))
{
match timeglyph::resolve_local(c.instant, zone) {
timeglyph::LocalResolution::Fold { earlier, later } => c.assumptions.push(format!(
"if this wall-clock is in the chosen zone: AMBIGUOUS (DST fall-back fold) \
— two instants: {} / {}",
earlier.to_rfc3339().unwrap_or_default(),
later.to_rfc3339().unwrap_or_default()
)),
timeglyph::LocalResolution::Gap => c.assumptions.push(
"if this wall-clock is in the chosen zone: NONEXISTENT (DST spring-forward \
gap) — a correctly-clocked device in this zone cannot have written it"
.to_string(),
),
timeglyph::LocalResolution::Unique(_) => {}
}
}
}
}
fn decode_composite(
format: &str,
value: &str,
) -> Option<(Result<timeglyph::PosixNs, String>, &'static str)> {
match format {
"filetime_hilo" => Some((parse_filetime_hilo(value), "filetime")),
"unix_sec_nsec" => Some((parse_unix_sec_nsec(value), "unix")),
"elapsed_realtime" => Some((parse_relative(value, timeglyph::Unit::Millis), "unix")),
"mach_continuous" => Some((parse_relative(value, timeglyph::Unit::Nanos), "unix")),
"syslog" => Some((parse_syslog(value), "unix")),
"vmsd" => Some((parse_vmsd(value), "unix")),
"oracle_date" => Some((
parse_wave2_bytes(value, timeglyph::compose::oracle_date),
"unix",
)),
"iso9660" => Some((
parse_wave2_bytes(value, timeglyph::compose::iso9660),
"unix",
)),
"cp56time2a" => Some((
parse_wave2_bytes(value, timeglyph::compose::cp56time2a),
"unix",
)),
"udf" => Some((parse_wave2_udf(value), "unix")),
"ext4_extra" => Some((parse_ext4_extra(value), "unix")),
_ => None,
}
}
fn parse_hex_array<const N: usize>(value: &str) -> Result<[u8; N], String> {
let clean: String = value
.chars()
.filter(|c| !c.is_whitespace() && *c != ':' && *c != '_')
.collect();
let clean = clean
.strip_prefix("0x")
.or_else(|| clean.strip_prefix("0X"))
.unwrap_or(&clean);
let bytes = hex::decode(clean).map_err(|_| format!("not valid hex: {value:?}"))?;
<[u8; N]>::try_from(bytes.as_slice())
.map_err(|_| format!("expected {N} bytes, got {}", bytes.len()))
}
fn parse_wave2_bytes(
value: &str,
decode: fn([u8; 7]) -> Result<timeglyph::PosixNs, timeglyph::ChronoError>,
) -> Result<timeglyph::PosixNs, String> {
decode(parse_hex_array::<7>(value)?).map_err(|e| e.to_string())
}
fn parse_wave2_udf(value: &str) -> Result<timeglyph::PosixNs, String> {
timeglyph::compose::udf(parse_hex_array::<12>(value)?).map_err(|e| e.to_string())
}
fn parse_ext4_extra(value: &str) -> Result<timeglyph::PosixNs, String> {
let (s, e) = value
.split_once([',', ':'])
.ok_or_else(|| format!("expected 'seconds,extra', got {value:?}"))?;
let secs: i64 = s
.trim()
.parse()
.map_err(|_| format!("not an i64 seconds: {s:?}"))?;
let extra: u32 = e
.trim()
.parse()
.map_err(|_| format!("not a u32 extra field: {e:?}"))?;
Ok(timeglyph::compose::ext4_extra(secs, extra))
}
fn parse_vmsd(value: &str) -> Result<timeglyph::PosixNs, String> {
let (h, l) = value
.split_once([',', ':'])
.ok_or_else(|| format!("expected 'high,low', got {value:?}"))?;
let high: i32 = h
.trim()
.parse()
.map_err(|_| format!("not a 32-bit createTimeHigh: {h:?}"))?;
let low: i32 = l
.trim()
.parse()
.map_err(|_| format!("not a 32-bit createTimeLow: {l:?}"))?;
Ok(timeglyph::compose::vmsd(high, low))
}
fn parse_syslog(value: &str) -> Result<timeglyph::PosixNs, String> {
let (dt_s, ref_s) = value
.split_once('@')
.ok_or_else(|| format!("expected '<Mon DD HH:MM:SS>@<reference>', got {value:?}"))?;
let reference = interpret::interpret_string(ref_s.trim())
.into_iter()
.find(|c| c.format_id == "iso8601")
.map(|c| c.instant)
.ok_or_else(|| format!("reference must be an ISO 8601 datetime: {ref_s:?}"))?;
interpret::parse_syslog_with_reference(dt_s.trim(), reference)
.ok_or_else(|| format!("not an RFC 3164 syslog date: {dt_s:?}"))
}
#[cfg(feature = "leap")]
fn parse_gps_week_tow(value: &str) -> Result<timeglyph::leap::LeapReading, String> {
let (w, t) = value
.split_once([':', '@', ','])
.ok_or_else(|| format!("expected '<week>:<tow>', got {value:?}"))?;
let week: u32 = w
.trim()
.parse()
.map_err(|_| format!("not an integer GPS week: {w:?}"))?;
let tow: f64 = t
.trim()
.parse()
.map_err(|_| format!("not a time-of-week: {t:?}"))?;
Ok(timeglyph::compose::gps_week_tow(week, tow))
}
fn parse_relative(value: &str, unit: timeglyph::Unit) -> Result<timeglyph::PosixNs, String> {
let (ticks_s, anchor_s) = value
.split_once('@')
.ok_or_else(|| format!("expected '<ticks>@<anchor>', got {value:?}"))?;
let ticks: i64 = ticks_s
.trim()
.parse()
.map_err(|_| format!("not integer ticks: {ticks_s:?}"))?;
let anchor = interpret::interpret_string(anchor_s.trim())
.into_iter()
.find(|c| c.format_id == "iso8601")
.map(|c| c.instant)
.ok_or_else(|| format!("anchor must be an ISO 8601 datetime: {anchor_s:?}"))?;
Ok(timeglyph::compose::relative(anchor, ticks, unit))
}
fn parse_unix_sec_nsec(value: &str) -> Result<timeglyph::PosixNs, String> {
let (s, n) = value
.split_once([':', '.', ','])
.ok_or_else(|| format!("expected 'sec:nsec', got {value:?}"))?;
let sec: i64 = s
.trim()
.parse()
.map_err(|_| format!("not integer seconds: {s:?}"))?;
let nsec: u32 = n
.trim()
.parse()
.map_err(|_| format!("not integer nanoseconds: {n:?}"))?;
Ok(timeglyph::compose::unix_sec_nsec(sec, nsec))
}
fn parse_filetime_hilo(value: &str) -> Result<timeglyph::PosixNs, String> {
let (lo, hi) = value
.split_once([':', '|'])
.ok_or_else(|| format!("expected 'low:high', got {value:?}"))?;
let half = |h: &str| -> Result<u32, String> {
u32::from_str_radix(h.trim().trim_start_matches("0x"), 16)
.map_err(|_| format!("not a 32-bit hex half: {h:?}"))
};
timeglyph::compose::filetime_hilo(half(lo)?, half(hi)?).map_err(|e| e.to_string())
}
fn run_decode(format: &str, value: &str, zone: &RenderZone, style: DateStyle) -> u8 {
#[cfg(feature = "leap")]
if let Ok(v) = value.parse::<i64>() {
if let Some(result) = timeglyph::leap::decode(format, v) {
return match result {
Ok(r) => {
println!(
"{} {value} -> {} (leap-correct UTC)",
r.scale, r.utc_rfc3339
);
for a in &r.assumptions {
println!(" - {a}");
}
EXIT_OK
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
};
}
}
#[cfg(feature = "leap")]
if format == "gps_week_tow" {
return match parse_gps_week_tow(value) {
Ok(r) => {
println!(
"{} {value} -> {} (leap-correct UTC)",
r.scale, r.utc_rfc3339
);
for a in &r.assumptions {
println!(" - {a}");
}
EXIT_OK
}
Err(msg) => {
eprintln!("error: cannot decode {value:?} as {format}: {msg}");
EXIT_ERR
}
};
}
if let Some((result, render_id)) = decode_composite(format, value) {
return match result {
Ok(instant) => match timeglyph::format(render_id) {
Ok(f) => print_decode(f, value, instant, zone, style),
Err(_) => EXIT_ERR, },
Err(msg) => {
eprintln!("error: cannot decode {value:?} as {format}: {msg}");
EXIT_ERR
}
};
}
let f = match timeglyph::format(format) {
Ok(f) => f,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
};
let mut int_err: Option<timeglyph::ChronoError> = None;
if let Ok(v) = value.parse::<i64>() {
let sentinel = interpret::sentinel_reason(v);
match f.decode_int(v) {
Ok(instant) => {
print_decode(f, value, instant, zone, style);
return sentinel_exit(v, sentinel);
}
Err(e) => {
if let Some(reason) = sentinel {
eprintln!("warning: {v} is a likely sentinel ({reason}) — 'unset'/'never', not a real instant");
return EXIT_AMBIGUOUS;
}
int_err = Some(e);
}
}
}
if let Ok(v) = value.parse::<f64>() {
match f.decode_float(v) {
Ok(instant) => return print_decode(f, value, instant, zone, style),
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
}
}
match int_err {
Some(e) => eprintln!("error: cannot decode {value:?} as {format}: {e}"),
None => eprintln!("error: could not decode {value:?} as {format}"),
}
EXIT_ERR
}
fn print_decode(
f: &timeglyph::Format,
value: &str,
instant: timeglyph::PosixNs,
zone: &RenderZone,
style: DateStyle,
) -> u8 {
let (rendered, caveat) = if matches!(f.tz, timeglyph::TzSemantics::LocalNaive) {
(
timeglyph::datefmt::format_naive(instant, style),
" (LOCAL naive — not UTC)",
)
} else {
(timeglyph::datefmt::format_instant(instant, zone, style), "")
};
println!("{} {value} -> {rendered}{caveat}", f.id);
EXIT_OK
}
fn sentinel_exit(value: i64, sentinel: Option<&str>) -> u8 {
if let Some(reason) = sentinel {
eprintln!(
"warning: {value} is a likely sentinel ({reason}) — 'unset'/'never', not a real instant"
);
EXIT_AMBIGUOUS
} else {
EXIT_OK
}
}
fn run_encode(format: &str, datetime: &str) -> u8 {
let Some(instant) = interpret::interpret_string(datetime)
.first()
.map(|c| c.instant)
else {
eprintln!("error: could not parse datetime {datetime:?} (try ISO 8601 / RFC 3339)");
return EXIT_ERR;
};
if format == "all" {
return run_encode_all(instant);
}
let f = match timeglyph::format(format) {
Ok(f) => f,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
};
match f.encode(instant) {
Ok(v) => {
println!("{v}");
EXIT_OK
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
}
}
fn needle_bytes(enc: timeglyph::Encoded, width: u8) -> (String, String) {
match enc {
timeglyph::Encoded::Int(v) => {
let w = width as usize;
(
hex::encode(&v.to_le_bytes()[..w]),
hex::encode(&v.to_be_bytes()[8 - w..]),
)
}
timeglyph::Encoded::Float(x) => {
(hex::encode(x.to_le_bytes()), hex::encode(x.to_be_bytes()))
}
}
}
fn run_encode_all(instant: timeglyph::PosixNs) -> u8 {
println!("# on-disk needles for this time (format value LE BE) — search representations, not proof of occurrence");
let mut any = false;
for f in timeglyph::registry::FORMATS.iter() {
let Ok(enc) = f.encode(instant) else { continue };
let (le, be) = needle_bytes(enc, f.storage_bytes());
println!("{:<16} {:<22} LE {le:<16} BE {be}", f.id, enc);
any = true;
}
if any {
EXIT_OK
} else {
eprintln!("error: no format could represent this instant");
EXIT_ERR
}
}
fn run_hex(bytes: &str, zone: &RenderZone, style: DateStyle, gap: f64) -> u8 {
match interpret::interpret_hex(bytes) {
Ok(groups) => {
let mut all: Vec<Candidate> = Vec::new();
for (layout, cands) in &groups {
println!("# byte layout: {layout}");
print_candidates(cands, zone, style);
all.extend(cands.iter().cloned());
}
all.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
ambiguity_code(&all, gap)
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
}
}
fn run_string(text: &str, zone: &RenderZone, style: DateStyle) -> u8 {
let cands = interpret::interpret_string(text);
if cands.is_empty() {
eprintln!("error: {text:?} did not parse as any known string timestamp form");
return EXIT_ERR;
}
println!("# readings consistent with {text:?}:");
print_candidates(&cands, zone, style);
EXIT_OK
}
const SCHEMA_VERSION: u32 = 1;
#[derive(serde::Serialize)]
struct ProvenanceEnvelope<'a> {
schema_version: u32,
engine: &'static str,
engine_version: &'static str,
registry_digest: String,
input: &'a str,
readings: &'a [Candidate],
}
#[derive(serde::Serialize)]
struct ReadingJson<'a> {
format_id: &'a str,
label: &'a str,
rendered: &'a str,
score: f64,
local: bool,
}
#[derive(serde::Serialize)]
struct ScanRecordJson<'a> {
schema_version: u32,
number: &'a str,
readings: Vec<ReadingJson<'a>>,
}
fn run_scan(
text: Option<&str>,
min_digits: usize,
top: Option<usize>,
json: bool,
zone: &RenderZone,
style: DateStyle,
) -> u8 {
let input = if let Some(t) = text {
t.to_string()
} else {
use std::io::Read;
let mut s = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut s) {
eprintln!("error: could not read stdin: {e}");
return EXIT_ERR;
}
s
};
let max = top.unwrap_or(usize::MAX);
let hits = timeglyph::scan::inspect_text_opts(&input, max, min_digits, true, zone, style);
if json {
for nr in &hits {
let record = ScanRecordJson {
schema_version: SCHEMA_VERSION,
number: &nr.number,
readings: nr
.readings
.iter()
.map(|r| ReadingJson {
format_id: &r.format_id,
label: &r.label,
rendered: &r.rendered,
score: r.score,
local: r.local,
})
.collect(),
};
match serde_json::to_string(&record) {
Ok(line) => println!("{line}"),
Err(e) => {
eprintln!("error: serializing scan record: {e}");
return EXIT_ERR;
}
}
}
return EXIT_OK;
}
for nr in &hits {
println!("{}", nr.number);
for r in &nr.readings {
println!(" {r}");
}
}
EXIT_OK
}
#[cfg(feature = "lunisolar")]
fn run_lunisolar(datetime: &str, longitude: Option<f64>, zone: &RenderZone, tz_given: bool) -> u8 {
if !tz_given {
eprintln!(
"error: lunisolar conversion requires a timezone (--tz) — the Chinese calendar is \
meridian-relative (China UTC+8, Vietnam UTC+7, Korea UTC+9)"
);
return EXIT_ERR;
}
let instant = if let Ok(secs) = datetime.parse::<i64>() {
match timeglyph::format("unix").and_then(|f| f.decode_int(secs)) {
Ok(i) => i,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
}
} else if let Some(c) = interpret::interpret_string(datetime).first() {
c.instant
} else {
eprintln!("error: could not parse {datetime:?} as a datetime (try ISO 8601 / RFC 3339)");
return EXIT_ERR;
};
match timeglyph::lunisolar::render(instant, zone, longitude) {
Ok(r) => {
let leap = if r.is_leap_month { "閏" } else { "" };
println!("{}", r.civil_local);
println!(
" lunisolar: {}年 {leap}{}月 {}日",
r.lunar_year, r.lunar_month, r.lunar_day
);
println!(
" 四柱 pillars: {}年 {}月 {}日 {}時",
r.year_pillar, r.month_pillar, r.day_pillar, r.hour_pillar
);
println!(
" solar: λ {:.2}° ({})",
r.solar_longitude_deg, r.solar_term
);
for a in &r.assumptions {
println!(" - {a}");
}
EXIT_OK
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
}
}
fn run_list() -> u8 {
for f in timeglyph::registry::FORMATS.iter() {
println!("{:<16} {:<48} {}", f.id, f.label, f.citation);
}
EXIT_OK
}
fn run_mcp() -> u8 {
use std::io::{BufRead, Write};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
if let Some(response) = timeglyph::mcp::handle(&line) {
if writeln!(stdout, "{response}").is_err() || stdout.flush().is_err() {
break;
}
}
}
EXIT_OK
}
fn run_explain(format: &str) -> u8 {
if let Some(card) = interpret::explain(format) {
println!("{card}");
EXIT_OK
} else {
eprintln!("error: unknown format '{format}' (see `list` for the ids)");
EXIT_ERR
}
}
enum CalWhen {
Year(i16),
Month(i16, i8),
Day(jiff::civil::Date),
Instant(jiff::civil::DateTime),
}
fn parse_cal_when(when: Option<&str>, zone: &RenderZone) -> Result<CalWhen, String> {
let Some(w) = when else {
let now = today_in(zone);
return Ok(CalWhen::Month(now.year(), now.month()));
};
let bad = || {
format!("error: expected YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDThh:mm[:ss], got \"{w}\"")
};
if w.contains('T') || w.contains(' ') {
let iso = w.replacen(' ', "T", 1);
return iso
.parse::<jiff::civil::DateTime>()
.map(CalWhen::Instant)
.map_err(|_| bad());
}
let parts: Vec<&str> = w.split('-').collect();
match parts.as_slice() {
[y] => y.parse::<i16>().map(CalWhen::Year).map_err(|_| bad()),
[y, m] => {
let (y, m) = (
y.parse::<i16>().map_err(|_| bad())?,
m.parse::<i8>().map_err(|_| bad())?,
);
Ok(CalWhen::Month(y, m))
}
[y, m, d] => {
let (y, m, d) = (
y.parse::<i16>().map_err(|_| bad())?,
m.parse::<i8>().map_err(|_| bad())?,
d.parse::<i8>().map_err(|_| bad())?,
);
jiff::civil::Date::new(y, m, d)
.map(CalWhen::Day)
.map_err(|_| bad())
}
_ => Err(bad()),
}
}
fn today_in(zone: &RenderZone) -> jiff::civil::Date {
let ts = jiff::Timestamp::now();
let tz = match zone {
RenderZone::Utc => jiff::tz::TimeZone::UTC,
RenderZone::Fixed(o) => o.to_time_zone(),
RenderZone::Named(t) => t.clone(),
};
ts.to_zoned(tz).date()
}
fn append_today_card(
today: jiff::civil::Date,
y: i16,
m: i8,
zone: &RenderZone,
color: timeglyph::cal_color::ColorMode,
cals: &timeglyph::cal_render::CalendarSel,
) {
if today.year() == y && today.month() == m {
if let Ok(day) = timeglyph::cal::build_day(today, zone) {
println!(
"\n{}",
timeglyph::cal_render::render_day_text_with_calendars(&day, color, cals)
);
}
}
}
fn emit_day(
day: &timeglyph::cal::CalDay,
json: bool,
color: timeglyph::cal_color::ColorMode,
cals: &timeglyph::cal_render::CalendarSel,
) -> u8 {
if json {
println!("{}", serde_json::to_string_pretty(day).unwrap_or_default());
} else {
println!(
"{}",
timeglyph::cal_render::render_day_text_with_calendars(day, color, cals)
);
}
EXIT_OK
}
fn resolve_cal_color(color_arg: &str, json: bool) -> timeglyph::cal_color::ColorMode {
use std::io::IsTerminal as _;
if json {
return timeglyph::cal_color::ColorMode::Mono;
}
timeglyph::cal_color::detect(
color_arg,
std::env::var_os("NO_COLOR").is_some(),
std::io::stdout().is_terminal(),
std::env::var("COLORTERM").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
)
}
fn parse_calendar_sel(arg: Option<&str>) -> Result<timeglyph::cal_render::CalendarSel, String> {
use std::collections::HashSet;
use timeglyph::cal_render::CalendarSel;
const KEYS: [&str; 7] = [
"roc",
"japanese",
"buddhist",
"hebrew",
"islamic",
"persian",
"lunisolar",
];
match arg {
None => Ok(CalendarSel::All),
Some(s) if s.eq_ignore_ascii_case("all") => Ok(CalendarSel::All),
Some(s) if s.eq_ignore_ascii_case("none") => Ok(CalendarSel::Only(HashSet::new())),
Some(s) => {
let mut set = HashSet::new();
for raw in s.split(',') {
let key = raw.trim().to_ascii_lowercase();
if key.is_empty() {
continue;
}
if !KEYS.contains(&key.as_str()) {
return Err(format!(
"unknown calendar '{key}' (valid: {}, or all/none)",
KEYS.join(", ")
));
}
set.insert(key);
}
Ok(CalendarSel::Only(set))
}
}
}
fn run_cal(
when: Option<&str>,
week_start: &str,
color_arg: &str,
json: bool,
calendars: Option<&str>,
zone: &RenderZone,
) -> u8 {
use timeglyph::cal::{build_day, build_month, WeekStart};
let ws = match week_start.to_ascii_lowercase().as_str() {
"monday" | "mon" => WeekStart::Monday,
"sunday" | "sun" => WeekStart::Sunday,
other => {
eprintln!("error: --week-start must be monday or sunday, got \"{other}\"");
return EXIT_ERR;
}
};
let color = resolve_cal_color(color_arg, json);
let cals = match parse_calendar_sel(calendars) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
};
let target = match parse_cal_when(when, zone) {
Ok(t) => t,
Err(e) => {
eprintln!("{e}");
return EXIT_ERR;
}
};
let today = today_in(zone);
match target {
CalWhen::Day(date) => {
let Ok(day) = build_day(date, zone) else {
eprintln!("error: {date} is out of the representable range");
return EXIT_ERR;
};
emit_day(&day, json, color, &cals)
}
CalWhen::Instant(dt) => {
let Ok(day) = timeglyph::cal::build_day_at(dt, zone) else {
eprintln!("error: {dt} is out of the representable range");
return EXIT_ERR;
};
emit_day(&day, json, color, &cals)
}
CalWhen::Month(y, m) => match build_month(y, m, zone, ws) {
Ok(month) => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&month).unwrap_or_default()
);
} else {
print!(
"{}",
timeglyph::cal_render::render_month_text_with_calendars(
&month,
Some(today),
color,
&cals
)
);
append_today_card(today, y, m, zone, color, &cals);
}
EXIT_OK
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
},
CalWhen::Year(y) => emit_year(y, zone, ws, json, color, &cals, today),
}
}
fn emit_year(
y: i16,
zone: &RenderZone,
ws: timeglyph::cal::WeekStart,
json: bool,
color: timeglyph::cal_color::ColorMode,
cals: &timeglyph::cal_render::CalendarSel,
today: jiff::civil::Date,
) -> u8 {
let mut months = Vec::new();
for m in 1..=12 {
match timeglyph::cal::build_month(y, m, zone, ws) {
Ok(month) => months.push(month),
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERR;
}
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&months).unwrap_or_default()
);
} else {
#[cfg(feature = "lunisolar")]
{
use timeglyph::cal::{hemisphere_for, season_markers};
print!(
"{}",
timeglyph::cal_art::season_strip(y, &season_markers(y), hemisphere_for(zone))
);
println!();
}
for month in &months {
print!(
"{}",
timeglyph::cal_render::render_month_text_with_calendars(
month,
Some(today),
color,
cals
)
);
println!();
}
}
EXIT_OK
}
fn run_carve(
hex: Option<&str>,
min_score: f64,
from: Option<i16>,
to: Option<i16>,
json: bool,
imhex: bool,
) -> u8 {
use std::io::Read;
let raw = match hex {
Some(h) if h != "-" => h.to_string(),
_ => {
let mut s = String::new();
if std::io::stdin().read_to_string(&mut s).is_err() {
eprintln!("error: could not read hex from stdin");
return EXIT_ERR;
}
s
}
};
let clean: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != '_' && *c != ':')
.collect();
let clean = clean
.strip_prefix("0x")
.or_else(|| clean.strip_prefix("0X"))
.unwrap_or(&clean);
let Ok(bytes) = hex::decode(clean) else {
eprintln!("error: input is not valid hex bytes");
return EXIT_ERR;
};
let year_ns = |y: i16| -> Option<i128> {
jiff::civil::Date::new(y, 1, 1)
.ok()?
.at(0, 0, 0, 0)
.to_zoned(jiff::tz::TimeZone::UTC)
.ok()
.map(|z| z.timestamp().as_nanosecond())
};
let window = match (from.and_then(year_ns), to.and_then(year_ns)) {
(Some(lo), Some(hi)) => Some((lo, hi)),
_ => None,
};
let hits = timeglyph::carve::carve(&bytes, min_score, window);
if json {
println!("{}", timeglyph::carve::to_jsonl(&hits));
} else if imhex {
println!("{}", timeglyph::carve::to_imhex_bookmarks(&hits));
} else if hits.is_empty() {
println!("# no timestamp readings above the score/window threshold");
} else {
for h in &hits {
println!(
" @{:<5} [{:.2}] {:<14} {} ({})",
h.offset,
h.reading.score,
h.reading.format_id,
h.reading.rendered.as_deref().unwrap_or("?"),
h.lane
);
}
}
EXIT_OK
}
#[cfg(feature = "csv")]
fn run_csv(
path: &str,
convert: &[String],
auto: bool,
replace: bool,
output: Option<&str>,
zone: &RenderZone,
) -> u8 {
let input = if path == "-" {
let mut s = String::new();
if let Err(e) = std::io::Read::read_to_string(&mut std::io::stdin(), &mut s) {
eprintln!("error: could not read stdin: {e}");
return EXIT_ERR;
}
s
} else {
match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: cannot read {path}: {e}");
return EXIT_ERR;
}
}
};
let mut conversions = Vec::new();
for c in convert {
match c.split_once(':') {
Some((col, fmt)) if !col.is_empty() && !fmt.is_empty() => {
conversions.push(Conversion {
column: col.to_string(),
format: fmt.to_string(),
});
}
_ => {
eprintln!("error: --convert expects COLUMN:FORMAT, got {c:?}");
return EXIT_ERR;
}
}
}
let auto = auto || conversions.is_empty();
let opts = EnrichOptions {
conversions,
auto,
replace,
zone: zone.clone(),
};
match timeglyph::csv_enrich::enrich(&input, &opts) {
Ok(out) => {
if let Some(path) = output {
if let Err(e) = std::fs::write(path, out) {
eprintln!("error: cannot write {path}: {e}");
return EXIT_ERR;
}
} else {
print!("{out}");
}
EXIT_OK
}
Err(e) => {
eprintln!("error: {e}");
EXIT_ERR
}
}
}
fn print_candidates(cands: &[Candidate], zone: &RenderZone, style: DateStyle) {
if cands.is_empty() {
println!(" (no plausible interpretation)");
return;
}
for c in cands {
let flag = if c.sentinel { " [sentinel]" } else { "" };
let rendered = render_candidate(c, zone, style);
println!(
" [{:.2}] {:<16} {} ({}){flag}",
c.score,
c.format_id,
rendered.as_deref().unwrap_or("<out of range>"),
c.label,
);
}
}