Files
IdeA/crates/infrastructure/src/timeparse.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

324 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Parsing d'heures **pur et dépendance-zéro**, partagé par les deux niveaux de
//! détection de limite de session (ARCHITECTURE §21) :
//!
//! - **niveau 1 structuré** (`session/claude.rs`) : normaliser le `resetsAt` d'un
//! `rate_limit_event` (entier epoch s/ms, ou chaîne ISO-8601) en époche-ms ;
//! - **niveau 2 déclaratif** (`ratelimit`) : interpréter l'heure capturée par un
//! `RateLimitPattern` selon son `time_format` (absolue, **murale locale**, ou
//! **relative**).
//!
//! Tout est **pur** (aucune I/O, `now_ms` injecté) ⇒ testable sans process ni
//! horloge réelle. Factorisé ici pour **ne pas dupliquer** le savoir de LS2
//! (l'algorithme jour-civil de Howard Hinnant, l'heuristique secondes-vs-ms, le
//! parseur RFC3339) entre les deux détecteurs (DRY, §21.2-T2).
/// Seuil de magnitude départageant un epoch en **secondes** d'un epoch en
/// **millisecondes** : `10^12 ms ≈ 2001-09-09`, `10^12 s ≈ an 33658`. Tout epoch
/// plausible (≥ 1970) de magnitude `≥ 10^12` est donc déjà des millisecondes ;
/// en-dessous, ce sont des secondes.
pub const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000;
/// Millisecondes dans une journée (24 h), pour le calcul d'heure murale.
pub const DAY_MS: i64 = 86_400_000;
/// Applique l'heuristique secondes-vs-ms à un epoch **entier**.
#[must_use]
pub const fn int_epoch_to_ms(n: i64) -> i64 {
if n.abs() >= EPOCH_MS_THRESHOLD {
n
} else {
n * 1000
}
}
/// Idem pour un epoch **flottant** (fraction de seconde préservée → ms).
#[must_use]
pub fn float_epoch_to_ms(f: f64) -> i64 {
if f.abs() >= EPOCH_MS_THRESHOLD as f64 {
f as i64
} else {
(f * 1000.0) as i64
}
}
/// Tente de lire une heure **absolue** depuis une chaîne : entier epoch, puis float
/// epoch (heuristique s-vs-ms), puis **ISO-8601 / RFC3339**. `None` si rien n'est
/// reconnu. C'est la branche « chaîne » réutilisée par le niveau 1
/// (`claude::parse_reset_ms`) et le niveau 2 (`time_format` absolu).
#[must_use]
pub fn parse_absolute_ms(s: &str) -> Option<i64> {
let t = s.trim();
if let Ok(i) = t.parse::<i64>() {
return Some(int_epoch_to_ms(i));
}
if let Ok(f) = t.parse::<f64>() {
return Some(float_epoch_to_ms(f));
}
parse_rfc3339_to_ms(t)
}
/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en
/// époche-ms. Pur, dépendance-zéro. Best-effort : `None` sur toute forme non
/// reconnue. Sans désignateur de fuseau ⇒ traité **UTC**.
#[must_use]
pub fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
let (date, rest) = s.split_once(['T', 't', ' '])?;
let mut dp = date.split('-');
let year: i64 = dp.next()?.parse().ok()?;
let month: i64 = dp.next()?.parse().ok()?;
let day: i64 = dp.next()?.parse().ok()?;
if dp.next().is_some() {
return None;
}
let (time, tz_offset_secs) = split_tz(rest)?;
let mut tp = time.split(':');
let hour: i64 = tp.next()?.parse().ok()?;
let minute: i64 = tp.next()?.parse().ok()?;
let (second, millis) = split_seconds_frac(tp.next().unwrap_or("0"))?;
if tp.next().is_some() {
return None;
}
let days = days_from_civil(year, month, day);
let epoch_secs = days * 86_400 + hour * 3_600 + minute * 60 + second - tz_offset_secs;
Some(epoch_secs * 1000 + millis)
}
/// Convertit une **heure murale locale** `(h, m, s)` en époche-ms en référence à
/// `now_ms`, avec **passage de minuit** (spike §21.10-2).
///
/// Règle : on calcule « **aujourd'hui** à `h:m:s` » sur la journée UTC contenant
/// `now_ms` ; si cet instant est **déjà passé** (≤ `now_ms`), on prend **le
/// lendemain** (`+ 24 h`). Ainsi « resets at 3pm » alors qu'il est 16 h vise 15 h
/// **demain**, jamais une heure dans le passé.
///
/// **Limite assumée (UTC)** : faute de base de fuseaux dans le binaire (dépendance-
/// zéro), la journée de référence est la **journée UTC**. Une heure murale d'un
/// fuseau très décalé peut donc viser le mauvais jour de ±1 ; le passage de minuit
/// borne l'erreur à « au plus tôt maintenant ». Un offset de fuseau explicite (champ
/// futur du profil) lèverait cette limite — confiné infra, hors périmètre LS5.
#[must_use]
pub fn wall_clock_to_ms(now_ms: i64, hour: u32, minute: u32, second: u32) -> i64 {
let day_start = now_ms - now_ms.rem_euclid(DAY_MS);
let tod_ms = (i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second)) * 1000;
let target = day_start + tod_ms;
if target <= now_ms {
target + DAY_MS
} else {
target
}
}
/// Parse une heure murale lâche : `"15:00"`, `"15:00:30"`, `"3pm"`, `"3:30 pm"`,
/// `"3 PM"` → `(heure 0-23, minute, seconde)`. `None` si non reconnue. Utilisé par le
/// niveau 2 quand `time_format` désigne une heure murale.
#[must_use]
pub fn parse_wall_clock(s: &str) -> Option<(u32, u32, u32)> {
let lower = s.trim().to_ascii_lowercase();
// Suffixe am/pm éventuel.
let (body, meridiem) = if let Some(b) = lower.strip_suffix("am") {
(b.trim(), Some(false))
} else if let Some(b) = lower.strip_suffix("pm") {
(b.trim(), Some(true))
} else {
(lower.as_str(), None)
};
let mut parts = body.split(':');
let mut hour: u32 = parts.next()?.trim().parse().ok()?;
let minute: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
let second: u32 = parts.next().map_or(Ok(0), |p| p.trim().parse()).ok()?;
if parts.next().is_some() {
return None;
}
// Conversion 12 h → 24 h si un méridien est présent.
match meridiem {
Some(true) => {
// pm : 12pm reste 12, 1..=11pm ⇒ +12.
if hour < 12 {
hour += 12;
}
}
Some(false) => {
// am : 12am ⇒ 0, le reste inchangé.
if hour == 12 {
hour = 0;
}
}
None => {}
}
if hour >= 24 || minute >= 60 || second >= 60 {
return None;
}
Some((hour, minute, second))
}
/// Sépare la partie heure de son **désignateur de fuseau** et renvoie l'offset en
/// secondes (à **soustraire** de l'heure locale pour obtenir l'UTC). `Z`/`z` ⇒ 0 ;
/// `±hh:mm` ou `±hhmm` ⇒ offset signé ; aucun désignateur ⇒ 0 (UTC best-effort).
fn split_tz(rest: &str) -> Option<(&str, i64)> {
if let Some(stripped) = rest.strip_suffix(['Z', 'z']) {
return Some((stripped, 0));
}
if let Some(pos) = rest.rfind(['+', '-']) {
let (time, tz) = rest.split_at(pos);
let sign = if tz.starts_with('-') { -1 } else { 1 };
let tz = &tz[1..];
let (h, m) = if let Some((h, m)) = tz.split_once(':') {
(h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
} else if tz.len() == 4 {
(tz[0..2].parse::<i64>().ok()?, tz[2..4].parse::<i64>().ok()?)
} else {
(tz.parse::<i64>().ok()?, 0)
};
return Some((time, sign * (h * 3_600 + m * 60)));
}
Some((rest, 0))
}
/// Sépare `SS` ou `SS.fff…` en `(secondes, millisecondes)`. La fraction est tronquée
/// /complétée à **3 chiffres** (précision ms).
fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
let Some((sec, frac)) = s.split_once('.') else {
return Some((s.parse::<i64>().ok()?, 0));
};
let sec = sec.parse::<i64>().ok()?;
let mut d3: String = frac
.chars()
.take_while(char::is_ascii_digit)
.take(3)
.collect();
while d3.len() < 3 {
d3.push('0');
}
let millis = d3.parse::<i64>().ok()?;
Some((sec, millis))
}
/// Jours depuis l'époque Unix (1970-01-01) pour une date civile proleptique
/// grégorienne. Algorithme de Howard Hinnant (`days_from_civil`), exact et
/// dépendance-zéro ; gère bissextiles et siècles.
#[must_use]
pub const fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
/// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000).
const DAY_START: i64 = 1_699_920_000_000;
// -- days_from_civil ------------------------------------------------------
#[test]
fn days_from_civil_epoch_is_zero() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn days_from_civil_leap_day_2024() {
// 2024-02-29T00:00:00Z = 1_709_164_800_000 ms = 19782 jours pleins.
assert_eq!(days_from_civil(2024, 2, 29), 19782);
// Cohérence avec le jour suivant (1ᵉʳ mars), preuve que le 29 février existe.
assert_eq!(
days_from_civil(2024, 3, 1),
days_from_civil(2024, 2, 29) + 1
);
}
// -- parse_wall_clock -----------------------------------------------------
#[test]
fn parse_wall_clock_variants() {
assert_eq!(parse_wall_clock("3pm"), Some((15, 0, 0)));
assert_eq!(parse_wall_clock("15:00:30"), Some((15, 0, 30)));
assert_eq!(parse_wall_clock("3:30 pm"), Some((15, 30, 0)));
assert_eq!(parse_wall_clock("12am"), Some((0, 0, 0))); // minuit
assert_eq!(parse_wall_clock("12pm"), Some((12, 0, 0))); // midi
}
#[test]
fn parse_wall_clock_rejects_invalid() {
assert_eq!(parse_wall_clock("pas une heure"), None);
assert_eq!(parse_wall_clock("25:00"), None); // heure hors plage
assert_eq!(parse_wall_clock("10:75"), None); // minute hors plage
assert_eq!(parse_wall_clock("1:2:3:4"), None); // trop de composantes
}
// -- wall_clock_to_ms (passage de minuit) --------------------------------
#[test]
fn wall_clock_to_ms_same_day_when_future() {
let now_10h = DAY_START + 10 * 3_600_000;
// 15:00 est dans le futur ⇒ même jour.
assert_eq!(
wall_clock_to_ms(now_10h, 15, 0, 0),
DAY_START + 15 * 3_600_000
);
}
#[test]
fn wall_clock_to_ms_next_day_when_past() {
let now_16h = DAY_START + 16 * 3_600_000;
// 15:00 est déjà passé ⇒ lendemain (+24 h).
assert_eq!(
wall_clock_to_ms(now_16h, 15, 0, 0),
DAY_START + 15 * 3_600_000 + DAY_MS
);
}
#[test]
fn wall_clock_to_ms_exactly_now_rolls_to_next_day() {
// Cas-limite : la cible == now ⇒ considérée passée ⇒ lendemain (jamais le présent).
let now_15h = DAY_START + 15 * 3_600_000;
assert_eq!(wall_clock_to_ms(now_15h, 15, 0, 0), now_15h + DAY_MS);
}
// -- parse_absolute_ms (recoupe LS2) -------------------------------------
#[test]
fn parse_absolute_ms_epoch_seconds_and_millis() {
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000));
// ms tel quel
}
#[test]
fn parse_absolute_ms_iso8601() {
assert_eq!(
parse_absolute_ms("2023-11-14T22:13:20Z"),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_absolute_ms_garbage_is_none() {
assert_eq!(parse_absolute_ms("pas une date"), None);
}
// -- parse_rfc3339_to_ms : offsets signés (cohérence niveau 1/2) ----------
#[test]
fn parse_rfc3339_offsets_convert_to_utc() {
// +01:00 et -01:00 autour de 22:13:20Z ⇒ même instant.
assert_eq!(
parse_rfc3339_to_ms("2023-11-14T23:13:20+01:00"),
Some(1_700_000_000_000)
);
assert_eq!(
parse_rfc3339_to_ms("2023-11-14T21:13:20-01:00"),
Some(1_700_000_000_000)
);
}
}