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.clone().into_boxed_str(),
55                raw: PathBuf::from(raw),
56            });
57        }
58
59        Ok(Self {
60            inner: Arc::new(ProjectPathsInner {
61                project_root,
62                source_path,
63                source: source.into_boxed_slice(),
64                entries,
65                by_name,
66            }),
67        })
68    }
69
70    pub fn project_root(&self) -> &Path {
71        &self.inner.project_root
72    }
73
74    pub fn source_path(&self) -> &Path {
75        &self.inner.source_path
76    }
77
78    pub fn source_json(&self) -> &[u8] {
79        &self.inner.source
80    }
81
82    pub fn len(&self) -> usize {
83        self.inner.entries.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.inner.entries.is_empty()
88    }
89
90    pub fn contains(&self, key: &str) -> bool {
91        self.inner.by_name.contains_key(key)
92    }
93
94    pub fn path(&self, key: &str) -> Option<&Path> {
95        let &position = self.inner.by_name.get(key)?;
96        Some(&self.inner.entries[position].raw)
97    }
98
99    pub fn require_path(&self, key: &str) -> Result<&Path, ConfigurationError> {
100        self.path(key)
101            .ok_or_else(|| ConfigurationError::UnknownProjectPath {
102                key: key.to_owned(),
103            })
104    }
105
106    pub fn resolve_path(&self, key: &str) -> Result<PathBuf, ConfigurationError> {
107        let path = self.require_path(key)?;
108        if path.is_absolute() {
109            Ok(path.to_path_buf())
110        } else {
111            Ok(self.project_root().join(path))
112        }
113    }
114
115    pub fn keys(&self) -> impl ExactSizeIterator<Item = &str> {
116        self.inner.entries.iter().map(|entry| entry.name.as_ref())
117    }
118
119    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &Path)> {
120        self.inner
121            .entries
122            .iter()
123            .map(|entry| (entry.name.as_ref(), entry.raw.as_path()))
124    }
125
126    /// Returns a deterministic JSON object suitable for task provenance.
127    pub fn to_json_value(&self) -> Value {
128        Value::Object(
129            self.inner
130                .entries
131                .iter()
132                .map(|entry| {
133                    (
134                        entry.name.to_string(),
135                        Value::String(entry.source.to_string()),
136                    )
137                })
138                .collect::<Map<_, _>>(),
139        )
140    }
141}
142
143impl fmt::Debug for ProjectPaths {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        formatter
146            .debug_struct("ProjectPaths")
147            .field("project_root", &self.project_root())
148            .field("source_path", &self.source_path())
149            .field("entries", &self.len())
150            .finish_non_exhaustive()
151    }
152}
153
154struct ProjectPathsInner {
155    project_root: PathBuf,
156    source_path: PathBuf,
157    source: Box<[u8]>,
158    entries: Vec<PathEntry>,
159    by_name: HashMap<Box<str>, usize>,
160}
161
162struct PathEntry {
163    name: Box<str>,
164    source: Box<str>,
165    raw: PathBuf,
166}