From 98bfcf4f22298e91d88feed647fed8282d18ea0b Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 20:02:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(session-limits):=20LS5=20=E2=80=94=20d?= =?UTF-8?q?=C3=A9tecteur=20niveau=202=20d=C3=A9claratif=20+=20parsing=20te?= =?UTF-8?q?mps=20partag=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un détecteur niveau 2 (RateLimitParser, regex confiné à l'infra) qui repère les mentions de limite de session dans la sortie textuelle, et factorise le parsing d'heures dans un module pur (timeparse) partagé entre les niveaux 1 et 2. Le détecteur Claude niveau 1 est refactoré vers timeparse (~-121 lignes). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + crates/infrastructure/Cargo.toml | 5 + crates/infrastructure/src/lib.rs | 3 + crates/infrastructure/src/ratelimit/mod.rs | 384 ++++++++++++++++++++ crates/infrastructure/src/scheduler/mod.rs | 17 +- crates/infrastructure/src/session/claude.rs | 121 +----- crates/infrastructure/src/timeparse.rs | 315 ++++++++++++++++ 7 files changed, 724 insertions(+), 122 deletions(-) create mode 100644 crates/infrastructure/src/ratelimit/mod.rs create mode 100644 crates/infrastructure/src/timeparse.rs diff --git a/Cargo.lock b/Cargo.lock index c0985bf..c6a6e82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1946,6 +1946,7 @@ dependencies = [ "landlock", "notify", "portable-pty", + "regex", "reqwest 0.12.28", "serde", "serde_json", diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index bbfa886..85b3c71 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -20,6 +20,11 @@ async-trait = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) : +# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex +# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur +# celle déjà présente dans le `Cargo.lock` (transitive). +regex = "1" portable-pty = "0.9" git2 = { workspace = true } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index fc8d01a..b05eb4a 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -27,12 +27,14 @@ pub mod orchestrator; pub mod permission; pub mod process; pub mod pty; +pub mod ratelimit; pub mod remote; pub mod runtime; pub mod sandbox; pub mod scheduler; pub mod session; pub mod store; +pub mod timeparse; pub use clock::SystemClock; pub use conversation::InMemoryConversationRegistry; @@ -59,6 +61,7 @@ pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; #[cfg(target_os = "linux")] pub use sandbox::LandlockSandbox; +pub use ratelimit::RateLimitParser; pub use sandbox::{default_enforcer, NoopSandbox}; pub use scheduler::TokioScheduler; pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory}; diff --git a/crates/infrastructure/src/ratelimit/mod.rs b/crates/infrastructure/src/ratelimit/mod.rs new file mode 100644 index 0000000..efcdf96 --- /dev/null +++ b/crates/infrastructure/src/ratelimit/mod.rs @@ -0,0 +1,384 @@ +//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21, +//! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter +//! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y +//! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1). +//! +//! # Frontière T2 respectée +//! +//! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le +//! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la +//! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure). +//! +//! # Robustesse (« solide même pour un novice ») +//! +//! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`] +//! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un +//! profil pourri ne doit pas faire tomber IdeA. +//! +//! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4) +//! +//! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir +//! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le +//! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser +//! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage +//! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`]. + +use regex::Regex; + +use domain::profile::{AgentProfile, RateLimitPattern}; +use domain::session_limit::{RateLimitSource, SessionLimit}; + +use crate::timeparse; + +/// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents +/// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil +/// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double- +/// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser. +#[must_use] +pub fn applies(profile: &AgentProfile) -> bool { + profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some() +} + +/// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du +/// champ `time_format` du profil (donnée opaque au domaine, comprise ici). +/// +/// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResetTimeFormat { + /// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601). + Auto, + /// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**. + EpochSeconds, + /// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**. + EpochMillis, + /// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue. + Iso8601, + /// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` : + /// **délai** en secondes ⇒ `now + delta`. + RelativeSeconds, + /// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`. + RelativeMillis, + /// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale** + /// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée. + WallClock, +} + +impl ResetTimeFormat { + /// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent). + fn from_opt(time_format: Option<&str>) -> Self { + let Some(raw) = time_format else { + return Self::Auto; + }; + match raw.trim().to_ascii_lowercase().as_str() { + "epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds, + "epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis, + "iso8601" | "rfc3339" | "iso" => Self::Iso8601, + "relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => { + Self::RelativeSeconds + } + "relative_ms" | "relative_millis" => Self::RelativeMillis, + "wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock, + _ => Self::Auto, + } + } + + /// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à + /// `now_ms` pour les heures relatives/murales. `None` si la capture est + /// inexploitable (⇒ détection sans heure ⇒ filet humain en aval). + fn resolve(self, captured: &str, now_ms: i64) -> Option { + let c = captured.trim(); + match self { + Self::Auto => timeparse::parse_absolute_ms(c), + Self::EpochSeconds => c.parse::().ok().map(|s| s.saturating_mul(1000)), + Self::EpochMillis => c.parse::().ok(), + Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c), + Self::RelativeSeconds => c + .parse::() + .ok() + .map(|d| now_ms + (d * 1000.0) as i64), + Self::RelativeMillis => c.parse::().ok().map(|d| now_ms + d), + Self::WallClock => { + let (h, m, s) = timeparse::parse_wall_clock(c)?; + Some(timeparse::wall_clock_to_ms(now_ms, h, m, s)) + } + } + } +} + +/// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure. +/// +/// À instancier **par agent** (au câblage PTY, LS7) à partir de son +/// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via +/// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois** +/// à la construction, pas à chaque fragment. +pub struct RateLimitParser { + /// Motif compilé recherché dans la sortie. + regex: Regex, + /// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset. + /// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`). + reset_capture: Option, + /// Stratégie d'interprétation de la capture, pré-calculée. + time_format: ResetTimeFormat, +} + +impl RateLimitParser { + /// Compile le motif du profil. `None` si la regex est **invalide** (profil mal + /// configuré) — robustesse : jamais de panique, l'agent tourne juste sans + /// détection niveau 2. + #[must_use] + pub fn new(pattern: &RateLimitPattern) -> Option { + let regex = Regex::new(&pattern.pattern).ok()?; + Some(Self { + regex, + reset_capture: pattern.reset_capture.clone(), + time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()), + }) + } + + /// Analyse un fragment de sortie texte. + /// + /// - **pas de match** ⇒ `None` (pas de limite) ; + /// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`, + /// `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture + /// `reset_capture` interprété selon `time_format` — `None` si pas de capture, + /// groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet + /// humain en aval, §21). + #[must_use] + pub fn detect(&self, text: &str, now_ms: i64) -> Option { + let caps = self.regex.captures(text)?; + let resets_at_ms = self.reset_capture.as_deref().and_then(|name| { + // Groupe nommé en priorité ; repli sur un index décimal si `name` en est un. + let raw = caps + .name(name) + .or_else(|| name.parse::().ok().and_then(|i| caps.get(i)))?; + self.time_format.resolve(raw.as_str(), now_ms) + }); + Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use domain::ids::ProfileId; + use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; + + /// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s. + const NOW: i64 = 1_700_000_000_000; + /// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales. + const DAY_START: i64 = 1_699_920_000_000; + + fn pattern( + pat: &str, + reset_capture: Option<&str>, + time_format: Option<&str>, + ) -> RateLimitPattern { + RateLimitPattern::new( + pat.to_owned(), + reset_capture.map(str::to_owned), + time_format.map(str::to_owned), + ) + .expect("motif valide (non vide)") + } + + // -- new : robustesse regex ---------------------------------------------- + + #[test] + fn new_returns_none_on_invalid_regex() { + // Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique). + let p = pattern("rate limit (", None, None); + assert!(RateLimitParser::new(&p).is_none()); + } + + // -- detect : pas de match ----------------------------------------------- + + #[test] + fn detect_returns_none_when_pattern_does_not_match() { + let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap(); + assert!(parser.detect("tout va bien", NOW).is_none()); + } + + // -- detect : match sans reset_capture ----------------------------------- + + #[test] + fn detect_match_without_reset_capture_has_no_time() { + let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap(); + let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté"); + assert_eq!(limit.resets_at_ms, None); + assert_eq!(limit.detected_at_ms, NOW); + assert_eq!(limit.source, RateLimitSource::Pattern); + } + + // -- detect : formats ABSOLUS -------------------------------------------- + + #[test] + fn detect_epoch_seconds_format() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P\d+)", + Some("reset"), + Some("epoch_s"), + )) + .unwrap(); + let limit = parser + .detect("limit, resets at 1700000000 ok", NOW) + .expect("détecté"); + assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); + assert_eq!(limit.source, RateLimitSource::Pattern); + } + + #[test] + fn detect_epoch_millis_format() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P\d+)", + Some("reset"), + Some("epoch_ms"), + )) + .unwrap(); + let limit = parser + .detect("resets at 1700000000000", NOW) + .expect("détecté"); + assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); + } + + #[test] + fn detect_iso8601_format() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P\S+)", + Some("reset"), + Some("iso8601"), + )) + .unwrap(); + let limit = parser + .detect("resets at 2023-11-14T22:13:20Z", NOW) + .expect("détecté"); + assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); + } + + // -- detect : format RELATIF --------------------------------------------- + + #[test] + fn detect_relative_seconds_format_uses_now() { + let parser = RateLimitParser::new(&pattern( + r"retry after (?P\d+)s", + Some("reset"), + Some("relative_s"), + )) + .unwrap(); + let limit = parser.detect("retry after 600s", NOW).expect("détecté"); + assert_eq!(limit.resets_at_ms, Some(NOW + 600_000)); + assert_eq!(limit.source, RateLimitSource::Pattern); + } + + // -- detect : format MURAL (passage de minuit) --------------------------- + + /// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC). + #[test] + fn detect_wall_clock_same_day_when_future() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P.+)", + Some("reset"), + Some("wall"), + )) + .unwrap(); + let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là + let limit = parser.detect("resets at 3pm", now_10h).expect("détecté"); + // 15:00 le même jour. + assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000)); + } + + /// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h). + #[test] + fn detect_wall_clock_next_day_when_past() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P.+)", + Some("reset"), + Some("wall"), + )) + .unwrap(); + let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC + let limit = parser.detect("resets at 3pm", now_16h).expect("détecté"); + // 15:00 le LENDEMAIN. + assert_eq!( + limit.resets_at_ms, + Some(DAY_START + 15 * 3_600_000 + 86_400_000) + ); + } + + // -- detect : match mais capture inexploitable ⇒ détection sans heure ----- + + #[test] + fn detect_match_with_missing_capture_group_has_no_time() { + // reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset". + let parser = + RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap(); + let limit = parser.detect("rate limited", NOW).expect("détecté"); + assert_eq!(limit.resets_at_ms, None); + assert_eq!(limit.source, RateLimitSource::Pattern); + } + + #[test] + fn detect_match_with_unparsable_value_has_no_time() { + // Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None. + let parser = RateLimitParser::new(&pattern( + r"reset (?P\w+)", + Some("reset"), + Some("epoch_s"), + )) + .unwrap(); + let limit = parser.detect("reset abc", NOW).expect("détecté"); + assert_eq!(limit.resets_at_ms, None); + } + + // -- detect : appelable plusieurs fois (regex compilé une seule fois) ----- + + #[test] + fn detect_can_be_called_multiple_times() { + let parser = RateLimitParser::new(&pattern( + r"resets at (?P\d+)", + Some("reset"), + Some("epoch_s"), + )) + .unwrap(); + let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté"); + assert!(parser.detect("rien ici", NOW).is_none()); + let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté"); + assert_eq!(a.resets_at_ms, b.resets_at_ms); + assert_eq!(a.resets_at_ms, Some(1_700_000_000_000)); + } + + // -- applies(profile) : règle anti-double-détection (§21.10-4) ------------ + + fn base_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(uuid::Uuid::nil()), + "Dev", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").expect("valide"), + None, + "{agentRunDir}", + None, + ) + .expect("profil valide") + } + + #[test] + fn applies_false_for_structured_profile_even_with_pattern() { + let profile = base_profile() + .with_structured_adapter(StructuredAdapter::Claude) + .with_rate_limit_pattern(pattern("rate limit", None, None)); + assert!(!applies(&profile), "un agent structuré détecte par le niveau 1"); + } + + #[test] + fn applies_true_for_pty_profile_with_pattern() { + let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None)); + assert!(applies(&profile)); + } + + #[test] + fn applies_false_for_pty_profile_without_pattern() { + assert!(!applies(&base_profile())); + } +} diff --git a/crates/infrastructure/src/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs index 6a2100c..5e60bb9 100644 --- a/crates/infrastructure/src/scheduler/mod.rs +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -250,12 +250,11 @@ mod tests { .await .expect("chaque réveil doit tirer") .expect("canal ouvert"); - if let ScheduledTask::ResumeAgent { + // `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`). + let ScheduledTask::ResumeAgent { conversation_id, .. - } = task - { - got.push(conversation_id.expect("conv présent")); - } + } = task; + got.push(conversation_id.expect("conv présent")); } got.sort(); assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]); @@ -282,12 +281,10 @@ mod tests { .await .expect("les survivants doivent tirer") .expect("canal ouvert"); - if let ScheduledTask::ResumeAgent { + let ScheduledTask::ResumeAgent { conversation_id, .. - } = task - { - got.push(conversation_id.expect("conv présent")); - } + } = task; + got.push(conversation_id.expect("conv présent")); } got.sort(); assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]); diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 94d08c4..470e9b7 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -190,131 +190,28 @@ pub fn parse_reset_ms(rate_limit_info: &Value) -> Option { value_to_epoch_ms(raw) } -/// 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. -const EPOCH_MS_THRESHOLD: i64 = 1_000_000_000_000; - /// Convertit une [`Value`] JSON (entier / float / chaîne) en époche-ms, défensivement. +/// +/// La conversion **générique** (heuristique secondes-vs-ms, parseur ISO-8601, algo +/// jour-civil) est factorisée dans [`crate::timeparse`] et **partagée** avec le +/// détecteur de niveau 2 (`ratelimit`) — pas de duplication (§21.2-T2). Seule +/// l'extraction depuis un `serde_json::Value` (typage int/float/str) reste ici. fn value_to_epoch_ms(v: &Value) -> Option { if let Some(i) = v.as_i64() { - return Some(int_epoch_to_ms(i)); + return Some(crate::timeparse::int_epoch_to_ms(i)); } if let Some(u) = v.as_u64() { - return Some(int_epoch_to_ms(i64::try_from(u).ok()?)); + return Some(crate::timeparse::int_epoch_to_ms(i64::try_from(u).ok()?)); } if let Some(f) = v.as_f64() { - return Some(float_epoch_to_ms(f)); + return Some(crate::timeparse::float_epoch_to_ms(f)); } if let Some(s) = v.as_str() { - let t = s.trim(); - if let Ok(i) = t.parse::() { - return Some(int_epoch_to_ms(i)); - } - if let Ok(f) = t.parse::() { - return Some(float_epoch_to_ms(f)); - } - return parse_rfc3339_to_ms(t); + return crate::timeparse::parse_absolute_ms(s); } None } -/// Applique l'heuristique secondes-vs-ms à un epoch **entier**. -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). -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 - } -} - -/// **Parse une chaîne ISO-8601 / RFC3339** (`YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`) en -/// époche-ms. Pur, dépendance-zéro (aucune crate de date). Best-effort : retourne -/// `None` sur toute forme non reconnue. Sans désignateur de fuseau ⇒ traité **UTC**. -fn parse_rfc3339_to_ms(s: &str) -> Option { - 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) -} - -/// 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::().ok()?, m.parse::().ok()?) - } else if tz.len() == 4 { - (tz[0..2].parse::().ok()?, tz[2..4].parse::().ok()?) - } else { - (tz.parse::().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::().ok()?, 0)); - }; - let sec = sec.parse::().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::().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. -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 -} - /// Adapter de session structurée Claude. /// /// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la diff --git a/crates/infrastructure/src/timeparse.rs b/crates/infrastructure/src/timeparse.rs new file mode 100644 index 0000000..a1eae6e --- /dev/null +++ b/crates/infrastructure/src/timeparse.rs @@ -0,0 +1,315 @@ +//! 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 { + let t = s.trim(); + if let Ok(i) = t.parse::() { + return Some(int_epoch_to_ms(i)); + } + if let Ok(f) = t.parse::() { + 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 { + 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::().ok()?, m.parse::().ok()?) + } else if tz.len() == 4 { + (tz[0..2].parse::().ok()?, tz[2..4].parse::().ok()?) + } else { + (tz.parse::().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::().ok()?, 0)); + }; + let sec = sec.parse::().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::().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) + ); + } +}