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