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`]. Exact export writes the original
15//! validated source bytes, not a reserialized representation.
16//!
17//! # Export publication
18//!
19//! [`ProjectConfig::write_source_config`] never overwrites an existing
20//! `config/` entry. It exclusively creates that directory, exclusively creates
21//! and synchronizes all three files, syncs the configuration directory, and
22//! finally syncs the project root. A failed operation may retain the newly
23//! created partial directory as diagnostic evidence, but it never replaces
24//! existing configuration.
25
26use std::fmt;
27use std::fs::{self, File, OpenOptions};
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30
31use super::error::ConfigurationError;
32use super::parameters::ParameterSpace;
33use super::paths::ProjectPaths;
34
35const CONFIGURATION_DIRECTORY: &str = "config";
36const FIXED_FILE: &str = "fixed.json";
37const SWEEP_FILE: &str = "sweep.json";
38const PATHS_FILE: &str = "paths.json";
39
40/// Complete validated configuration for one scientific project.
41///
42/// This facade keeps fixed/sweep expansion and path resolution distinct while
43/// guaranteeing that both were loaded from the same standard project root.
44/// Cloning it is lightweight because the component values share their parsed
45/// allocations through [`std::sync::Arc`].
46#[derive(Clone)]
47pub struct ProjectConfig {
48    project_root: PathBuf,
49    parameters: ParameterSpace,
50    paths: ProjectPaths,
51}
52
53impl ProjectConfig {
54    /// Loads all three standard JSON files beneath `project_root/config`.
55    ///
56    /// Loading is read-only. The supplied project root is retained without
57    /// canonicalization, and no configured path target needs to exist.
58    ///
59    /// # Errors
60    ///
61    /// Returns the precise [`ConfigurationError`] produced by fixed/sweep
62    /// loading or path loading. A caller never receives a partially validated
63    /// `ProjectConfig`.
64    pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
65        let project_root = project_root.into();
66        let configuration_directory = project_root.join(CONFIGURATION_DIRECTORY);
67        let parameters = ParameterSpace::load(&configuration_directory)?;
68        let paths = ProjectPaths::load(&project_root)?;
69        Ok(Self {
70            project_root,
71            parameters,
72            paths,
73        })
74    }
75
76    /// Returns the project root exactly as supplied at load time.
77    pub fn project_root(&self) -> &Path {
78        &self.project_root
79    }
80
81    /// Returns the standard `config/` directory derived from the project root.
82    pub fn configuration_directory(&self) -> &Path {
83        self.parameters.configuration_directory()
84    }
85
86    /// Borrows the validated fixed-and-swept parameter space.
87    pub fn parameters(&self) -> &ParameterSpace {
88        &self.parameters
89    }
90
91    /// Borrows the validated named project-path dictionary.
92    pub fn paths(&self) -> &ProjectPaths {
93        &self.paths
94    }
95
96    /// Consumes the facade and returns its parameter and path components.
97    ///
98    /// Both returned handles retain their shared source allocations. No source
99    /// bytes, parsed JSON values, path values, or task parameters are cloned.
100    pub fn into_parts(self) -> (ParameterSpace, ProjectPaths) {
101        (self.parameters, self.paths)
102    }
103
104    /// Writes an exact non-overwriting copy beneath `destination_project_root`.
105    ///
106    /// The destination root is created when absent. Publication refuses an
107    /// existing `config/` path. All three files are created exclusively from
108    /// the original validated byte slices and synchronized before directory
109    /// publication is considered durable.
110    ///
111    /// # Errors
112    ///
113    /// Any root creation, exclusive configuration/file creation, write, or
114    /// sync failure is returned as
115    /// [`ConfigurationError::WriteConfigurationFile`] with the exact path at
116    /// which it occurred. Existing destination data is never overwritten.
117    pub fn write_source_config(
118        &self,
119        destination_project_root: impl AsRef<Path>,
120    ) -> Result<(), ConfigurationError> {
121        let destination_project_root = destination_project_root.as_ref();
122        create_destination_root(destination_project_root)?;
123        let destination = destination_project_root.join(CONFIGURATION_DIRECTORY);
124        create_configuration_directory(&destination)?;
125
126        write_source_file(
127            &destination.join(FIXED_FILE),
128            self.parameters.fixed_source_json(),
129        )?;
130        write_source_file(
131            &destination.join(SWEEP_FILE),
132            self.parameters.sweep_source_json(),
133        )?;
134        write_source_file(&destination.join(PATHS_FILE), self.paths.source_json())?;
135        sync_directory(&destination)?;
136        sync_directory(destination_project_root)
137    }
138}
139
140impl fmt::Debug for ProjectConfig {
141    /// Formats only bounded roots and component counts.
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        formatter
144            .debug_struct("ProjectConfig")
145            .field("project_root", &self.project_root())
146            .field("parameters", &self.parameters.parameter_count())
147            .field("tasks", &self.parameters.task_count())
148            .field("paths", &self.paths.len())
149            .finish_non_exhaustive()
150    }
151}
152
153/// Creates a missing destination root or verifies that an existing entry is a
154/// directory.
155fn create_destination_root(path: &Path) -> Result<(), ConfigurationError> {
156    match fs::create_dir_all(path) {
157        Ok(()) => Ok(()),
158        Err(source) => Err(write_error(path.to_path_buf(), source)),
159    }
160}
161
162/// Exclusively creates the standard destination directory, closing the
163/// check/create race without platform-specific rename semantics.
164fn create_configuration_directory(path: &Path) -> Result<(), ConfigurationError> {
165    fs::create_dir(path).map_err(|source| write_error(path.to_path_buf(), source))
166}
167
168/// Exclusively creates, writes, and synchronizes one exact source file.
169fn write_source_file(path: &Path, source_bytes: &[u8]) -> Result<(), ConfigurationError> {
170    let mut output = OpenOptions::new()
171        .write(true)
172        .create_new(true)
173        .open(path)
174        .map_err(|source| write_error(path.to_path_buf(), source))?;
175    output
176        .write_all(source_bytes)
177        .map_err(|source| write_error(path.to_path_buf(), source))?;
178    output
179        .sync_all()
180        .map_err(|source| write_error(path.to_path_buf(), source))
181}
182
183/// Synchronizes directory-entry changes at one publication boundary.
184fn sync_directory(path: &Path) -> Result<(), ConfigurationError> {
185    File::open(path)
186        .and_then(|directory| directory.sync_all())
187        .map_err(|source| write_error(path.to_path_buf(), source))
188}
189
190/// Constructs the shared exact-export IO variant.
191fn write_error(path: PathBuf, source: io::Error) -> ConfigurationError {
192    ConfigurationError::WriteConfigurationFile { path, source }
193}