Skip to main content

scientific_workflow/configuration/
project_config.rs

1//! Coordinated loading and exact export of standard project configuration.
2//!
3//! [`ProjectConfig`] is the normal entry point for the on-disk layout:
4//!
5//! ```text
6//! project-root/
7//! └── config/
8//!     ├── fixed.json
9//!     ├── sweep.json
10//!     └── paths.json
11//! ```
12//!
13//! Loading delegates scientific parameter expansion to [`ParameterSpace`] and
14//! path semantics to [`ProjectPaths`]. The facade then combines their shared
15//! handles into complete [`TaskConfig`] values for task execution. Exact export
16//! writes the original validated source bytes, not a reserialized representation.
17//!
18//! # Export publication
19//!
20//! [`ProjectConfig::write_source_config`] never overwrites an existing
21//! `config/` entry. It exclusively creates that directory, exclusively creates
22//! and synchronizes all three files, syncs the configuration directory, and
23//! finally syncs the project root. A failed operation may retain the newly
24//! created partial directory as diagnostic evidence, but it never replaces
25//! existing configuration.
26
27use std::fmt;
28use std::fs::{self, File, OpenOptions};
29use std::io::{self, Write};
30use std::iter::FusedIterator;
31use std::path::{Path, PathBuf};
32
33use serde::Serialize;
34use serde::de::DeserializeOwned;
35use serde_json::Value;
36
37use super::error::ConfigurationError;
38use super::parameter_key_tuple::ParameterKeyTuple;
39use super::parameters::{ParameterSpace, TaskParameters, TaskParametersIter};
40use super::paths::ProjectPaths;
41
42const CONFIGURATION_DIRECTORY: &str = "config";
43const FIXED_FILE: &str = "fixed.json";
44const SWEEP_FILE: &str = "sweep.json";
45const PATHS_FILE: &str = "paths.json";
46
47/// Complete validated configuration for one scientific project.
48///
49/// This facade keeps fixed/sweep expansion and path storage internally distinct
50/// while exposing complete task handles loaded from the same standard root.
51/// Cloning it is lightweight because the component values share their parsed
52/// allocations through [`std::sync::Arc`].
53#[derive(Clone)]
54pub struct ProjectConfig {
55    project_root: PathBuf,
56    parameters: ParameterSpace,
57    paths: ProjectPaths,
58}
59
60impl ProjectConfig {
61    /// Loads all three standard JSON files beneath `project_root/config`.
62    ///
63    /// Loading is read-only. The supplied project root is retained without
64    /// canonicalization, and no configured path target needs to exist.
65    ///
66    /// # Errors
67    ///
68    /// Returns the precise [`ConfigurationError`] produced by fixed/sweep
69    /// loading or path loading. A caller never receives a partially validated
70    /// `ProjectConfig`.
71    pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
72        let project_root = project_root.into();
73        let configuration_directory = project_root.join(CONFIGURATION_DIRECTORY);
74        let parameters = ParameterSpace::load(&configuration_directory)?;
75        let paths = ProjectPaths::load(&project_root)?;
76        Ok(Self {
77            project_root,
78            parameters,
79            paths,
80        })
81    }
82
83    /// Returns the project root exactly as supplied at load time.
84    pub fn project_root(&self) -> &Path {
85        &self.project_root
86    }
87
88    /// Returns the standard `config/` directory derived from the project root.
89    pub fn configuration_directory(&self) -> &Path {
90        self.parameters.configuration_directory()
91    }
92
93    /// Borrows the validated fixed-and-swept parameter space.
94    pub fn parameters(&self) -> &ParameterSpace {
95        &self.parameters
96    }
97
98    /// Borrows the validated named project-path dictionary.
99    pub fn paths(&self) -> &ProjectPaths {
100        &self.paths
101    }
102
103    /// Returns the checked number of complete task configurations.
104    pub fn task_count(&self) -> u64 {
105        self.parameters.task_count()
106    }
107
108    /// Resolves one complete task configuration by deterministic ordinal.
109    ///
110    /// The returned handle shares all parsed fixed, sweep, and path storage.
111    /// No merged parameter map or path table is allocated.
112    pub fn task_config(&self, ordinal: u64) -> Result<TaskConfig, ConfigurationError> {
113        Ok(TaskConfig {
114            parameters: self.parameters.task(ordinal)?,
115            paths: self.paths.clone(),
116        })
117    }
118
119    /// Lazily iterates every complete task configuration.
120    ///
121    /// Cartesian sweeps yield their full product in canonical task order, with
122    /// the final axis changing fastest. Explicit-case sweeps yield exactly the
123    /// declared cases. Iterator items are cheap owned handles suitable for
124    /// moving into scoped work queues or other task schedulers.
125    pub fn task_configs(&self) -> TaskConfigIter {
126        TaskConfigIter {
127            parameters: self.parameters.tasks(),
128            paths: self.paths.clone(),
129        }
130    }
131
132    /// Lazily yields every task whose selected sweep value exactly matches
133    /// `value`.
134    ///
135    /// Other sweep dimensions remain unconstrained, so a Cartesian project can
136    /// yield several configurations. Selection is restricted to sweep keys;
137    /// fixed constants and paths do not define task identity. `value` is
138    /// converted to JSON once and compared with exact JSON equality.
139    pub fn task_configs_matching<V>(
140        &self,
141        key: impl Into<String>,
142        value: V,
143    ) -> Result<MatchingTaskConfigIter, ConfigurationError>
144    where
145        V: Serialize,
146    {
147        let key = key.into();
148        if !self
149            .parameters
150            .sweep_keys()
151            .any(|candidate| candidate == key)
152        {
153            return Err(ConfigurationError::UnknownSweepParameter { key });
154        }
155        let value = serde_json::to_value(value).map_err(|source| {
156            ConfigurationError::EncodeTaskSelection {
157                key: key.clone(),
158                source,
159            }
160        })?;
161        Ok(MatchingTaskConfigIter {
162            tasks: self.task_configs(),
163            key: key.into_boxed_str(),
164            value,
165        })
166    }
167
168    /// Returns the only task matching one exact sweep key/value pair.
169    ///
170    /// No match and multiple matches are distinct errors. In a multidimensional
171    /// Cartesian sweep, callers should normally use
172    /// [`ProjectConfig::task_configs_matching`] unless the selected key is
173    /// known to identify one task uniquely.
174    pub fn unique_task_config_matching<V>(
175        &self,
176        key: impl Into<String>,
177        value: V,
178    ) -> Result<TaskConfig, ConfigurationError>
179    where
180        V: Serialize,
181    {
182        let key = key.into();
183        let mut matches = self.task_configs_matching(key.clone(), value)?;
184        let task = matches
185            .next()
186            .ok_or_else(|| ConfigurationError::NoMatchingTaskConfiguration { key: key.clone() })?;
187        if matches.next().is_some() {
188            return Err(ConfigurationError::AmbiguousTaskConfiguration { key });
189        }
190        Ok(task)
191    }
192
193    /// Consumes the facade and returns its parameter and path components.
194    ///
195    /// Both returned handles retain their shared source allocations. No source
196    /// bytes, parsed JSON values, path values, or task parameters are cloned.
197    pub fn into_parts(self) -> (ParameterSpace, ProjectPaths) {
198        (self.parameters, self.paths)
199    }
200
201    /// Writes an exact non-overwriting copy beneath `destination_project_root`.
202    ///
203    /// The destination root is created when absent. Publication refuses an
204    /// existing `config/` path. All three files are created exclusively from
205    /// the original validated byte slices and synchronized before directory
206    /// publication is considered durable.
207    ///
208    /// # Errors
209    ///
210    /// Any root creation, exclusive configuration/file creation, write, or
211    /// sync failure is returned as
212    /// [`ConfigurationError::WriteConfigurationFile`] with the exact path at
213    /// which it occurred. Existing destination data is never overwritten.
214    pub fn write_source_config(
215        &self,
216        destination_project_root: impl AsRef<Path>,
217    ) -> Result<(), ConfigurationError> {
218        let destination_project_root = destination_project_root.as_ref();
219        create_destination_root(destination_project_root)?;
220        let destination = destination_project_root.join(CONFIGURATION_DIRECTORY);
221        create_configuration_directory(&destination)?;
222
223        write_source_file(
224            &destination.join(FIXED_FILE),
225            self.parameters.fixed_source_json(),
226        )?;
227        write_source_file(
228            &destination.join(SWEEP_FILE),
229            self.parameters.sweep_source_json(),
230        )?;
231        write_source_file(&destination.join(PATHS_FILE), self.paths.source_json())?;
232        sync_directory(&destination)?;
233        sync_directory(destination_project_root)
234    }
235}
236
237/// One complete immutable task configuration.
238///
239/// This is an owned handle rather than an owned copy of configuration data.
240/// [`TaskParameters`] shares fixed/sweep storage and [`ProjectPaths`] shares
241/// path storage through independent [`std::sync::Arc`] allocations, making a
242/// `TaskConfig` cheap to move into worker queues and safe to retain after the
243/// originating [`ProjectConfig`] is dropped.
244#[derive(Clone)]
245pub struct TaskConfig {
246    parameters: TaskParameters,
247    paths: ProjectPaths,
248}
249
250impl TaskConfig {
251    /// Returns the stable zero-based task ordinal.
252    pub fn task_ordinal(&self) -> u64 {
253        self.parameters.task_ordinal()
254    }
255
256    /// Borrows the fixed-plus-selected-sweep dictionary.
257    pub fn parameters(&self) -> &TaskParameters {
258        &self.parameters
259    }
260
261    /// Borrows the shared project path dictionary.
262    pub fn paths(&self) -> &ProjectPaths {
263        &self.paths
264    }
265
266    /// Borrows one fixed or selected sweep value by exact key.
267    pub fn value(&self, key: &str) -> Option<&Value> {
268        self.parameters.value(key)
269    }
270
271    /// Borrows one required fixed or selected sweep value.
272    pub fn require_value(&self, key: &str) -> Result<&Value, ConfigurationError> {
273        self.parameters.require_value(key)
274    }
275
276    /// Decodes one required parameter into the requested concrete Rust type.
277    pub fn decode_value<T>(&self, key: &str) -> Result<T, ConfigurationError>
278    where
279        T: DeserializeOwned,
280    {
281        self.parameters.decode_value(key)
282    }
283
284    /// Decodes several required parameters into a heterogeneous tuple.
285    pub fn decode_values<Values, Keys>(&self, keys: Keys) -> Result<Values, ConfigurationError>
286    where
287        Keys: ParameterKeyTuple<Values>,
288    {
289        self.parameters.decode_values(keys)
290    }
291
292    /// Resolves one named path lexically against the project root.
293    pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
294        self.paths.resolve_path(key)
295    }
296}
297
298impl fmt::Debug for TaskConfig {
299    /// Formats task identity and bounded dictionary counts without values.
300    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301        formatter
302            .debug_struct("TaskConfig")
303            .field("task_ordinal", &self.task_ordinal())
304            .field("parameters", &self.parameters.len())
305            .field("paths", &self.paths.len())
306            .finish_non_exhaustive()
307    }
308}
309
310/// Owning lazy iterator over every complete task configuration.
311#[derive(Clone)]
312pub struct TaskConfigIter {
313    parameters: TaskParametersIter,
314    paths: ProjectPaths,
315}
316
317impl Iterator for TaskConfigIter {
318    type Item = TaskConfig;
319
320    fn next(&mut self) -> Option<Self::Item> {
321        self.parameters.next().map(|parameters| TaskConfig {
322            parameters,
323            paths: self.paths.clone(),
324        })
325    }
326
327    fn size_hint(&self) -> (usize, Option<usize>) {
328        self.parameters.size_hint()
329    }
330}
331
332impl FusedIterator for TaskConfigIter {}
333
334impl fmt::Debug for TaskConfigIter {
335    /// Formats only the underlying ordinal range and shared path count.
336    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
337        formatter
338            .debug_struct("TaskConfigIter")
339            .field("parameters", &self.parameters)
340            .field("paths", &self.paths.len())
341            .finish_non_exhaustive()
342    }
343}
344
345/// Lazy exact-JSON filter over complete task configurations.
346pub struct MatchingTaskConfigIter {
347    tasks: TaskConfigIter,
348    key: Box<str>,
349    value: Value,
350}
351
352impl Iterator for MatchingTaskConfigIter {
353    type Item = TaskConfig;
354
355    fn next(&mut self) -> Option<Self::Item> {
356        self.tasks
357            .find(|task| task.value(&self.key) == Some(&self.value))
358    }
359
360    fn size_hint(&self) -> (usize, Option<usize>) {
361        (0, self.tasks.size_hint().1)
362    }
363}
364
365impl FusedIterator for MatchingTaskConfigIter {}
366
367impl fmt::Debug for MatchingTaskConfigIter {
368    /// Formats the selector key without exposing its potentially large value.
369    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
370        formatter
371            .debug_struct("MatchingTaskConfigIter")
372            .field("key", &self.key)
373            .field("tasks", &self.tasks)
374            .finish_non_exhaustive()
375    }
376}
377
378impl fmt::Debug for ProjectConfig {
379    /// Formats only bounded roots and component counts.
380    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381        formatter
382            .debug_struct("ProjectConfig")
383            .field("project_root", &self.project_root())
384            .field("parameters", &self.parameters.parameter_count())
385            .field("tasks", &self.parameters.task_count())
386            .field("paths", &self.paths.len())
387            .finish_non_exhaustive()
388    }
389}
390
391/// Creates a missing destination root or verifies that an existing entry is a
392/// directory.
393fn create_destination_root(path: &Path) -> Result<(), ConfigurationError> {
394    match fs::create_dir_all(path) {
395        Ok(()) => Ok(()),
396        Err(source) => Err(write_error(path.to_path_buf(), source)),
397    }
398}
399
400/// Exclusively creates the standard destination directory, closing the
401/// check/create race without platform-specific rename semantics.
402fn create_configuration_directory(path: &Path) -> Result<(), ConfigurationError> {
403    fs::create_dir(path).map_err(|source| write_error(path.to_path_buf(), source))
404}
405
406/// Exclusively creates, writes, and synchronizes one exact source file.
407fn write_source_file(path: &Path, source_bytes: &[u8]) -> Result<(), ConfigurationError> {
408    let mut output = OpenOptions::new()
409        .write(true)
410        .create_new(true)
411        .open(path)
412        .map_err(|source| write_error(path.to_path_buf(), source))?;
413    output
414        .write_all(source_bytes)
415        .map_err(|source| write_error(path.to_path_buf(), source))?;
416    output
417        .sync_all()
418        .map_err(|source| write_error(path.to_path_buf(), source))
419}
420
421/// Synchronizes directory-entry changes at one publication boundary.
422fn sync_directory(path: &Path) -> Result<(), ConfigurationError> {
423    File::open(path)
424        .and_then(|directory| directory.sync_all())
425        .map_err(|source| write_error(path.to_path_buf(), source))
426}
427
428/// Constructs the shared exact-export IO variant.
429fn write_error(path: PathBuf, source: io::Error) -> ConfigurationError {
430    ConfigurationError::WriteConfigurationFile { path, source }
431}