blob: c4ff0d4e2dff6dadc060015177c2714b75efdd7e [file] [log] [blame]
// Copyright 2022 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.
use std::{
cell::{Ref, RefCell},
ffi::OsStr,
path::{Path, PathBuf},
};
use crate::{registry::Registry, target::Provider};
use crate::{Error, Result};
use manifest::Manifest;
pub mod file;
pub(crate) mod manifest;
#[doc(inline)]
pub use file::File;
/// A `qg`-based project.
#[derive(Debug)]
pub struct Project {
root: PathBuf,
cache_dir: PathBuf,
name: String,
// Use a RefCell to provide interior mutability on the registry so that it can
// be lazily loaded without taking a mutable reference on the Project.
registry: RefCell<Option<Registry>>,
}
impl Project {
const MANIFEST_FILE: &str = "qg.toml";
const CACHE_DIRECTORY: &str = "qg-cache";
/// Checks if a project name is valid.
///
/// Names may consist of unicode letters or numbers, hyphens, and underscores.
#[must_use]
pub fn name_is_valid(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
/// Searches for a qg project in the current directory and its ancestors.
///
/// # Errors
/// May return one of the following errors:
/// - [`Error::File`]: Failed to access the filesystem.
/// - [`Error::ProjectNotFound`]: No project could be located.
pub fn locate() -> Result<Self> {
let cwd = std::env::current_dir()?;
let cwd = cwd.as_path();
Self::locate_from_path(cwd)
}
// Implementation details of `locate()` without looking up the
// current working directory. This allows testing of the core
// logic without dependence on the environment and makes it
// friendlier to parallel test execution.
fn locate_from_path<P: AsRef<Path>>(cwd: P) -> Result<Self> {
let mut cwd = cwd.as_ref();
loop {
if let Ok(project) = Self::load(cwd) {
return Ok(project);
}
cwd = match cwd.parent() {
Some(p) => p,
None => return Err(Error::ProjectNotFound),
}
}
}
/// Load a qg project from an specific path.
///
/// # Errors
/// May return one of the following errors:
/// - [`Error::File`]: Failed to access the filesystem.
/// - [`Error::ProjectNotFound`]: No project manifest found in `project_root`.
pub fn load<P: AsRef<Path>>(project_root: P) -> Result<Self> {
let project_root = project_root.as_ref();
if !project_root.join(Self::MANIFEST_FILE).exists() {
return Err(Error::ProjectNotFound);
}
let manifest = self::File::new(project_root.join(Self::MANIFEST_FILE))
.deserialize_toml::<Manifest>()?;
Ok(Self {
root: project_root.to_owned(),
cache_dir: project_root.join(Self::CACHE_DIRECTORY),
name: manifest.project.name,
registry: RefCell::new(None),
})
}
/// Initializes a new project at the specified `root`.
///
/// # Errors
/// Returns an error if the root directory does not exist or is
/// inaccessible.
///
/// # Panics
/// Temporarily panics if a project already exists within `root` as
/// subprojects are not yet supported.
pub fn create(root: &Path, name: &str) -> Result<Self> {
let parent_project = match Self::locate() {
Ok(p) => Some(p),
Err(Error::ProjectNotFound) => None,
Err(e) => return Err(e),
};
// TODO(frolv): Initialize a new subproject in an existing project.
assert!(parent_project.is_none(), "subprojects not yet supported");
if !Self::name_is_valid(name) {
return Err(Error::InvalidName(name.into()));
}
let project = Self {
root: root.to_owned(),
cache_dir: root.join(Self::CACHE_DIRECTORY),
name: name.into(),
registry: RefCell::new(None),
};
let manifest = Manifest::new(name);
project.root_manifest().serialize_toml(&manifest)?;
Ok(project)
}
/// Returns the root directory of the project.
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// Returns the name of the project.
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
/// Returns a [`File`](self::File) representing some configuration file located
/// at `path` within the project directory.
///
/// # Errors
/// Returns an [`Error::InvalidPath`] if the provided path is absolute.
pub fn file(&self, path: impl AsRef<OsStr>) -> Result<self::File> {
Self::relative_file(&self.root, Path::new(path.as_ref()))
}
/// Returns a [`File`](self::File) representing some file located at `path`
/// within the project's cache directory.
///
/// # Errors
/// Returns an [`Error::InvalidPath`] if the provided path is absolute.
pub fn cache_file(&self, path: impl AsRef<OsStr>) -> Result<self::File> {
Self::relative_file(&self.cache_dir, Path::new(path.as_ref()))
}
/// Returns the target registry for this project. Will locate and parse
/// manifests if they have not already been read.
///
/// # Errors
/// Returns an error if manifest files could not be read or are improperly
/// structured.
pub fn registry(&self) -> Result<Ref<Registry>> {
{
// Scope the mutable borrow to just the existence check and
// manifest parsing.
let mut registry = self.registry.borrow_mut();
if registry.is_none() {
*registry = Some(self.parse_manifests()?);
}
}
Ok(Ref::map(self.registry.borrow(), |registry| {
registry
.as_ref()
.expect("registry should be valid due to being created above")
}))
}
/// Reads `qg` manifest files starting from the project root into a registry
/// of available packages.
///
/// # Errors
/// Returns an error if manifest files could not be read or are improperly
/// structured.
fn parse_manifests(&self) -> Result<Registry> {
let root_manifest_file = self.root_manifest();
let root_manifest = root_manifest_file.deserialize_toml::<Manifest>()?;
let mut registry = Registry::new();
let project_provider = Provider::new(
&root_manifest.project.name,
root_manifest_file.path(),
false,
);
registry.add_provider(project_provider)?;
for (name, target) in root_manifest.targets {
registry.add_target(crate::Target::from_manifest(
&name,
&root_manifest.project.name,
target.namespace.is_global(),
target,
)?)?;
}
for (provider, desc) in &root_manifest.providers {
if let Some(manifest) = &desc.manifest {
let provider_file = root_manifest_file.relative_file(manifest);
let provider_data = provider_file.deserialize_toml::<manifest::ProviderFile>()?;
let global_provider = if let Some(desc) = &provider_data.provider {
desc.namespace.is_global()
} else {
false
};
registry.add_provider(Provider::new(
provider,
provider_file.path(),
global_provider,
))?;
for (name, target) in provider_data.targets {
registry.add_target(crate::Target::from_manifest(
&name,
provider,
global_provider || target.namespace.is_global(),
target,
)?)?;
}
}
}
Ok(registry)
}
fn root_manifest(&self) -> self::File {
self.file(Self::MANIFEST_FILE)
.expect("manifest path is not absolute")
}
fn relative_file(root: &Path, path: &Path) -> Result<self::File> {
if !path.as_os_str().is_empty() && !path.is_absolute() {
Ok(self::File::new(root.join(path)))
} else {
Err(Error::InvalidPath)
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::target::Metadata;
use super::*;
#[test]
fn name_is_valid_valid() {
assert!(Project::name_is_valid("qg"));
assert!(Project::name_is_valid("my-project"));
assert!(Project::name_is_valid("my_other_project"));
assert!(Project::name_is_valid("мой-проект"));
}
#[test]
fn name_is_valid_invalid() {
assert!(!Project::name_is_valid(""));
assert!(!Project::name_is_valid("project!"));
assert!(!Project::name_is_valid("💩"));
}
#[test]
fn file_valid_path() {
let root = PathBuf::from("/qg");
let project = Project {
cache_dir: root.join("qg-cache"),
root,
name: "qg2".into(),
registry: RefCell::new(None),
};
assert_eq!(
project.file("emoji.json").unwrap().path(),
Path::new("/qg/emoji.json")
);
assert_eq!(
project.file("secrets/nuclear_codes.toml").unwrap().path(),
Path::new("/qg/secrets/nuclear_codes.toml")
);
assert_eq!(
project.cache_file("downloads.xml").unwrap().path(),
Path::new("/qg/qg-cache/downloads.xml")
);
}
#[test]
fn file_invalid_path() {
let root = PathBuf::from("/qg");
let project = Project {
cache_dir: root.join("qg-cache"),
root,
name: "qg2".into(),
registry: RefCell::new(None),
};
assert!(matches!(
project.file("/bin").unwrap_err(),
Error::InvalidPath,
));
assert!(matches!(
project.cache_file("/").unwrap_err(),
Error::InvalidPath,
));
assert!(matches!(project.file("").unwrap_err(), Error::InvalidPath));
assert!(matches!(
project.cache_file("").unwrap_err(),
Error::InvalidPath,
));
}
#[test]
fn locate_test_project() {
// Test that `locate()` and find a manifest from a parent directory.
let project =
Project::locate_from_path("./src/test_projects/simple/subdir/subsubdir").unwrap();
assert_eq!(project.name, "test-qg-project");
}
#[test]
fn load_test_project() {
// Loads from the project root succeed.
let project = Project::load("./src/test_projects/simple").unwrap();
assert_eq!(project.name, "test-qg-project");
// Loads from a project subdirectory fail.
assert!(Project::load("./src/test_projects/simple/subdir/subsubdir").is_err());
}
#[test]
fn simple_dependencies() {
let project = Project::load("./src/test_projects/dependency_test").unwrap();
assert_eq!(project.name, "dep-test");
let registry = project.registry().unwrap();
let a_id = registry.get_node_id("dep-test:a").unwrap();
let b_id = registry.get_node_id("dep-test:b").unwrap();
let c_id = registry.get_node_id("dep-test:c").unwrap();
let d_id = registry.get_node_id("dep-test:d").unwrap();
let e_id = registry.get_node_id("dep-test:e").unwrap();
for (name, id, duration_ticks, children) in [
("a", a_id, 1, vec![b_id, c_id]),
("b", b_id, 2, vec![]),
("c", c_id, 3, vec![d_id, e_id]),
("d", d_id, 4, vec![]),
("e", e_id, 5, vec![]),
] {
let target = registry.get_target_by_id(id).unwrap();
let Metadata::Fake(fake) = target.metadata() else {
panic!("{name} is not of the target type Fake: {target:?}.");
};
assert_eq!(
fake.duration_ticks,
duration_ticks,
"Node {name}'s durations ticks ({}) do not match expected value ({duration_ticks}).",
fake.duration_ticks);
let deps: HashSet<_> = registry.node_deps(id).collect();
let expected: HashSet<_> = children.into_iter().collect();
assert_eq!(
deps, expected,
"Node {name}'s dependencies ({deps:?}) do not match expected value ({expected:?})."
);
}
}
}