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