| // 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 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(); |
| |
| let a_deps: HashSet<_> = registry.node_deps(a_id).collect(); |
| assert_eq!(a_deps, [b_id, c_id].into_iter().collect()); |
| |
| let b_deps: HashSet<_> = registry.node_deps(b_id).collect(); |
| assert_eq!(b_deps, [].into_iter().collect()); |
| |
| let c_deps: HashSet<_> = registry.node_deps(c_id).collect(); |
| assert_eq!(c_deps, [d_id, e_id].into_iter().collect()); |
| |
| let d_deps: HashSet<_> = registry.node_deps(d_id).collect(); |
| assert_eq!(d_deps, [].into_iter().collect()); |
| |
| let e_deps: HashSet<_> = registry.node_deps(e_id).collect(); |
| assert_eq!(e_deps, [].into_iter().collect()); |
| } |
| } |