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::{Map, 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    /// Materializes the complete resolved task configuration as canonical JSON.
298    ///
299    /// Parameters retain their nested fixed-plus-sweep shape. Project paths
300    /// retain the exact strings declared in `paths.json`; they are not
301    /// canonicalized or resolved against the host filesystem.
302    pub fn resolved_json(&self) -> Value {
303        let mut paths = Map::with_capacity(self.paths.len());
304        for (key, path) in self.paths.iter() {
305            paths.insert(
306                key.to_owned(),
307                Value::String(path.to_string_lossy().into_owned()),
308            );
309        }
310        let mut task = Map::with_capacity(2);
311        task.insert(
312            "parameters".to_owned(),
313            Value::Object(self.parameters.resolved_object().clone()),
314        );
315        task.insert("paths".to_owned(), Value::Object(paths));
316        Value::Object(task)
317    }
318
319    /// Writes this resolved configuration as deterministic pretty JSON.
320    ///
321    /// An existing byte-identical file is reused. Different existing content
322    /// is rejected and never overwritten.
323    pub fn write_resolved_json(&self, path: impl AsRef<Path>) -> Result<(), ConfigurationError> {
324        let path = path.as_ref();
325        let mut bytes = serde_json::to_vec_pretty(&self.resolved_json()).map_err(|source| {
326            ConfigurationError::SerializeTaskParameters {
327                task_ordinal: self.task_ordinal(),
328                source,
329            }
330        })?;
331        bytes.push(b'\n');
332        write_identical_or_create(path, &bytes)
333    }
334}
335
336fn write_identical_or_create(path: &Path, bytes: &[u8]) -> Result<(), ConfigurationError> {
337    match OpenOptions::new().write(true).create_new(true).open(path) {
338        Ok(mut file) => file
339            .write_all(bytes)
340            .and_then(|()| file.sync_all())
341            .map_err(|source| ConfigurationError::WriteConfigurationFile {
342                path: path.to_path_buf(),
343                source,
344            }),
345        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
346            let existing =
347                fs::read(path).map_err(|source| ConfigurationError::WriteConfigurationFile {
348                    path: path.to_path_buf(),
349                    source,
350                })?;
351            if existing == bytes {
352                Ok(())
353            } else {
354                Err(ConfigurationError::ResolvedTaskConfigConflict {
355                    path: path.to_path_buf(),
356                })
357            }
358        }
359        Err(source) => Err(ConfigurationError::WriteConfigurationFile {
360            path: path.to_path_buf(),
361            source,
362        }),
363    }
364}
365
366impl fmt::Debug for TaskConfig {
367    /// Formats task identity and bounded dictionary counts without values.
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter
370            .debug_struct("TaskConfig")
371            .field("task_ordinal", &self.task_ordinal())
372            .field("parameters", &self.parameters.len())
373            .field("paths", &self.paths.len())
374            .finish_non_exhaustive()
375    }
376}
377
378/// Owning lazy iterator over every complete task configuration.
379#[derive(Clone)]
380pub struct TaskConfigIter {
381    parameters: TaskParametersIter,
382    paths: ProjectPaths,
383}
384
385impl Iterator for TaskConfigIter {
386    type Item = TaskConfig;
387
388    fn next(&mut self) -> Option<Self::Item> {
389        self.parameters.next().map(|parameters| TaskConfig {
390            parameters,
391            paths: self.paths.clone(),
392        })
393    }
394
395    fn size_hint(&self) -> (usize, Option<usize>) {
396        self.parameters.size_hint()
397    }
398}
399
400impl FusedIterator for TaskConfigIter {}
401
402impl fmt::Debug for TaskConfigIter {
403    /// Formats only the underlying ordinal range and shared path count.
404    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
405        formatter
406            .debug_struct("TaskConfigIter")
407            .field("parameters", &self.parameters)
408            .field("paths", &self.paths.len())
409            .finish_non_exhaustive()
410    }
411}
412
413/// Lazy exact-JSON filter over complete task configurations.
414pub struct MatchingTaskConfigIter {
415    tasks: TaskConfigIter,
416    key: Box<str>,
417    value: Value,
418}
419
420impl Iterator for MatchingTaskConfigIter {
421    type Item = TaskConfig;
422
423    fn next(&mut self) -> Option<Self::Item> {
424        self.tasks
425            .find(|task| task.value(&self.key) == Some(&self.value))
426    }
427
428    fn size_hint(&self) -> (usize, Option<usize>) {
429        (0, self.tasks.size_hint().1)
430    }
431}
432
433impl FusedIterator for MatchingTaskConfigIter {}
434
435impl fmt::Debug for MatchingTaskConfigIter {
436    /// Formats the selector key without exposing its potentially large value.
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        formatter
439            .debug_struct("MatchingTaskConfigIter")
440            .field("key", &self.key)
441            .field("tasks", &self.tasks)
442            .finish_non_exhaustive()
443    }
444}
445
446impl fmt::Debug for ProjectConfig {
447    /// Formats only bounded roots and component counts.
448    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449        formatter
450            .debug_struct("ProjectConfig")
451            .field("project_root", &self.project_root())
452            .field("parameters", &self.parameters.parameter_count())
453            .field("tasks", &self.parameters.task_count())
454            .field("paths", &self.paths.len())
455            .finish_non_exhaustive()
456    }
457}
458
459/// Creates a missing destination root or verifies that an existing entry is a
460/// directory.
461fn create_destination_root(path: &Path) -> Result<(), ConfigurationError> {
462    match fs::create_dir_all(path) {
463        Ok(()) => Ok(()),
464        Err(source) => Err(write_error(path.to_path_buf(), source)),
465    }
466}
467
468/// Exclusively creates the standard destination directory, closing the
469/// check/create race without platform-specific rename semantics.
470fn create_configuration_directory(path: &Path) -> Result<(), ConfigurationError> {
471    fs::create_dir(path).map_err(|source| write_error(path.to_path_buf(), source))
472}
473
474/// Exclusively creates, writes, and synchronizes one exact source file.
475fn write_source_file(path: &Path, source_bytes: &[u8]) -> Result<(), ConfigurationError> {
476    let mut output = OpenOptions::new()
477        .write(true)
478        .create_new(true)
479        .open(path)
480        .map_err(|source| write_error(path.to_path_buf(), source))?;
481    output
482        .write_all(source_bytes)
483        .map_err(|source| write_error(path.to_path_buf(), source))?;
484    output
485        .sync_all()
486        .map_err(|source| write_error(path.to_path_buf(), source))
487}
488
489/// Synchronizes directory-entry changes at one publication boundary.
490fn sync_directory(path: &Path) -> Result<(), ConfigurationError> {
491    File::open(path)
492        .and_then(|directory| directory.sync_all())
493        .map_err(|source| write_error(path.to_path_buf(), source))
494}
495
496/// Constructs the shared exact-export IO variant.
497fn write_error(path: PathBuf, source: io::Error) -> ConfigurationError {
498    ConfigurationError::WriteConfigurationFile { path, source }
499}