crates\nfarms-core\src\time.rs¶
源码路径:
crates\nfarms-core\src\time.rs· 273 行
//! 极小的日期 / 时间工具。
//!
//! **为什么不用 chrono**:chrono 在 Windows 上会拖进 `windows-link`,
//! 而 `windows-link` 用 `raw-dylib` 声明 Win32 调用 —— GNU 工具链上 rustc 要现场
//! 调 `dlltool` + GNU 汇编器合成导入库,rustup 的 GNU 工具链并不含汇编器,于是:
//!
//! ```text
//! error: dlltool could not create import library ... dlltool.exe: CreateProcess
//! ```
//!
//! 而这里真正需要的只有两件事:**本地时间的格式化字符串**和**某一天是星期几**。
//! 前者直接问 Win32(`GetLocalTime`,走 `libkernel32.a` 导入库,不碰 raw-dylib),
//! 后者用坂本算法十行搞定。所以 **`time` 模块自身零第三方依赖**。
//!
//! (这里以前写的是"于是整个 core 只剩 serde / serde_json 两个依赖" —— 那是假的:
//! `Cargo.toml` 里还有 `ureq`。留着这句会让人以为 core 是"依赖自由"的,
//! 进而觉得"再加个日期库也无所谓",正好把上面那段踩坑的理由抵消掉。)
//!
//! # 本模块不做什么
//!
//! 下面这些**暂时不需要**,真需要时再加(取舍口径同 [`parse_iso_date`]:
//! 眼下用不上,就不为它引依赖):
//!
//! - **"时:分:秒"的解析** —— 本模块只解析日期。真要解析带时间的字符串,
//! [`crate::netclock::parse_http_date`] 已经演过一遍"手写而不引依赖";
//! - **Unix 时间戳 ↔ 日期互转**;
//! - **时区 / 夏令时换算** —— 只读系统本地的墙上时钟,不做任何换算;
//! - **毫秒** —— 本模块精度到秒,毫秒入口是 [`crate::netclock::now_ms()`]。
//!
//! # 年份范围
//!
//! 公开的日期计算只接受 **`y >= 1`**;`y < 1` 一律返回 `None`。理由见
//! [`weekday_index`]:负数年份会让公式里的截断除法算出错误答案,
//! 与其返回一个错的星期几,不如明确拒绝。
/// 中文星期,下标 0 = 周日。
pub const WEEKDAY_CN: [&str; 7] = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
/// 本地时间,格式 `yyyy-MM-dd HH:mm:ss`(与 C# 版日志格式逐字节一致)。
///
/// **非 Windows 上返回的其实是 UTC**,函数名和输出里都没有任何时区标记:
/// 那个分支只服务交叉编译/文档构建,没有零依赖的本地时区接口可用。
///
/// 取舍:给非 Windows 分支补一个字面量 ` UTC` 会更诚实,但会同时破坏两件事 ——
/// 与 C# 版日志的逐字节一致(验收就是拿原版当 oracle 逐字节 diff),
/// 以及下面测试钉死的 19 字符定长。所以只在文档里说清;真要给人看的 UTC 标注,
/// 由 GUI 时钟页自己拼(见 `core_pages.rs` 里那句 `… UTC`),它不承诺和 C# 日志对齐。
/// 不写这段的后果:有人在非 Windows 上用这个函数做跨时区判断,拿到 UTC 却以为是本地时间,
/// 差 8 小时且不会报错。
pub fn local_now() -> String {
let (y, mo, d, h, mi, s) = local_now_parts();
format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}")
}
/// 拆出年月日时分秒,调用方想怎么排就怎么排。
pub fn local_now_parts() -> (i32, u32, u32, u32, u32, u32) {
#[cfg(windows)]
{
let mut st = sys::SystemTime::default();
// SAFETY: GetLocalTime 只写这一个 POD 结构体,指针有效且独占。
unsafe { sys::GetLocalTime(&mut st) };
(
st.w_year as i32,
st.w_month as u32,
st.w_day as u32,
st.w_hour as u32,
st.w_minute as u32,
st.w_second as u32,
)
}
#[cfg(not(windows))]
{
// 非 Windows 只用于交叉编译/文档构建,退化成 UTC。
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
let rem = secs % 86_400;
(y, m, d, (rem / 3600) as u32, (rem % 3600 / 60) as u32, (rem % 60) as u32)
}
}
/// 解析 `yyyy-MM-dd`。解析不了、或者是个不存在的日期(比如 2026-02-31)就返回 `None`。
///
/// 与 C# 的 `DateTime.TryParse` 相比,这里**只认 ISO 短日期**。
/// 天气接口固定给 `2026-09-20` 这种格式,够用;多余格式支持等真需要再加。
///
/// **实际比这更宽松,宽松的那部分也是契约,别当成 bug 去"收紧"**:
///
/// - 月/日不要求补零:`2026-9-20` 收;
/// - 首尾空白会被 `trim` 掉;
/// - 数字段接受前导 `+`(`"+2026-09-20"` → 2026-09-20),这是 Rust 整数解析
/// 自带的规则(`[b'+', rest @ ..] => (true, rest)`),不是这里特意支持的功能;
/// - 反过来,`2026/09/20`、`2026-09-20T10:00`、非数字、空串一律拒绝
/// (恰好 3 段,且必须有值)。
///
/// 年份必须是 `y >= 1`(见模块文档「年份范围」)。`0000-01-01` 这种会返回 `None`:
/// 以前它会一路传下去,让 [`weekday_index`] 用截断除法算出一个错的星期几。
pub fn parse_iso_date(s: &str) -> Option<(i32, u32, u32)> {
let mut it = s.trim().split('-');
let y: i32 = it.next()?.parse().ok()?;
let m: u32 = it.next()?.parse().ok()?;
let d: u32 = it.next()?.parse().ok()?;
if it.next().is_some() {
return None;
}
// `y < 1` 也拒:分隔符就是 `-`,所以负年份根本拼不出来,这里实际挡的是 0 年。
// 挡在这里而不是留给调用方,是因为"能解析出来但星期几是错的"最难查。
if y < 1 || !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
return None;
}
Some((y, m, d))
}
/// 某一天是星期几。下标 0 = 周日。
///
/// 坂本算法(Sakamoto):不需要闰年表,不需要 1970 之后的限制 ——
/// 但只对 **`y >= 1`** 成立:式子里的 `y / 4`、`y / 100`、`y / 400` 是**截断除法**,
/// 负年份会朝 0 截(`-1 / 4 == 0`,按历法该是 `-1`),而 `% 7` 的负号又被
/// `rem_euclid` 兜住了 —— 于是**不会崩、只会安静地算出一个错的星期几**。
/// 所以 `y < 1` 直接返回 `None`;哪天真要支持公元前,得先把整条式子换成 `div_euclid`。
pub fn weekday_index(y: i32, m: u32, d: u32) -> Option<usize> {
if y < 1 || !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
return None;
}
const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = if m < 3 { y - 1 } else { y };
let w = (y + y / 4 - y / 100 + y / 400 + T[(m - 1) as usize] + d as i32) % 7;
Some(w.rem_euclid(7) as usize)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn days_in_month(y: i32, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(y) => 29,
2 => 28,
_ => 0,
}
}
#[cfg(not(windows))]
fn civil_from_days(z: i64) -> (i32, u32, u32) {
// Howard Hinnant 的 civil_from_days
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 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 };
((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
}
#[cfg(windows)]
mod sys {
/// `SYSTEMTIME`(`minwindef.h` / `winbase.h`)。
///
/// 字段一个都不能少、顺序也不能动:`#[repr(C)]` 的布局必须和 Win32 逐字段对齐,
/// `GetLocalTime` 是按这个布局整块写内存的。删掉"用不上的"字段,后面的
/// `w_day` / `w_hour` 就会整体错位,读出来是相邻字段的值 —— 而且不报错。
#[repr(C)]
#[derive(Default, Clone, Copy)]
pub struct SystemTime {
pub w_year: u16,
pub w_month: u16,
/// 只为撑住 `#[repr(C)]` 布局而保留,本模块不读(星期几由坂本算法算)。
pub w_day_of_week: u16,
pub w_day: u16,
pub w_hour: u16,
pub w_minute: u16,
pub w_second: u16,
/// 同上;要毫秒请用 `crate::netclock::now_ms()`。
pub w_milliseconds: u16,
}
// 走普通的 kernel32 导入库(工具链自带 libkernel32.a),
// 不用 raw-dylib —— 那正是 chrono 把 dlltool 拖进来的原因。
#[link(name = "kernel32")]
unsafe extern "system" {
pub fn GetLocalTime(lp_system_time: *mut SystemTime);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weekday_matches_known_dates() {
// 取自 C# 版离线样本:2026-09-20 是周日
assert_eq!(weekday_index(2026, 9, 20), Some(0));
assert_eq!(weekday_index(2026, 9, 21), Some(1));
assert_eq!(weekday_index(2026, 9, 22), Some(2));
// 几个有代表性的锚点
assert_eq!(weekday_index(2000, 1, 1), Some(6)); // 周六
assert_eq!(weekday_index(1970, 1, 1), Some(4)); // 周四
assert_eq!(weekday_index(2024, 2, 29), Some(4)); // 闰日,周四
}
#[test]
fn weekday_rejects_impossible_dates() {
assert_eq!(weekday_index(2026, 2, 31), None);
assert_eq!(weekday_index(2026, 13, 1), None);
assert_eq!(weekday_index(2026, 0, 1), None);
assert_eq!(weekday_index(2026, 4, 31), None);
}
#[test]
fn parses_iso_dates_only() {
assert_eq!(parse_iso_date("2026-09-20"), Some((2026, 9, 20)));
assert_eq!(parse_iso_date("2026-9-20"), Some((2026, 9, 20)));
assert_eq!(parse_iso_date(" 2026-09-20 "), Some((2026, 9, 20)));
// 前导 `+` 是 Rust 整数解析自带的宽松,已写进 parse_iso_date 的文档契约:
// 这条断言把它钉住 —— 谁要收紧解析,就得同时改文档,不能让两者再漂开。
assert_eq!(parse_iso_date("+2026-09-20"), Some((2026, 9, 20)));
assert_eq!(parse_iso_date("2026/09/20"), None);
assert_eq!(parse_iso_date("2026-09-20T10:00"), None);
assert_eq!(parse_iso_date("不是日期"), None);
assert_eq!(parse_iso_date(""), None);
assert_eq!(parse_iso_date("2026-02-30"), None);
}
#[test]
fn years_before_one_are_rejected() {
// 0 年以前能解析成功,再交给 weekday_index 就会用截断除法算出**错的**星期几
// —— 不崩(`% 7` 的负号被 rem_euclid 兜住了),而错答案比 None 难查得多。
// 分隔符就是 `-`,负年份根本拼不出来,所以这里实际只需要挡 0 年。
assert_eq!(parse_iso_date("0000-01-01"), None);
assert_eq!(parse_iso_date("0-01-01"), None);
assert_eq!(weekday_index(0, 1, 1), None);
assert_eq!(weekday_index(-1, 1, 1), None);
// 边界另一侧照常工作:公元 1 年 1 月 1 日(格里高利历)是周一。
assert_eq!(weekday_index(1, 1, 1), Some(1));
}
#[test]
fn timestamp_is_zero_padded_and_shaped_like_the_csharp_one() {
let s = local_now();
assert_eq!(s.len(), 19, "yyyy-MM-dd HH:mm:ss 固定 19 字符: {s}");
assert_eq!(s.as_bytes()[4], b'-');
assert_eq!(s.as_bytes()[10], b' ');
assert_eq!(s.as_bytes()[13], b':');
assert!(s[..4].chars().all(|c| c.is_ascii_digit()));
// 年份得是合理的(说明真的取到了系统时间,而不是返回了 0000)。
// 只设下界:以前是 `(2020..2100).contains(&y)`(上界排他),
// 到了 2100-01-01 这条断言必然失败 —— 那是把"机器时钟没被设置好"
// 和"我们活在 2100 年之后"混成了一件事故。失败信息也要点明是系统时钟的问题。
let y: i32 = s[..4].parse().unwrap();
assert!(y >= 1970, "系统时钟疑似不正确(年份 {y},整串 {s})");
}
#[test]
fn local_now_date_part_is_parseable_by_our_own_parser() {
// 时间戳的前 10 字符必须能被自己的解析器吃下去:两边一旦漂开
// (比如给 local_now 加了时区后缀、或改了分隔符),「年份 >= 1」这条
// 契约在真实输入上就永远走不到,等于没写。这条断言把两边钉在一起。
let s = local_now();
let (y, m, d) = parse_iso_date(&s[..10]).expect("前 10 字符应是 yyyy-MM-dd");
assert!((1..=12).contains(&m), "月份不合理: {s}");
assert!((1..=31).contains(&d), "日不合理: {s}");
assert!(y >= 1970, "系统时钟疑似不正确(年份 {y},整串 {s})");
}
}