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>
324 lines
12 KiB
Rust
324 lines
12 KiB
Rust
//! 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)
|
||
);
|
||
}
|
||
}
|