mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
Avec LOKI_TASKS=rust (expérimental), la façade sert les routes /api/tasks* et fait tourner le planificateur, que le Go ne démarre plus. Elle exécute elle-même les tâches « script seul » (shell de l'agent, limite de 10 min, arrêt à la main). Une tâche IA passe par le verrou de génération et les outils de l'agent, restés au Go : la façade la lui fait exécuter (/internal/v1/tasks/run), comme son propre planificateur l'aurait fait. Les notifications partent par la façade si elle les tient, sinon par le Go (/internal/v1/push/send). - src/tz.rs : les fuseaux IANA comme le Go (TZif v1/v2 et règle POSIX, time.Local, time.Date et ses heures qui sautent ou se répètent) ; - tasks/cron.rs : les quatre formes de fréquence, avec ParseDuration du Go et ses messages d'erreur ; instants sur 128 bits (dates au-delà de 2262) ; - tasks/mod.rs : la base, l'exécution, le planificateur (un tic par minute, une tâche due par tic) et les sept routes. Le Go reçoit un crochet de test pour l'heure des tâches (tasksNow). Correction de la façade : les appels à l'API interne du Go étaient tous bornés à 10 s. Un outil exécuté par le Go qui dure plus longtemps (page lente, navigateur, MCP tenu par le Go) était coupé, alors qu'il continuait. Ces appels sont désormais bornés à 30 min ; une tâche IA est attendue jusqu'à 24 h. Parité : parity_tasks_test.go tire du Go un journal rejoué par tests/tasks.rs, l'heure des tâches fixée pas à pas. Il couvre l'enregistrement (chaque forme et chaque erreur de fréquence, plusieurs fuseaux), la bascule, l'interrupteur maître, la suppression, les lancements et les tics du planificateur. Les mutations (79, dont 77 tuées et 2 équivalentes) ont fait trouver et corriger cinq écarts du port : dates lointaines, intervalle en jours démesuré, heure qui saute au printemps, compte-rendu coupé au milieu d'un caractère, noms de fuseau contenant « .. ». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
757 lines
24 KiB
Rust
757 lines
24 KiB
Rust
//! Fuseaux horaires à la Go (`time.LoadLocation`, `time.Local`) : les fichiers
|
|
//! TZif du système (`/usr/share/zoneinfo`…), lus comme le fait la bibliothèque
|
|
//! standard du Go — transitions 64 bits (v2+), règle POSIX du pied de fichier
|
|
//! pour les dates au-delà de la dernière transition — et la conversion d'une
|
|
//! heure murale en instant comme `time.Date`.
|
|
//!
|
|
//! Sert aux tâches planifiées (fréquences cron, « tous les jours à 23:00 »,
|
|
//! « @once ») : la même zoneinfo que le Go, donc les mêmes déclenchements.
|
|
|
|
use std::path::Path;
|
|
|
|
/// Une zone : décalage UTC (s), heure d'été, abréviation.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Zone {
|
|
pub name: String,
|
|
pub offset: i64,
|
|
pub is_dst: bool,
|
|
}
|
|
|
|
/// Un fuseau chargé.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Location {
|
|
pub name: String,
|
|
zones: Vec<Zone>,
|
|
/// (instant, index de zone), triés.
|
|
tx: Vec<(i64, usize)>,
|
|
/// Règle POSIX au-delà de la dernière transition.
|
|
extend: String,
|
|
}
|
|
|
|
const ALPHA: i64 = i64::MIN;
|
|
const OMEGA: i64 = i64::MAX;
|
|
|
|
impl Location {
|
|
/// `time.UTC`.
|
|
pub fn utc() -> Self {
|
|
Location {
|
|
name: "UTC".into(),
|
|
zones: vec![Zone {
|
|
name: "UTC".into(),
|
|
offset: 0,
|
|
is_dst: false,
|
|
}],
|
|
tx: Vec::new(),
|
|
extend: String::new(),
|
|
}
|
|
}
|
|
|
|
/// `time.LoadLocation` : "" ou "UTC" = UTC ; sinon le fichier TZif du
|
|
/// système. Erreur si introuvable ou illisible.
|
|
pub fn load(name: &str) -> Result<Self, String> {
|
|
if name.is_empty() || name == "UTC" {
|
|
return Ok(Self::utc());
|
|
}
|
|
if name == "Local" {
|
|
return Ok(Self::local());
|
|
}
|
|
let mut sources: Vec<String> = Vec::new();
|
|
if let Ok(z) = std::env::var("ZONEINFO")
|
|
&& !z.is_empty()
|
|
{
|
|
sources.push(z);
|
|
}
|
|
sources.extend(
|
|
[
|
|
"/usr/share/zoneinfo/",
|
|
"/usr/share/lib/zoneinfo/",
|
|
"/usr/lib/locale/TZ/",
|
|
"/etc/zoneinfo/",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string()),
|
|
);
|
|
Self::load_in(name, &sources)
|
|
}
|
|
|
|
/// `LoadLocation` sur les bases données (dans l'ordre).
|
|
fn load_in(name: &str, sources: &[String]) -> Result<Self, String> {
|
|
if name.is_empty() || name == "UTC" {
|
|
return Ok(Self::utc());
|
|
}
|
|
if !valid_name(name) {
|
|
return Err("time: invalid location name".to_string());
|
|
}
|
|
for src in sources {
|
|
let p = Path::new(src).join(name);
|
|
if let Ok(b) = std::fs::read(&p) {
|
|
return parse_tzif(name, &b);
|
|
}
|
|
}
|
|
Err(format!("unknown time zone {name}"))
|
|
}
|
|
|
|
/// `time.Local` : `$TZ` (vide = UTC, « :chemin » accepté), sinon
|
|
/// /etc/localtime, sinon UTC.
|
|
pub fn local() -> Self {
|
|
Self::local_from(std::env::var("TZ").ok(), Path::new("/etc/localtime"))
|
|
}
|
|
|
|
/// `initLocal` : `tz` la valeur de `$TZ` (absente : `None`), `etc` le
|
|
/// fuseau du système.
|
|
fn local_from(tz: Option<String>, etc: &Path) -> Self {
|
|
match tz {
|
|
Some(tz) => {
|
|
let tz = tz.strip_prefix(':').unwrap_or(&tz).to_string();
|
|
if tz.is_empty() || tz == "UTC" {
|
|
return Self::utc();
|
|
}
|
|
if tz.starts_with('/') {
|
|
if let Ok(b) = std::fs::read(&tz)
|
|
&& let Ok(mut l) = parse_tzif("Local", &b)
|
|
{
|
|
l.name = "Local".into();
|
|
return l;
|
|
}
|
|
return Self::utc();
|
|
}
|
|
match Self::load(&tz) {
|
|
Ok(mut l) => {
|
|
l.name = "Local".into();
|
|
l
|
|
}
|
|
Err(_) => Self::utc(),
|
|
}
|
|
}
|
|
None => match std::fs::read(etc)
|
|
.ok()
|
|
.and_then(|b| parse_tzif("Local", &b).ok())
|
|
{
|
|
Some(mut l) => {
|
|
l.name = "Local".into();
|
|
l
|
|
}
|
|
None => {
|
|
let mut u = Self::utc();
|
|
u.name = "Local".into();
|
|
u
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `(*Location).lookup` : la zone d'un instant et la période où elle
|
|
/// vaut, [start, end[.
|
|
pub fn lookup(&self, sec: i64) -> (Zone, i64, i64) {
|
|
if self.zones.is_empty() {
|
|
return (
|
|
Zone {
|
|
name: "UTC".into(),
|
|
offset: 0,
|
|
is_dst: false,
|
|
},
|
|
ALPHA,
|
|
OMEGA,
|
|
);
|
|
}
|
|
if self.tx.is_empty() || sec < self.tx[0].0 {
|
|
let z = self.zones[self.lookup_first_zone()].clone();
|
|
let end = self.tx.first().map_or(OMEGA, |t| t.0);
|
|
return (z, ALPHA, end);
|
|
}
|
|
// Dichotomie : la dernière transition <= sec.
|
|
let (mut lo, mut hi) = (0usize, self.tx.len());
|
|
let mut end = OMEGA;
|
|
while hi - lo > 1 {
|
|
let m = lo + (hi - lo) / 2;
|
|
let lim = self.tx[m].0;
|
|
if sec < lim {
|
|
end = lim;
|
|
hi = m;
|
|
} else {
|
|
lo = m;
|
|
}
|
|
}
|
|
let zone = self.zones[self.tx[lo].1].clone();
|
|
let start = self.tx[lo].0;
|
|
if lo == self.tx.len() - 1
|
|
&& !self.extend.is_empty()
|
|
&& let Some((z, s, e)) = tzset(&self.extend, start, sec)
|
|
{
|
|
return (z, s, e);
|
|
}
|
|
(zone, start, end)
|
|
}
|
|
|
|
/// `lookupFirstZone`.
|
|
fn lookup_first_zone(&self) -> usize {
|
|
if !self.first_zone_used() {
|
|
return 0;
|
|
}
|
|
if !self.tx.is_empty() && self.zones[self.tx[0].1].is_dst {
|
|
let mut zi = self.tx[0].1 as i64 - 1;
|
|
while zi >= 0 {
|
|
if !self.zones[zi as usize].is_dst {
|
|
return zi as usize;
|
|
}
|
|
zi -= 1;
|
|
}
|
|
}
|
|
for (i, z) in self.zones.iter().enumerate() {
|
|
if !z.is_dst {
|
|
return i;
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
fn first_zone_used(&self) -> bool {
|
|
self.tx.iter().any(|t| t.1 == 0)
|
|
}
|
|
|
|
/// Le décalage d'un instant.
|
|
pub fn offset_at(&self, sec: i64) -> i64 {
|
|
self.lookup(sec).0.offset
|
|
}
|
|
|
|
/// `time.Date(y, m, d, h, mi, s, 0, loc)` : l'instant (s Unix) d'une heure
|
|
/// murale, normalisée comme le Go (débordements reportés).
|
|
pub fn date(&self, y: i64, m: i64, d: i64, h: i64, mi: i64, s: i64) -> i64 {
|
|
// Normalisation du mois puis des secondes du jour.
|
|
let (mut y, mut m) = (y, m - 1);
|
|
y += m.div_euclid(12);
|
|
m = m.rem_euclid(12);
|
|
let days = days_from_civil(y, m + 1, 1) + (d - 1);
|
|
let unix = days * 86400 + h * 3600 + mi * 60 + s;
|
|
let (z, start, end) = self.lookup(unix);
|
|
let mut offset = z.offset;
|
|
let mut out = unix;
|
|
if offset != 0 {
|
|
let utc = unix - offset;
|
|
if utc < start || utc >= end {
|
|
offset = self.lookup(utc).0.offset;
|
|
}
|
|
out -= offset;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// L'heure murale d'un instant : (année, mois, jour, heure, minute,
|
|
/// seconde, jour de la semaine 0 = dimanche).
|
|
pub fn civil(&self, sec: i64) -> Civil {
|
|
civil(sec + self.offset_at(sec))
|
|
}
|
|
}
|
|
|
|
/// Une heure murale.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Civil {
|
|
pub year: i64,
|
|
pub month: i64,
|
|
pub day: i64,
|
|
pub hour: i64,
|
|
pub minute: i64,
|
|
pub second: i64,
|
|
pub weekday: i64,
|
|
}
|
|
|
|
impl Civil {
|
|
/// `Format("2006-01-02 15:04")`.
|
|
pub fn ymd_hm(&self) -> String {
|
|
format!(
|
|
"{:04}-{:02}-{:02} {:02}:{:02}",
|
|
self.year, self.month, self.day, self.hour, self.minute
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Les secondes « locales » (UTC + décalage) en heure murale.
|
|
pub fn civil(local: i64) -> Civil {
|
|
let days = local.div_euclid(86400);
|
|
let rem = local.rem_euclid(86400);
|
|
let (y, m, d) = civil_from_days(days);
|
|
Civil {
|
|
year: y,
|
|
month: m,
|
|
day: d,
|
|
hour: rem / 3600,
|
|
minute: rem % 3600 / 60,
|
|
second: rem % 60,
|
|
weekday: (days + 4).rem_euclid(7),
|
|
}
|
|
}
|
|
|
|
/// Jours depuis 1970-01-01 (calendrier grégorien proleptique).
|
|
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|
let y = if m <= 2 { y - 1 } else { y };
|
|
let era = y.div_euclid(400);
|
|
let yoe = y - era * 400;
|
|
let mp = (m + 9) % 12;
|
|
let doy = (153 * mp + 2) / 5 + d - 1;
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
era * 146097 + doe - 719468
|
|
}
|
|
|
|
/// L'inverse de [`days_from_civil`].
|
|
pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
|
let z = z + 719468;
|
|
let era = z.div_euclid(146097);
|
|
let doe = z - era * 146097;
|
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
|
let y = yoe + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
}
|
|
|
|
fn is_leap(y: i64) -> bool {
|
|
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
|
|
}
|
|
|
|
/// `containsDotDot` et consorts : un nom de zone sans « .. » ni chemin
|
|
/// absolu.
|
|
/// Ni `..` (n'importe où, comme `containsDotDot`), ni chemin absolu.
|
|
fn valid_name(name: &str) -> bool {
|
|
!(name.contains("..") || name.starts_with('/') || name.starts_with('\\'))
|
|
}
|
|
|
|
fn be32(b: &[u8]) -> i64 {
|
|
i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as i64
|
|
}
|
|
|
|
fn be64(b: &[u8]) -> i64 {
|
|
i64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
|
|
}
|
|
|
|
/// `LoadLocationFromTZData`.
|
|
pub fn parse_tzif(name: &str, data: &[u8]) -> Result<Location, String> {
|
|
let bad = || "malformed time zone information".to_string();
|
|
if data.len() < 44 || &data[..4] != b"TZif" {
|
|
return Err(bad());
|
|
}
|
|
let version = data[4];
|
|
let counts = |off: usize| -> [usize; 6] {
|
|
let mut n = [0usize; 6];
|
|
for (i, v) in n.iter_mut().enumerate() {
|
|
*v = u32::from_be_bytes([
|
|
data[off + 20 + i * 4],
|
|
data[off + 21 + i * 4],
|
|
data[off + 22 + i * 4],
|
|
data[off + 23 + i * 4],
|
|
]) as usize;
|
|
}
|
|
n
|
|
};
|
|
// [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt]
|
|
let mut n = counts(0);
|
|
let mut off = 44;
|
|
let mut wide = false;
|
|
if version >= b'2' {
|
|
// Saute le bloc v1.
|
|
let skip = n[3] * 4 + n[3] + n[4] * 6 + n[5] + n[2] * 8 + n[1] + n[0];
|
|
let o2 = 44 + skip;
|
|
if data.len() < o2 + 44 || &data[o2..o2 + 4] != b"TZif" {
|
|
return Err(bad());
|
|
}
|
|
n = counts(o2);
|
|
off = o2 + 44;
|
|
wide = true;
|
|
}
|
|
let tsz = if wide { 8 } else { 4 };
|
|
let need = n[3] * tsz + n[3] + n[4] * 6 + n[5] + n[2] * (tsz + 4) + n[1] + n[0];
|
|
if data.len() < off + need {
|
|
return Err(bad());
|
|
}
|
|
let times = &data[off..off + n[3] * tsz];
|
|
let idx = &data[off + n[3] * tsz..off + n[3] * tsz + n[3]];
|
|
let zo = off + n[3] * tsz + n[3];
|
|
let zdata = &data[zo..zo + n[4] * 6];
|
|
let chars = &data[zo + n[4] * 6..zo + n[4] * 6 + n[5]];
|
|
let after = zo + n[4] * 6 + n[5] + n[2] * (tsz + 4) + n[1] + n[0];
|
|
let mut extend = String::new();
|
|
if wide && data.len() > after && data[after] == b'\n' {
|
|
let rest = &data[after + 1..];
|
|
if let Some(e) = rest.iter().position(|c| *c == b'\n') {
|
|
extend = String::from_utf8_lossy(&rest[..e]).into_owned();
|
|
}
|
|
}
|
|
if n[4] == 0 {
|
|
return Err(bad());
|
|
}
|
|
let mut zones = Vec::new();
|
|
for i in 0..n[4] {
|
|
let z = &zdata[i * 6..i * 6 + 6];
|
|
let off = be32(z);
|
|
let is_dst = z[4] != 0;
|
|
let di = z[5] as usize;
|
|
if di >= chars.len() {
|
|
return Err(bad());
|
|
}
|
|
let end = chars[di..]
|
|
.iter()
|
|
.position(|c| *c == 0)
|
|
.unwrap_or(chars.len() - di);
|
|
zones.push(Zone {
|
|
name: String::from_utf8_lossy(&chars[di..di + end]).into_owned(),
|
|
offset: off,
|
|
is_dst,
|
|
});
|
|
}
|
|
let mut tx = Vec::new();
|
|
for i in 0..n[3] {
|
|
let t = if wide {
|
|
be64(×[i * 8..])
|
|
} else {
|
|
be32(×[i * 4..])
|
|
};
|
|
let zi = idx[i] as usize;
|
|
if zi >= zones.len() {
|
|
return Err(bad());
|
|
}
|
|
tx.push((t, zi));
|
|
}
|
|
if tx.is_empty() {
|
|
// Comme le Go : une transition fictive au début des temps.
|
|
tx.push((ALPHA, 0));
|
|
}
|
|
Ok(Location {
|
|
name: name.to_string(),
|
|
zones,
|
|
tx,
|
|
extend,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Règle POSIX (tzset du Go)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `tzset` : la zone d'un instant d'après une règle POSIX (« CET-1CEST,M3.5.0,
|
|
/// M10.5.0/3 »), et sa période. `None` : règle illisible.
|
|
fn tzset(s: &str, last_tx: i64, sec: i64) -> Option<(Zone, i64, i64)> {
|
|
let (std_name, s) = tzset_name(s)?;
|
|
let (std_off, s) = tzset_offset(s)?;
|
|
let std_offset = -std_off;
|
|
if s.is_empty() || s.starts_with(',') {
|
|
// Pas d'heure d'été.
|
|
return Some((
|
|
Zone {
|
|
name: std_name,
|
|
offset: std_offset,
|
|
is_dst: false,
|
|
},
|
|
last_tx,
|
|
OMEGA,
|
|
));
|
|
}
|
|
let (dst_name, s) = tzset_name(s)?;
|
|
let (dst_offset, s) = if s.is_empty() || s.starts_with(',') {
|
|
(std_offset + 3600, s)
|
|
} else {
|
|
let (o, r) = tzset_offset(s)?;
|
|
(-o, r)
|
|
};
|
|
let s = if s.is_empty() { ",M3.2.0,M11.1.0" } else { s };
|
|
let s = s.strip_prefix(',')?;
|
|
let (start_rule, s) = tzset_rule(s)?;
|
|
let s = s.strip_prefix(',')?;
|
|
let (end_rule, s) = tzset_rule(s)?;
|
|
if !s.is_empty() {
|
|
return None;
|
|
}
|
|
let (year, yday) = {
|
|
let c = civil(sec);
|
|
let doy = days_from_civil(c.year, c.month, c.day) - days_from_civil(c.year, 1, 1);
|
|
(c.year, doy)
|
|
};
|
|
let _ = yday;
|
|
let ysec = days_from_civil(year, 1, 1) * 86400;
|
|
let mut start = ysec + tzrule_time(year, &start_rule, std_offset);
|
|
let mut end = ysec + tzrule_time(year, &end_rule, dst_offset);
|
|
let (dst_is_dst, std_is_dst) = (true, false);
|
|
let (mut lo, mut hi) = (
|
|
Zone {
|
|
name: std_name.clone(),
|
|
offset: std_offset,
|
|
is_dst: std_is_dst,
|
|
},
|
|
Zone {
|
|
name: dst_name.clone(),
|
|
offset: dst_offset,
|
|
is_dst: dst_is_dst,
|
|
},
|
|
);
|
|
if end < start {
|
|
std::mem::swap(&mut start, &mut end);
|
|
std::mem::swap(&mut lo, &mut hi);
|
|
}
|
|
let yearend = days_from_civil(year + 1, 1, 1) * 86400;
|
|
if sec < start {
|
|
return Some((lo, ysec, start));
|
|
} else if sec >= end {
|
|
return Some((lo, end, yearend));
|
|
}
|
|
Some((hi, start, end))
|
|
}
|
|
|
|
fn tzset_name(s: &str) -> Option<(String, &str)> {
|
|
if s.is_empty() {
|
|
return None;
|
|
}
|
|
if let Some(r) = s.strip_prefix('<') {
|
|
let e = r.find('>')?;
|
|
return Some((r[..e].to_string(), &r[e + 1..]));
|
|
}
|
|
let e = s
|
|
.find(|c: char| c.is_ascii_digit() || c == ',' || c == '-' || c == '+')
|
|
.unwrap_or(s.len());
|
|
if e < 3 {
|
|
return None;
|
|
}
|
|
Some((s[..e].to_string(), &s[e..]))
|
|
}
|
|
|
|
fn tzset_offset(s: &str) -> Option<(i64, &str)> {
|
|
let (neg, s) = match s.as_bytes().first()? {
|
|
b'+' => (false, &s[1..]),
|
|
b'-' => (true, &s[1..]),
|
|
_ => (false, s),
|
|
};
|
|
let (h, mut s) = tzset_num(s, 0, 24 * 7)?;
|
|
let mut off = h * 3600;
|
|
if let Some(r) = s.strip_prefix(':') {
|
|
let (m, r) = tzset_num(r, 0, 59)?;
|
|
off += m * 60;
|
|
s = r;
|
|
if let Some(r) = s.strip_prefix(':') {
|
|
let (sec, r) = tzset_num(r, 0, 59)?;
|
|
off += sec;
|
|
s = r;
|
|
}
|
|
}
|
|
Some((if neg { -off } else { off }, s))
|
|
}
|
|
|
|
fn tzset_num(s: &str, min: i64, max: i64) -> Option<(i64, &str)> {
|
|
let e = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
|
|
if e == 0 {
|
|
return None;
|
|
}
|
|
let n: i64 = s[..e].parse().ok()?;
|
|
if n < min || n > max {
|
|
return None;
|
|
}
|
|
Some((n, &s[e..]))
|
|
}
|
|
|
|
enum RuleKind {
|
|
Julian(i64),
|
|
Doy(i64),
|
|
Mwd(i64, i64, i64),
|
|
}
|
|
|
|
struct Rule {
|
|
kind: RuleKind,
|
|
time: i64,
|
|
}
|
|
|
|
fn tzset_rule(s: &str) -> Option<(Rule, &str)> {
|
|
let (kind, s) = if let Some(r) = s.strip_prefix('J') {
|
|
let (d, r) = tzset_num(r, 1, 365)?;
|
|
(RuleKind::Julian(d), r)
|
|
} else if let Some(r) = s.strip_prefix('M') {
|
|
let (m, r) = tzset_num(r, 1, 12)?;
|
|
let r = r.strip_prefix('.')?;
|
|
let (w, r) = tzset_num(r, 1, 5)?;
|
|
let r = r.strip_prefix('.')?;
|
|
let (d, r) = tzset_num(r, 0, 6)?;
|
|
(RuleKind::Mwd(m, w, d), r)
|
|
} else {
|
|
let (d, r) = tzset_num(s, 0, 365)?;
|
|
(RuleKind::Doy(d), r)
|
|
};
|
|
let (time, s) = if let Some(r) = s.strip_prefix('/') {
|
|
tzset_offset(r)?
|
|
} else {
|
|
(2 * 3600, s)
|
|
};
|
|
Some((Rule { kind, time }, s))
|
|
}
|
|
|
|
/// `tzruleTime` : secondes depuis le début de l'année (UTC) où la règle
|
|
/// s'applique, pour le décalage en vigueur avant elle.
|
|
fn tzrule_time(year: i64, r: &Rule, off: i64) -> i64 {
|
|
let s = match r.kind {
|
|
RuleKind::Julian(d) => {
|
|
let mut s = (d - 1) * 86400;
|
|
if is_leap(year) && d >= 60 {
|
|
s += 86400;
|
|
}
|
|
s
|
|
}
|
|
RuleKind::Doy(d) => d * 86400,
|
|
RuleKind::Mwd(mon, week, day) => {
|
|
// Zeller : jour de la semaine du 1er du mois.
|
|
let m1 = (mon + 9) % 12 + 1;
|
|
let mut yy0 = year;
|
|
if mon <= 2 {
|
|
yy0 -= 1;
|
|
}
|
|
let yy1 = yy0 / 100;
|
|
let yy2 = yy0 % 100;
|
|
let mut dow = ((26 * m1 - 2) / 10 + 1 + yy2 + yy2 / 4 + yy1 / 4 - 2 * yy1) % 7;
|
|
if dow < 0 {
|
|
dow += 7;
|
|
}
|
|
let mut d = day - dow;
|
|
if d < 0 {
|
|
d += 7;
|
|
}
|
|
for _ in 1..week {
|
|
if d + 7 >= days_in(mon, year) {
|
|
break;
|
|
}
|
|
d += 7;
|
|
}
|
|
let mut s = d * 86400;
|
|
for m in 1..mon {
|
|
s += days_in(m, year) * 86400;
|
|
}
|
|
s
|
|
}
|
|
};
|
|
s + r.time - off
|
|
}
|
|
|
|
fn days_in(m: i64, year: i64) -> i64 {
|
|
match m {
|
|
2 => {
|
|
if is_leap(year) {
|
|
29
|
|
} else {
|
|
28
|
|
}
|
|
}
|
|
4 | 6 | 9 | 11 => 30,
|
|
_ => 31,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn heure_locale() {
|
|
let hiver = 1_767_268_800; // 2026-01-01 12:00 UTC
|
|
let etc = Path::new("/usr/share/zoneinfo/Europe/Paris");
|
|
let off = |tz: Option<&str>, etc: &Path| {
|
|
Location::local_from(tz.map(str::to_string), etc).offset_at(hiver)
|
|
};
|
|
assert_eq!(off(None, etc), 3600);
|
|
assert_eq!(off(None, Path::new("/nulle/part")), 0);
|
|
assert_eq!(off(Some(""), etc), 0);
|
|
assert_eq!(off(Some(":"), etc), 0);
|
|
assert_eq!(off(Some("UTC"), etc), 0);
|
|
assert_eq!(off(Some(":Asia/Kolkata"), etc), 19_800);
|
|
assert_eq!(off(Some("America/New_York"), etc), -18_000);
|
|
assert_eq!(off(Some(":/usr/share/zoneinfo/Asia/Kolkata"), etc), 19_800);
|
|
assert_eq!(off(Some("/nulle/part"), etc), 0);
|
|
assert_eq!(off(Some("Nulle/Part"), etc), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn noms_de_fuseau() {
|
|
let sys = vec!["/usr/share/zoneinfo/".to_string()];
|
|
// « UTC » sans base : comme le Go, sans fichier.
|
|
assert_eq!(Location::load_in("UTC", &[]).unwrap().name, "UTC");
|
|
assert_eq!(Location::load_in("", &[]).unwrap().offset_at(0), 0);
|
|
for bad in ["../etc/passwd", "a..b", "/etc/localtime", "\\x"] {
|
|
assert_eq!(
|
|
Location::load_in(bad, &sys).err().as_deref(),
|
|
Some("time: invalid location name"),
|
|
"{bad}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
Location::load_in("Europe/Paris", &[]).err().as_deref(),
|
|
Some("unknown time zone Europe/Paris")
|
|
);
|
|
assert!(Location::load_in("Europe/Paris", &sys).is_ok());
|
|
}
|
|
|
|
/// Valeurs tirées du Go.
|
|
#[test]
|
|
fn transitions_et_normalisation() {
|
|
let ny = Location::load("America/New_York").unwrap();
|
|
let paris = Location::load("Europe/Paris").unwrap();
|
|
// Avant la première transition : l'heure solaire de New York.
|
|
assert_eq!(ny.offset_at(-3_000_000_000), -17_762);
|
|
// Pas d'heure d'été en France en 1970 : la table, pas la règle.
|
|
assert_eq!(paris.offset_at(paris.date(1970, 7, 1, 14, 0, 0)), 3600);
|
|
assert_eq!(paris.offset_at(1_782_907_200), 7200);
|
|
// Pile à la transition : la nouvelle heure.
|
|
assert_eq!(paris.offset_at(828_234_000), 7200);
|
|
assert_eq!(paris.offset_at(828_233_999), 3600);
|
|
assert_eq!(paris.offset_at(846_378_000), 3600);
|
|
assert_eq!(paris.offset_at(846_377_999), 7200);
|
|
// L'heure qui saute, à l'ouest de Greenwich.
|
|
assert_eq!(ny.date(2026, 3, 8, 3, 30, 0), 1_772_955_000);
|
|
assert_eq!(ny.date(2026, 3, 8, 2, 30, 0), 1_772_951_400);
|
|
assert_eq!(ny.offset_at(1_782_907_200), -14_400);
|
|
// Mois hors bornes, reportés sur l'année.
|
|
let u = Location::utc();
|
|
assert_eq!(u.date(2026, 0, 15, 10, 0, 0), 1_765_792_800);
|
|
assert_eq!(u.date(2026, -13, 15, 10, 0, 0), 1_731_664_800);
|
|
}
|
|
|
|
#[test]
|
|
fn calendrier() {
|
|
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
|
assert_eq!(days_from_civil(2026, 10, 15), 20741);
|
|
assert_eq!(civil_from_days(20741), (2026, 10, 15));
|
|
assert_eq!(civil(0).weekday, 4, "jeudi");
|
|
// Mois et jours hors bornes, normalisés comme time.Date.
|
|
let u = Location::utc();
|
|
assert_eq!(u.date(2026, 13, 1, 0, 0, 0), u.date(2027, 1, 1, 0, 0, 0));
|
|
assert_eq!(u.date(2026, 2, 30, 0, 0, 0), u.date(2026, 3, 2, 0, 0, 0));
|
|
assert_eq!(u.date(2026, 1, 0, 0, 0, 0), u.date(2025, 12, 31, 0, 0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn paris() {
|
|
let Ok(p) = Location::load("Europe/Paris") else {
|
|
eprintln!("zoneinfo absente : test sauté");
|
|
return;
|
|
};
|
|
// Hiver : +1 h ; été : +2 h.
|
|
let jan = Location::utc().date(2026, 1, 15, 12, 0, 0);
|
|
assert_eq!(p.offset_at(jan), 3600);
|
|
let jul = Location::utc().date(2026, 7, 15, 12, 0, 0);
|
|
assert_eq!(p.offset_at(jul), 7200);
|
|
// Bien au-delà de la dernière transition du fichier : la règle POSIX.
|
|
let far = Location::utc().date(2090, 7, 15, 12, 0, 0);
|
|
assert_eq!(p.offset_at(far), 7200);
|
|
let far_w = Location::utc().date(2090, 12, 15, 12, 0, 0);
|
|
assert_eq!(p.offset_at(far_w), 3600);
|
|
// Heure murale → instant.
|
|
let t = p.date(2026, 7, 15, 23, 0, 0);
|
|
assert_eq!(p.civil(t).ymd_hm(), "2026-07-15 23:00");
|
|
assert_eq!(Location::utc().civil(t).ymd_hm(), "2026-07-15 21:00");
|
|
// Heure qui n'existe pas (passage à l'été) : comme le Go, 02:30 → 03:30.
|
|
let gap = p.date(2026, 3, 29, 2, 30, 0);
|
|
assert_eq!(p.civil(gap).ymd_hm(), "2026-03-29 03:30");
|
|
assert_eq!(gap, 1774747800, "instant donné par le Go");
|
|
// Heure double (passage à l'hiver) : la seconde, comme l'algorithme de
|
|
// time.Date (vérifié contre le Go).
|
|
let dbl = p.date(2026, 10, 25, 2, 30, 0);
|
|
assert_eq!((dbl, p.offset_at(dbl)), (1792891800, 3600));
|
|
assert_eq!(t, 1784149200);
|
|
assert!(Location::load("Nulle/Part").is_err());
|
|
assert!(Location::load("../etc/passwd").is_err());
|
|
}
|
|
}
|