1mod document;
12mod lockfile;
14mod 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#[derive(Debug, Error)]
35pub enum ProjectError {
36 #[error("i/o error at `{path}`")]
38 Io {
39 path: PathBuf,
41 #[source]
43 source: std::io::Error,
44 },
45
46 #[error("invalid module manifest at `{path}`")]
48 Manifest {
49 path: PathBuf,
51 #[source]
53 source: ManifestDocumentError,
54 },
55
56 #[error("invalid module lockfile at `{path}`")]
58 Lockfile {
59 path: PathBuf,
61 #[source]
63 source: LockfileError,
64 },
65}
66
67#[derive(Clone, Debug)]
74pub struct ModuleProject {
75 root: PathBuf,
77 manifest_path: PathBuf,
79 lockfile_path: PathBuf,
81 document: ManifestDocument,
83}
84
85impl ModuleProject {
86 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 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 pub fn root(&self) -> &Path {
141 &self.root
142 }
143
144 pub fn manifest_path(&self) -> &Path {
146 &self.manifest_path
147 }
148
149 pub fn lockfile_path(&self) -> &Path {
153 &self.lockfile_path
154 }
155
156 pub fn document(&self) -> &ManifestDocument {
159 &self.document
160 }
161
162 pub fn manifest(&self) -> &Manifest {
165 self.document.manifest()
166 }
167
168 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 pub fn load_lockfile(&self) -> Result<Option<Lockfile>, ProjectError> {
189 LockedLockfile::read(&self.lockfile_path)
190 }
191
192 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
209fn 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
233fn 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}