Skip to main content

linreg_core/serialization/
json.rs

1//! JSON file I/O for model serialization.
2//!
3//! Provides functions for reading and writing model files,
4//! plus utility functions for timestamp generation and version validation.
5
6use crate::error::Error;
7use crate::serialization::types::SerializedModel;
8use std::fs;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// Generate an ISO 8601 timestamp from the current system time.
12///
13/// Returns a string in the format "YYYY-MM-DDTHH:MM:SSZ" using UTC.
14/// This avoids adding a chrono dependency.
15///
16/// # Example
17///
18/// ```ignore
19/// let ts = iso_timestamp();
20/// // Returns something like "2026-02-10T15:30:00Z"
21/// ```
22pub fn iso_timestamp() -> String {
23    let now = SystemTime::now()
24        .duration_since(UNIX_EPOCH)
25        .unwrap_or_default();
26
27    let secs = now.as_secs();
28
29    // Convert Unix timestamp to UTC datetime components
30    // Algorithm from: https://howardhinnant.github.io/date_algorithms.html
31    let days_since_epoch = secs / 86400;
32    let secs_in_day = secs % 86400;
33
34    // Days from March 1, 0000 (proleptic Gregorian calendar)
35    let days = days_since_epoch + 719468;
36
37    // Extract year, month, day
38    let era = days / 146097;
39    let day_of_era = days % 146097;
40    let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365;
41    let year = era * 400 + year_of_era;
42    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
43    let mp = (5 * day_of_year + 2) / 153;
44    let month = if mp < 10 { mp + 3 } else { mp - 9 };
45    let day = day_of_year - (153 * mp + 2) / 5 + 1;
46
47    // Adjust year if month is January or February
48    let adjusted_year = if month <= 2 { year - 1 } else { year };
49
50    // Calculate time components
51    let hours = secs_in_day / 3600;
52    let mins = (secs_in_day % 3600) / 60;
53    let secs = secs_in_day % 60;
54
55    format!(
56        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
57        adjusted_year, month, day, hours, mins, secs
58    )
59}
60
61/// Validate that a format version is compatible with the current version.
62///
63/// Compatibility rules:
64/// - Major version must match exactly (breaking changes)
65/// - Minor version can be higher (forward compatible)
66/// - Minor version can be lower (backward compatible)
67///
68/// # Arguments
69///
70/// * `file_version` - Version string from the loaded file (e.g., "1.0")
71///
72/// # Returns
73///
74/// Returns `Ok(())` if compatible, `Error::IncompatibleFormatVersion` otherwise.
75pub fn validate_format_version(file_version: &str) -> Result<(), Error> {
76    let current_major = super::FORMAT_VERSION
77        .split('.')
78        .next()
79        .and_then(|s| s.parse::<u32>().ok())
80        .unwrap_or(1);
81
82    let file_major = file_version
83        .split('.')
84        .next()
85        .and_then(|s| s.parse::<u32>().ok())
86        .unwrap_or(0);
87
88    if current_major != file_major {
89        return Err(Error::IncompatibleFormatVersion {
90            file_version: file_version.to_string(),
91            supported: super::FORMAT_VERSION.to_string(),
92        });
93    }
94
95    Ok(())
96}
97
98/// Save a serialized model to a file as formatted JSON.
99///
100/// The output is pretty-printed with 4-space indentation for human readability.
101///
102/// # Arguments
103///
104/// * `model` - The serialized model to save
105/// * `path` - File path to write to
106///
107/// # Returns
108///
109/// Returns `Ok(())` on success, or an `Error` if serialization or file I/O fails.
110pub fn save_to_file(model: &SerializedModel, path: &str) -> Result<(), Error> {
111    // Validate format version before saving (sanity check)
112    validate_format_version(&model.metadata.format_version)?;
113
114    // Serialize to pretty JSON
115    let json = serde_json::to_string_pretty(model).map_err(|e| {
116        Error::SerializationError(format!("Failed to serialize model: {}", e))
117    })?;
118
119    // Write to file
120    fs::write(path, json).map_err(|e| {
121        Error::IoError(format!("Failed to write to file '{}': {}", path, e))
122    })?;
123
124    Ok(())
125}
126
127/// Load a serialized model from a file.
128///
129/// This validates the format version but does not validate the model type.
130/// Type validation happens when converting to a specific model type.
131///
132/// # Arguments
133///
134/// * `path` - File path to read from
135///
136/// # Returns
137///
138/// Returns the `SerializedModel` on success, or an `Error` if reading fails.
139pub fn load_from_file(path: &str) -> Result<SerializedModel, Error> {
140    // Read file content
141    let content = fs::read_to_string(path).map_err(|e| {
142        Error::IoError(format!("Failed to read file '{}': {}", path, e))
143    })?;
144
145    // Parse JSON
146    let model: SerializedModel = serde_json::from_str(&content).map_err(|e| {
147        Error::DeserializationError(format!("Failed to parse JSON from '{}': {}", path, e))
148    })?;
149
150    // Validate format version
151    validate_format_version(&model.metadata.format_version)?;
152
153    Ok(model)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::serialization::{ModelMetadata, ModelType};
160    use serde_json::json;
161
162    #[test]
163    fn test_iso_timestamp_format() {
164        let ts = iso_timestamp();
165        // Should match ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
166        assert!(ts.len() == 20);
167        assert!(ts.contains('T'));
168        assert!(ts.ends_with('Z'));
169        // Check basic structure
170        let parts: Vec<&str> = ts.split(&['T', '-', ':'][..]).collect();
171        assert_eq!(parts.len(), 6);
172    }
173
174    #[test]
175    fn test_validate_format_version_compatible() {
176        // Same version
177        assert!(validate_format_version("1.0").is_ok());
178
179        // Compatible minor version
180        assert!(validate_format_version("1.5").is_ok());
181        assert!(validate_format_version("1.99").is_ok());
182    }
183
184    #[test]
185    fn test_validate_format_version_incompatible() {
186        // Different major version
187        let result = validate_format_version("2.0");
188        assert!(matches!(result, Err(Error::IncompatibleFormatVersion { .. })));
189
190        let result = validate_format_version("0.9");
191        assert!(matches!(result, Err(Error::IncompatibleFormatVersion { .. })));
192    }
193
194    #[test]
195    fn test_validate_format_version_invalid() {
196        // Invalid format - should not panic
197        // Will default to major version 0, which is incompatible with 1.x
198        assert!(validate_format_version("invalid").is_err());
199        assert!(validate_format_version("").is_err());
200    }
201
202    #[test]
203    fn test_serialize_deserialize_roundtrip() {
204        // Create a test metadata
205        let metadata = ModelMetadata {
206            format_version: "1.0".to_string(),
207            library_version: "0.6.0".to_string(),
208            model_type: ModelType::OLS,
209            created_at: "2026-02-10T15:30:00Z".to_string(),
210            name: Some("Test Model".to_string()),
211        };
212
213        // Create a test data object
214        let data = json!({
215            "coefficients": [1.0, 2.0, 3.0],
216            "r_squared": 0.95,
217            "n_observations": 100
218        });
219
220        let model = SerializedModel::new(metadata.clone(), data);
221
222        // Serialize to JSON
223        let json_str = serde_json::to_string(&model).unwrap();
224        let parsed: SerializedModel = serde_json::from_str(&json_str).unwrap();
225
226        // Verify metadata
227        assert_eq!(parsed.metadata.format_version, metadata.format_version);
228        assert_eq!(parsed.metadata.library_version, metadata.library_version);
229        assert_eq!(parsed.metadata.model_type, ModelType::OLS);
230        assert_eq!(parsed.metadata.created_at, metadata.created_at);
231        assert_eq!(parsed.metadata.name, metadata.name);
232
233        // Verify data
234        assert_eq!(parsed.data["coefficients"][0], 1.0);
235        assert_eq!(parsed.data["r_squared"], 0.95);
236        assert_eq!(parsed.data["n_observations"], 100);
237    }
238
239    #[test]
240    fn test_serialized_model_json_structure() {
241        let metadata = ModelMetadata::new(ModelType::Ridge, "0.6.0".to_string());
242        let data = json!({"test": "value"});
243
244        let model = SerializedModel::new(metadata, data);
245        let json = serde_json::to_string_pretty(&model).unwrap();
246
247        // Verify JSON structure
248        assert!(json.contains("\"metadata\""));
249        assert!(json.contains("\"data\""));
250        assert!(json.contains("\"format_version\""));
251        assert!(json.contains("\"model_type\""));
252        assert!(json.contains("\"Ridge\""));
253    }
254
255    #[test]
256    fn test_model_type() {
257        let model = SerializedModel {
258            metadata: ModelMetadata {
259                format_version: "1.0".to_string(),
260                library_version: "0.6.0".to_string(),
261                model_type: ModelType::Lasso,
262                created_at: "2026-02-10T00:00:00Z".to_string(),
263                name: None,
264            },
265            data: json!({}),
266        };
267
268        assert_eq!(model.model_type(), &ModelType::Lasso);
269    }
270}