feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé
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 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1946,6 +1946,7 @@ dependencies = [
|
|||||||
"landlock",
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -20,6 +20,11 @@ async-trait = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { 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"
|
portable-pty = "0.9"
|
||||||
git2 = { workspace = true }
|
git2 = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@ -27,12 +27,14 @@ pub mod orchestrator;
|
|||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
pub mod ratelimit;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod timeparse;
|
||||||
|
|
||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
@ -59,6 +61,7 @@ pub use remote::{remote_host, LocalHost};
|
|||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use sandbox::LandlockSandbox;
|
pub use sandbox::LandlockSandbox;
|
||||||
|
pub use ratelimit::RateLimitParser;
|
||||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||||
pub use scheduler::TokioScheduler;
|
pub use scheduler::TokioScheduler;
|
||||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||||
|
|||||||
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
@ -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<i64> {
|
||||||
|
let c = captured.trim();
|
||||||
|
match self {
|
||||||
|
Self::Auto => timeparse::parse_absolute_ms(c),
|
||||||
|
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||||
|
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||||
|
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||||
|
Self::RelativeSeconds => c
|
||||||
|
.parse::<f64>()
|
||||||
|
.ok()
|
||||||
|
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||||
|
Self::RelativeMillis => c.parse::<i64>().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<String>,
|
||||||
|
/// 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<Self> {
|
||||||
|
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<SessionLimit> {
|
||||||
|
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::<usize>().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<reset>\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<reset>\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<reset>\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<reset>\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<reset>.+)",
|
||||||
|
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<reset>.+)",
|
||||||
|
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<reset>\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<reset>\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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -250,12 +250,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("chaque réveil doit tirer")
|
.expect("chaque réveil doit tirer")
|
||||||
.expect("canal ouvert");
|
.expect("canal ouvert");
|
||||||
if let ScheduledTask::ResumeAgent {
|
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
|
||||||
|
let ScheduledTask::ResumeAgent {
|
||||||
conversation_id, ..
|
conversation_id, ..
|
||||||
} = task
|
} = task;
|
||||||
{
|
got.push(conversation_id.expect("conv présent"));
|
||||||
got.push(conversation_id.expect("conv présent"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
got.sort();
|
got.sort();
|
||||||
assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
|
assert_eq!(got, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
|
||||||
@ -282,12 +281,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("les survivants doivent tirer")
|
.expect("les survivants doivent tirer")
|
||||||
.expect("canal ouvert");
|
.expect("canal ouvert");
|
||||||
if let ScheduledTask::ResumeAgent {
|
let ScheduledTask::ResumeAgent {
|
||||||
conversation_id, ..
|
conversation_id, ..
|
||||||
} = task
|
} = task;
|
||||||
{
|
got.push(conversation_id.expect("conv présent"));
|
||||||
got.push(conversation_id.expect("conv présent"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
got.sort();
|
got.sort();
|
||||||
assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]);
|
assert_eq!(got, vec!["keep-1".to_owned(), "keep-2".to_owned()]);
|
||||||
|
|||||||
@ -190,131 +190,28 @@ pub fn parse_reset_ms(rate_limit_info: &Value) -> Option<i64> {
|
|||||||
value_to_epoch_ms(raw)
|
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.
|
/// 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<i64> {
|
fn value_to_epoch_ms(v: &Value) -> Option<i64> {
|
||||||
if let Some(i) = v.as_i64() {
|
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() {
|
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() {
|
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() {
|
if let Some(s) = v.as_str() {
|
||||||
let t = s.trim();
|
return crate::timeparse::parse_absolute_ms(s);
|
||||||
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));
|
|
||||||
}
|
|
||||||
return parse_rfc3339_to_ms(t);
|
|
||||||
}
|
}
|
||||||
None
|
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<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
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.
|
/// Adapter de session structurée Claude.
|
||||||
///
|
///
|
||||||
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
|
/// Incarnation « un `claude -p` par tour » (§17.2 (b)) : chaque `send` relance la
|
||||||
|
|||||||
315
crates/infrastructure/src/timeparse.rs
Normal file
315
crates/infrastructure/src/timeparse.rs
Normal file
@ -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<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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user