use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunManifest {
pub version: String,
pub git_hash: Option<String>,
pub timestamp: String,
pub platform: PlatformInfo,
pub config: SearchConfigInfo,
pub results: Vec<MatchInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformInfo {
pub os: String,
pub arch: String,
pub rust_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfigInfo {
pub target: f64,
pub level: f32,
pub max_lhs_complexity: u32,
pub max_rhs_complexity: u32,
pub deterministic: bool,
pub parallel: bool,
pub max_error: f64,
pub max_matches: usize,
pub ranking_mode: String,
pub user_constants: Vec<UserConstantInfo>,
pub excluded_symbols: Vec<String>,
pub allowed_symbols: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserConstantInfo {
pub name: String,
pub value: f64,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchInfo {
pub lhs_postfix: String,
pub rhs_postfix: String,
pub lhs_infix: String,
pub rhs_infix: String,
pub error: f64,
pub is_exact: bool,
pub complexity: u32,
pub x_value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stability: Option<f64>,
}
impl RunManifest {
pub fn new(config: SearchConfigInfo, results: Vec<MatchInfo>) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| {
let secs = d.as_secs();
chrono_like_timestamp(secs)
})
.unwrap_or_else(|_| "unknown".to_string());
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
git_hash: get_git_hash(),
timestamp,
platform: PlatformInfo::current(),
config,
results,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn to_json_compact(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
impl PlatformInfo {
pub fn current() -> Self {
Self {
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
rust_version: rustc_version().unwrap_or_else(|| "unknown".to_string()),
}
}
}
fn get_git_hash() -> Option<String> {
option_env!("GIT_HASH").map(|s| s.to_string()).or_else(|| {
#[cfg(debug_assertions)]
{
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[cfg(not(debug_assertions))]
{
None
}
})
}
fn rustc_version() -> Option<String> {
#[cfg(debug_assertions)]
{
std::process::Command::new("rustc")
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[cfg(not(debug_assertions))]
{
None
}
}
fn chrono_like_timestamp(secs: u64) -> String {
let days = secs / 86400;
let remaining = secs % 86400;
let hours = remaining / 3600;
let minutes = (remaining % 3600) / 60;
let seconds = remaining % 60;
let (year, month, day) = days_to_ymd(days);
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn days_to_ymd(days: u64) -> (i32, u32, u32) {
let mut year = 1970_i32;
let mut remaining_days = days as i64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let days_in_months = if is_leap_year(year) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1_u32;
for &days_in_month in &days_in_months {
if remaining_days < days_in_month as i64 {
break;
}
remaining_days -= days_in_month as i64;
month += 1;
}
let day = (remaining_days + 1) as u32; (year, month, day)
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::MatchInfo;
use super::*;
#[test]
fn test_timestamp_format() {
let ts = chrono_like_timestamp(1705318245);
assert!(ts.starts_with("2024-01-"));
assert!(ts.ends_with("Z"));
}
#[test]
fn test_leap_year() {
assert!(is_leap_year(2024));
assert!(!is_leap_year(2023));
assert!(!is_leap_year(1900));
assert!(is_leap_year(2000));
}
#[test]
fn test_timestamp_leap_year_feb29() {
let ts = chrono_like_timestamp(951782400);
assert!(ts.starts_with("2000-02-29"), "got: {}", ts);
}
#[test]
fn test_timestamp_year_boundary() {
let ts = chrono_like_timestamp(946684799);
assert!(ts.starts_with("1999-12-31"), "got: {}", ts);
let ts2 = chrono_like_timestamp(946684800);
assert!(ts2.starts_with("2000-01-01"), "got: {}", ts2);
}
#[test]
fn test_leap_year_century_rules() {
assert!(!is_leap_year(1900));
assert!(!is_leap_year(2100));
assert!(is_leap_year(2000));
assert!(is_leap_year(2400));
}
#[test]
fn test_match_info_omits_optional_stability_when_absent() {
let info = MatchInfo {
lhs_postfix: "x".to_string(),
rhs_postfix: "1".to_string(),
lhs_infix: "x".to_string(),
rhs_infix: "1".to_string(),
error: 0.0,
is_exact: true,
complexity: 2,
x_value: 1.0,
stability: None,
};
let value = serde_json::to_value(info).expect("manifest match should serialize");
assert!(
value.get("stability").is_none(),
"stability should be omitted when unavailable"
);
}
}