use std::{
env,
fs::{self, File},
io::{BufRead, BufReader, Read},
path::{Path, PathBuf},
time::SystemTime,
};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::fmt::Write;
#[derive(Debug, Clone)]
pub(crate) struct SourceProvenance {
pub source_name: String,
pub source_url: String,
pub byte_count: u64,
pub sha256_hex: String,
pub retrieved_at: String,
}
impl SourceProvenance {
fn from_file(name: &str, url: &str, path: &Path) -> Result<Self> {
let mut file = File::open(path).with_context(|| format!("open {path:?}"))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
let mut count = 0u64;
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("read {path:?}"))?;
if n == 0 {
break;
}
count += n as u64;
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
Ok(Self {
source_name: name.to_string(),
source_url: url.to_string(),
byte_count: count,
sha256_hex: hex_encode(&digest),
retrieved_at: iso8601_now(),
})
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{:02x}", b);
}
s
}
fn iso8601_now() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (year, month, day, hour, minute, second) = unix_to_civil(secs);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn unix_to_civil(secs: u64) -> (i32, u8, u8, u8, u8, u8) {
let days = (secs / 86_400) as i64;
let rem = (secs % 86_400) as u32;
let hour = (rem / 3_600) as u8;
let minute = ((rem % 3_600) / 60) as u8;
let second = (rem % 60) as u8;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
let year = (y + if m <= 2 { 1 } else { 0 }) as i32;
(year, m, d, hour, minute, second)
}
#[derive(Debug)]
#[allow(dead_code)]
struct EopRow {
mjd: f64,
xp: f64,
yp: f64,
dut1: f64,
dx: f64,
dy: f64,
is_bulletin_b: bool,
}
fn parse_line(line: &str) -> Option<EopRow> {
if line.len() < 15 {
return None;
}
let mjd: f64 = line.get(7..15)?.trim().parse().ok()?;
if mjd < 37665.0 {
return None;
}
let (xp, yp, dut1, dx, dy, is_b) = if line.len() >= 185 {
let b_xp: Option<f64> = line.get(134..144).and_then(|s| s.trim().parse().ok());
let b_yp: Option<f64> = line.get(144..154).and_then(|s| s.trim().parse().ok());
let b_dut1: Option<f64> = line.get(154..165).and_then(|s| s.trim().parse().ok());
let b_dx: Option<f64> = line.get(165..175).and_then(|s| s.trim().parse().ok());
let b_dy: Option<f64> = line.get(175..185).and_then(|s| s.trim().parse().ok());
if let (Some(xp), Some(yp), Some(dut1)) = (b_xp, b_yp, b_dut1) {
(xp, yp, dut1, b_dx.unwrap_or(0.0), b_dy.unwrap_or(0.0), true)
} else {
parse_bulletin_a(line)?
}
} else {
parse_bulletin_a(line)?
};
Some(EopRow {
mjd,
xp,
yp,
dut1,
dx,
dy,
is_bulletin_b: is_b,
})
}
fn parse_bulletin_a(line: &str) -> Option<(f64, f64, f64, f64, f64, bool)> {
let xp: f64 = line.get(18..27)?.trim().parse().ok()?;
let yp: f64 = line.get(37..46)?.trim().parse().ok()?;
let dut1: f64 = if line.len() >= 68 {
line.get(58..68)?.trim().parse().ok()?
} else {
return None;
};
let dx: f64 = if line.len() >= 106 {
line.get(97..106)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0)
} else {
0.0
};
let dy: f64 = if line.len() >= 125 {
line.get(116..125)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0)
} else {
0.0
};
Some((xp, yp, dut1, dx, dy, false))
}
fn parse_finals(path: &Path) -> Result<Vec<EopRow>> {
let file = File::open(path).with_context(|| format!("open {path:?}"))?;
let reader = BufReader::new(file);
let rows: Vec<EopRow> = reader
.lines()
.map_while(Result::ok)
.filter_map(|line| parse_line(&line))
.collect();
anyhow::ensure!(!rows.is_empty(), "No valid EOP rows parsed from {path:?}");
Ok(rows)
}
fn generate_rust(rows: &[EopRow], provenance: &SourceProvenance) -> Result<String> {
let mut out = String::with_capacity(rows.len() * 120);
writeln!(
out,
"// ───────────────────────────────────────────────────────────────────"
)?;
writeln!(
out,
"// **AUTOGENERATED** by build.rs – DO NOT EDIT BY HAND"
)?;
writeln!(out, "//")?;
writeln!(out, "// Source name : {}", provenance.source_name)?;
writeln!(out, "// Source URL : {}", provenance.source_url)?;
writeln!(out, "// Source bytes: {}", provenance.byte_count)?;
writeln!(out, "// Source SHA256: {}", provenance.sha256_hex)?;
writeln!(out, "// Retrieved at: {}", provenance.retrieved_at)?;
writeln!(out, "// Generator : siderust scripts/iers/mod.rs")?;
writeln!(
out,
"// ───────────────────────────────────────────────────────────────────"
)?;
writeln!(out)?;
writeln!(out, "/// A single daily EOP record from IERS finals2000A.")?;
writeln!(out, "///")?;
writeln!(
out,
"/// All angular quantities are in their native IERS units:"
)?;
writeln!(
out,
"/// pole coordinates in arcseconds, UT1−UTC in seconds,"
)?;
writeln!(out, "/// CIP offsets dX/dY in milliarcseconds.")?;
writeln!(out, "#[derive(Debug, Clone, Copy)]")?;
writeln!(out, "pub struct EopEntry {{")?;
writeln!(out, " /// Modified Julian Date (UTC).")?;
writeln!(out, " pub mjd: f64,")?;
writeln!(out, " /// Pole x-coordinate (arcseconds).")?;
writeln!(out, " pub xp: f64,")?;
writeln!(out, " /// Pole y-coordinate (arcseconds).")?;
writeln!(out, " pub yp: f64,")?;
writeln!(out, " /// UT1 − UTC (seconds).")?;
writeln!(out, " pub dut1: f64,")?;
writeln!(
out,
" /// Celestial pole offset dX wrt IAU 2000A (milliarcseconds)."
)?;
writeln!(out, " pub dx: f64,")?;
writeln!(
out,
" /// Celestial pole offset dY wrt IAU 2000A (milliarcseconds)."
)?;
writeln!(out, " pub dy: f64,")?;
writeln!(out, "}}")?;
writeln!(out)?;
writeln!(
out,
"/// Embedded EOP table from IERS finals2000A.all ({} entries).",
rows.len()
)?;
writeln!(
out,
"/// MJD range: {:.1} – {:.1}.",
rows.first().unwrap().mjd,
rows.last().unwrap().mjd,
)?;
writeln!(out, "pub static EOP_TABLE: &[EopEntry] = &[")?;
for r in rows {
writeln!(
out,
" EopEntry {{ mjd: {:.2}, xp: {:.6}, yp: {:.6}, dut1: {:.7}, dx: {:.3}, dy: {:.3} }},",
r.mjd, r.xp, r.yp, r.dut1, r.dx, r.dy,
)?;
}
writeln!(out, "];")?;
Ok(out)
}
const FINALS_FILENAME: &str = "finals2000A.all";
const IERS_URL: &str = "https://datacenter.iers.org/products/eop/rapid/standard/finals2000A.all";
const USNO_URL: &str = "https://maia.usno.navy.mil/ser7/finals2000A.all";
fn ensure_dataset(dir: &Path) -> Result<PathBuf> {
let target = dir.join(FINALS_FILENAME);
if target.exists() {
eprintln!("IERS EOP: reusing cached {}", target.display());
return Ok(target);
}
fs::create_dir_all(dir)?;
download_finals(&target)?;
Ok(target)
}
fn download_finals(dst: &Path) -> Result<()> {
use reqwest::blocking::Client;
let client = Client::builder()
.user_agent("siderust-build (rust)")
.build()?;
for url in &[IERS_URL, USNO_URL] {
eprintln!("IERS EOP: downloading from {url}");
match client.get(*url).send() {
Ok(resp) if resp.status().is_success() => {
let bytes = resp.bytes()?;
if bytes.len() < 1000 {
eprintln!(
"IERS EOP: response too small ({} bytes), skipping",
bytes.len()
);
continue;
}
fs::write(dst, &bytes).with_context(|| format!("write {dst:?}"))?;
eprintln!("IERS EOP: saved {} bytes to {:?}", bytes.len(), dst);
return Ok(());
}
Ok(resp) => {
eprintln!("IERS EOP: HTTP {} from {url}", resp.status());
}
Err(e) => {
eprintln!("IERS EOP: download failed from {url}: {e}");
}
}
}
anyhow::bail!(
"Could not download finals2000A.all from IERS or USNO. \
You can manually download it and place it at: {}",
dst.display()
);
}
#[allow(dead_code)]
pub fn run(data_dir: &Path) -> Result<()> {
let finals_path = ensure_dataset(data_dir)?;
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={}", data_dir.display());
let provenance = SourceProvenance::from_file(FINALS_FILENAME, IERS_URL, &finals_path)?;
eprintln!(
"IERS EOP: input SHA-256 = {} ({} bytes)",
provenance.sha256_hex, provenance.byte_count
);
let rows = parse_finals(&finals_path)?;
eprintln!(
"IERS EOP: parsed {} rows (MJD {:.1}–{:.1})",
rows.len(),
rows.first().unwrap().mjd,
rows.last().unwrap().mjd,
);
let code = generate_rust(&rows, &provenance)?;
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
fs::write(out_dir.join("iers_eop_data.rs"), code.as_bytes())?;
eprintln!("IERS EOP: iers_eop_data.rs generated");
Ok(())
}
pub fn run_regen(data_dir: &Path, gen_dir: &Path) -> Result<()> {
let finals_path = ensure_dataset(data_dir)?;
let provenance = SourceProvenance::from_file(FINALS_FILENAME, IERS_URL, &finals_path)?;
eprintln!(
"IERS EOP: input SHA-256 = {} ({} bytes)",
provenance.sha256_hex, provenance.byte_count
);
if let Ok(expected) = env::var("SIDERUST_IERS_SHA256") {
let expected = expected.trim().to_ascii_lowercase();
if !expected.is_empty() && expected != provenance.sha256_hex {
anyhow::bail!(
"IERS EOP: SHA-256 mismatch.\n expected: {expected}\n actual: {}\n\
The downloaded finals2000A.all does not match the pinned hash. \
Refusing to overwrite committed tables with unverified data.",
provenance.sha256_hex
);
}
}
let rows = parse_finals(&finals_path)?;
eprintln!(
"IERS EOP: parsed {} rows (MJD {:.1}–{:.1})",
rows.len(),
rows.first().unwrap().mjd,
rows.last().unwrap().mjd,
);
let code = generate_rust(&rows, &provenance)?;
fs::create_dir_all(gen_dir)?;
fs::write(gen_dir.join("iers_eop_data.rs"), code.as_bytes())?;
eprintln!("IERS EOP: iers_eop_data.rs regenerated in {:?}", gen_dir);
Ok(())
}