pub mod application_name;
pub mod configuration_file;
pub mod configuration_path;
pub mod data_directory;
pub mod no_configuration;
pub mod project_identifier;
pub mod project_root;
mod local_data_directory;
mod portable_name;
use std::error::Error;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use bon::bon;
use kawauso_config::Loader;
use serde::Serialize;
use serde::de::DeserializeOwned;
pub use self::application_name::ApplicationName;
pub use self::configuration_file::ConfigurationFile;
pub use self::configuration_path::ConfigurationPath;
pub use self::data_directory::DataDirectory;
use self::local_data_directory::LocalDataDirectory;
pub use self::no_configuration::NoConfiguration;
pub use self::project_identifier::ProjectIdentifier;
pub use self::project_root::ProjectRoot;
use crate::error::CreateDataDirectoryError;
use crate::error::DiscoverProjectError;
use crate::error::LoadProjectError;
use crate::error::discover::Markers;
use crate::search::Fallback;
use crate::search::Marker;
use crate::search::Search;
use crate::search::StartDirectory;
use crate::search::state::Marked;
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub struct Project<T = NoConfiguration> {
root: ProjectRoot,
application: ApplicationName,
marker: Option<Marker>,
configuration: Option<T>,
configuration_path: ConfigurationPath,
}
#[bon]
impl<T> Project<T>
where
T: DeserializeOwned,
{
#[builder(
builder_type(vis = "pub"),
state_mod(vis = "pub"),
start_fn(name = builder, vis = "pub"),
finish_fn(name = finish, vis = "")
)]
fn new(
#[builder(finish_fn)] search: &Search<Marked>,
application: ApplicationName,
#[builder(default = ConfigurationSource::Conventional, setters(name = configuration_source, vis = ""))]
configuration: ConfigurationSource,
#[builder(default, setters(name = no_configuration_file, vis = ""))]
without_configuration: WithoutConfiguration,
) -> Result<Self, LoadProjectError> {
let start = resolve(search.start_directory().get(), std::env::current_dir())
.map_err(|source| LoadProjectError::UndiscoverableProject { source })?;
let discovery = walk(&start, search.markers(), search.fallback())
.map_err(|source| LoadProjectError::UndiscoverableProject { source })?;
let configuration_file = match &configuration {
ConfigurationSource::File(file) => file.clone(),
ConfigurationSource::Conventional => configuration_file_of(&application),
ConfigurationSource::Directory => configuration_directory_of(&application),
};
let configuration_path =
ConfigurationPath::new(discovery.root.get().join(configuration_file.get()));
let configuration = match without_configuration {
WithoutConfiguration::Present => None,
WithoutConfiguration::Absent => {
if exists(configuration_path.get()) {
Some(load_configuration(&configuration_path)?)
} else {
None
}
}
};
Ok(Self {
root: discovery.root,
application,
marker: discovery.marker,
configuration,
configuration_path,
})
}
}
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
enum ConfigurationSource {
Conventional,
File(ConfigurationFile),
Directory,
}
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
enum WithoutConfiguration {
#[default]
Absent,
Present,
}
impl<'search, T, S: project_builder::State> ProjectBuilder<'search, T, S>
where
T: DeserializeOwned,
{
pub fn configuration_file(
self,
configuration_file: impl Into<ConfigurationFile>,
) -> ProjectBuilder<'search, T, project_builder::SetConfiguration<S>>
where
S::Configuration: project_builder::IsUnset,
S::WithoutConfiguration: project_builder::IsUnset,
{
self.configuration_source(ConfigurationSource::File(configuration_file.into()))
}
pub fn without_configuration(
self,
) -> ProjectBuilder<'search, T, project_builder::SetWithoutConfiguration<S>>
where
S::Configuration: project_builder::IsUnset,
S::WithoutConfiguration: project_builder::IsUnset,
{
self.no_configuration_file(WithoutConfiguration::Present)
}
pub fn with_configuration_directory(
self,
) -> ProjectBuilder<'search, T, project_builder::SetConfiguration<S>>
where
S::Configuration: project_builder::IsUnset,
S::WithoutConfiguration: project_builder::IsUnset,
{
self.configuration_source(ConfigurationSource::Directory)
}
}
impl<T, S> ProjectBuilder<'_, T, S>
where
T: DeserializeOwned,
S: project_builder::IsComplete,
{
pub fn load(self, search: &Search<Marked>) -> Result<Project<T>, LoadProjectError> {
self.finish(search)
}
pub fn load_or_create<F>(
self,
search: &Search<Marked>,
configuration: F,
) -> Result<Project<T>, LoadProjectError>
where
F: FnOnce() -> T,
S::WithoutConfiguration: project_builder::IsUnset,
T: Serialize,
{
let mut project = self.finish(search)?;
if project.configuration.is_none() {
let configuration = configuration();
create_configuration(&project.configuration_path, &configuration)?;
project.configuration = Some(configuration);
}
Ok(project)
}
}
impl<T> Project<T> {
pub fn configuration(&self) -> Option<&T> {
self.configuration.as_ref()
}
pub fn configuration_path(&self) -> &ConfigurationPath {
&self.configuration_path
}
pub fn data_directory(
&self,
identifier: &ProjectIdentifier,
) -> Result<DataDirectory, CreateDataDirectoryError> {
self.data_directory_below(LocalDataDirectory::of_platform(), identifier)
}
fn data_directory_below(
&self,
base: Option<LocalDataDirectory>,
identifier: &ProjectIdentifier,
) -> Result<DataDirectory, CreateDataDirectoryError> {
create_data_directory(base, &self.application, identifier)
}
pub fn marker(&self) -> Option<&Marker> {
self.marker.as_ref()
}
pub fn root(&self) -> &ProjectRoot {
&self.root
}
}
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
struct Discovery {
root: ProjectRoot,
marker: Option<Marker>,
}
fn configuration_file_of(application: &ApplicationName) -> ConfigurationFile {
ConfigurationFile::new(PathBuf::from(".config").join(format!("{application}.toml")))
}
fn configuration_directory_of(application: &ApplicationName) -> ConfigurationFile {
ConfigurationFile::new(
PathBuf::from(".config")
.join(application.get())
.join("config.toml"),
)
}
fn create_data_directory(
base: Option<LocalDataDirectory>,
application: &ApplicationName,
identifier: &ProjectIdentifier,
) -> Result<DataDirectory, CreateDataDirectoryError> {
let base = base
.filter(|base| base.get().is_absolute())
.ok_or(CreateDataDirectoryError::UnknownLocalDataDirectory {})?;
let directory = DataDirectory::new(
base.get()
.join(application.get())
.join("projects")
.join(identifier.get()),
);
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(directory.get()).map_err(|source| {
CreateDataDirectoryError::UncreatableDataDirectory {
path: directory.clone(),
source,
}
})?;
Ok(directory)
}
fn create_configuration<T>(
path: &ConfigurationPath,
configuration: &T,
) -> Result<(), LoadProjectError>
where
T: Serialize,
{
let uncreatable =
|source: Box<dyn Error + Send + Sync>| LoadProjectError::UncreatableConfiguration {
path: path.clone(),
source,
};
let contents =
toml::to_string_pretty(configuration).map_err(|source| uncreatable(Box::new(source)))?;
if let Some(directory) = path.get().parent() {
std::fs::create_dir_all(directory).map_err(|source| uncreatable(Box::new(source)))?;
}
std::fs::write(path.get(), contents).map_err(|source| uncreatable(Box::new(source)))
}
fn load_configuration<T>(path: &ConfigurationPath) -> Result<T, LoadProjectError>
where
T: DeserializeOwned,
{
Loader::path(path.get())
.load()
.map_err(|source| LoadProjectError::UnloadableConfiguration {
path: path.clone(),
source: Box::new(source),
})
}
fn exists(path: &Path) -> bool {
std::fs::metadata(path).is_ok()
}
fn is_inside(marker: &Marker) -> bool {
let mut names_an_entry = false;
for component in marker.get().components() {
match component {
Component::Normal(_) => names_an_entry = true,
Component::CurDir => {}
Component::ParentDir | Component::Prefix(_) | Component::RootDir => return false,
}
}
names_an_entry
}
fn resolve(
start: &Path,
working_directory: std::io::Result<PathBuf>,
) -> Result<PathBuf, DiscoverProjectError> {
let absolute = if start.is_absolute() {
start.to_path_buf()
} else {
let working_directory = working_directory
.map_err(|source| DiscoverProjectError::UnknownWorkingDirectory { source })?;
working_directory.join(start)
};
let start = std::fs::canonicalize(&absolute).map_err(|source| {
DiscoverProjectError::UnreadableStart {
start: StartDirectory::new(absolute),
source,
}
})?;
if start.is_dir() {
return Ok(start);
}
let directory = start
.parent()
.map_or_else(|| start.clone(), Path::to_path_buf);
Ok(directory)
}
fn walk(
start: &Path,
markers: &[Marker],
fallback: Fallback,
) -> Result<Discovery, DiscoverProjectError> {
if let Some(marker) = markers.iter().find(|marker| !is_inside(marker)) {
return Err(DiscoverProjectError::OutsideMarker {
marker: marker.clone(),
});
}
for directory in start.ancestors() {
for marker in markers {
if exists(&directory.join(marker.get())) {
return Ok(Discovery {
root: ProjectRoot::new(directory.to_path_buf()),
marker: Some(marker.clone()),
});
}
}
}
match fallback {
Fallback::Start => Ok(Discovery {
root: ProjectRoot::new(start.to_path_buf()),
marker: None,
}),
Fallback::Error => Err(DiscoverProjectError::MissingProject {
start: StartDirectory::new(start.to_path_buf()),
markers: Markers::new(markers.to_vec()),
}),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::missing_panics_doc)]
use std::io::ErrorKind;
use tempfile::TempDir;
use super::*;
const ABSENT: &str = ".kawauso-project-absent-marker";
fn canonical(root: &TempDir) -> PathBuf {
std::fs::canonicalize(root.path()).unwrap()
}
fn directory(root: &TempDir, path: &str) -> PathBuf {
let directory = root.path().join(path);
std::fs::create_dir_all(&directory).unwrap();
directory
}
fn file(root: &TempDir, path: &str) {
let file = root.path().join(path);
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(file, "").unwrap();
}
fn application() -> ApplicationName {
"example".parse().unwrap()
}
fn identifier() -> ProjectIdentifier {
"example-project".parse().unwrap()
}
#[test]
fn create_data_directory_creates_the_directory() {
let root = tempfile::tempdir().unwrap();
let base = LocalDataDirectory::new(root.path().join("data"));
let directory = create_data_directory(Some(base), &application(), &identifier()).unwrap();
assert!(directory.get().is_dir());
}
#[cfg(unix)]
#[test]
fn create_data_directory_on_unix_creates_directories_for_the_user_alone() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let base = root.path().join("data");
create_data_directory(
Some(LocalDataDirectory::new(base.clone())),
&application(),
&identifier(),
)
.unwrap();
let modes: Vec<u32> = [
base.clone(),
base.join("example"),
base.join("example").join("projects"),
base.join("example")
.join("projects")
.join("example-project"),
]
.iter()
.map(|path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777)
.collect();
assert_eq!(modes, vec![0o700; 4]);
}
#[test]
fn create_data_directory_returns_the_directory_of_the_project() {
let root = tempfile::tempdir().unwrap();
let base = LocalDataDirectory::new(root.path().to_path_buf());
let directory = create_data_directory(Some(base), &application(), &identifier()).unwrap();
assert_eq!(
directory.get(),
root.path()
.join("example")
.join("projects")
.join("example-project")
);
}
#[test]
fn create_data_directory_with_a_file_as_the_base_names_the_directory() {
let root = tempfile::tempdir().unwrap();
file(&root, "data");
let base = LocalDataDirectory::new(root.path().join("data"));
let error = create_data_directory(Some(base), &application(), &identifier()).unwrap_err();
assert_eq!(
error.to_string(),
format!(
"failed to create the data directory of the project at `{}`",
root.path()
.join("data")
.join("example")
.join("projects")
.join("example-project")
.display()
)
);
}
#[test]
fn create_data_directory_with_a_file_as_the_base_reports_the_cause() {
let root = tempfile::tempdir().unwrap();
file(&root, "data");
let base = LocalDataDirectory::new(root.path().join("data"));
let error = create_data_directory(Some(base), &application(), &identifier()).unwrap_err();
assert!(Error::source(&error).is_some());
}
#[test]
fn create_data_directory_with_a_file_as_the_base_returns_an_error() {
let root = tempfile::tempdir().unwrap();
file(&root, "data");
let base = LocalDataDirectory::new(root.path().join("data"));
let error = create_data_directory(Some(base), &application(), &identifier()).unwrap_err();
assert!(matches!(
error,
CreateDataDirectoryError::UncreatableDataDirectory { .. }
));
}
#[test]
fn create_data_directory_with_a_relative_base_returns_an_error() {
let base = LocalDataDirectory::new(PathBuf::from("Cargo.toml").join("data"));
let error = create_data_directory(Some(base), &application(), &identifier()).unwrap_err();
assert!(matches!(
error,
CreateDataDirectoryError::UnknownLocalDataDirectory { .. }
));
}
#[test]
fn create_data_directory_with_an_existing_directory_returns_the_directory() {
let root = tempfile::tempdir().unwrap();
let existing = directory(&root, "example/projects/example-project");
let base = LocalDataDirectory::new(root.path().to_path_buf());
let directory = create_data_directory(Some(base), &application(), &identifier()).unwrap();
assert_eq!(directory.get(), existing);
}
#[test]
fn create_data_directory_without_a_base_returns_an_error() {
let error = create_data_directory(None, &application(), &identifier()).unwrap_err();
assert!(matches!(
error,
CreateDataDirectoryError::UnknownLocalDataDirectory { .. }
));
}
#[test]
fn data_directory_below_returns_the_directory_of_the_application() {
let root = tempfile::tempdir().unwrap();
directory(&root, "project/.git");
let search = Search::start(root.path().join("project")).marker(".git");
let project: Project = Project::builder()
.application("other".parse().unwrap())
.load(&search)
.unwrap();
let base = LocalDataDirectory::new(root.path().join("data"));
let directory = project
.data_directory_below(Some(base), &identifier())
.unwrap();
assert_eq!(
directory.get(),
root.path()
.join("data")
.join("other")
.join("projects")
.join("example-project")
);
}
#[test]
fn load_creates_no_data_directory() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let application: ApplicationName =
"kawauso-project-test-load-creates-nothing".parse().unwrap();
let search = Search::start(root.path()).marker(".git");
let _project: Project = Project::builder()
.application(application.clone())
.load(&search)
.unwrap();
let base = LocalDataDirectory::of_platform().expect("the test needs a data directory");
assert!(!exists(&base.get().join(application.get())));
}
#[test]
fn resolve_with_a_file_returns_its_directory() {
let root = tempfile::tempdir().unwrap();
file(&root, "src/main.rs");
let start = resolve(&root.path().join("src").join("main.rs"), Ok(PathBuf::new())).unwrap();
assert_eq!(start, canonical(&root).join("src"));
}
#[test]
fn resolve_with_a_relative_start_resolves_against_the_working_directory() {
let root = tempfile::tempdir().unwrap();
directory(&root, "src");
let start = resolve(Path::new("src"), Ok(root.path().to_path_buf())).unwrap();
assert_eq!(start, canonical(&root).join("src"));
}
#[test]
fn resolve_with_a_start_that_does_not_exist_returns_an_error() {
let root = tempfile::tempdir().unwrap();
let error = resolve(&root.path().join("absent"), Ok(PathBuf::new())).unwrap_err();
assert!(matches!(
error,
DiscoverProjectError::UnreadableStart { .. }
));
}
#[cfg(unix)]
#[test]
fn resolve_with_a_symbolic_link_returns_the_real_path() {
let root = tempfile::tempdir().unwrap();
directory(&root, "real/inner");
std::os::unix::fs::symlink(root.path().join("real"), root.path().join("link")).unwrap();
let start = resolve(&root.path().join("link").join("inner"), Ok(PathBuf::new())).unwrap();
assert_eq!(start, canonical(&root).join("real").join("inner"));
}
#[test]
fn resolve_with_dot_components_removes_them() {
let root = tempfile::tempdir().unwrap();
directory(&root, "src");
let start = resolve(
&root.path().join("src").join("..").join("src"),
Ok(PathBuf::new()),
)
.unwrap();
assert_eq!(start, canonical(&root).join("src"));
}
#[test]
fn resolve_without_a_working_directory_returns_an_error() {
let unknown = std::io::Error::new(ErrorKind::NotFound, "the working directory is gone");
let error = resolve(Path::new("src"), Err(unknown)).unwrap_err();
assert!(matches!(
error,
DiscoverProjectError::UnknownWorkingDirectory { .. }
));
}
#[test]
fn walk_with_a_directory_as_the_marker_returns_the_project() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let discovery = walk(root.path(), &[Marker::from(".git")], Fallback::Error).unwrap();
assert_eq!(discovery.root.get(), root.path());
}
#[test]
fn walk_with_a_marker_above_the_directory_returns_an_error() {
let root = tempfile::tempdir().unwrap();
let error = walk(root.path(), &[Marker::from("../.git")], Fallback::Error).unwrap_err();
assert!(matches!(error, DiscoverProjectError::OutsideMarker { .. }));
}
#[test]
fn walk_with_a_marker_in_an_ancestor_returns_the_ancestor() {
let root = tempfile::tempdir().unwrap();
file(&root, "Cargo.toml");
let start = directory(&root, "crates/example/src");
let discovery = walk(&start, &[Marker::from("Cargo.toml")], Fallback::Error).unwrap();
assert_eq!(discovery.root.get(), root.path());
}
#[test]
fn walk_with_a_marker_in_an_ancestor_returns_the_marker() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let start = directory(&root, "src");
let discovery = walk(&start, &[Marker::from(".git")], Fallback::Error).unwrap();
assert_eq!(discovery.marker.unwrap().get(), Path::new(".git"));
}
#[test]
fn walk_with_a_marker_in_two_ancestors_returns_the_nearer_ancestor() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let parent = directory(&root, "crates");
directory(&root, "crates/.git");
let start = directory(&root, "crates/example");
let discovery = walk(&start, &[Marker::from(".git")], Fallback::Error).unwrap();
assert_eq!(discovery.root.get(), parent);
}
#[test]
fn walk_with_a_marker_that_names_no_entry_returns_an_error() {
let root = tempfile::tempdir().unwrap();
let error = walk(root.path(), &[Marker::from(".")], Fallback::Error).unwrap_err();
assert!(matches!(error, DiscoverProjectError::OutsideMarker { .. }));
}
#[test]
fn walk_with_an_absolute_marker_returns_an_error() {
let root = tempfile::tempdir().unwrap();
let error = walk(root.path(), &[Marker::from("/etc")], Fallback::Error).unwrap_err();
assert!(matches!(error, DiscoverProjectError::OutsideMarker { .. }));
}
#[test]
fn walk_with_markers_in_the_start_and_an_ancestor_reports_the_start() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let start = directory(&root, "crates/example");
directory(&root, "crates/example/.git");
let discovery = walk(&start, &[Marker::from(".git")], Fallback::Error).unwrap();
assert_eq!(discovery.root.get(), start);
}
#[test]
fn walk_with_the_second_marker_in_the_start_returns_the_start() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
let start = directory(&root, "crates/example");
file(&root, "crates/example/Cargo.toml");
let discovery = walk(
&start,
&[Marker::from(".git"), Marker::from("Cargo.toml")],
Fallback::Error,
)
.unwrap();
assert_eq!(discovery.root.get(), start);
}
#[test]
fn walk_with_two_markers_in_the_start_returns_the_first() {
let root = tempfile::tempdir().unwrap();
directory(&root, ".git");
file(&root, "Cargo.toml");
let discovery = walk(
root.path(),
&[Marker::from(".git"), Marker::from("Cargo.toml")],
Fallback::Error,
)
.unwrap();
assert_eq!(discovery.marker.unwrap().get(), Path::new(".git"));
}
#[test]
fn walk_without_a_match_and_the_fallback_returns_no_marker() {
let root = tempfile::tempdir().unwrap();
let discovery = walk(root.path(), &[Marker::from(ABSENT)], Fallback::Start).unwrap();
assert!(discovery.marker.is_none());
}
#[test]
fn walk_without_a_match_and_the_fallback_returns_the_start() {
let root = tempfile::tempdir().unwrap();
let discovery = walk(root.path(), &[Marker::from(ABSENT)], Fallback::Start).unwrap();
assert_eq!(discovery.root.get(), root.path());
}
#[test]
fn walk_without_a_match_names_the_start_and_the_markers() {
let root = tempfile::tempdir().unwrap();
let error = walk(root.path(), &[Marker::from(ABSENT)], Fallback::Error).unwrap_err();
assert_eq!(
error.to_string(),
format!(
"no project exists at or above `{}`, because no directory holds any of these markers: `{ABSENT}`",
root.path().display()
)
);
}
#[test]
fn walk_without_a_match_returns_an_error() {
let root = tempfile::tempdir().unwrap();
let error = walk(root.path(), &[Marker::from(ABSENT)], Fallback::Error).unwrap_err();
assert!(matches!(error, DiscoverProjectError::MissingProject { .. }));
}
}