Skip to main content

tempoch_time_data/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4use chrono::{DateTime, NaiveDate, Utc};
5use std::fmt;
6
7pub mod generated;
8
9pub const TEMPOCH_DATA_DIR_ENV: &str = "TEMPOCH_DATA_DIR";
10pub const UTC_TAI_HISTORY_URL: &str = "https://hpiers.obspm.fr/eoppc/bul/bulc/UTC-TAI.history";
11pub const DELTA_T_OBSERVED_URL: &str = "https://maia.usno.navy.mil/ser7/deltat.data";
12pub const DELTA_T_PREDICTIONS_URL: &str = "https://maia.usno.navy.mil/ser7/deltat.preds";
13pub const EOP_FINALS_URL: &str = "https://datacenter.iers.org/data/9/finals2000A.all";
14pub const PRE_1961_TAI_MINUS_UTC_APPROX: f64 = 10.0;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct UtcTaiSegment {
18    pub start_mjd: i32,
19    pub end_mjd: Option<i32>,
20    pub base_seconds: f64,
21    pub reference_mjd: f64,
22    pub slope_seconds_per_day: f64,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct EopPoint {
27    pub mjd: i32,
28    pub pm_observed: bool,
29    pub ut1_observed: bool,
30    pub nutation_observed: bool,
31    pub pm_xp_arcsec: Option<f64>,
32    pub pm_yp_arcsec: Option<f64>,
33    pub ut1_minus_utc_seconds: f64,
34    pub lod_milliseconds: Option<f64>,
35    pub dx_milliarcsec: Option<f64>,
36    pub dy_milliarcsec: Option<f64>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TimeDataProvenance {
41    fetched_utc: String,
42    utc_tai_sha256: String,
43    delta_t_observed_sha256: String,
44    delta_t_predictions_sha256: String,
45    eop_finals_sha256: String,
46}
47
48impl TimeDataProvenance {
49    pub fn new(
50        fetched_utc: impl Into<String>,
51        utc_tai_sha256: impl Into<String>,
52        delta_t_observed_sha256: impl Into<String>,
53        delta_t_predictions_sha256: impl Into<String>,
54        eop_finals_sha256: impl Into<String>,
55    ) -> Self {
56        Self {
57            fetched_utc: fetched_utc.into(),
58            utc_tai_sha256: utc_tai_sha256.into(),
59            delta_t_observed_sha256: delta_t_observed_sha256.into(),
60            delta_t_predictions_sha256: delta_t_predictions_sha256.into(),
61            eop_finals_sha256: eop_finals_sha256.into(),
62        }
63    }
64
65    pub fn fetched_utc(&self) -> &str {
66        &self.fetched_utc
67    }
68
69    pub fn fetched_at(&self) -> Option<DateTime<Utc>> {
70        chrono::NaiveDateTime::parse_from_str(&self.fetched_utc, "%Y-%m-%dT%H:%M:%S")
71            .ok()
72            .map(|dt| dt.and_utc())
73    }
74
75    pub fn utc_tai_sha256(&self) -> &str {
76        &self.utc_tai_sha256
77    }
78
79    pub fn delta_t_observed_sha256(&self) -> &str {
80        &self.delta_t_observed_sha256
81    }
82
83    pub fn delta_t_predictions_sha256(&self) -> &str {
84        &self.delta_t_predictions_sha256
85    }
86
87    pub fn eop_finals_sha256(&self) -> &str {
88        &self.eop_finals_sha256
89    }
90}
91
92#[derive(Debug, Clone)]
93pub struct TimeDataBundle {
94    utc_tai_segments: Vec<UtcTaiSegment>,
95    modern_delta_t_points: Vec<(f64, f64)>,
96    modern_delta_t_observed_end_mjd: f64,
97    eop_points: Vec<EopPoint>,
98    provenance: TimeDataProvenance,
99}
100
101impl TimeDataBundle {
102    pub fn new(
103        utc_tai_segments: Vec<UtcTaiSegment>,
104        modern_delta_t_points: Vec<(f64, f64)>,
105        modern_delta_t_observed_end_mjd: f64,
106        eop_points: Vec<EopPoint>,
107        provenance: TimeDataProvenance,
108    ) -> Self {
109        Self {
110            utc_tai_segments,
111            modern_delta_t_points,
112            modern_delta_t_observed_end_mjd,
113            eop_points,
114            provenance,
115        }
116    }
117
118    pub fn utc_tai_segments(&self) -> &[UtcTaiSegment] {
119        &self.utc_tai_segments
120    }
121
122    pub fn modern_delta_t_points(&self) -> &[(f64, f64)] {
123        &self.modern_delta_t_points
124    }
125
126    pub fn modern_delta_t_observed_end_mjd(&self) -> f64 {
127        self.modern_delta_t_observed_end_mjd
128    }
129
130    pub fn eop_points(&self) -> &[EopPoint] {
131        &self.eop_points
132    }
133
134    pub fn provenance(&self) -> &TimeDataProvenance {
135        &self.provenance
136    }
137
138    pub fn eop_observed_end_mjd(&self) -> i32 {
139        observed_end_mjd(&self.eop_points)
140    }
141
142    pub fn eop_end_mjd(&self) -> i32 {
143        self.eop_points
144            .last()
145            .map(|point| point.mjd)
146            .unwrap_or_default()
147    }
148
149    #[cfg(feature = "fetch")]
150    fn from_raw_sources(
151        utc_tai_history: &str,
152        delta_t_observed: &str,
153        delta_t_predictions: &str,
154        eop_finals: &str,
155        provenance: TimeDataProvenance,
156    ) -> Result<Self, TimeDataError> {
157        let utc_tai_segments =
158            parse_utc_tai_segments(utc_tai_history).map_err(TimeDataError::Parse)?;
159        let observed = parse_delta_t_observed(delta_t_observed).map_err(TimeDataError::Parse)?;
160        let predicted =
161            parse_delta_t_predictions(delta_t_predictions).map_err(TimeDataError::Parse)?;
162        let (modern_delta_t_points, modern_delta_t_observed_end_mjd) =
163            build_modern_delta_t_points(&observed, &predicted).map_err(TimeDataError::Parse)?;
164        let eop_points = parse_eop_finals(eop_finals).map_err(TimeDataError::Parse)?;
165        Ok(Self::new(
166            utc_tai_segments,
167            modern_delta_t_points,
168            modern_delta_t_observed_end_mjd,
169            eop_points,
170            provenance,
171        ))
172    }
173}
174
175/// Build the checked-in bundled time-data snapshot shipped with the crate.
176pub fn bundled_time_data() -> TimeDataBundle {
177    TimeDataBundle::new(
178        generated::time_data::UTC_TAI_SEGMENTS
179            .iter()
180            .map(|segment| UtcTaiSegment {
181                start_mjd: segment.start_mjd,
182                end_mjd: segment.end_mjd,
183                base_seconds: segment.base_seconds,
184                reference_mjd: segment.reference_mjd,
185                slope_seconds_per_day: segment.slope_seconds_per_day,
186            })
187            .collect(),
188        generated::time_data::MODERN_DELTA_T_POINTS.to_vec(),
189        generated::MODERN_DELTA_T_OBSERVED_END_MJD.value(),
190        generated::eop_data::EOP_POINTS
191            .iter()
192            .map(|point| EopPoint {
193                mjd: point.mjd,
194                pm_observed: point.pm_observed,
195                ut1_observed: point.ut1_observed,
196                nutation_observed: point.nutation_observed,
197                pm_xp_arcsec: point.pm_xp_arcsec,
198                pm_yp_arcsec: point.pm_yp_arcsec,
199                ut1_minus_utc_seconds: point.ut1_minus_utc_seconds,
200                lod_milliseconds: point.lod_milliseconds,
201                dx_milliarcsec: point.dx_milliarcsec,
202                dy_milliarcsec: point.dy_milliarcsec,
203            })
204            .collect(),
205        TimeDataProvenance::new("compiled", "compiled", "compiled", "compiled", "compiled"),
206    )
207}
208
209#[derive(Debug)]
210pub enum TimeDataError {
211    Io(std::io::Error),
212    Download(String),
213    Parse(String),
214    Integrity(String),
215}
216
217impl fmt::Display for TimeDataError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            Self::Io(err) => write!(f, "I/O error: {err}"),
221            Self::Download(msg) => write!(f, "download error: {msg}"),
222            Self::Parse(msg) => write!(f, "parse error: {msg}"),
223            Self::Integrity(msg) => write!(f, "integrity error: {msg}"),
224        }
225    }
226}
227
228impl std::error::Error for TimeDataError {
229    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
230        match self {
231            Self::Io(err) => Some(err),
232            _ => None,
233        }
234    }
235}
236
237impl From<std::io::Error> for TimeDataError {
238    fn from(value: std::io::Error) -> Self {
239        Self::Io(value)
240    }
241}
242
243#[cfg(feature = "fetch")]
244mod fetch_support {
245    use super::*;
246    use serde_json::Value;
247    use sha2::{Digest, Sha256};
248    use std::fs;
249    use std::io::Read;
250    use std::path::{Path, PathBuf};
251    use std::time::{SystemTime, UNIX_EPOCH};
252
253    const DEFAULT_SUBDIR: &str = ".tempoch/data";
254    const BUNDLE_DIR_NAME: &str = "bundle";
255    const PROVENANCE_FILE: &str = "time_data.provenance.json";
256    const UTC_TAI_HISTORY_FILE: &str = "UTC-TAI.history";
257    const DELTA_T_OBSERVED_FILE: &str = "deltat.data";
258    const DELTA_T_PREDICTIONS_FILE: &str = "deltat.preds";
259    const EOP_FINALS_FILE: &str = "finals2000A.all";
260    const FETCH_TIMEOUT_SECS: u64 = 60;
261
262    pub struct TimeDataManager {
263        data_dir: PathBuf,
264    }
265
266    impl TimeDataManager {
267        pub fn new() -> Result<Self, TimeDataError> {
268            let data_dir = resolve_data_dir()?;
269            fs::create_dir_all(&data_dir)?;
270            Ok(Self { data_dir })
271        }
272
273        pub fn with_dir(dir: impl Into<PathBuf>) -> Result<Self, TimeDataError> {
274            let data_dir = dir.into();
275            fs::create_dir_all(&data_dir)?;
276            Ok(Self { data_dir })
277        }
278
279        pub fn data_dir(&self) -> &Path {
280            &self.data_dir
281        }
282
283        pub fn load_cached(&self) -> Result<TimeDataBundle, TimeDataError> {
284            load_cached_bundle(self.bundle_dir())
285        }
286
287        pub fn refresh(&self) -> Result<(), TimeDataError> {
288            fs::create_dir_all(&self.data_dir)?;
289            let staging_dir = self.staging_dir();
290            if staging_dir.exists() {
291                fs::remove_dir_all(&staging_dir)?;
292            }
293            fs::create_dir_all(&staging_dir)?;
294
295            let fetch_ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
296            let utc_tai = fetch_text(UTC_TAI_HISTORY_URL)?;
297            let delta_obs = fetch_text(DELTA_T_OBSERVED_URL)?;
298            let delta_pred = fetch_text(DELTA_T_PREDICTIONS_URL)?;
299            let eop = fetch_text(EOP_FINALS_URL)?;
300
301            fs::write(staging_dir.join(UTC_TAI_HISTORY_FILE), &utc_tai.text)?;
302            fs::write(staging_dir.join(DELTA_T_OBSERVED_FILE), &delta_obs.text)?;
303            fs::write(staging_dir.join(DELTA_T_PREDICTIONS_FILE), &delta_pred.text)?;
304            fs::write(staging_dir.join(EOP_FINALS_FILE), &eop.text)?;
305
306            let provenance = TimeDataProvenance::new(
307                fetch_ts,
308                utc_tai.sha256,
309                delta_obs.sha256,
310                delta_pred.sha256,
311                eop.sha256,
312            );
313            fs::write(
314                staging_dir.join(PROVENANCE_FILE),
315                render_provenance_json(&provenance),
316            )?;
317
318            load_cached_bundle(staging_dir.clone())?;
319            swap_bundle_dirs(&staging_dir, self.bundle_dir())?;
320            Ok(())
321        }
322
323        pub fn refresh_and_load(&self) -> Result<TimeDataBundle, TimeDataError> {
324            self.refresh()?;
325            self.load_cached()
326        }
327
328        fn bundle_dir(&self) -> PathBuf {
329            self.data_dir.join(BUNDLE_DIR_NAME)
330        }
331
332        fn staging_dir(&self) -> PathBuf {
333            let nonce = SystemTime::now()
334                .duration_since(UNIX_EPOCH)
335                .unwrap_or_default()
336                .as_nanos();
337            self.data_dir.join(format!(
338                ".{BUNDLE_DIR_NAME}.staging-{}-{nonce}",
339                std::process::id()
340            ))
341        }
342    }
343
344    struct DownloadedText {
345        text: String,
346        sha256: String,
347    }
348
349    fn resolve_data_dir() -> Result<PathBuf, TimeDataError> {
350        if let Ok(dir) = std::env::var(TEMPOCH_DATA_DIR_ENV) {
351            let trimmed = dir.trim();
352            if !trimmed.is_empty() {
353                return Ok(PathBuf::from(trimmed));
354            }
355        }
356
357        let home = std::env::var("HOME")
358            .or_else(|_| std::env::var("USERPROFILE"))
359            .map_err(|_| {
360                TimeDataError::Io(std::io::Error::new(
361                    std::io::ErrorKind::NotFound,
362                    "Cannot determine home directory. Set TEMPOCH_DATA_DIR explicitly.",
363                ))
364            })?;
365
366        Ok(PathBuf::from(home).join(DEFAULT_SUBDIR))
367    }
368
369    fn load_cached_bundle(bundle_dir: PathBuf) -> Result<TimeDataBundle, TimeDataError> {
370        if !bundle_dir.exists() {
371            return Err(TimeDataError::Integrity(format!(
372                "cached bundle not found at {}",
373                bundle_dir.display()
374            )));
375        }
376
377        let utc_tai_history = read_text(bundle_dir.join(UTC_TAI_HISTORY_FILE))?;
378        let delta_t_observed = read_text(bundle_dir.join(DELTA_T_OBSERVED_FILE))?;
379        let delta_t_predictions = read_text(bundle_dir.join(DELTA_T_PREDICTIONS_FILE))?;
380        let eop_finals = read_text(bundle_dir.join(EOP_FINALS_FILE))?;
381        let provenance_text = read_text(bundle_dir.join(PROVENANCE_FILE))?;
382        let provenance = parse_provenance_json(&provenance_text)?;
383
384        verify_sha256(
385            "UTC-TAI history",
386            &utc_tai_history,
387            provenance.utc_tai_sha256(),
388        )?;
389        verify_sha256(
390            "Delta T observed",
391            &delta_t_observed,
392            provenance.delta_t_observed_sha256(),
393        )?;
394        verify_sha256(
395            "Delta T predictions",
396            &delta_t_predictions,
397            provenance.delta_t_predictions_sha256(),
398        )?;
399        verify_sha256("EOP finals", &eop_finals, provenance.eop_finals_sha256())?;
400
401        TimeDataBundle::from_raw_sources(
402            &utc_tai_history,
403            &delta_t_observed,
404            &delta_t_predictions,
405            &eop_finals,
406            provenance,
407        )
408    }
409
410    fn swap_bundle_dirs(staging_dir: &Path, live_dir: PathBuf) -> Result<(), TimeDataError> {
411        let backup_dir = live_dir.with_extension("backup");
412        if backup_dir.exists() {
413            fs::remove_dir_all(&backup_dir)?;
414        }
415        if live_dir.exists() {
416            fs::rename(&live_dir, &backup_dir)?;
417        }
418        match fs::rename(staging_dir, &live_dir) {
419            Ok(()) => {
420                if backup_dir.exists() {
421                    fs::remove_dir_all(&backup_dir)?;
422                }
423                Ok(())
424            }
425            Err(err) => {
426                if backup_dir.exists() && !live_dir.exists() {
427                    let _ = fs::rename(&backup_dir, &live_dir);
428                }
429                Err(TimeDataError::Io(err))
430            }
431        }
432    }
433
434    fn read_text(path: PathBuf) -> Result<String, TimeDataError> {
435        fs::read_to_string(&path).map_err(|err| {
436            TimeDataError::Io(std::io::Error::new(
437                err.kind(),
438                format!("{}: {err}", path.display()),
439            ))
440        })
441    }
442
443    fn fetch_text(url: &str) -> Result<DownloadedText, TimeDataError> {
444        let response = ureq::get(url)
445            .set("User-Agent", "tempoch-runtime-data/1.0")
446            .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
447            .call()
448            .map_err(|err| TimeDataError::Download(format!("fetch {url} failed: {err}")))?;
449        let bytes = {
450            let mut buf = Vec::new();
451            let mut reader = response.into_reader();
452            reader
453                .read_to_end(&mut buf)
454                .map_err(|err| TimeDataError::Download(format!("read {url} body failed: {err}")))?;
455            buf
456        };
457        let text = String::from_utf8(bytes.clone())
458            .map_err(|err| TimeDataError::Download(format!("{url} is not UTF-8: {err}")))?;
459        Ok(DownloadedText {
460            text,
461            sha256: sha256_bytes(&bytes),
462        })
463    }
464
465    fn render_provenance_json(provenance: &TimeDataProvenance) -> String {
466        let value = serde_json::json!({
467            "fetched_utc": provenance.fetched_utc(),
468            "utc_tai_sha256": provenance.utc_tai_sha256(),
469            "delta_t_observed_sha256": provenance.delta_t_observed_sha256(),
470            "delta_t_predictions_sha256": provenance.delta_t_predictions_sha256(),
471            "eop_finals_sha256": provenance.eop_finals_sha256(),
472        });
473        let mut rendered = serde_json::to_string_pretty(&value)
474            .expect("serializing time-data provenance should work");
475        rendered.push('\n');
476        rendered
477    }
478
479    fn parse_provenance_json(text: &str) -> Result<TimeDataProvenance, TimeDataError> {
480        let json: Value =
481            serde_json::from_str(text).map_err(|err| TimeDataError::Integrity(err.to_string()))?;
482        let string_field = |name: &str| -> Result<String, TimeDataError> {
483            json.get(name)
484                .and_then(Value::as_str)
485                .map(str::to_owned)
486                .ok_or_else(|| TimeDataError::Integrity(format!("missing provenance field {name}")))
487        };
488        Ok(TimeDataProvenance::new(
489            string_field("fetched_utc")?,
490            string_field("utc_tai_sha256")?,
491            string_field("delta_t_observed_sha256")?,
492            string_field("delta_t_predictions_sha256")?,
493            string_field("eop_finals_sha256")?,
494        ))
495    }
496
497    fn sha256_bytes(bytes: &[u8]) -> String {
498        let mut hasher = Sha256::new();
499        hasher.update(bytes);
500        let digest = hasher.finalize();
501        let mut out = String::with_capacity(digest.len() * 2);
502        for byte in digest {
503            out.push_str(&format!("{byte:02x}"));
504        }
505        out
506    }
507
508    fn verify_sha256(label: &str, text: &str, expected: &str) -> Result<(), TimeDataError> {
509        let actual = sha256_bytes(text.as_bytes());
510        if actual != expected {
511            return Err(TimeDataError::Integrity(format!(
512                "{label} SHA-256 mismatch: expected {expected}, got {actual}"
513            )));
514        }
515        Ok(())
516    }
517}
518
519#[cfg(feature = "fetch")]
520pub use fetch_support::TimeDataManager;
521
522fn mjd_epoch() -> NaiveDate {
523    NaiveDate::from_ymd_opt(1858, 11, 17).unwrap()
524}
525
526fn mjd_from_date(d: NaiveDate) -> i32 {
527    (d - mjd_epoch()).num_days() as i32
528}
529
530fn normalize_ws(s: &str) -> String {
531    s.replace('\t', " ")
532        .split_whitespace()
533        .collect::<Vec<_>>()
534        .join(" ")
535}
536
537fn parse_month(token: &str) -> Result<u32, String> {
538    let key: String = token
539        .chars()
540        .filter(|c| c.is_ascii_alphabetic())
541        .map(|c| c.to_ascii_lowercase())
542        .collect();
543    let month = match key.as_str() {
544        "jan" | "january" => 1,
545        "feb" | "february" => 2,
546        "mar" | "march" => 3,
547        "apr" | "april" => 4,
548        "may" => 5,
549        "jun" | "june" => 6,
550        "jul" | "july" => 7,
551        "aug" | "august" => 8,
552        "sep" | "sept" | "september" => 9,
553        "oct" | "october" => 10,
554        "nov" | "november" => 11,
555        "dec" | "december" => 12,
556        _ => return Err(format!("unknown month token: {token:?}")),
557    };
558    Ok(month)
559}
560
561fn parse_date_fragment(fragment: &str, default_year: Option<i32>) -> Result<NaiveDate, String> {
562    let normalized = normalize_ws(fragment);
563    let normalized = normalized.trim_end_matches('.').trim();
564    let tokens: Vec<&str> = normalized.split_whitespace().collect();
565    let (year, month_token, day_token) = match tokens.as_slice() {
566        [year, month, day] if year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) => (
567            year.parse::<i32>().map_err(|err| err.to_string())?,
568            *month,
569            *day,
570        ),
571        [month, day] => (
572            default_year.ok_or_else(|| format!("missing year for fragment: {fragment:?}"))?,
573            *month,
574            *day,
575        ),
576        _ => return Err(format!("unable to parse date fragment: {fragment:?}")),
577    };
578    let month = parse_month(month_token)?;
579    let day = day_token
580        .parse::<u32>()
581        .map_err(|_| format!("bad day in fragment: {fragment:?}"))?;
582    NaiveDate::from_ymd_opt(year, month, day)
583        .ok_or_else(|| format!("invalid calendar date in fragment: {fragment:?}"))
584}
585
586fn compact_number(s: &str) -> Result<f64, String> {
587    s.replace(' ', "")
588        .parse::<f64>()
589        .map_err(|err| format!("bad number {s:?}: {err}"))
590}
591
592fn extract_base_seconds(formula: &str) -> Result<f64, String> {
593    let bytes = formula.as_bytes();
594    let mut index = 0usize;
595    while index < bytes.len() {
596        if bytes[index] == b's' {
597            let mut start = index;
598            while start > 0 {
599                let c = bytes[start - 1];
600                if c.is_ascii_digit() || c == b'.' || c == b' ' {
601                    start -= 1;
602                } else {
603                    break;
604                }
605            }
606            let candidate = &formula[start..index];
607            if candidate.chars().any(|c| c.is_ascii_digit()) {
608                return compact_number(candidate);
609            }
610        }
611        index += 1;
612    }
613    Err(format!("unable to parse TAI-UTC base from {formula:?}"))
614}
615
616fn extract_slope(formula: &str) -> Result<Option<(f64, f64)>, String> {
617    let Some(mjd_idx) = formula.find("MJD") else {
618        return Ok(None);
619    };
620    let rest = &formula[mjd_idx + 3..];
621    let rest = rest.trim_start();
622    if !rest.starts_with('-') {
623        return Ok(None);
624    }
625    let after_dash = rest[1..].trim_start();
626    let ref_end = after_dash
627        .char_indices()
628        .find(|(_, c)| !(c.is_ascii_digit() || *c == ' '))
629        .map(|(idx, _)| idx)
630        .unwrap_or(after_dash.len());
631    let ref_str = after_dash[..ref_end].trim();
632    if ref_str.is_empty() {
633        return Ok(None);
634    }
635    let reference_mjd = compact_number(ref_str)?;
636    let after_ref = after_dash[ref_end..].trim_start();
637    let after_paren = after_ref
638        .strip_prefix(')')
639        .unwrap_or(after_ref)
640        .trim_start();
641    let after_x = match after_paren.strip_prefix('x') {
642        Some(rest) => rest.trim_start(),
643        None => return Ok(None),
644    };
645    let slope_end = after_x
646        .char_indices()
647        .find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == ' '))
648        .map(|(idx, _)| idx)
649        .unwrap_or(after_x.len());
650    let slope_str = after_x[..slope_end].trim();
651    if slope_str.is_empty() {
652        return Ok(None);
653    }
654    let rest_after_slope = after_x[slope_end..].trim_start();
655    if !rest_after_slope.starts_with('s') {
656        return Ok(None);
657    }
658    let slope = compact_number(slope_str)?;
659    Ok(Some((reference_mjd, slope)))
660}
661
662pub fn parse_utc_tai_segments(text: &str) -> Result<Vec<UtcTaiSegment>, String> {
663    let mut segments = Vec::new();
664    let mut previous_end: Option<NaiveDate> = None;
665    let mut previous_reference_mjd: Option<f64> = None;
666    let mut previous_slope: Option<f64> = None;
667
668    for raw_line in text.lines() {
669        let line = raw_line.trim_end();
670        if !line.contains('-')
671            || line.contains("UTC-TAI.history")
672            || line.contains("Limits of validity")
673        {
674            continue;
675        }
676        if !line.chars().any(|c| c.is_ascii_digit()) {
677            continue;
678        }
679
680        let dash_idx = line.find('-').unwrap();
681        let (left, right) = line.split_at(dash_idx);
682        let right = &right[1..];
683        if !left.chars().any(|c| c.is_ascii_alphabetic()) {
684            continue;
685        }
686
687        let default_start_year = previous_end.map(date_year);
688        let start_date = parse_date_fragment(left, default_start_year)?;
689        let right_normalized = normalize_ws(right);
690        let (end_date, formula) = match parse_end_and_formula(&right_normalized, start_date) {
691            Some((end_date, formula)) => (Some(end_date), formula),
692            None => (None, right_normalized.clone()),
693        };
694
695        let base_seconds = extract_base_seconds(&formula)?;
696        let (reference_mjd, slope_seconds_per_day) =
697            if let Some((reference_mjd, slope)) = extract_slope(&formula)? {
698                (reference_mjd, slope)
699            } else if formula.contains("\"\"") {
700                match (previous_reference_mjd, previous_slope) {
701                    (Some(reference_mjd), Some(slope)) => (reference_mjd, slope),
702                    _ => {
703                        return Err(format!(
704                            "repeated UTC formula without previous state: {formula:?}"
705                        ))
706                    }
707                }
708            } else {
709                (mjd_from_date(start_date) as f64, 0.0)
710            };
711
712        segments.push(UtcTaiSegment {
713            start_mjd: mjd_from_date(start_date),
714            end_mjd: end_date.map(mjd_from_date),
715            base_seconds,
716            reference_mjd,
717            slope_seconds_per_day,
718        });
719
720        previous_end = end_date;
721        previous_reference_mjd = Some(reference_mjd);
722        previous_slope = Some(slope_seconds_per_day);
723    }
724
725    validate_utc_tai_segments(&segments)?;
726    Ok(segments)
727}
728
729fn date_year(date: NaiveDate) -> i32 {
730    use chrono::Datelike;
731    date.year()
732}
733
734fn parse_end_and_formula(
735    right_normalized: &str,
736    start_date: NaiveDate,
737) -> Option<(NaiveDate, String)> {
738    let tokens: Vec<&str> = right_normalized.splitn(4, ' ').collect();
739    if tokens.len() < 3 {
740        return None;
741    }
742    if tokens.len() == 4
743        && tokens[0].len() == 4
744        && tokens[0].chars().all(|c| c.is_ascii_digit())
745        && parse_month(tokens[1]).is_ok()
746        && !tokens[2].is_empty()
747        && tokens[2].chars().all(|c| c.is_ascii_digit())
748    {
749        let year = tokens[0].parse::<i32>().ok()?;
750        let month = parse_month(tokens[1]).ok()?;
751        let day = tokens[2].parse::<u32>().ok()?;
752        let end_date = NaiveDate::from_ymd_opt(year, month, day)?;
753        return Some((end_date, tokens[3].to_string()));
754    }
755    if parse_month(tokens[0]).is_ok()
756        && !tokens[1].is_empty()
757        && tokens[1].chars().all(|c| c.is_ascii_digit())
758    {
759        let month = parse_month(tokens[0]).ok()?;
760        let day = tokens[1].parse::<u32>().ok()?;
761        let end_date = NaiveDate::from_ymd_opt(date_year(start_date), month, day)?;
762        let rest = right_normalized
763            .splitn(3, ' ')
764            .nth(2)
765            .unwrap_or("")
766            .to_string();
767        return Some((end_date, rest));
768    }
769    None
770}
771
772fn validate_utc_tai_segments(segments: &[UtcTaiSegment]) -> Result<(), String> {
773    if segments.is_empty() {
774        return Err("UTC-TAI history parsing produced no segments".into());
775    }
776    for (idx, segment) in segments.iter().enumerate() {
777        if let Some(end_mjd) = segment.end_mjd {
778            if end_mjd <= segment.start_mjd {
779                return Err(format!(
780                    "UTC-TAI segment ending at MJD {end_mjd} does not extend past start {}",
781                    segment.start_mjd
782                ));
783            }
784        }
785        let Some(next) = segments.get(idx + 1) else {
786            continue;
787        };
788        if next.start_mjd == segment.start_mjd {
789            return Err(format!(
790                "UTC-TAI segment list contains duplicate start MJD {}",
791                segment.start_mjd
792            ));
793        }
794        if next.start_mjd < segment.start_mjd {
795            return Err(format!(
796                "UTC-TAI segment list is not strictly increasing near {} -> {}",
797                segment.start_mjd, next.start_mjd
798            ));
799        }
800        match segment.end_mjd {
801            Some(end_mjd) if end_mjd == next.start_mjd => {}
802            Some(end_mjd) => {
803                return Err(format!(
804                    "UTC-TAI segment boundary mismatch near {} -> {}",
805                    end_mjd, next.start_mjd
806                ))
807            }
808            None => {
809                return Err(format!(
810                    "UTC-TAI segment starting at MJD {} is open-ended before the next segment {}",
811                    segment.start_mjd, next.start_mjd
812                ))
813            }
814        }
815    }
816    Ok(())
817}
818
819fn validate_strictly_increasing_mjds(label: &str, points: &[(f64, f64)]) -> Result<(), String> {
820    for window in points.windows(2) {
821        let current = window[0].0;
822        let next = window[1].0;
823        if next == current {
824            return Err(format!(
825                "{label} MJD column contains duplicate entry at {current:.3}"
826            ));
827        }
828        if next < current {
829            return Err(format!(
830                "{label} MJD column is not strictly increasing near {current:.3} -> {next:.3}"
831            ));
832        }
833    }
834    Ok(())
835}
836
837fn validate_eop_points(points: &[EopPoint]) -> Result<(), String> {
838    if points.len() < 2 {
839        return Err("EOP finals parsing produced fewer than two usable rows".into());
840    }
841    for window in points.windows(2) {
842        let current = window[0].mjd;
843        let next = window[1].mjd;
844        if next == current {
845            return Err(format!(
846                "EOP finals MJD column contains duplicate entry at {current}"
847            ));
848        }
849        if next < current {
850            return Err(format!(
851                "EOP finals MJD column is not strictly increasing near {current} -> {next}"
852            ));
853        }
854        if next != current + 1 {
855            return Err(format!(
856                "EOP finals MJD column has a daily gap near {current} -> {next}"
857            ));
858        }
859    }
860    Ok(())
861}
862
863pub fn parse_delta_t_observed(text: &str) -> Result<Vec<(f64, f64)>, String> {
864    let mut points = Vec::new();
865    for raw_line in text.lines() {
866        let parts: Vec<&str> = raw_line.split_whitespace().collect();
867        if parts.len() != 4 {
868            continue;
869        }
870        if !parts[0].chars().all(|c| c.is_ascii_digit()) {
871            continue;
872        }
873        let year = parts[0]
874            .parse::<i32>()
875            .map_err(|err: std::num::ParseIntError| err.to_string())?;
876        let month = parts[1]
877            .parse::<u32>()
878            .map_err(|err: std::num::ParseIntError| err.to_string())?;
879        let day = parts[2]
880            .parse::<u32>()
881            .map_err(|err: std::num::ParseIntError| err.to_string())?;
882        let delta_t = parts[3]
883            .parse::<f64>()
884            .map_err(|err: std::num::ParseFloatError| err.to_string())?;
885        let date = NaiveDate::from_ymd_opt(year, month, day)
886            .ok_or_else(|| format!("invalid date in observed Delta T: {raw_line:?}"))?;
887        points.push((mjd_from_date(date) as f64, delta_t));
888    }
889    if points.is_empty() {
890        return Err("observed Delta T parsing produced no points".into());
891    }
892    validate_strictly_increasing_mjds("observed Delta T", &points)?;
893    Ok(points)
894}
895
896pub fn parse_delta_t_predictions(text: &str) -> Result<Vec<(f64, f64)>, String> {
897    let mut points = Vec::new();
898    for raw_line in text.lines() {
899        let parts: Vec<&str> = raw_line.split_whitespace().collect();
900        if parts.is_empty() || parts[0] == "MJD" || parts.len() < 3 {
901            continue;
902        }
903        let Ok(mjd) = parts[0].parse::<f64>() else {
904            continue;
905        };
906        let Ok(delta_t) = parts[2].parse::<f64>() else {
907            continue;
908        };
909        points.push((mjd, delta_t));
910    }
911    if points.is_empty() {
912        return Err("predicted Delta T parsing produced no points".into());
913    }
914    validate_strictly_increasing_mjds("predicted Delta T", &points)?;
915    Ok(points)
916}
917
918pub fn build_modern_delta_t_points(
919    observed_points: &[(f64, f64)],
920    predicted_points: &[(f64, f64)],
921) -> Result<(Vec<(f64, f64)>, f64), String> {
922    let (last_obs_mjd, last_obs_dt) = *observed_points.last().ok_or("observed Delta T is empty")?;
923    validate_strictly_increasing_mjds("observed Delta T", observed_points)?;
924    validate_strictly_increasing_mjds("predicted Delta T", predicted_points)?;
925    let mut future: Vec<(f64, f64)> = predicted_points
926        .iter()
927        .copied()
928        .filter(|(mjd, _)| *mjd > last_obs_mjd)
929        .collect();
930
931    if !future.is_empty() {
932        let (m0, d0) = future[0];
933        let (m1, d1) = if future.len() >= 2 {
934            future[1]
935        } else {
936            (m0, d0)
937        };
938        let frac = if m1 != m0 {
939            (last_obs_mjd - m0) / (m1 - m0)
940        } else {
941            0.0
942        };
943        let pred_at_stitch = d0 + frac * (d1 - d0);
944        let continuity_offset = last_obs_dt - pred_at_stitch;
945        for point in &mut future {
946            point.1 += continuity_offset;
947        }
948    }
949
950    let mut combined = Vec::with_capacity(observed_points.len() + future.len());
951    combined.extend_from_slice(observed_points);
952    combined.extend_from_slice(&future);
953    if combined.len() < 2 {
954        return Err("modern Delta T series must contain at least two points".into());
955    }
956    validate_strictly_increasing_mjds("modern Delta T", &combined)?;
957    Ok((combined, last_obs_mjd))
958}
959
960pub fn parse_eop_finals(text: &str) -> Result<Vec<EopPoint>, String> {
961    let mut points = Vec::new();
962
963    for line in text.lines() {
964        if line.len() < 68 {
965            continue;
966        }
967        let Some(mjd_f) = col(line, 8, 15).and_then(parse_f64) else {
968            continue;
969        };
970        let mjd = mjd_f.round() as i32;
971        let Some(ut1_flag) = col(line, 58, 58).and_then(parse_flag) else {
972            continue;
973        };
974        if !matches!(ut1_flag, 'I' | 'P') {
975            continue;
976        }
977        let Some(ut1_minus_utc_seconds) = col(line, 59, 68).and_then(parse_f64) else {
978            continue;
979        };
980
981        let pm_flag = col(line, 17, 17).and_then(parse_flag);
982        let nutation_flag = col(line, 96, 96).and_then(parse_flag);
983        points.push(EopPoint {
984            mjd,
985            pm_observed: matches!(pm_flag, Some('I')),
986            ut1_observed: ut1_flag == 'I',
987            nutation_observed: matches!(nutation_flag, Some('I')),
988            pm_xp_arcsec: col(line, 19, 27).and_then(parse_f64),
989            pm_yp_arcsec: col(line, 38, 46).and_then(parse_f64),
990            ut1_minus_utc_seconds,
991            lod_milliseconds: col(line, 80, 86).and_then(parse_f64),
992            dx_milliarcsec: col(line, 98, 106).and_then(parse_f64),
993            dy_milliarcsec: col(line, 117, 125).and_then(parse_f64),
994        });
995    }
996
997    validate_eop_points(&points)?;
998    Ok(points)
999}
1000
1001pub fn observed_end_mjd(points: &[EopPoint]) -> i32 {
1002    points
1003        .iter()
1004        .rev()
1005        .find(|point| point.ut1_observed)
1006        .map(|point| point.mjd)
1007        .unwrap_or(points[0].mjd)
1008}
1009
1010fn col(line: &str, start_1based: usize, end_1based_inclusive: usize) -> Option<&str> {
1011    let start = start_1based.checked_sub(1)?;
1012    let end = end_1based_inclusive;
1013    if line.len() < end {
1014        return None;
1015    }
1016    Some(&line[start..end])
1017}
1018
1019fn parse_f64(slice: &str) -> Option<f64> {
1020    let trimmed = slice.trim();
1021    if trimmed.is_empty() {
1022        return None;
1023    }
1024    trimmed.parse::<f64>().ok()
1025}
1026
1027fn parse_flag(slice: &str) -> Option<char> {
1028    slice.trim().chars().next()
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    fn sample_utc_tai_history() -> &'static str {
1036        "1961 Jan. 1 - Aug. 1 1.4228180s + (MJD - 37300) x 0.001296s\n\
1037         Aug. 1 - 1962 Jan. 1 1.3728180s + \"\"\n\
1038         1962 Jan. 1 - 10s\n"
1039    }
1040
1041    fn set_field(line: &mut [u8], start_1based: usize, end_1based_inclusive: usize, value: &str) {
1042        let start = start_1based - 1;
1043        let width = end_1based_inclusive - start_1based + 1;
1044        let bytes = value.as_bytes();
1045        assert!(
1046            bytes.len() <= width,
1047            "{value:?} does not fit in width {width}"
1048        );
1049        let offset = width - bytes.len();
1050        line[start + offset..start + offset + bytes.len()].copy_from_slice(bytes);
1051    }
1052
1053    #[allow(clippy::too_many_arguments)]
1054    fn sample_eop_line(
1055        mjd: i32,
1056        ut1_flag: char,
1057        ut1_minus_utc_seconds: f64,
1058        pm_xp_arcsec: Option<f64>,
1059        pm_yp_arcsec: Option<f64>,
1060        lod_milliseconds: Option<f64>,
1061        dx_milliarcsec: Option<f64>,
1062        dy_milliarcsec: Option<f64>,
1063    ) -> String {
1064        let mut line = vec![b' '; 125];
1065        set_field(&mut line, 8, 15, &format!("{:8.2}", mjd as f64));
1066        line[16] = b'I';
1067        if let Some(value) = pm_xp_arcsec {
1068            set_field(&mut line, 19, 27, &format!("{value:>9.6}"));
1069        }
1070        if let Some(value) = pm_yp_arcsec {
1071            set_field(&mut line, 38, 46, &format!("{value:>9.6}"));
1072        }
1073        line[57] = ut1_flag as u8;
1074        set_field(&mut line, 59, 68, &format!("{ut1_minus_utc_seconds:>10.7}"));
1075        if let Some(value) = lod_milliseconds {
1076            set_field(&mut line, 80, 86, &format!("{value:>7.4}"));
1077        }
1078        line[95] = b'I';
1079        if let Some(value) = dx_milliarcsec {
1080            set_field(&mut line, 98, 106, &format!("{value:>9.3}"));
1081        }
1082        if let Some(value) = dy_milliarcsec {
1083            set_field(&mut line, 117, 125, &format!("{value:>9.3}"));
1084        }
1085        String::from_utf8(line).expect("sample EOP line must stay ASCII")
1086    }
1087
1088    #[test]
1089    fn parse_utc_tai_segments_reads_piecewise_rules() {
1090        let segments = parse_utc_tai_segments(sample_utc_tai_history()).unwrap();
1091        assert_eq!(segments.len(), 3);
1092        assert_eq!(segments[0].start_mjd, 37_300);
1093        assert_eq!(segments[0].end_mjd, Some(37_512));
1094        assert_eq!(segments[0].reference_mjd, 37_300.0);
1095        assert_eq!(segments[0].slope_seconds_per_day, 0.001_296);
1096        assert_eq!(segments[1].reference_mjd, segments[0].reference_mjd);
1097        assert_eq!(
1098            segments[1].slope_seconds_per_day,
1099            segments[0].slope_seconds_per_day
1100        );
1101        assert_eq!(segments[2].end_mjd, None);
1102        assert_eq!(segments[2].base_seconds, 10.0);
1103    }
1104
1105    #[test]
1106    fn parse_delta_t_observed_reads_representative_rows() {
1107        let points = parse_delta_t_observed(
1108            "2024 01 01 69.1000\n\
1109             2024 02 01 69.2000\n",
1110        )
1111        .unwrap();
1112        assert_eq!(points.len(), 2);
1113        assert_eq!(
1114            points[0].0,
1115            mjd_from_date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()) as f64
1116        );
1117        assert_eq!(points[1].1, 69.2);
1118    }
1119
1120    #[test]
1121    fn parse_delta_t_predictions_reads_representative_rows() {
1122        let points = parse_delta_t_predictions(
1123            "MJD YEAR DELTAT\n\
1124             60310 2024.1 69.4000\n\
1125             60341 2024.2 69.5000\n",
1126        )
1127        .unwrap();
1128        assert_eq!(points, vec![(60_310.0, 69.4), (60_341.0, 69.5)]);
1129    }
1130
1131    #[test]
1132    fn build_modern_delta_t_points_applies_continuity_offset() {
1133        let observed = [(60_000.0, 69.8), (60_030.0, 71.0)];
1134        let predicted = [(60_040.0, 70.0), (60_050.0, 72.0)];
1135        let (combined, observed_end_mjd) =
1136            build_modern_delta_t_points(&observed, &predicted).unwrap();
1137
1138        assert_eq!(observed_end_mjd, 60_030.0);
1139        assert_eq!(combined.len(), 4);
1140        let (m0, d0) = combined[2];
1141        let (m1, d1) = combined[3];
1142        let frac = (observed_end_mjd - m0) / (m1 - m0);
1143        let stitched_value = d0 + frac * (d1 - d0);
1144        assert!((stitched_value - 71.0).abs() < 1e-12);
1145    }
1146
1147    #[test]
1148    fn build_modern_delta_t_points_rejects_duplicate_input_mjds() {
1149        let observed = [(60_000.0, 69.8), (60_000.0, 69.9)];
1150        let predicted = [(60_031.0, 70.0), (60_062.0, 70.2)];
1151        let err = build_modern_delta_t_points(&observed, &predicted).unwrap_err();
1152        assert!(err.contains("observed Delta T"));
1153        assert!(err.contains("duplicate"));
1154    }
1155
1156    #[test]
1157    fn parse_delta_t_predictions_rejects_non_increasing_mjds() {
1158        let err = parse_delta_t_predictions(
1159            "MJD YEAR DELTAT\n\
1160             60341 2024.2 69.5000\n\
1161             60310 2024.1 69.4000\n",
1162        )
1163        .unwrap_err();
1164        assert!(err.contains("predicted Delta T"));
1165        assert!(err.contains("not strictly increasing"));
1166    }
1167
1168    #[test]
1169    fn parse_eop_finals_reads_representative_rows() {
1170        let text = format!(
1171            "{}\n{}\n",
1172            sample_eop_line(
1173                60_000,
1174                'I',
1175                -0.123_456_7,
1176                Some(0.123_456),
1177                Some(-0.234_567),
1178                Some(1.2345),
1179                Some(0.321),
1180                Some(-0.111),
1181            ),
1182            sample_eop_line(60_001, 'P', -0.223_456_7, None, None, None, None, None,),
1183        );
1184        let points = parse_eop_finals(&text).unwrap();
1185        assert_eq!(points.len(), 2);
1186        assert_eq!(points[0].mjd, 60_000);
1187        assert!(points[0].ut1_observed);
1188        assert_eq!(points[0].pm_xp_arcsec, Some(0.123_456));
1189        assert_eq!(points[0].lod_milliseconds, Some(1.2345));
1190        assert_eq!(points[0].dx_milliarcsec, Some(0.321));
1191        assert_eq!(points[1].mjd, 60_001);
1192        assert!(!points[1].ut1_observed);
1193        assert_eq!(points[1].pm_xp_arcsec, None);
1194        assert_eq!(points[1].dx_milliarcsec, None);
1195    }
1196
1197    #[test]
1198    fn parse_eop_finals_rejects_duplicate_mjds() {
1199        let text = format!(
1200            "{}\n{}\n",
1201            sample_eop_line(60_000, 'I', -0.1, Some(0.1), Some(0.2), None, None, None),
1202            sample_eop_line(60_000, 'P', -0.2, Some(0.1), Some(0.2), None, None, None),
1203        );
1204        let err = parse_eop_finals(&text).unwrap_err();
1205        assert!(err.contains("duplicate"));
1206    }
1207
1208    #[test]
1209    fn parse_eop_finals_rejects_daily_gaps() {
1210        let text = format!(
1211            "{}\n{}\n",
1212            sample_eop_line(60_000, 'I', -0.1, Some(0.1), Some(0.2), None, None, None),
1213            sample_eop_line(60_002, 'P', -0.2, Some(0.1), Some(0.2), None, None, None),
1214        );
1215        let err = parse_eop_finals(&text).unwrap_err();
1216        assert!(err.contains("daily gap"));
1217    }
1218
1219    // ── TimeDataError display, source and From ───────────────────────────────
1220
1221    #[test]
1222    fn time_data_error_display_io() {
1223        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1224        let err = TimeDataError::Io(io_err);
1225        assert!(err.to_string().contains("I/O error"));
1226        assert!(err.to_string().contains("file not found"));
1227    }
1228
1229    #[test]
1230    fn time_data_error_display_download() {
1231        let err = TimeDataError::Download("timeout".into());
1232        assert!(err.to_string().contains("download error"));
1233        assert!(err.to_string().contains("timeout"));
1234    }
1235
1236    #[test]
1237    fn time_data_error_display_parse() {
1238        let err = TimeDataError::Parse("bad line".into());
1239        assert!(err.to_string().contains("parse error"));
1240        assert!(err.to_string().contains("bad line"));
1241    }
1242
1243    #[test]
1244    fn time_data_error_display_integrity() {
1245        let err = TimeDataError::Integrity("hash mismatch".into());
1246        assert!(err.to_string().contains("integrity error"));
1247        assert!(err.to_string().contains("hash mismatch"));
1248    }
1249
1250    #[test]
1251    fn time_data_error_source_io_is_some() {
1252        use std::error::Error;
1253        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1254        let err = TimeDataError::Io(io_err);
1255        assert!(err.source().is_some());
1256    }
1257
1258    #[test]
1259    fn time_data_error_source_non_io_is_none() {
1260        use std::error::Error;
1261        assert!(TimeDataError::Download("x".into()).source().is_none());
1262        assert!(TimeDataError::Parse("x".into()).source().is_none());
1263        assert!(TimeDataError::Integrity("x".into()).source().is_none());
1264    }
1265
1266    #[test]
1267    fn time_data_error_from_io_error() {
1268        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe");
1269        let err = TimeDataError::from(io_err);
1270        assert!(matches!(err, TimeDataError::Io(_)));
1271    }
1272
1273    // ── parse_month additional month names ───────────────────────────────────
1274
1275    #[test]
1276    fn parse_utc_tai_segments_with_sep_oct_nov_dec() {
1277        // Test month names not exercised by the default sample fixture.
1278        let history = "\
12791961 Sep. 1 - Oct. 1 0.5s\n\
1280Oct. 1 - Nov. 1 0.6s\n\
1281Nov. 1 - Dec. 1 0.7s\n\
1282Dec. 1 - 1962 Jan. 1 0.8s\n\
12831962 Jan. 1 - 1.0s\n";
1284        let segments = parse_utc_tai_segments(history).unwrap();
1285        assert!(segments.len() >= 5);
1286    }
1287
1288    // ── parse_date_fragment error path ───────────────────────────────────────
1289
1290    #[test]
1291    fn parse_utc_tai_segments_rejects_bad_date_fragment() {
1292        // A line that has a dash but whose left side has alphabetic chars yet
1293        // cannot be parsed as a valid date fragment (too many tokens).
1294        let history = "baddate foo bar baz qux - 1962 Jan. 1 1.0s\n1962 Jan. 1 - 2.0s\n";
1295        let result = parse_utc_tai_segments(history);
1296        assert!(result.is_err());
1297    }
1298
1299    // ── parse_utc_tai_segments with no-slope formula ─────────────────────────
1300
1301    #[test]
1302    fn parse_utc_tai_segments_constant_offset_formula() {
1303        let history = "1972 Jan. 1 - 1973 Jan. 1 10s\n1973 Jan. 1 - 11s\n";
1304        let segments = parse_utc_tai_segments(history).unwrap();
1305        assert_eq!(segments.len(), 2);
1306        assert_eq!(segments[0].base_seconds, 10.0);
1307        assert_eq!(segments[0].slope_seconds_per_day, 0.0);
1308        assert_eq!(segments[1].base_seconds, 11.0);
1309        assert_eq!(segments[1].slope_seconds_per_day, 0.0);
1310        assert!(segments[1].end_mjd.is_none());
1311    }
1312}