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}