| // 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::{ |
| ffi::OsStr, |
| fs, |
| path::{Path, PathBuf}, |
| sync::Arc, |
| }; |
| |
| use crate::{executor::Executor, registry::Registry, Target}; |
| 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, |
| qg_dir: PathBuf, |
| name: String, |
| registry: Arc<Registry>, |
| } |
| |
| impl Project { |
| const MANIFEST_FILE: &str = "qg.toml"; |
| const QG_DIRECTORY: &str = "qg"; |
| |
| /// 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(); |
| let manifest_path = project_root.join(Self::MANIFEST_FILE); |
| if !manifest_path.exists() { |
| return Err(Error::ProjectNotFound); |
| } |
| |
| let manifest_file = self::File::new(manifest_path); |
| let manifest = manifest_file.deserialize_toml::<Manifest>()?; |
| |
| let registry = Registry::parse_manifests(&manifest_file)?; |
| |
| Ok(Self { |
| root: project_root.to_owned(), |
| qg_dir: project_root.join(Self::QG_DIRECTORY), |
| name: manifest.project.name, |
| registry: Arc::new(registry), |
| }) |
| } |
| |
| /// 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 manifest = Manifest::new(name); |
| Self::relative_file(root, Path::new(Self::MANIFEST_FILE))?.serialize_toml(&manifest)?; |
| |
| Self::load(root) |
| } |
| |
| /// 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 `PathBuf` pointing to the directory located at `path` within |
| /// the project root. Will create the directory if it does not exist. |
| /// |
| /// # Errors |
| /// Returns an error if the directory does not exist and can not be |
| /// created. |
| fn directory(&self, path: impl AsRef<Path>) -> Result<PathBuf> { |
| let dir_path = self.root.join(path.as_ref()); |
| fs::create_dir_all(&dir_path)?; |
| Ok(dir_path) |
| } |
| |
| /// Returns a `PathBuf` pointing to the output directory for a given target. |
| /// Will create the directory if it does not exist. |
| /// |
| /// # Errors |
| /// Returns an error if the directory does not exist and can not be |
| /// created. |
| pub fn target_output_directory(&self, target: &Target) -> Result<PathBuf> { |
| self.directory( |
| self.qg_dir |
| .join("out") |
| .join(target.provider()) |
| .join(target.name()), |
| ) |
| } |
| |
| /// Returns a `PathBuf` pointing to the output directory for a given target. |
| /// Will create the directory if it does not exist. |
| /// |
| /// # Errors |
| /// Returns an error if the directory does not exist and can not be |
| /// created. |
| pub fn target_work_directory(&self, target: &Target) -> Result<PathBuf> { |
| self.directory( |
| self.qg_dir |
| .join("work") |
| .join(target.provider()) |
| .join(target.name()), |
| ) |
| } |
| |
| /// Executes a single target in the build graph. |
| /// |
| /// # Errors |
| /// Returns an error if the target or any of its dependencies failed to run. |
| pub async fn run_target(&self, target: &str) -> Result<()> { |
| // TODO(frolv): Configure the number of workers based on the system thread count. |
| let mut executor = Executor::new(self, 2); |
| executor.execute_target(target).await |
| } |
| |
| /// Returns the target registry for this project. |
| #[must_use] |
| pub fn registry(&self) -> Arc<Registry> { |
| self.registry.clone() |
| } |
| |
| 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, test_util::TestProject}; |
| |
| 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 { |
| qg_dir: root.join("qg"), |
| root, |
| name: "qg2".into(), |
| registry: Arc::new(Registry::new()), |
| }; |
| |
| 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") |
| ); |
| } |
| |
| #[test] |
| fn file_invalid_path() { |
| let root = PathBuf::from("/qg"); |
| let project = Project { |
| qg_dir: root.join("qg"), |
| root, |
| name: "qg2".into(), |
| registry: Arc::new(Registry::new()), |
| }; |
| |
| assert!(matches!( |
| project.file("/bin").unwrap_err(), |
| Error::InvalidPath, |
| )); |
| assert!(matches!(project.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 out_and_work_directories() { |
| let test_project = TestProject::new("./src/test_projects/dependency_test").unwrap(); |
| let project = &test_project.project; |
| assert_eq!(project.name, "dep-test"); |
| |
| // Neither dep-test:a's out or work directories should exist before we |
| // ask for their paths. |
| assert!(!test_project.relative_path("qg/out/dep-test/a").exists()); |
| assert!(!test_project.relative_path("qg/work/dep-test/a").exists()); |
| |
| let target = project.registry().get_target("dep-test:a").unwrap(); |
| |
| project.target_output_directory(&target).unwrap(); |
| assert!(test_project.relative_path("qg/out/dep-test/a").exists()); |
| |
| project.target_work_directory(&target).unwrap(); |
| assert!(test_project.relative_path("qg/work/dep-test/a").exists()); |
| } |
| |
| #[test] |
| fn simple_dependencies() { |
| let test_project = TestProject::new("./src/test_projects/dependency_test").unwrap(); |
| let project = &test_project.project; |
| assert_eq!(project.name, "dep-test"); |
| |
| let registry = project.registry(); |
| 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:?})." |
| ); |
| } |
| } |
| } |