Skip to main content

scientific_workflow/configuration/
paths.rs

1//! Strict, immutable named paths loaded from `config/paths.json`.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use serde_json::{Map, Value};
9
10use super::error::ConfigurationError;
11use super::source::{
12    StrictValue, invalid, parse_strict_json, read_source, require_object, validate_name,
13};
14
15const CONFIGURATION_DIRECTORY: &str = "config";
16const PATHS_FILE: &str = "paths.json";
17
18/// A validated read-only dictionary of project-wide named filesystem paths.
19///
20/// Loading preserves declaration order and exact source bytes. It does not
21/// canonicalize paths, inspect targets, or require targets to exist.
22#[derive(Clone)]
23pub struct ProjectPaths {
24    inner: Arc<ProjectPathsInner>,
25}
26
27impl ProjectPaths {
28    /// Loads the standard `config/paths.json` beneath `project_root`.
29    pub fn load(project_root: impl Into<PathBuf>) -> Result<Self, ConfigurationError> {
30        let project_root = project_root.into();
31        let source_path = project_root.join(CONFIGURATION_DIRECTORY).join(PATHS_FILE);
32        let source = read_source(&source_path)?;
33        let document = parse_strict_json(&source_path, &source)?;
34        let fields = require_object(&source_path, document, "paths.json root must be an object")?;
35        let mut entries = Vec::with_capacity(fields.len());
36        let mut by_name = HashMap::with_capacity(fields.len());
37        for (position, (name, value)) in fields.into_iter().enumerate() {
38            validate_name(&source_path, &name, "project path")?;
39            let StrictValue::String(raw) = value else {
40                return invalid(
41                    &source_path,
42                    format!("project path `{name}` must be a JSON string"),
43                );
44            };
45            if raw.trim().is_empty() {
46                return invalid(
47                    &source_path,
48                    format!("project path `{name}` must not be empty or whitespace-only"),
49                );
50            }
51            by_name.insert(name.clone().into_boxed_str(), position);
52            entries.push(PathEntry {
53                name: name.into_boxed_str(),
54                source: raw.into_boxed_str(),
55            });
56        }
57
58        Ok(Self {
59            inner: Arc::new(ProjectPathsInner {
60                project_root,
61                source_path,
62                source: source.into_boxed_slice(),
63                entries,
64                by_name,
65            }),
66        })
67    }
68
69    /// Returns the project root supplied to [`Self::load`].
70    pub fn project_root(&self) -> &Path {
71        &self.inner.project_root
72    }
73
74    /// Returns the exact `paths.json` source path.
75    pub fn source_path(&self) -> &Path {
76        &self.inner.source_path
77    }
78
79    /// Borrows the original validated source bytes without reserialization.
80    pub fn source_json(&self) -> &[u8] {
81        &self.inner.source
82    }
83
84    /// Returns the number of declared named paths.
85    pub fn len(&self) -> usize {
86        self.inner.entries.len()
87    }
88
89    /// Reports whether the path table contains no entries.
90    pub fn is_empty(&self) -> bool {
91        self.inner.entries.is_empty()
92    }
93
94    /// Reports whether `key` is declared.
95    pub fn contains(&self, key: &str) -> bool {
96        self.inner.by_name.contains_key(key)
97    }
98
99    /// Borrows the declared path without resolving it against the project root.
100    pub fn path(&self, key: &str) -> Option<&Path> {
101        let &position = self.inner.by_name.get(key)?;
102        Some(Path::new(self.inner.entries[position].source.as_ref()))
103    }
104
105    /// Borrows a required declared path or returns an unknown-path error.
106    pub fn require_path(&self, key: &str) -> Result<&Path, ConfigurationError> {
107        self.path(key)
108            .ok_or_else(|| ConfigurationError::UnknownProjectPath {
109                key: key.to_owned(),
110            })
111    }
112
113    /// Returns an absolute declaration unchanged or joins a relative one to the project root.
114    pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
115        let path = self.require_path(key)?;
116        if path.is_absolute() {
117            Ok(path.to_path_buf())
118        } else {
119            Ok(self.project_root().join(path))
120        }
121    }
122
123    /// Iterates names in source declaration order.
124    pub fn keys(&self) -> impl ExactSizeIterator<Item = &str> {
125        self.inner.entries.iter().map(|entry| entry.name.as_ref())
126    }
127
128    /// Iterates names and unresolved paths in source declaration order.
129    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Path)> {
130        self.inner
131            .entries
132            .iter()
133            .map(|entry| (entry.name.as_ref(), Path::new(entry.source.as_ref())))
134    }
135
136    /// Returns a deterministic JSON object suitable for task provenance.
137    pub fn to_json_value(&self) -> Value {
138        Value::Object(
139            self.inner
140                .entries
141                .iter()
142                .map(|entry| {
143                    (
144                        entry.name.to_string(),
145                        Value::String(entry.source.to_string()),
146                    )
147                })
148                .collect::<Map<_, _>>(),
149        )
150    }
151}
152
153impl fmt::Debug for ProjectPaths {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        formatter
156            .debug_struct("ProjectPaths")
157            .field("project_root", &self.project_root())
158            .field("source_path", &self.source_path())
159            .field("entries", &self.len())
160            .finish_non_exhaustive()
161    }
162}
163
164struct ProjectPathsInner {
165    project_root: PathBuf,
166    source_path: PathBuf,
167    source: Box<[u8]>,
168    entries: Vec<PathEntry>,
169    by_name: HashMap<Box<str>, usize>,
170}
171
172struct PathEntry {
173    name: Box<str>,
174    source: Box<str>,
175}