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 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 {}
231impl 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 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 }
458 other => panic!("Expected FileError, got: {:?}", other),
459 }
460
461 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 }
472 other => panic!("Expected JsonParseError, got: {:?}", other),
473 }
474
475 let _ = fs::remove_file(&temp_path);
477
478 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 let _ = fs::remove_file(&temp_path);
491 }
492
493 #[test]
494 fn test_from_file_backward_compatibility() {
495 use std::collections::BTreeMap;
496
497 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 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}