Skip to main content

somatize_runtime/
study_io.rs

1//! Reading and writing a [`Study`] to disk.
2//!
3//! `soma-core` describes what a study *is*; putting it on a filesystem is
4//! I/O, and a contract crate does not do I/O. Bring [`StudyIo`] into scope
5//! and the calls read exactly as they did before the split.
6//!
7//! ```ignore
8//! use somatize_runtime::study_io::StudyIo;
9//!
10//! study.save(dir.join("study.json"))?;
11//! let study = Study::load(dir.join("study.json"))?;
12//! ```
13
14use somatize_core::error::{Result, SomaError};
15use somatize_core::study::Study;
16use std::path::Path;
17
18/// Persist a [`Study`] as JSON.
19pub trait StudyIo: Sized {
20    /// Serialize to pretty JSON at `path`.
21    fn save(&self, path: impl AsRef<Path>) -> Result<()>;
22
23    /// Load a study previously written by [`save`](StudyIo::save) — or by
24    /// a tracker's `study.json`, which is the same format.
25    fn load(path: impl AsRef<Path>) -> Result<Self>;
26}
27
28impl StudyIo for Study {
29    fn save(&self, path: impl AsRef<Path>) -> Result<()> {
30        let json =
31            serde_json::to_vec_pretty(self).map_err(|e| SomaError::Serialization(e.to_string()))?;
32        std::fs::write(path, json)?;
33        Ok(())
34    }
35
36    fn load(path: impl AsRef<Path>) -> Result<Self> {
37        let bytes = std::fs::read(path)?;
38        serde_json::from_slice(&bytes).map_err(|e| SomaError::Serialization(e.to_string()))
39    }
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use somatize_core::search::SearchSpace;
46    use somatize_core::study::{Direction, Objective, SearchStrategy, Study};
47
48    fn study() -> Study {
49        Study::new(
50            "persisted",
51            SearchSpace::new(),
52            SearchStrategy::Grid { points_per_dim: 3 },
53            vec![Objective {
54                metric: "f1".into(),
55                direction: Direction::Maximize,
56            }],
57        )
58    }
59
60    #[test]
61    fn a_study_survives_a_round_trip_through_a_file() {
62        let dir = tempfile::tempdir().unwrap();
63        let path = dir.path().join("study.json");
64
65        let mut original = study();
66        original.tags = vec!["mos".into()];
67        original.planned_trials = Some(6);
68        original.git_sha = Some("abc123".into());
69        original.save(&path).unwrap();
70
71        let back = Study::load(&path).unwrap();
72        assert_eq!(back.name, "persisted");
73        assert_eq!(back.tags, vec!["mos"]);
74        assert_eq!(back.planned_trials, Some(6));
75        assert_eq!(back.git_sha.as_deref(), Some("abc123"));
76        assert!(back.created_at.is_some());
77    }
78
79    /// A missing file and a corrupt one are different failures, and a
80    /// caller that wants to distinguish "no study yet" from "the study on
81    /// disk is broken" needs them to stay different.
82    #[test]
83    fn load_errors_are_typed() {
84        let missing = Study::load("/nonexistent/dir/study.json");
85        assert!(matches!(missing, Err(SomaError::Io(_))), "{missing:?}");
86
87        let dir = tempfile::tempdir().unwrap();
88        let path = dir.path().join("study.json");
89        std::fs::write(&path, "{not json").unwrap();
90        let corrupt = Study::load(&path);
91        assert!(
92            matches!(corrupt, Err(SomaError::Serialization(_))),
93            "{corrupt:?}"
94        );
95    }
96}