Skip to main content

scientific_workflow/configuration/
settings.rs

1//! Strict Workflow policy and typed application settings loaded from `study.json`.
2
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use serde::Deserialize;
8use serde::de::DeserializeOwned;
9use serde_json::{Map, Value};
10
11use super::error::ConfigurationError;
12use super::source::{invalid, parse_strict_json, read_source};
13
14const STUDY_SETTINGS_FILE: &str = "study.json";
15
16/// Validated, immutable study manifest.
17///
18/// Workflow owns and validates `replicate_settings`. The `application` object
19/// remains opaque until the user program requests it as one application-owned
20/// type through [`Self::application`]. Scientific parameters remain in
21/// `config/parameters.json`, and named paths remain in `config/paths.json`.
22#[derive(Clone)]
23pub struct StudySettings {
24    inner: Arc<StudySettingsInner>,
25}
26
27impl StudySettings {
28    /// Loads and validates `study.json` directly beneath `study_root`.
29    pub fn load(study_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
30        let study_root = study_root.into();
31        let source_path = study_root.join(STUDY_SETTINGS_FILE);
32        let source = read_source(&source_path)?;
33        let document = parse_strict_json(&source_path, &source)?.into_json();
34        let raw: RawStudySettings = serde_json::from_value(document).map_err(|source| {
35            ConfigurationError::InvalidConfigurationDocument {
36                path: source_path.clone(),
37                reason: source.to_string(),
38            }
39        })?;
40        if raw.replicate_settings.replicates == 0 {
41            return invalid(
42                &source_path,
43                "replicate_settings.replicates must be positive",
44            );
45        }
46
47        Ok(Self {
48            inner: Arc::new(StudySettingsInner {
49                study_root,
50                source_path,
51                source: source.into_boxed_slice(),
52                replicate_settings: ReplicateSettings {
53                    replicates: raw.replicate_settings.replicates,
54                    scheduling: raw.replicate_settings.scheduling,
55                    failure_policy: raw.replicate_settings.failure_policy,
56                    base_seed: raw.replicate_settings.base_seed,
57                },
58                application: raw.application,
59            }),
60        })
61    }
62
63    /// Returns the study root supplied to [`Self::load`].
64    pub fn study_root(&self) -> &Path {
65        &self.inner.study_root
66    }
67
68    /// Returns the exact `study.json` source path.
69    pub fn source_path(&self) -> &Path {
70        &self.inner.source_path
71    }
72
73    /// Borrows the original validated source bytes without reserialization.
74    pub fn source_json(&self) -> &[u8] {
75        &self.inner.source
76    }
77
78    /// Returns the complete replicate policy.
79    pub fn replicate_settings(&self) -> ReplicateSettings {
80        self.inner.replicate_settings
81    }
82
83    /// Decodes the application-owned object without rereading `study.json`.
84    ///
85    /// Workflow does not interpret this object. Applications should use a
86    /// strongly typed `Deserialize` implementation—normally with
87    /// `#[serde(deny_unknown_fields)]`—to own and validate their settings.
88    pub fn application<T>(&self) -> Result<T, ConfigurationError>
89    where
90        T: DeserializeOwned,
91    {
92        serde_json::from_value(Value::Object(self.inner.application.clone())).map_err(|source| {
93            ConfigurationError::InvalidConfigurationDocument {
94                path: self.source_path().to_path_buf(),
95                reason: format!("application settings do not match the requested type: {source}"),
96            }
97        })
98    }
99}
100
101impl fmt::Debug for StudySettings {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter
104            .debug_struct("StudySettings")
105            .field("study_root", &self.study_root())
106            .field("source_path", &self.source_path())
107            .field("replicate_settings", &self.replicate_settings())
108            .field("application_fields", &self.inner.application.len())
109            .finish_non_exhaustive()
110    }
111}
112
113struct StudySettingsInner {
114    study_root: PathBuf,
115    source_path: PathBuf,
116    source: Box<[u8]>,
117    replicate_settings: ReplicateSettings,
118    application: Map<String, Value>,
119}
120
121/// Validated policy for executing one or more isolated study replicates.
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub struct ReplicateSettings {
124    replicates: u64,
125    scheduling: ReplicateScheduling,
126    failure_policy: ReplicateFailurePolicy,
127    base_seed: u64,
128}
129
130impl ReplicateSettings {
131    /// Returns the positive number of replicate subprocesses.
132    pub const fn replicates(self) -> u64 {
133        self.replicates
134    }
135
136    /// Returns whether replicate subprocesses are scheduled sequentially or in parallel.
137    pub const fn scheduling(self) -> ReplicateScheduling {
138        self.scheduling
139    }
140
141    /// Returns the controller response to a failed replicate subprocess.
142    pub const fn failure_policy(self) -> ReplicateFailurePolicy {
143        self.failure_policy
144    }
145
146    /// Returns the study-level base seed used for lazy per-replicate derivation.
147    pub const fn base_seed(self) -> u64 {
148        self.base_seed
149    }
150}
151
152/// Process-level scheduling mode for study replicates.
153#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
154#[serde(rename_all = "snake_case")]
155pub enum ReplicateScheduling {
156    /// Start and await one replicate subprocess at a time.
157    Sequential,
158    /// Start one subprocess for every replicate before awaiting completion.
159    Parallel,
160}
161
162impl ReplicateScheduling {
163    /// Returns the exact `study.json` spelling.
164    pub const fn as_str(self) -> &'static str {
165        match self {
166            Self::Sequential => "sequential",
167            Self::Parallel => "parallel",
168        }
169    }
170}
171
172/// Controller response when a replicate subprocess fails.
173#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
174#[serde(rename_all = "snake_case")]
175pub enum ReplicateFailurePolicy {
176    /// Stop launching sequential work or terminate active parallel children.
177    FailFast,
178    /// Allow every declared replicate subprocess to finish.
179    FinishAll,
180}
181
182impl ReplicateFailurePolicy {
183    /// Returns the exact `study.json` spelling.
184    pub const fn as_str(self) -> &'static str {
185        match self {
186            Self::FailFast => "fail_fast",
187            Self::FinishAll => "finish_all",
188        }
189    }
190}
191
192#[derive(Deserialize)]
193#[serde(deny_unknown_fields)]
194struct RawStudySettings {
195    replicate_settings: RawReplicateSettings,
196    #[serde(default)]
197    application: Map<String, Value>,
198}
199
200#[derive(Deserialize)]
201#[serde(deny_unknown_fields)]
202struct RawReplicateSettings {
203    replicates: u64,
204    scheduling: ReplicateScheduling,
205    failure_policy: ReplicateFailurePolicy,
206    base_seed: u64,
207}