Simple manifest parsing
This defines the basic provider and package structure of manifest files,
and adds a function to parse manifests starting at a project root. This
parsing is initially simple, only visiting providers defined within the
root manifest file.
A `qg package` CLI command is added to demonstrate manifest parsing.
Change-Id: I171fc21bd6299a726dd13615c070a406b85b61cc
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/121904
Reviewed-by: Erik Gilling <konkers@google.com>
Commit-Queue: Alexei Frolov <frolv@google.com>
diff --git a/qg-cli/src/main.rs b/qg-cli/src/main.rs
index 33e2932..7824800 100644
--- a/qg-cli/src/main.rs
+++ b/qg-cli/src/main.rs
@@ -16,6 +16,7 @@
#![cfg_attr(feature = "strict", deny(warnings))]
mod hello;
+mod packages;
mod subcommands;
#[cfg(feature = "new_command")]
@@ -38,7 +39,7 @@
let matches = app.get_matches();
match Subcommands::from_arg_matches(&matches) {
- Ok(subcommands) => subcommands.run(),
+ Ok(subcommands) => subcommands.run().await,
Err(e) if e.kind() == clap::error::ErrorKind::MissingSubcommand => {
run_main_command(&matches);
Ok(())
diff --git a/qg-cli/src/packages.rs b/qg-cli/src/packages.rs
new file mode 100644
index 0000000..18d1c65
--- /dev/null
+++ b/qg-cli/src/packages.rs
@@ -0,0 +1,39 @@
+// 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 anyhow::Result;
+use qg::Project;
+
+/// Manages packages in a project.
+#[derive(clap::Parser, Debug)]
+#[command(about)]
+pub struct Command {}
+
+// TODO(frolv): Actually use async.
+#[allow(clippy::unused_async)]
+pub async fn run(_args: &Command) -> Result<()> {
+ let registry = Project::locate()?.parse_manifests()?;
+
+ for (name, packages) in registry.packages_by_name() {
+ if packages.len() == 1 {
+ println!("{name}");
+ } else {
+ for package in packages {
+ println!("{}", package.canonical_slug());
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/qg-cli/src/subcommands.rs b/qg-cli/src/subcommands.rs
index 97a0c12..43b00c4 100644
--- a/qg-cli/src/subcommands.rs
+++ b/qg-cli/src/subcommands.rs
@@ -14,7 +14,7 @@
use anyhow::Result;
-use crate::hello;
+use crate::{hello, packages};
#[cfg(feature = "new_command")]
use crate::new;
@@ -32,6 +32,7 @@
#[cfg(feature = "new_command")]
New(new::Command),
+ Package(packages::Command),
#[cfg(feature = "python")]
PyDemo(py_demo::Command),
@@ -39,11 +40,14 @@
impl Subcommands {
/// Invokes a subcommand with its parsed arguments.
- pub fn run(&self) -> Result<()> {
+ pub async fn run(&self) -> Result<()> {
match self {
Self::Hello(args) => {
hello::run(args);
}
+ Self::Package(args) => {
+ packages::run(args).await?;
+ }
#[cfg(feature = "new_command")]
Self::New(args) => {
new::run(args)?;
diff --git a/qg/Cargo.toml b/qg/Cargo.toml
index e83dd5d..f0e12f8 100644
--- a/qg/Cargo.toml
+++ b/qg/Cargo.toml
@@ -11,7 +11,9 @@
num-traits = "0.2.15"
serde = { version = "1.0.147", features = ["derive"] }
thiserror = "1.0.37"
+tokio = { version = "1.21.2", features = ["full"] }
toml = "0.5.9"
+futures = "0.3.25"
[dependencies.rustpython]
git = "https://github.com/RustPython/RustPython"
diff --git a/qg/src/lib.rs b/qg/src/lib.rs
index beb126b..91e3893 100644
--- a/qg/src/lib.rs
+++ b/qg/src/lib.rs
@@ -14,10 +14,16 @@
// Allows presubmit to fail on warnings.
#![cfg_attr(feature = "strict", deny(warnings))]
+#![warn(clippy::pedantic)]
use std::path::{Path, PathBuf};
+pub mod package;
pub mod project;
+pub mod registry;
+
+#[doc(inline)]
+pub use package::Package;
#[doc(inline)]
pub use project::Project;
diff --git a/qg/src/package.rs b/qg/src/package.rs
new file mode 100644
index 0000000..44bf754
--- /dev/null
+++ b/qg/src/package.rs
@@ -0,0 +1,76 @@
+// 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 crate::project::manifest;
+
+/// An installable `qg` package.
+#[derive(Debug)]
+pub struct Package {
+ name: String,
+ provider: String,
+ metadata: Metadata,
+}
+
+#[derive(Debug)]
+enum Metadata {
+ Cipd(manifest::CipdPackage),
+}
+
+impl From<manifest::PackageType> for Metadata {
+ fn from(pt: manifest::PackageType) -> Self {
+ match pt {
+ manifest::PackageType::Cipd(data) => Self::Cipd(data),
+ }
+ }
+}
+
+impl Package {
+ pub(crate) fn from_manifest(name: &str, provider: &str, package: manifest::Package) -> Self {
+ Self {
+ name: name.to_owned(),
+ provider: provider.to_owned(),
+ metadata: package.desc.into(),
+ }
+ }
+
+ /// Returns the name of the package.
+ #[must_use]
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// Returns the name of the package's provider.
+ #[must_use]
+ pub fn provider(&self) -> &str {
+ &self.provider
+ }
+
+ /// Returns the type of the package as a string.
+ #[must_use]
+ pub fn type_string(&self) -> &str {
+ match self.metadata {
+ Metadata::Cipd(_) => "cipd",
+ }
+ }
+
+ /// Returns the globally-unique identifier for the package.
+ #[must_use]
+ pub fn canonical_slug(&self) -> String {
+ if self.provider.is_empty() {
+ self.name.clone()
+ } else {
+ format!("{}:{}", self.provider, self.name)
+ }
+ }
+}
diff --git a/qg/src/project/file.rs b/qg/src/project/file.rs
index 0d4f8df..24e5360 100644
--- a/qg/src/project/file.rs
+++ b/qg/src/project/file.rs
@@ -28,8 +28,6 @@
}
impl File {
- // TODO(konkers): Remove once this is referenced.
- #[allow(dead_code)]
pub(super) fn new(path: impl AsRef<Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
@@ -42,6 +40,24 @@
&self.path
}
+ /// Reads the contents of the file into a string.
+ ///
+ /// # Errors
+ /// Returns an error if the file could not be read.
+ pub fn read_text(&self) -> Result<String> {
+ std::fs::read_to_string(&self.path).map_err(Error::from)
+ }
+
+ /// Writes a string to the file.
+ ///
+ /// Creates the file if it does not exist, or overwrites existing contents.
+ ///
+ /// # Errors
+ /// Returns an error if the file could not be written.
+ pub fn write_text(&self, contents: &str) -> Result<()> {
+ std::fs::write(&self.path, contents).map_err(Error::from)
+ }
+
/// Writes a serializable struct to the file as TOML.
///
/// Creates the file if it does not exist, or overwrites existing contents.
@@ -82,6 +98,15 @@
func(&mut value)?;
self.serialize_toml(&value)
}
+
+ pub(crate) fn relative_file(&self, path: &Path) -> Self {
+ Self::new(
+ self.path
+ .parent()
+ .expect("file must have parent")
+ .join(path),
+ )
+ }
}
#[cfg(test)]
diff --git a/qg/src/project/manifest.rs b/qg/src/project/manifest.rs
index 3207f2c..271130f 100644
--- a/qg/src/project/manifest.rs
+++ b/qg/src/project/manifest.rs
@@ -12,16 +12,65 @@
// License for the specific language governing permissions and limitations under
// the License.
+use std::{collections::HashMap, path::PathBuf};
+
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct Manifest {
+ /// Information about the project to which the manifest belongs.
pub project: Project,
+
+ #[serde(default)]
+ pub providers: HashMap<String, ProviderDescriptor>,
+}
+
+/// A qg-based project.
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Project {
+ /// The name of the project.
+ pub name: String,
}
#[derive(Debug, Deserialize, Serialize)]
-pub struct Project {
- pub name: String,
+pub struct ProviderDescriptor {
+ pub manifest: Option<PathBuf>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct ProviderFile {
+ #[serde(default)]
+ pub packages: HashMap<String, Package>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Package {
+ #[serde(flatten)]
+ pub desc: PackageType,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum PackageType {
+ Cipd(CipdPackage),
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct CipdPackage {
+ /// The location on of the package in CIPD's repositories.
+ pub path: String,
+
+ /// Platforms the package supports.
+ #[serde(default)]
+ pub platforms: Vec<String>,
+
+ /// CIPD tags to match against when finding a package revision.
+ #[serde(default)]
+ pub tags: Vec<String>,
+
+ /// Local subdirectory under the CIPD installation root into which the
+ /// package is installed.
+ pub subdir: Option<PathBuf>,
}
impl Manifest {
@@ -30,6 +79,7 @@
project: Project {
name: name.to_owned(),
},
+ providers: HashMap::new(),
}
}
}
diff --git a/qg/src/project/mod.rs b/qg/src/project/mod.rs
index 31df184..32183a0 100644
--- a/qg/src/project/mod.rs
+++ b/qg/src/project/mod.rs
@@ -17,11 +17,12 @@
path::{Path, PathBuf},
};
+use crate::registry::Registry;
use crate::{Error, Result};
use manifest::Manifest;
pub mod file;
-mod manifest;
+pub(crate) mod manifest;
#[doc(inline)]
pub use file::File;
@@ -145,6 +146,34 @@
Self::relative_file(&self.cache_dir, Path::new(path.as_ref()))
}
+ /// 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.
+ pub 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();
+
+ for (provider, desc) in &root_manifest.providers {
+ registry.add_provider(provider);
+
+ if let Some(manifest) = &desc.manifest {
+ let provider_file = root_manifest_file
+ .relative_file(manifest)
+ .deserialize_toml::<manifest::ProviderFile>()?;
+
+ for (name, package) in provider_file.packages {
+ registry.add_package(crate::Package::from_manifest(&name, provider, package));
+ }
+ }
+ }
+
+ Ok(registry)
+ }
+
fn root_manifest(&self) -> self::File {
self.file(Self::MANIFEST_FILE)
.expect("manifest path is not absolute")
diff --git a/qg/src/registry.rs b/qg/src/registry.rs
new file mode 100644
index 0000000..ada1f20
--- /dev/null
+++ b/qg/src/registry.rs
@@ -0,0 +1,81 @@
+// 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::{
+ collections::{HashMap, HashSet},
+ sync::Arc,
+};
+
+use crate::Package;
+
+/// A database of packages known to `qg`.
+#[derive(Debug)]
+pub struct Registry {
+ /// Mapping of package slug to package. Slugs are globally unique.
+ packages: HashMap<String, Arc<Package>>,
+
+ /// Mapping of package name to packages. The same package can come form
+ /// multiple providers.
+ packages_by_name: HashMap<String, Vec<Arc<Package>>>,
+
+ /// All known package providers by name.
+ /// TODO(frolv): Make this a map storing provider metadata.
+ providers: HashSet<String>,
+}
+
+impl Registry {
+ /// Initializes an empty package registry.
+ #[must_use]
+ pub fn new() -> Self {
+ Self {
+ packages: HashMap::new(),
+ packages_by_name: HashMap::new(),
+ providers: HashSet::new(),
+ }
+ }
+
+ /// Returns an iterator over all known packages grouped by package name.
+ pub fn packages_by_name(&self) -> impl Iterator<Item = (&str, Vec<&Package>)> {
+ self.packages_by_name
+ .iter()
+ .map(|(k, v)| (k.as_str(), v.iter().map(Arc::as_ref).collect()))
+ }
+
+ pub(crate) fn add_provider(&mut self, name: &str) -> bool {
+ self.providers.insert(name.to_owned())
+ }
+
+ pub(crate) fn add_package(&mut self, package: Package) -> bool {
+ if !self.providers.contains(package.provider()) {
+ return false;
+ }
+
+ let package = Arc::new(package);
+ self.packages
+ .insert(package.canonical_slug(), package.clone());
+
+ self.packages_by_name
+ .entry(package.name().into())
+ .or_default()
+ .push(package);
+
+ true
+ }
+}
+
+impl Default for Registry {
+ fn default() -> Self {
+ Self::new()
+ }
+}