Begin project definition and setup
This defines a qg-based project as a directory tree containing a
`qg.toml` file at its root and adds basic functionality to locate one.
Additionally, simple project initialization is implemented through the
`qg new` subcommand, creating a project directory with a starter
`qg.toml`. This is available behind the `new_command` feature.
Change-Id: Iac4622bc2eb2fd5ef519cb07bc6156a580a90c9b
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/119991
Commit-Queue: Alexei Frolov <frolv@google.com>
Reviewed-by: Erik Gilling <konkers@google.com>
diff --git a/qg-cli/Cargo.toml b/qg-cli/Cargo.toml
index 315008c..bee90ca 100644
--- a/qg-cli/Cargo.toml
+++ b/qg-cli/Cargo.toml
@@ -15,4 +15,5 @@
[features]
default = []
+new_command = []
python = ["qg/python"]
diff --git a/qg-cli/src/main.rs b/qg-cli/src/main.rs
index 5b7d147..33e2932 100644
--- a/qg-cli/src/main.rs
+++ b/qg-cli/src/main.rs
@@ -16,9 +16,11 @@
#![cfg_attr(feature = "strict", deny(warnings))]
mod hello;
-mod new;
mod subcommands;
+#[cfg(feature = "new_command")]
+mod new;
+
#[cfg(feature = "python")]
mod py_demo;
diff --git a/qg-cli/src/new.rs b/qg-cli/src/new.rs
index d645b60..2aea2d7 100644
--- a/qg-cli/src/new.rs
+++ b/qg-cli/src/new.rs
@@ -12,11 +12,27 @@
// License for the specific language governing permissions and limitations under
// the License.
-use anyhow::{anyhow, Result};
+use std::{fs, path::Path};
+
+use anyhow::{bail, Result};
+use qg::Project;
#[derive(clap::Parser, Debug)]
-pub struct Command {}
+pub struct Command {
+ /// The name of the project.
+ name: String,
+}
-pub fn run(_args: &Command) -> Result<()> {
- Err(anyhow!("Not implemented"))
+pub fn run(args: &Command) -> Result<()> {
+ let root = Path::new(&args.name);
+ if root.exists() {
+ bail!(r#""{}" already exists in the current directory"#, args.name);
+ }
+
+ println!("Creating new project {}", args.name);
+ fs::create_dir_all(root)?;
+
+ Project::create(root, &args.name)?;
+
+ Ok(())
}
diff --git a/qg-cli/src/subcommands.rs b/qg-cli/src/subcommands.rs
index aa3b870..97a0c12 100644
--- a/qg-cli/src/subcommands.rs
+++ b/qg-cli/src/subcommands.rs
@@ -14,7 +14,10 @@
use anyhow::Result;
-use crate::{hello, new};
+use crate::hello;
+
+#[cfg(feature = "new_command")]
+use crate::new;
#[cfg(feature = "python")]
use crate::py_demo;
@@ -26,6 +29,8 @@
#[derive(clap::Parser, Debug)]
pub enum Subcommands {
Hello(hello::Command),
+
+ #[cfg(feature = "new_command")]
New(new::Command),
#[cfg(feature = "python")]
@@ -39,6 +44,7 @@
Self::Hello(args) => {
hello::run(args);
}
+ #[cfg(feature = "new_command")]
Self::New(args) => {
new::run(args)?;
}
diff --git a/qg/src/lib.rs b/qg/src/lib.rs
index 64e25a2..beb126b 100644
--- a/qg/src/lib.rs
+++ b/qg/src/lib.rs
@@ -19,6 +19,9 @@
pub mod project;
+#[doc(inline)]
+pub use project::Project;
+
#[cfg(feature = "python")]
mod py;
@@ -36,6 +39,15 @@
#[error("failed to serialize data: {0}")]
Serialization(String),
+
+ #[error("project not found")]
+ ProjectNotFound,
+
+ #[error(r#""{0}" is not a valid name"#)]
+ InvalidName(String),
+
+ #[error("invalid path")]
+ InvalidPath,
}
impl Error {
diff --git a/qg/src/project/file.rs b/qg/src/project/file.rs
index 76e14c3..0d4f8df 100644
--- a/qg/src/project/file.rs
+++ b/qg/src/project/file.rs
@@ -22,6 +22,7 @@
use crate::{Error, Result};
/// A file storing data in some serialized format.
+#[derive(Debug)]
pub struct File {
path: PathBuf,
}
diff --git a/qg/src/project/manifest.rs b/qg/src/project/manifest.rs
new file mode 100644
index 0000000..3207f2c
--- /dev/null
+++ b/qg/src/project/manifest.rs
@@ -0,0 +1,35 @@
+// 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 serde::{Deserialize, Serialize};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Manifest {
+ pub project: Project,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Project {
+ pub name: String,
+}
+
+impl Manifest {
+ pub fn new(name: &str) -> Self {
+ Self {
+ project: Project {
+ name: name.to_owned(),
+ },
+ }
+ }
+}
diff --git a/qg/src/project/mod.rs b/qg/src/project/mod.rs
index 2585fd1..31df184 100644
--- a/qg/src/project/mod.rs
+++ b/qg/src/project/mod.rs
@@ -12,6 +12,216 @@
// License for the specific language governing permissions and limitations under
// the License.
-pub mod file;
+use std::{
+ ffi::OsStr,
+ path::{Path, PathBuf},
+};
+use crate::{Error, Result};
+use manifest::Manifest;
+
+pub mod file;
+mod manifest;
+
+#[doc(inline)]
pub use file::File;
+
+/// A `qg`-based project.
+#[derive(Debug)]
+pub struct Project {
+ root: PathBuf,
+ cache_dir: PathBuf,
+ name: String,
+}
+
+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 mut cwd = cwd.as_path();
+
+ let project_root = loop {
+ if cwd.join(Self::MANIFEST_FILE).exists() {
+ break cwd;
+ }
+
+ cwd = match cwd.parent() {
+ Some(p) => p,
+ None => 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,
+ })
+ }
+
+ /// 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(),
+ };
+
+ 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()))
+ }
+
+ 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 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(),
+ };
+
+ 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(),
+ };
+
+ 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,
+ ));
+ }
+}
diff --git a/tools/presubmit.sh b/tools/presubmit.sh
index ba47a29..3896558 100755
--- a/tools/presubmit.sh
+++ b/tools/presubmit.sh
@@ -36,7 +36,7 @@
export PATH="$CIPD_DIR/install/bin:$CIPD_DIR/install:$PATH"
fi
-FEATURES=python,strict
+FEATURES=new_command,python,strict
# Build and test in release mode to make tests execute faster.
check cargo build --release --features ${FEATURES}