Files
LogiFlow/server-rs/src/js.rs
T
LogiFlowandClaude Opus 5.5 600627c632 feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
2026-10-15 20:31:14 +02:00

543 lines
18 KiB
Rust

// Conversions au comportement de JavaScript, sur lesquelles le code Node s'appuie
// (Number(), parseInt(), new Date(), longueur d'une chaîne, toISOString()).
// Le serveur Node tourne en UTC (image Docker sans TZ) : l'heure locale est l'UTC.
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
use serde_json::{Number, Value};
const MAX_DATE_MS: f64 = 8.64e15;
/// Number(valeur)
pub fn to_number(value: &Value) -> f64 {
match value {
Value::Null => 0.0,
Value::Bool(true) => 1.0,
Value::Bool(false) => 0.0,
Value::Number(number) => number.as_f64().unwrap_or(f64::NAN),
Value::String(text) => string_to_number(text),
Value::Array(items) => match items.as_slice() {
[] => 0.0,
[single] if !single.is_array() && !single.is_object() => match single {
Value::Null => 0.0,
other => to_number(&Value::String(display(other))),
},
_ => f64::NAN,
},
Value::Object(_) => f64::NAN,
}
}
fn display(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn string_to_number(text: &str) -> f64 {
let trimmed = text.trim();
if trimmed.is_empty() {
return 0.0;
}
let (sign, unsigned) = match trimmed.as_bytes()[0] {
b'-' => (-1.0, &trimmed[1..]),
b'+' => (1.0, &trimmed[1..]),
_ => (1.0, trimmed),
};
if unsigned == "Infinity" {
return sign * f64::INFINITY;
}
// Préfixes 0x / 0o / 0b : sans signe en JavaScript
if sign == 1.0 && trimmed.len() > 2 && !trimmed.starts_with(['+', '-']) {
let radix = match &trimmed[..2] {
"0x" | "0X" => Some(16),
"0o" | "0O" => Some(8),
"0b" | "0B" => Some(2),
_ => None,
};
if let Some(radix) = radix {
return u64::from_str_radix(&trimmed[2..], radix)
.map(|n| n as f64)
.unwrap_or(f64::NAN);
}
}
let valid = {
let bytes = unsigned.as_bytes();
let mut i = 0;
let mut digits = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
digits += 1;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
digits += 1;
}
}
if digits > 0 && i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
i += 1;
if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
i += 1;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if start == i {
return f64::NAN;
}
}
digits > 0 && i == bytes.len()
};
if !valid {
return f64::NAN;
}
unsigned.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
}
/// Valeur « vraie » en JavaScript (`if (x)`, `x || défaut`, `Boolean(x)`) :
/// fausse pour null, false, 0, NaN et la chaîne vide
pub fn truthy(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(flag) => *flag,
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()),
Value::String(text) => !text.is_empty(),
Value::Array(_) | Value::Object(_) => true,
}
}
/// parseInt(texte, 10) : chiffres de tête, None si aucun (NaN)
pub fn parse_int(text: &str) -> Option<f64> {
let trimmed = text.trim_start();
let (sign, rest) = match trimmed.chars().next() {
Some('-') => (-1.0, &trimmed[1..]),
Some('+') => (1.0, &trimmed[1..]),
_ => (1.0, trimmed),
};
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
digits.parse::<f64>().ok().map(|n| sign * n)
}
/// Blancs retirés par String.prototype.trim() (WhiteSpace et LineTerminator) :
/// U+FEFF en fait partie, U+0085 non, à l'inverse de str::trim
fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}'
)
}
/// texte.trim()
pub fn trim(text: &str) -> &str {
text.trim_matches(is_js_whitespace)
}
/// String(valeur) : texte tel quel, nombre sans « .0 », tableau joint par « , »,
/// objet « [object Object] » (gabarits `${…}`, clés d'objet)
pub fn to_js_string(value: &Value) -> String {
string_of(value)
}
/// parseFloat(texte) : plus long préfixe décimal après les blancs de tête, NaN sinon
pub fn parse_float(text: &str) -> f64 {
let text = text.trim_start_matches(is_js_whitespace);
let (sign, unsigned) = match text.as_bytes().first() {
Some(b'-') => (-1.0, &text[1..]),
Some(b'+') => (1.0, &text[1..]),
_ => (1.0, text),
};
if unsigned.starts_with("Infinity") {
return sign * f64::INFINITY;
}
let bytes = unsigned.as_bytes();
let mut end = 0;
let mut digits = 0;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
digits += 1;
}
if end < bytes.len() && bytes[end] == b'.' {
let mut fraction_end = end + 1;
while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() {
fraction_end += 1;
digits += 1;
}
end = fraction_end;
}
if digits == 0 {
return f64::NAN;
}
// Exposant seulement s'il a au moins un chiffre (« 1e » vaut 1)
if end < bytes.len() && matches!(bytes[end], b'e' | b'E') {
let mut exponent_end = end + 1;
if exponent_end < bytes.len() && matches!(bytes[exponent_end], b'+' | b'-') {
exponent_end += 1;
}
let start = exponent_end;
while exponent_end < bytes.len() && bytes[exponent_end].is_ascii_digit() {
exponent_end += 1;
}
if exponent_end > start {
end = exponent_end;
}
}
unsigned[..end]
.parse::<f64>()
.map(|n| sign * n)
.unwrap_or(f64::NAN)
}
/// encodeURIComponent(texte) : tout sauf A-Z a-z 0-9 - _ . ! ~ * ' ( ) en %XX (UTF-8)
pub fn encode_uri_component(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
/// Buffer.from(texte, 'base64') : alphabets standard et « url » acceptés, autres
/// caractères ignorés, lecture arrêtée au premier « = »
pub fn buffer_from_base64(text: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(text.len() * 3 / 4);
let (mut bits, mut count) = (0u32, 0u32);
for c in text.chars() {
let value = match c {
'A'..='Z' => c as u32 - 'A' as u32,
'a'..='z' => c as u32 - 'a' as u32 + 26,
'0'..='9' => c as u32 - '0' as u32 + 52,
'+' | '-' => 62,
'/' | '_' => 63,
'=' => break,
_ => continue,
};
bits = (bits << 6) | value;
count += 6;
if count >= 8 {
count -= 8;
out.push((bits >> count) as u8);
bits &= (1 << count) - 1;
}
}
out
}
/// Nombre JSON tel que JSON.stringify l'écrirait (entier sans « .0 »)
pub fn number_value(number: f64) -> Value {
if number.fract() == 0.0 && number.abs() <= 9_007_199_254_740_991.0 {
Value::Number(Number::from(number as i64))
} else {
Number::from_f64(number).map(Value::Number).unwrap_or(Value::Null)
}
}
/// String(nombre) pour les messages
pub fn number_to_string(number: f64) -> String {
number_string(number)
}
/// String(nombre) complet (Number.prototype.toString) : -0 → « 0 », notation
/// exponentielle au-delà de 1e21 et en deçà de 1e-6, NaN et Infinity
pub fn number_string(number: f64) -> String {
if number.is_nan() {
return "NaN".into();
}
if number.is_infinite() {
return if number > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if number == 0.0 {
return "0".into();
}
// Chiffres significatifs les plus courts et exposant, comme JavaScript
let scientific = format!("{:e}", number.abs());
let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
let k = digits.len() as i32;
let n = exponent.parse::<i32>().unwrap_or(0) + 1;
let body = if k <= n && n <= 21 {
format!("{digits}{}", "0".repeat((n - k) as usize))
} else if 0 < n && n <= 21 {
format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
} else if -6 < n && n <= 0 {
format!("0.{}{digits}", "0".repeat((-n) as usize))
} else {
let sign = if n > 0 { '+' } else { '-' };
let (first, rest) = digits.split_at(1);
let fraction = if rest.is_empty() {
String::new()
} else {
format!(".{rest}")
};
format!("{first}{fraction}e{sign}{}", (n - 1).abs())
};
if number < 0.0 { format!("-{body}") } else { body }
}
/// String(valeur) d'une valeur JSON (gabarits `${…}`, Array.prototype.join)
pub fn string_of(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(flag) => flag.to_string(),
Value::Number(number) => number_string(number.as_f64().unwrap_or(f64::NAN)),
Value::String(text) => text.clone(),
// [a, null, b].join(',') : null vide
Value::Array(items) => items
.iter()
.map(|item| {
if item.is_null() {
String::new()
} else {
string_of(item)
}
})
.collect::<Vec<_>>()
.join(","),
Value::Object(_) => "[object Object]".into(),
}
}
/// Longueur d'une chaîne en JavaScript (unités UTF-16)
pub fn length(text: &str) -> usize {
text.encode_utf16().count()
}
/// new Date(valeur), None si « Invalid Date »
pub fn new_date(value: &Value) -> Option<DateTime<Utc>> {
let millis = match value {
Value::Null => 0.0,
Value::Bool(flag) => f64::from(u8::from(*flag)),
Value::Number(number) => number.as_f64()?,
Value::String(text) => return parse_date(text),
_ => return None,
};
from_millis(millis)
}
fn from_millis(millis: f64) -> Option<DateTime<Utc>> {
if !millis.is_finite() || millis.abs() > MAX_DATE_MS {
return None;
}
Utc.timestamp_millis_opt(millis.trunc() as i64).single()
}
/// Date.parse pour les formats envoyés par l'interface : ISO 8601 (date seule = UTC
/// minuit, date et heure sans fuseau = heure locale, donc UTC ici), et « AAAA-MM-JJ HH:MM:SS »
pub fn parse_date(text: &str) -> Option<DateTime<Utc>> {
let text = text.trim();
if let Ok(date) = DateTime::parse_from_rfc3339(text) {
return Some(date.with_timezone(&Utc));
}
if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m-%d") {
return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M",
] {
if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
return Some(Utc.from_utc_datetime(&naive));
}
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f%:z",
"%Y-%m-%dT%H:%M%:z",
"%Y-%m-%dT%H:%M:%S%.fZ",
"%Y-%m-%dT%H:%MZ",
] {
if let Ok(date) = DateTime::parse_from_str(text, format) {
return Some(date.with_timezone(&Utc));
}
if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
return Some(Utc.from_utc_datetime(&naive));
}
}
if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m") {
return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
}
DateTime::parse_from_rfc2822(text)
.ok()
.map(|date| date.with_timezone(&Utc))
}
/// date.toISOString()
pub fn iso(date: &DateTime<Utc>) -> String {
date.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn number_comme_javascript() {
assert_eq!(to_number(&json!("")), 0.0);
assert_eq!(to_number(&json!(" 12 ")), 12.0);
assert_eq!(to_number(&json!("1e3")), 1000.0);
assert_eq!(to_number(&json!("0x1A")), 26.0);
assert!(to_number(&json!("12abc")).is_nan());
assert!(to_number(&json!("abc")).is_nan());
assert_eq!(to_number(&json!(null)), 0.0);
assert_eq!(to_number(&json!(true)), 1.0);
assert_eq!(to_number(&json!([])), 0.0);
assert_eq!(to_number(&json!(["7"])), 7.0);
assert!(to_number(&json!({})).is_nan());
assert_eq!(to_number(&json!("-3.5")), -3.5);
assert_eq!(to_number(&json!(".5")), 0.5);
}
#[test]
fn verite_comme_javascript() {
for falsy in [json!(null), json!(false), json!(0), json!(0.0), json!("")] {
assert!(!truthy(&falsy), "{falsy}");
}
for truthy_value in [json!(true), json!(-1), json!("0"), json!([]), json!({})] {
assert!(truthy(&truthy_value), "{truthy_value}");
}
}
#[test]
fn parse_int_comme_javascript() {
assert_eq!(parse_int("12abc"), Some(12.0));
assert_eq!(parse_int(" -4"), Some(-4.0));
assert_eq!(parse_int("abc"), None);
assert_eq!(parse_int(""), None);
}
#[test]
fn dates_comme_javascript() {
assert_eq!(
iso(&new_date(&json!("2026-10-15")).unwrap()),
"2026-10-15T00:00:00.000Z"
);
assert_eq!(
iso(&new_date(&json!("2026-10-15T08:30")).unwrap()),
"2026-10-15T08:30:00.000Z"
);
assert_eq!(
iso(&new_date(&json!("2026-10-15T08:30:00.123+02:00")).unwrap()),
"2026-10-15T06:30:00.123Z"
);
assert_eq!(iso(&new_date(&json!(null)).unwrap()), "1970-01-01T00:00:00.000Z");
assert!(new_date(&json!("pas une date")).is_none());
}
#[test]
fn string_de_nombres_comme_javascript() {
// Valeurs de String(n) dans Node
for (number, expected) in [
(0.0, "0"),
(-0.0, "0"),
(2026.0, "2026"),
(-3.0, "-3"),
(18.3, "18.3"),
(0.1 + 0.2, "0.30000000000000004"),
(1.2291666666666667, "1.2291666666666667"),
(123456789012345680000.0, "123456789012345680000"),
(1e21, "1e+21"),
(1.5e300, "1.5e+300"),
(0.000001, "0.000001"),
(1e-7, "1e-7"),
(-2.5e-8, "-2.5e-8"),
(f64::NAN, "NaN"),
(f64::NEG_INFINITY, "-Infinity"),
] {
assert_eq!(number_string(number), expected, "{number}");
}
}
#[test]
fn string_et_verite_des_valeurs_json() {
assert_eq!(string_of(&json!([1, null, "a", [2, 3]])), "1,,a,2,3");
assert_eq!(string_of(&json!({ "a": 1 })), "[object Object]");
assert_eq!(string_of(&json!(null)), "null");
assert_eq!(string_of(&json!(48.5)), "48.5");
assert!(!truthy(&json!(0)) && !truthy(&json!("")) && !truthy(&json!(null)));
assert!(truthy(&json!("0")) && truthy(&json!([])) && truthy(&json!({})));
}
#[test]
fn parse_float_comme_javascript() {
assert_eq!(parse_float("18.3"), 18.3);
assert_eq!(parse_float(" -2.50abc"), -2.5);
assert_eq!(parse_float(".5"), 0.5);
assert_eq!(parse_float("5."), 5.0);
assert_eq!(parse_float("1e3x"), 1000.0);
assert_eq!(parse_float("1e"), 1.0);
assert_eq!(parse_float("-Infinityx"), f64::NEG_INFINITY);
assert!(parse_float("abc").is_nan());
assert!(parse_float(".").is_nan());
assert!(parse_float("").is_nan());
}
#[test]
fn parse_float_valeurs_de_node() {
// Valeurs relevées avec parseFloat() dans Node 22
assert_eq!(parse_float("1250.5"), 1250.5);
assert_eq!(parse_float(" 12.5abc"), 12.5);
assert_eq!(parse_float("1 234,56"), 1.0);
assert!(parse_float("abc").is_nan());
assert!(parse_float("").is_nan());
assert_eq!(parse_float(".5"), 0.5);
assert_eq!(parse_float("-Infinity x"), f64::NEG_INFINITY);
assert_eq!(parse_float("1e3"), 1000.0);
assert_eq!(parse_float("0x10"), 0.0);
assert_eq!(parse_float("1e"), 1.0);
assert_eq!(parse_float("+3."), 3.0);
assert_eq!(parse_float("1.2.3"), 1.2);
assert_eq!(parse_float("\u{a0} 7"), 7.0);
}
#[test]
fn chaines_comme_javascript() {
assert_eq!(trim(" \u{a0}\u{feff}x\u{85}"), "x\u{85}");
assert_eq!(to_js_string(&json!(1.0)), "1");
assert_eq!(to_js_string(&json!([1, null, "a"])), "1,,a");
assert_eq!(to_js_string(&json!({})), "[object Object]");
assert_eq!(
encode_uri_component("FAC 2001/é?&=#+()!*'~-_."),
"FAC%202001%2F%C3%A9%3F%26%3D%23%2B()!*'~-_."
);
}
#[test]
fn base64_comme_buffer_from() {
// Buffer.from(texte, 'base64').toString('hex') dans Node 22
let hex = |text: &str| {
buffer_from_base64(text)
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
};
assert_eq!(hex("ab!cd"), "69b71d");
assert_eq!(hex("QU JD"), "414243");
assert_eq!(hex("Q=UJD"), "");
assert_eq!(hex("QUJ=D"), "4142");
assert_eq!(hex("QU-_"), "414fbf");
assert_eq!(hex("QUJDRA==x"), "41424344");
assert_eq!(hex("Q\u{e9}UJD"), "414243");
assert_eq!(buffer_from_base64("JVBE Ri0x\nLjQ=").len(), 8);
assert_eq!(buffer_from_base64("a").len(), 0);
assert_eq!(buffer_from_base64("AA=A").len(), 1);
}
#[test]
fn longueur_utf16() {
assert_eq!(length("été"), 3);
assert_eq!(length("😀"), 2);
}
}