Skip to main content

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}