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}