Skip to main content

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