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) 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 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 {}
258impl 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 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 }
485 other => panic!("Expected FileError, got: {:?}", other),
486 }
487
488 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 }
499 other => panic!("Expected JsonParseError, got: {:?}", other),
500 }
501
502 let _ = fs::remove_file(&temp_path);
504
505 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 let _ = fs::remove_file(&temp_path);
518 }
519
520 #[test]
521 fn test_from_file_backward_compatibility() {
522 use std::collections::BTreeMap;
523
524 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 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}