scientific_workflow/configuration/paths.rs
1//! Immutable named project paths loaded from `config/paths.json`.
2//!
3//! [`ProjectPaths`] separates filesystem locations from scientific task
4//! parameters. Every JSON value must be a string. The original string becomes
5//! a [`PathBuf`] for direct inspection, while relative paths can be joined to
6//! the configured project root through [`ProjectPaths::resolve_path`]. Loading
7//! does not canonicalize paths, expand environment variables or `~`, inspect
8//! target metadata, or require a target to exist.
9//!
10//! The complete validated source bytes are retained unchanged for the exact
11//! three-file export performed by the later `ProjectConfig` facade. Parsed path
12//! entries remain in JSON declaration order and are shared by every clone.
13
14use std::collections::HashMap;
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use super::error::ConfigurationError;
20use super::parameters::{
21 StrictValue, invalid, parse_strict_json, read_source, require_object, validate_name,
22};
23
24const CONFIGURATION_DIRECTORY: &str = "config";
25const PATHS_FILE: &str = "paths.json";
26
27/// A validated read-only dictionary of project-wide named filesystem paths.
28///
29/// Cloning this type clones only an [`Arc`]. The project root, exact source
30/// bytes, declaration-ordered entries, and lookup index remain in one shared
31/// immutable allocation.
32#[derive(Clone)]
33pub struct ProjectPaths {
34 inner: Arc<ProjectPathsInner>,
35}
36
37impl ProjectPaths {
38 /// Loads the standard `config/paths.json` beneath `project_root`.
39 ///
40 /// `project_root` is retained exactly as supplied. Relative configured paths
41 /// are later joined to it lexically; neither the root nor configured values
42 /// are canonicalized.
43 ///
44 /// # Errors
45 ///
46 /// Returns contextual file or JSON errors, recursive duplicate-key errors,
47 /// or [`ConfigurationError::InvalidConfigurationDocument`] when the root is
48 /// not an object or a key has a non-string or empty path value.
49 pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
50 let project_root = project_root.into();
51 let source_path = project_root.join(CONFIGURATION_DIRECTORY).join(PATHS_FILE);
52 let source = read_source(&source_path)?;
53 let document = parse_strict_json(&source_path, &source)?;
54 let fields = require_object(&source_path, document, "paths.json root must be an object")?;
55 let mut entries = Vec::with_capacity(fields.len());
56 let mut by_name = HashMap::with_capacity(fields.len());
57 for (position, (name, value)) in fields.into_iter().enumerate() {
58 validate_name(&source_path, &name, "project path")?;
59 let StrictValue::String(raw) = value else {
60 return invalid(
61 &source_path,
62 format!("project path `{name}` must be a JSON string"),
63 );
64 };
65 if raw.trim().is_empty() {
66 return invalid(
67 &source_path,
68 format!("project path `{name}` must not be empty or whitespace-only"),
69 );
70 }
71 by_name.insert(name.clone().into_boxed_str(), position);
72 entries.push(PathEntry {
73 name: name.into_boxed_str(),
74 raw: PathBuf::from(raw),
75 });
76 }
77
78 Ok(Self {
79 inner: Arc::new(ProjectPathsInner {
80 project_root,
81 source_path,
82 source: source.into_boxed_slice(),
83 entries,
84 by_name,
85 }),
86 })
87 }
88
89 /// Returns the project root exactly as supplied at load time.
90 pub fn project_root(&self) -> &Path {
91 &self.inner.project_root
92 }
93
94 /// Returns the derived `config/paths.json` source path.
95 pub fn source_path(&self) -> &Path {
96 &self.inner.source_path
97 }
98
99 /// Borrows the validated original bytes of `paths.json` unchanged.
100 ///
101 /// The slice preserves whitespace, declaration order, escaping, and every
102 /// other byte-level source detail.
103 pub fn source_json(&self) -> &[u8] {
104 &self.inner.source
105 }
106
107 /// Returns the number of declared path names.
108 pub fn len(&self) -> usize {
109 self.inner.entries.len()
110 }
111
112 /// Reports whether `paths.json` declares no entries.
113 pub fn is_empty(&self) -> bool {
114 self.inner.entries.is_empty()
115 }
116
117 /// Reports whether an exact, case-sensitive path key is declared.
118 pub fn contains(&self, key: &str) -> bool {
119 self.inner.by_name.contains_key(key)
120 }
121
122 /// Borrows one configured path exactly as represented by its JSON string.
123 ///
124 /// Relative values remain relative. Missing keys return `None`; no
125 /// filesystem operation or allocation occurs.
126 pub fn path(&self, key: &str) -> Option<&Path> {
127 let &position = self.inner.by_name.get(key)?;
128 Some(&self.inner.entries[position].raw)
129 }
130
131 /// Borrows one required configured path or returns its exact missing key.
132 pub fn require_path(&self, key: &str) -> Result<&Path, ConfigurationError> {
133 self.path(key)
134 .ok_or_else(|| ConfigurationError::UnknownProjectPath {
135 key: key.to_owned(),
136 })
137 }
138
139 /// Returns one path resolved lexically against the project root.
140 ///
141 /// Absolute configured paths are returned unchanged. Relative paths are
142 /// joined with [`ProjectPaths::project_root`]. The result is not
143 /// canonicalized, normalized, opened, or checked for existence.
144 pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
145 let path = self.require_path(key)?;
146 if path.is_absolute() {
147 Ok(path.to_path_buf())
148 } else {
149 Ok(self.project_root().join(path))
150 }
151 }
152
153 /// Iterates exact path keys in JSON declaration order.
154 pub fn keys(&self) -> impl ExactSizeIterator<Item = &str> {
155 self.inner.entries.iter().map(|entry| entry.name.as_ref())
156 }
157
158 /// Iterates declaration-ordered exact keys and unresolved path values.
159 pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Path)> {
160 self.inner
161 .entries
162 .iter()
163 .map(|entry| (entry.name.as_ref(), entry.raw.as_path()))
164 }
165}
166
167impl fmt::Debug for ProjectPaths {
168 /// Formats only the project root, source path, and entry count.
169 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170 formatter
171 .debug_struct("ProjectPaths")
172 .field("project_root", &self.project_root())
173 .field("source_path", &self.source_path())
174 .field("entries", &self.len())
175 .finish_non_exhaustive()
176 }
177}
178
179/// Shared immutable allocation behind every `ProjectPaths` clone.
180struct ProjectPathsInner {
181 project_root: PathBuf,
182 source_path: PathBuf,
183 source: Box<[u8]>,
184 entries: Vec<PathEntry>,
185 by_name: HashMap<Box<str>, usize>,
186}
187
188/// One declaration-ordered exact name and unresolved path value.
189struct PathEntry {
190 name: Box<str>,
191 raw: PathBuf,
192}