Skip to main content

gammalooprs/utils/
serde_utils.rs

1use dirs::home_dir;
2use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
3use std::{
4    env,
5    io::Write,
6    path::{Path, PathBuf},
7    process::Command,
8    sync::{
9        Mutex, MutexGuard,
10        atomic::{AtomicBool, Ordering},
11    },
12};
13
14use color_eyre::{Result, Section};
15use eyre::{Context, eyre};
16use std::collections::BTreeMap;
17use thiserror::Error;
18
19use std::{fs, fs::File, io::Read};
20
21#[derive(Error, Debug)]
22pub enum SerdeFileError {
23    #[error("File error: {0}")]
24    FileError(#[from] std::io::Error),
25    #[error("JSON parse error: {0}")]
26    JsonParseError(#[from] serde_json::Error),
27    #[error("YAML parse error: {0}")]
28    YamlParseError(#[from] serde_yaml::Error),
29    #[error("TOML parse error: {0}")]
30    TomlParseError(#[from] toml::de::Error),
31    #[error("Unknown file extension: {0}")]
32    UnknownExtension(String),
33    #[error("Could not determine file extension of file {0}")]
34    NoExtension(String),
35}
36
37pub(crate) fn deserialize_nonnegative_finite_f64<'de, D>(
38    deserializer: D,
39) -> std::result::Result<f64, D::Error>
40where
41    D: Deserializer<'de>,
42{
43    let value = f64::deserialize(deserializer)?;
44    if value.is_finite() && value >= 0.0 {
45        Ok(value)
46    } else {
47        Err(serde::de::Error::custom(
48            "expected a finite, nonnegative floating-point value",
49        ))
50    }
51}
52
53fn current_git_commit(git_dir: &Path) -> Option<String> {
54    let output = Command::new("git")
55        .arg("rev-parse")
56        .arg("HEAD")
57        .current_dir(git_dir)
58        .output()
59        .ok()?;
60    if !output.status.success() {
61        return None;
62    }
63
64    let commit = String::from_utf8(output.stdout).ok()?;
65    let commit = commit.trim();
66    if commit.is_empty() {
67        None
68    } else {
69        Some(commit.to_owned())
70    }
71}
72
73fn schema_ref() -> String {
74    env::var("GAMMALOOP_SCHEMA_REF")
75        .ok()
76        .or_else(|| current_git_commit(Path::new(".")))
77        .or_else(|| current_git_commit(Path::new(env!("CARGO_MANIFEST_DIR"))))
78        .unwrap_or_else(|| format!("v{}", env!("CARGO_PKG_VERSION")))
79}
80
81pub trait SmartSerde: Serialize + DeserializeOwned {
82    fn to_file(&self, file_path: impl AsRef<Path>, override_existing: bool) -> Result<()> {
83        if let Some(parent) = file_path.as_ref().parent()
84            && !parent.as_os_str().is_empty()
85        {
86            fs::create_dir_all(parent)?;
87        }
88
89        let mut f = if override_existing {
90            File::create(file_path.as_ref())?
91        } else {
92            File::create_new(file_path.as_ref())?
93        };
94
95        if let Some(ext) = file_path.as_ref().extension() {
96            if let Some(ext) = ext.to_str() {
97                match ext {
98                    "json" => serde_json::to_writer_pretty(f, self)
99                        .map_err(|e| eyre!(format!("Error serializing json: {}", e)))?,
100                    "yaml" | "yml" => serde_yaml::to_writer(f, self)
101                        .map_err(|e| eyre!(format!("Error serializing yaml: {}", e)))?,
102                    "toml" => {
103                        let mut toml_string = if let Some(schema_path) = self.has_schema_path(true)
104                        {
105                            let schema_path = schema_path?;
106                            format!("#:schema {}\n", schema_path.display())
107                        } else {
108                            String::new()
109                        };
110
111                        toml_string.push_str(&toml::to_string_pretty(&self)?);
112
113                        f.write_all(toml_string.as_bytes())?;
114                    }
115                    _ => return Err(eyre!(format!("Unknown file extension: {}", ext))),
116                }
117            } else {
118                return Err(eyre!(format!(
119                    "Could not determine file extension of file {}",
120                    file_path.as_ref().display()
121                )));
122            }
123        } else {
124            return Err(eyre!(format!(
125                "Could not determine file extension of file {}",
126                file_path.as_ref().display()
127            )));
128        }
129
130        Ok(())
131    }
132
133    /// Loads and deserializes data from a file with typed error handling.
134    ///
135    /// This function differentiates between file errors (e.g., file not found)
136    /// and parse errors (e.g., invalid JSON/YAML/TOML syntax) using `SerdeFileError`.
137    ///
138    /// # Arguments
139    /// * `file_path` - Path to the file to load
140    /// * `_name` - Name of the data type being loaded (for error messages, currently unused)
141    ///
142    /// # Returns
143    /// * `Ok(Self)` - Successfully loaded and parsed data
144    /// * `Err(SerdeFileError)` - Typed error indicating whether it was a file or parse error
145    fn from_file_typed(file_path: impl AsRef<Path>) -> std::result::Result<Self, SerdeFileError> {
146        let mut f = File::open(file_path.as_ref())?;
147
148        if let Some(ext) = file_path.as_ref().extension() {
149            if let Some(ext) = ext.to_str() {
150                match ext {
151                    "json" => Ok(serde_json::from_reader(f)?),
152                    "yaml" | "yml" => Ok(serde_yaml::from_reader(f)?),
153                    "toml" => {
154                        let mut buf = String::new();
155                        let _bytes = f.read_to_string(&mut buf)?;
156                        Ok(toml::from_str(&buf)?)
157                    }
158                    _ => Err(SerdeFileError::UnknownExtension(ext.to_string())),
159                }
160            } else {
161                Err(SerdeFileError::NoExtension(
162                    file_path.as_ref().display().to_string(),
163                ))
164            }
165        } else {
166            Err(SerdeFileError::NoExtension(
167                file_path.as_ref().display().to_string(),
168            ))
169        }
170    }
171
172    fn from_file(file_path: impl AsRef<Path>, name: &str) -> Result<Self> {
173        Self::from_file_typed(file_path.as_ref()).map_err(|e| match e {
174            SerdeFileError::FileError(io_err) => eyre::Report::from(io_err)
175                .wrap_err(format!(
176                    "Could not open {} file {}",
177                    name,
178                    file_path.as_ref().display()
179                ))
180                .suggestion("Does the path exist?"),
181            SerdeFileError::JsonParseError(json_err) => eyre::Report::from(json_err)
182                .wrap_err(format!("Error parsing {} json", name))
183                .suggestion("Is it a correct json file?"),
184            SerdeFileError::YamlParseError(yaml_err) => eyre::Report::from(yaml_err)
185                .wrap_err(format!("Error parsing {} yaml", name))
186                .suggestion("Is it a correct yaml file?"),
187            SerdeFileError::TomlParseError(toml_err) => eyre::Report::from(toml_err)
188                .wrap_err(format!("Error parsing {} toml", name))
189                .suggestion("Is it a correct toml file?"),
190            SerdeFileError::UnknownExtension(ext) => {
191                eyre::eyre!("Unknown {} file extension: {}", name, ext)
192                    .suggestion("Is it a .json, .yaml, or .toml file?")
193            }
194            SerdeFileError::NoExtension(path) => eyre::eyre!(
195                "Could not determine file extension of {} file {}",
196                name,
197                path
198            )
199            .suggestion("Does the path exist?"),
200        })
201    }
202
203    fn from_str(contents: String, format: &str, name: &str) -> Result<Self> {
204        match format {
205            "json" => serde_json::from_str(&contents)
206                .map_err(|e| eyre!(format!("Error parsing {name} json: {}", e)))
207                .suggestion("Is it a correct json file"),
208            "yaml" | "yml" => serde_yaml::from_str(&contents)
209                .map_err(|e| eyre!(format!("Error parsing {name} yaml: {}", e)))
210                .suggestion("Is it a correct yaml file"),
211            "toml" => toml::from_str(&contents)
212                .map_err(|e| eyre!(format!("Error parsing {name} toml: {}", e)))
213                .suggestion("Is it a correct toml file"),
214
215            _ => Err(eyre!(format!("Unknown {name} file extension: {}", format)))
216                .suggestion("Is it a .json or .yaml file?"),
217        }
218    }
219
220    fn has_schema_path(&self, _online: bool) -> Option<Result<PathBuf>> {
221        Option::None
222    }
223}
224
225impl<T> SmartSerde for BTreeMap<String, (T, T)> where
226    T: Clone + From<f64> + Serialize + DeserializeOwned
227{
228}
229
230impl SmartSerde for SerializableModel {}
231// impl SmartSerde for Schema {}
232
233impl SmartSerde for GlobalSettings {
234    fn has_schema_path(&self, online: bool) -> Option<Result<PathBuf>> {
235        Some(get_schema_folder(online).map(|f| f.join("global.json")))
236    }
237}
238impl SmartSerde for RuntimeSettings {
239    fn has_schema_path(&self, online: bool) -> Option<Result<PathBuf>> {
240        Some(get_schema_folder(online).map(|f| f.join("runtime.json")))
241    }
242}
243
244pub fn get_schema_folder(online: bool) -> Result<PathBuf> {
245    let folder = match env::var("GAMMALOOP_SCHEMA_PATH") {
246        Ok(path) => PathBuf::from(path),
247        Err(_) => {
248            if online {
249                let schema_ref = schema_ref();
250                PathBuf::from(format!(
251                    "https://raw.githubusercontent.com/alphal00p/gammaloop/{schema_ref}/assets/schemas"
252                ))
253            } else {
254                match home_dir() {
255                    Some(home) => home.join(".config").join("gammaloop").join("schemas"),
256                    None => {
257                        return Err(eyre!("Could not determine home directory")).with_suggestion(
258                            || "Set the GAMMALOOP_SCHEMA_PATH environment variable",
259                        );
260                    }
261                }
262            }
263        }
264    };
265
266    if !online && !folder.exists() {
267        std::fs::create_dir_all(&folder).wrap_err_with(|| {
268            format!(
269                "Could not create schema folder at {}",
270                folder.to_string_lossy()
271            )
272        })?;
273    }
274
275    Ok(folder)
276}
277
278use crate::{
279    model::SerializableModel,
280    settings::{GlobalSettings, RuntimeSettings},
281    utils::F,
282};
283
284pub trait IsDefault {
285    fn is_default(&self) -> bool;
286}
287
288pub static SHOWDEFAULTS: AtomicBool = AtomicBool::new(false);
289
290pub(crate) fn show_defaults_helper(condition: bool) -> bool {
291    if SHOWDEFAULTS.load(Ordering::Relaxed) {
292        false
293    } else {
294        condition
295    }
296}
297
298pub struct ShowDefaultsGuard {
299    _lock: MutexGuard<'static, ()>,
300    previous: bool,
301}
302
303impl ShowDefaultsGuard {
304    pub fn new(show_defaults: bool) -> Self {
305        static SHOWDEFAULTS_MUTEX: Mutex<()> = Mutex::new(());
306        let lock = SHOWDEFAULTS_MUTEX
307            .lock()
308            .expect("SHOWDEFAULTS serialization mutex must not be poisoned");
309        let previous = SHOWDEFAULTS.swap(show_defaults, Ordering::Relaxed);
310        Self {
311            _lock: lock,
312            previous,
313        }
314    }
315}
316
317impl Drop for ShowDefaultsGuard {
318    fn drop(&mut self) {
319        SHOWDEFAULTS.store(self.previous, Ordering::Relaxed);
320    }
321}
322
323impl<T: Default + PartialEq> IsDefault for T {
324    fn is_default(&self) -> bool {
325        show_defaults_helper(self == &T::default())
326    }
327}
328
329pub fn is_default_pysecdec_relative_precision(val: &f64) -> bool {
330    show_defaults_helper(*val == 1.0e-7_f64)
331}
332
333pub fn is_default_esurface_existence_threshold(val: &f64) -> bool {
334    show_defaults_helper(*val == super::DEFAULT_ESURFACE_EXISTENCE_THRESHOLD)
335}
336
337pub fn is_default_vakint_normalization(val: &String) -> bool {
338    show_defaults_helper(val == "MSbar")
339}
340
341pub fn is_minus_one_string(val: &String) -> bool {
342    show_defaults_helper(val == "-1")
343}
344
345pub fn is_float<const D: i64>(val: &f64) -> bool {
346    show_defaults_helper(*val == D as f64)
347}
348pub fn is_ffloat<const D: i64>(val: &F<f64>) -> bool {
349    show_defaults_helper(*val == F(D as f64))
350}
351
352pub fn is_usize<const D: usize>(val: &usize) -> bool {
353    show_defaults_helper(*val == D)
354}
355
356pub fn is_u64<const D: u64>(val: &u64) -> bool {
357    show_defaults_helper(*val == D)
358}
359
360pub fn is_false(val: &bool) -> bool {
361    show_defaults_helper(!*val)
362}
363
364pub fn is_true(val: &bool) -> bool {
365    show_defaults_helper(*val)
366}
367
368pub fn is_default_input_rescaling(input_rescaling: &Vec<Vec<(f64, f64)>>) -> bool {
369    show_defaults_helper(input_rescaling == &_default_input_rescaling())
370}
371
372pub fn is_default_shifts(shifts: &Vec<(f64, f64, f64, f64)>) -> bool {
373    show_defaults_helper(shifts == &_default_shifts())
374}
375
376pub fn _default_input_rescaling() -> Vec<Vec<(f64, f64)>> {
377    vec![vec![(0.0, 1.0); 3]; 15]
378}
379pub fn _default_shifts() -> Vec<(f64, f64, f64, f64)> {
380    vec![(1.0, 0.0, 0.0, 0.0); 15]
381}
382
383pub fn is_default_form_path(form_path: &String) -> bool {
384    show_defaults_helper(form_path == &_default_form_path())
385}
386
387pub fn _default_form_path() -> String {
388    "form".to_string()
389}
390
391pub fn is_default_python_path(python_path: &String) -> bool {
392    show_defaults_helper(python_path == &_default_python_path())
393}
394
395pub fn _default_python_path() -> String {
396    "python3".to_string()
397}
398
399pub fn is_default_vakint_evaluation_methods(evaluation_methods: &Vec<String>) -> bool {
400    show_defaults_helper(evaluation_methods == &_default_vakint_evaluation_methods())
401}
402
403pub fn _default_vakint_evaluation_methods() -> Vec<String> {
404    vec![
405        "alphaloop".to_string(),
406        "matad".to_string(),
407        "fmft".to_string(),
408    ]
409}
410
411pub fn _default_stability_levels() -> Vec<crate::settings::runtime::StabilityLevelSetting> {
412    vec![
413        crate::settings::runtime::StabilityLevelSetting::default_double(),
414        crate::settings::runtime::StabilityLevelSetting::default_quad(),
415        crate::settings::runtime::StabilityLevelSetting::default_arb(),
416    ]
417}
418
419pub fn is_default_stability_levels(
420    levels: &Vec<crate::settings::runtime::StabilityLevelSetting>,
421) -> bool {
422    show_defaults_helper(levels == &_default_stability_levels())
423}
424
425pub fn _default_rotation_axis() -> Vec<crate::settings::runtime::RotationSetting> {
426    vec![crate::settings::runtime::RotationSetting::EulerAngles {
427        alpha: 0.1,
428        beta: 0.2,
429        gamma: 0.3,
430    }]
431}
432
433pub fn is_default_rotation_axis(
434    rotation_axis: &Vec<crate::settings::runtime::RotationSetting>,
435) -> bool {
436    show_defaults_helper(rotation_axis == &_default_rotation_axis())
437}
438
439#[cfg(test)]
440mod tests {
441    use crate::utils::{load_generic_model, test_utils::output_dir};
442    use std::fs;
443
444    use super::{SerdeFileError, SmartSerde};
445
446    #[test]
447    fn test_file_vs_parse_errors() {
448        use std::collections::BTreeMap;
449
450        // Test file not found error
451        let result: Result<BTreeMap<String, (f64, f64)>, SerdeFileError> =
452            SmartSerde::from_file_typed("/nonexistent/file.json");
453
454        match result {
455            Err(SerdeFileError::FileError(_)) => {
456                // This is the expected file error
457            }
458            other => panic!("Expected FileError, got: {:?}", other),
459        }
460
461        // Test parse error with invalid JSON
462        let temp_path = std::env::temp_dir().join("test_invalid.json");
463        fs::write(&temp_path, "{ invalid json content").unwrap();
464
465        let result: Result<BTreeMap<String, (f64, f64)>, SerdeFileError> =
466            SmartSerde::from_file_typed(&temp_path);
467
468        match result {
469            Err(SerdeFileError::JsonParseError(_)) => {
470                // This is the expected parse error
471            }
472            other => panic!("Expected JsonParseError, got: {:?}", other),
473        }
474
475        // Clean up
476        let _ = fs::remove_file(&temp_path);
477
478        // Test successful parsing
479        let temp_path = std::env::temp_dir().join("test_valid.json");
480        fs::write(&temp_path, r#"{"key": [1.0, 2.0]}"#).unwrap();
481
482        let result: Result<BTreeMap<String, (f64, f64)>, SerdeFileError> =
483            SmartSerde::from_file_typed(&temp_path);
484
485        assert!(result.is_ok());
486        let data = result.unwrap();
487        assert_eq!(data.get("key"), Some(&(1.0, 2.0)));
488
489        // Clean up
490        let _ = fs::remove_file(&temp_path);
491    }
492
493    #[test]
494    fn test_from_file_backward_compatibility() {
495        use std::collections::BTreeMap;
496
497        // Test that the regular from_file function still works and returns color_eyre::Result
498        let temp_path = std::env::temp_dir().join("test_compat.json");
499        fs::write(&temp_path, r#"{"test": [1.0, 2.0]}"#).unwrap();
500
501        let result: color_eyre::Result<BTreeMap<String, (f64, f64)>> =
502            SmartSerde::from_file(&temp_path, "test");
503
504        assert!(result.is_ok());
505        let data = result.unwrap();
506        assert_eq!(data.get("test"), Some(&(1.0, 2.0)));
507
508        // Clean up
509        let _ = fs::remove_file(&temp_path);
510    }
511
512    #[test]
513    fn test_to_file_creates_missing_parent_directories() {
514        use std::collections::BTreeMap;
515
516        let unique_dir = std::env::temp_dir().join(format!(
517            "gammaloop-smart-serde-{}-{}",
518            std::process::id(),
519            std::time::SystemTime::now()
520                .duration_since(std::time::UNIX_EPOCH)
521                .unwrap()
522                .as_nanos()
523        ));
524        let file_path = unique_dir.join("nested").join("data.json");
525
526        let mut data = BTreeMap::new();
527        data.insert("test".to_string(), (1.0, 2.0));
528
529        data.to_file(&file_path, true).unwrap();
530
531        assert!(file_path.exists());
532        let roundtrip: BTreeMap<String, (f64, f64)> =
533            SmartSerde::from_file(&file_path, "test").unwrap();
534        assert_eq!(roundtrip.get("test"), Some(&(1.0, 2.0)));
535
536        fs::remove_dir_all(&unique_dir).unwrap();
537    }
538
539    mod failing {
540        use super::*;
541
542        #[test]
543        fn convert_models() {
544            let name = "scalars";
545            load_generic_model(name)
546                .to_serializable()
547                .to_file(
548                    output_dir().join(format!("gammaloop_models/{name}.json")),
549                    true,
550                )
551                .unwrap();
552        }
553    }
554}