mod document;
mod lockfile;
mod validation;
use std::io::Write as _;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
pub use self::document::ManifestDocument;
pub use self::document::ManifestDocumentError;
pub use self::lockfile::LockedLockfile;
pub use self::validation::ProjectFileKind;
pub use self::validation::ProjectValidationError;
use crate::Lockfile;
use crate::Manifest;
use crate::lockfile::LockfileError;
#[derive(Debug, Error)]
pub enum ProjectError {
#[error("i/o error at `{path}`")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid module manifest at `{path}`")]
Manifest {
path: PathBuf,
#[source]
source: ManifestDocumentError,
},
#[error("invalid module lockfile at `{path}`")]
Lockfile {
path: PathBuf,
#[source]
source: LockfileError,
},
}
#[derive(Clone, Debug)]
pub struct ModuleProject {
root: PathBuf,
manifest_path: PathBuf,
lockfile_path: PathBuf,
document: ManifestDocument,
}
impl ModuleProject {
pub fn load(path: impl Into<PathBuf>) -> Result<Self, ProjectError> {
let manifest_path = path.into();
let bytes = std::fs::read(&manifest_path).map_err(|source| ProjectError::Io {
path: manifest_path.clone(),
source,
})?;
let document =
ManifestDocument::parse(&bytes).map_err(|source| ProjectError::Manifest {
path: manifest_path.clone(),
source,
})?;
let root = manifest_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let lockfile_path = manifest_path.with_file_name(crate::LOCKFILE_FILENAME);
Ok(Self {
root,
manifest_path,
lockfile_path,
document,
})
}
pub fn discover(start: &Path) -> Result<Option<Self>, ProjectError> {
for directory in start.ancestors() {
let manifest_path = directory.join(crate::MANIFEST_FILENAME);
match std::fs::symlink_metadata(&manifest_path) {
Ok(_) => return Self::load(manifest_path).map(Some),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(ProjectError::Io {
path: manifest_path,
source,
});
}
}
if directory.join(".git").exists() {
break;
}
}
Ok(None)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn manifest_path(&self) -> &Path {
&self.manifest_path
}
pub fn lockfile_path(&self) -> &Path {
&self.lockfile_path
}
pub fn document(&self) -> &ManifestDocument {
&self.document
}
pub fn manifest(&self) -> &Manifest {
self.document.manifest()
}
pub fn reload(&mut self) -> Result<(), ProjectError> {
let bytes = std::fs::read(&self.manifest_path).map_err(|source| ProjectError::Io {
path: self.manifest_path.clone(),
source,
})?;
self.document =
ManifestDocument::parse(&bytes).map_err(|source| ProjectError::Manifest {
path: self.manifest_path.clone(),
source,
})?;
Ok(())
}
pub fn load_lockfile(&self) -> Result<Option<Lockfile>, ProjectError> {
LockedLockfile::read(&self.lockfile_path)
}
pub fn write_manifest(&self, document: &ManifestDocument) -> Result<(), ProjectError> {
let bytes = document
.to_bytes()
.map_err(|source| ProjectError::Manifest {
path: self.manifest_path.clone(),
source,
})?;
write_atomically(&self.manifest_path, &bytes)
}
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), ProjectError> {
let directory = path.parent().unwrap_or_else(|| Path::new("."));
let mut temp =
tempfile::NamedTempFile::new_in(directory).map_err(|source| ProjectError::Io {
path: directory.to_path_buf(),
source,
})?;
temp.write_all(bytes).map_err(|source| ProjectError::Io {
path: temp.path().to_path_buf(),
source,
})?;
align_temp_permissions(&temp, path)?;
temp.persist(path).map_err(|e| ProjectError::Io {
path: path.to_path_buf(),
source: e.error,
})?;
Ok(())
}
fn align_temp_permissions(temp: &tempfile::NamedTempFile, path: &Path) -> Result<(), ProjectError> {
if let Ok(metadata) = std::fs::metadata(path) {
temp.as_file()
.set_permissions(metadata.permissions())
.map_err(|source| ProjectError::Io {
path: temp.path().to_path_buf(),
source,
})?;
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
temp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o644))
.map_err(|source| ProjectError::Io {
path: temp.path().to_path_buf(),
source,
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const MANIFEST: &[u8] = br#"{"name":"example","license":"MIT"}"#;
#[test]
fn load_uses_exact_manifest_and_sibling_lockfile_paths() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(crate::MANIFEST_FILENAME);
std::fs::write(&path, MANIFEST).unwrap();
let project = ModuleProject::load(&path).unwrap();
assert_eq!(project.root(), directory.path());
assert_eq!(project.manifest_path(), path);
assert_eq!(
project.lockfile_path(),
directory.path().join(crate::LOCKFILE_FILENAME)
);
assert_eq!(project.manifest().name, "example");
}
#[test]
fn discover_finds_nearest_ancestor_manifest() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join(crate::MANIFEST_FILENAME), MANIFEST).unwrap();
let nested = directory.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let project = ModuleProject::discover(&nested).unwrap().unwrap();
assert_eq!(project.root(), directory.path());
}
#[test]
fn discover_stops_after_git_boundary() {
let outer = tempfile::tempdir().unwrap();
std::fs::write(outer.path().join(crate::MANIFEST_FILENAME), MANIFEST).unwrap();
let repository = outer.path().join("repo");
let nested = repository.join("nested");
std::fs::create_dir_all(repository.join(".git")).unwrap();
std::fs::create_dir_all(&nested).unwrap();
assert!(ModuleProject::discover(&nested).unwrap().is_none());
}
#[test]
fn missing_lockfile_loads_as_none() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join(crate::MANIFEST_FILENAME);
std::fs::write(&path, MANIFEST).unwrap();
let project = ModuleProject::load(path).unwrap();
assert!(project.load_lockfile().unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn write_atomically_gives_new_files_mode_0644()
-> std::result::Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir()?;
let path = directory.path().join(crate::MANIFEST_FILENAME);
write_atomically(&path, b"{}\n")?;
assert_eq!(
std::fs::metadata(&path)?.permissions().mode() & 0o777,
0o644
);
Ok(())
}
#[cfg(unix)]
#[test]
fn write_manifest_preserves_an_existing_mode()
-> std::result::Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir()?;
let path = directory.path().join(crate::MANIFEST_FILENAME);
std::fs::write(&path, MANIFEST)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
let project = ModuleProject::load(&path)?;
project.write_manifest(project.document())?;
assert_eq!(
std::fs::metadata(&path)?.permissions().mode() & 0o777,
0o600
);
Ok(())
}
}