Skip to main content

wdl_modules/
project.rs

1//! Module project loading, upward discovery, and paired persistence.
2//!
3//! [`ModuleProject`] loads an exact `module.json` path and remembers the
4//! sibling `module-lock.json` path beside it. [`ModuleProject::discover`]
5//! walks upward from a caller-supplied start path until it finds `module.json`
6//! or reaches a `.git` boundary, so project discovery returns `Ok(None)` when
7//! no ancestor project exists. [`ManifestDocument`] preserves unknown manifest
8//! extension fields and validates each edit before it lands.
9
10/// Lossless `module.json` document editing helpers.
11mod document;
12/// Advisory locking for the project lockfile.
13mod lockfile;
14/// Project validation helpers for manifest-referenced files and content
15/// hashing.
16mod validation;
17
18use std::io::Write as _;
19use std::path::Path;
20use std::path::PathBuf;
21
22use thiserror::Error;
23
24pub use self::document::ManifestDocument;
25pub use self::document::ManifestDocumentError;
26pub use self::lockfile::LockedLockfile;
27pub use self::validation::ProjectFileKind;
28pub use self::validation::ProjectValidationError;
29use crate::Lockfile;
30use crate::Manifest;
31use crate::lockfile::LockfileError;
32
33/// An error loading, discovering, or reloading a module project.
34#[derive(Debug, Error)]
35pub enum ProjectError {
36    /// Reading or inspecting a project file on disk failed.
37    #[error("i/o error at `{path}`")]
38    Io {
39        /// The manifest candidate or sibling project path that failed.
40        path: PathBuf,
41        /// The underlying I/O error.
42        #[source]
43        source: std::io::Error,
44    },
45
46    /// The bytes at `path` were not a valid `module.json` document.
47    #[error("invalid module manifest at `{path}`")]
48    Manifest {
49        /// The exact `module.json` path that failed validation.
50        path: PathBuf,
51        /// The underlying manifest-document error.
52        #[source]
53        source: ManifestDocumentError,
54    },
55
56    /// The bytes at `path` were not a valid `module-lock.json` document.
57    #[error("invalid module lockfile at `{path}`")]
58    Lockfile {
59        /// The sibling `module-lock.json` path that failed validation.
60        path: PathBuf,
61        /// The underlying lockfile error.
62        #[source]
63        source: LockfileError,
64    },
65}
66
67/// A loaded module project rooted at an exact `module.json` path.
68///
69/// The project keeps the caller-selected manifest path, exposes the sibling
70/// `module-lock.json` path even when the lockfile is absent, and keeps the
71/// manifest in a lossless [`ManifestDocument`] so extension fields survive
72/// future edits.
73#[derive(Clone, Debug)]
74pub struct ModuleProject {
75    /// Directory containing the loaded `module.json`.
76    root: PathBuf,
77    /// Exact `module.json` path used for loads and reloads.
78    manifest_path: PathBuf,
79    /// Sibling `module-lock.json` path beside `manifest_path`.
80    lockfile_path: PathBuf,
81    /// Lossless in-memory `module.json` document for this project.
82    document: ManifestDocument,
83}
84
85impl ModuleProject {
86    /// Loads a project from the exact `module.json` path.
87    ///
88    /// The returned value reloads this same path on later reads and derives
89    /// the sibling `module-lock.json` path beside it.
90    pub fn load(path: impl Into<PathBuf>) -> Result<Self, ProjectError> {
91        let manifest_path = path.into();
92        let bytes = std::fs::read(&manifest_path).map_err(|source| ProjectError::Io {
93            path: manifest_path.clone(),
94            source,
95        })?;
96        let document =
97            ManifestDocument::parse(&bytes).map_err(|source| ProjectError::Manifest {
98                path: manifest_path.clone(),
99                source,
100            })?;
101        let root = manifest_path
102            .parent()
103            .unwrap_or_else(|| Path::new("."))
104            .to_path_buf();
105        let lockfile_path = manifest_path.with_file_name(crate::LOCKFILE_FILENAME);
106        Ok(Self {
107            root,
108            manifest_path,
109            lockfile_path,
110            document,
111        })
112    }
113
114    /// Discovers the nearest ancestor project starting from `start`.
115    ///
116    /// This walks each ancestor directory, checking for `module.json`, and
117    /// stops at the first `.git` directory boundary. It returns `Ok(None)`
118    /// when no ancestor project exists before that boundary.
119    pub fn discover(start: &Path) -> Result<Option<Self>, ProjectError> {
120        for directory in start.ancestors() {
121            let manifest_path = directory.join(crate::MANIFEST_FILENAME);
122            match std::fs::symlink_metadata(&manifest_path) {
123                Ok(_) => return Self::load(manifest_path).map(Some),
124                Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
125                Err(source) => {
126                    return Err(ProjectError::Io {
127                        path: manifest_path,
128                        source,
129                    });
130                }
131            }
132            if directory.join(".git").exists() {
133                break;
134            }
135        }
136        Ok(None)
137    }
138
139    /// Returns the directory containing the loaded `module.json`.
140    pub fn root(&self) -> &Path {
141        &self.root
142    }
143
144    /// Returns the exact `module.json` path that loaded this project.
145    pub fn manifest_path(&self) -> &Path {
146        &self.manifest_path
147    }
148
149    /// Returns the sibling `module-lock.json` path for this project.
150    ///
151    /// The path is stable even when no lockfile exists yet.
152    pub fn lockfile_path(&self) -> &Path {
153        &self.lockfile_path
154    }
155
156    /// Returns the lossless `module.json` document loaded from
157    /// [`Self::manifest_path`].
158    pub fn document(&self) -> &ManifestDocument {
159        &self.document
160    }
161
162    /// Returns the validated manifest view of the current `module.json`
163    /// document.
164    pub fn manifest(&self) -> &Manifest {
165        self.document.manifest()
166    }
167
168    /// Reloads the exact `module.json` path from disk.
169    ///
170    /// This keeps the same project root and sibling lockfile path while
171    /// replacing the in-memory manifest document with the latest bytes.
172    pub fn reload(&mut self) -> Result<(), ProjectError> {
173        let bytes = std::fs::read(&self.manifest_path).map_err(|source| ProjectError::Io {
174            path: self.manifest_path.clone(),
175            source,
176        })?;
177        self.document =
178            ManifestDocument::parse(&bytes).map_err(|source| ProjectError::Manifest {
179                path: self.manifest_path.clone(),
180                source,
181            })?;
182        Ok(())
183    }
184
185    /// Loads the sibling `module-lock.json` when it exists.
186    ///
187    /// See [`LockedLockfile::read`] for the locking and empty-file behavior.
188    pub fn load_lockfile(&self) -> Result<Option<Lockfile>, ProjectError> {
189        LockedLockfile::read(&self.lockfile_path)
190    }
191
192    /// Writes `document` to this project's `module.json`.
193    ///
194    /// The manifest is replaced by an atomic rename, so a reader sees either
195    /// the previous file or the complete new one. No lock is taken, because a
196    /// text editor or another tool may rewrite the manifest at any time; the
197    /// last writer wins.
198    pub fn write_manifest(&self, document: &ManifestDocument) -> Result<(), ProjectError> {
199        let bytes = document
200            .to_bytes()
201            .map_err(|source| ProjectError::Manifest {
202                path: self.manifest_path.clone(),
203                source,
204            })?;
205        write_atomically(&self.manifest_path, &bytes)
206    }
207}
208
209/// Replaces `path` with `bytes` through an atomic rename.
210///
211/// The temporary file is created in the destination directory so the rename
212/// stays on one filesystem, and its permissions are aligned with the
213/// destination before the rename.
214fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
215    let directory = path.parent().unwrap_or_else(|| Path::new("."));
216    let mut temp =
217        tempfile::NamedTempFile::new_in(directory).map_err(|source| ProjectError::Io {
218            path: directory.to_path_buf(),
219            source,
220        })?;
221    temp.write_all(bytes).map_err(|source| ProjectError::Io {
222        path: temp.path().to_path_buf(),
223        source,
224    })?;
225    align_temp_permissions(&temp, path)?;
226    temp.persist(path).map_err(|e| ProjectError::Io {
227        path: path.to_path_buf(),
228        source: e.error,
229    })?;
230    Ok(())
231}
232
233/// Aligns a temporary file's permissions with the destination before rename.
234fn align_temp_permissions(temp: &tempfile::NamedTempFile, path: &Path) -> Result<(), ProjectError> {
235    if let Ok(metadata) = std::fs::metadata(path) {
236        temp.as_file()
237            .set_permissions(metadata.permissions())
238            .map_err(|source| ProjectError::Io {
239                path: temp.path().to_path_buf(),
240                source,
241            })?;
242        return Ok(());
243    }
244
245    #[cfg(unix)]
246    {
247        use std::os::unix::fs::PermissionsExt as _;
248        temp.as_file()
249            .set_permissions(std::fs::Permissions::from_mode(0o644))
250            .map_err(|source| ProjectError::Io {
251                path: temp.path().to_path_buf(),
252                source,
253            })?;
254    }
255
256    Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    const MANIFEST: &[u8] = br#"{"name":"example","license":"MIT"}"#;
264
265    #[test]
266    fn load_uses_exact_manifest_and_sibling_lockfile_paths() {
267        let directory = tempfile::tempdir().unwrap();
268        let path = directory.path().join(crate::MANIFEST_FILENAME);
269        std::fs::write(&path, MANIFEST).unwrap();
270
271        let project = ModuleProject::load(&path).unwrap();
272
273        assert_eq!(project.root(), directory.path());
274        assert_eq!(project.manifest_path(), path);
275        assert_eq!(
276            project.lockfile_path(),
277            directory.path().join(crate::LOCKFILE_FILENAME)
278        );
279        assert_eq!(project.manifest().name, "example");
280    }
281
282    #[test]
283    fn discover_finds_nearest_ancestor_manifest() {
284        let directory = tempfile::tempdir().unwrap();
285        std::fs::write(directory.path().join(crate::MANIFEST_FILENAME), MANIFEST).unwrap();
286        let nested = directory.path().join("a").join("b");
287        std::fs::create_dir_all(&nested).unwrap();
288
289        let project = ModuleProject::discover(&nested).unwrap().unwrap();
290        assert_eq!(project.root(), directory.path());
291    }
292
293    #[test]
294    fn discover_stops_after_git_boundary() {
295        let outer = tempfile::tempdir().unwrap();
296        std::fs::write(outer.path().join(crate::MANIFEST_FILENAME), MANIFEST).unwrap();
297        let repository = outer.path().join("repo");
298        let nested = repository.join("nested");
299        std::fs::create_dir_all(repository.join(".git")).unwrap();
300        std::fs::create_dir_all(&nested).unwrap();
301
302        assert!(ModuleProject::discover(&nested).unwrap().is_none());
303    }
304
305    #[test]
306    fn missing_lockfile_loads_as_none() {
307        let directory = tempfile::tempdir().unwrap();
308        let path = directory.path().join(crate::MANIFEST_FILENAME);
309        std::fs::write(&path, MANIFEST).unwrap();
310        let project = ModuleProject::load(path).unwrap();
311
312        assert!(project.load_lockfile().unwrap().is_none());
313    }
314
315    #[cfg(unix)]
316    #[test]
317    fn write_atomically_gives_new_files_mode_0644()
318    -> std::result::Result<(), Box<dyn std::error::Error>> {
319        use std::os::unix::fs::PermissionsExt as _;
320
321        let directory = tempfile::tempdir()?;
322        let path = directory.path().join(crate::MANIFEST_FILENAME);
323
324        write_atomically(&path, b"{}\n")?;
325
326        assert_eq!(
327            std::fs::metadata(&path)?.permissions().mode() & 0o777,
328            0o644
329        );
330        Ok(())
331    }
332
333    #[cfg(unix)]
334    #[test]
335    fn write_manifest_preserves_an_existing_mode()
336    -> std::result::Result<(), Box<dyn std::error::Error>> {
337        use std::os::unix::fs::PermissionsExt as _;
338
339        let directory = tempfile::tempdir()?;
340        let path = directory.path().join(crate::MANIFEST_FILENAME);
341        std::fs::write(&path, MANIFEST)?;
342        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
343        let project = ModuleProject::load(&path)?;
344
345        project.write_manifest(project.document())?;
346
347        assert_eq!(
348            std::fs::metadata(&path)?.permissions().mode() & 0o777,
349            0o600
350        );
351        Ok(())
352    }
353}