Define downloadable targets

This adds a second type of target to manifests: downloadable targets.
These are targets which can be downloaded from a defined URL. URLs may
contain interpolated variables with platform-specific overrides.

This change only defines these targets in the manifest; it does not
implement interpolation or platform overriding.

Change-Id: I9d3552ac5c6495a17e53301ed9a475d3d5377fc3
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/qg/+/124956
Commit-Queue: Alexei Frolov <frolv@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/examples/simple_project/qg.toml b/examples/simple_project/qg.toml
new file mode 100644
index 0000000..3b41715
--- /dev/null
+++ b/examples/simple_project/qg.toml
@@ -0,0 +1,20 @@
+[project]
+name = "simple-qg-project"
+
+[targets.cipd]
+type = "download"
+namespace = "global"
+url = "https://chrome-infra-packages.appspot.com/client?platform={platform}&version={version}"
+url_parameters = { version = "git_revision:97c6e32ecb4f25a703bbe23aa459ea57886464c2" }
+
+[[targets.cipd.variants]]
+match = { os = "linux", arch = "x64" }
+url_parameters = { platform = "linux-amd64" }
+
+[[targets.cipd.variants]]
+match = { os = "macos", arch = "x64" }
+url_parameters = { platform = "mac-amd64" }
+
+[[targets.cipd.variants]]
+match = { os = "windows", arch = "x64" }
+url_parameters = { platform = "windows-amd64" }
diff --git a/qg-cli/src/target.rs b/qg-cli/src/target.rs
index 7fd8bef..497da63 100644
--- a/qg-cli/src/target.rs
+++ b/qg-cli/src/target.rs
@@ -32,8 +32,8 @@
         registry.target_count()
     );
 
-    for (name, target) in registry.targets() {
-        println!("* {name}");
+    for target in registry.targets() {
+        println!("* {}", target.full_name());
         println!("  type: {}", target.type_string());
     }
 
diff --git a/qg/src/project/manifest.rs b/qg/src/project/manifest.rs
index eef95a4..0d55272 100644
--- a/qg/src/project/manifest.rs
+++ b/qg/src/project/manifest.rs
@@ -55,6 +55,12 @@
     }
 }
 
+impl ProviderNamespace {
+    pub fn is_global(&self) -> bool {
+        self == &Self::Global
+    }
+}
+
 /// Information about a target provider.
 #[derive(Debug, Deserialize, Serialize)]
 pub struct ProviderDescriptor {
@@ -85,6 +91,10 @@
     #[serde(flatten)]
     pub desc: Option<TargetType>,
 
+    /// Scope of the target. Overrides the default provider scope.
+    #[serde(default)]
+    pub namespace: ProviderNamespace,
+
     /// List of other targets on which this one depends.
     #[serde(default)]
     pub deps: Vec<String>,
@@ -94,6 +104,7 @@
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum TargetType {
     Cipd(CipdPackage),
+    Download(DownloadablePackage),
 }
 
 /// A downloadable CIPD package.
@@ -115,6 +126,47 @@
     pub subdir: Option<PathBuf>,
 }
 
+/// A package downloadable from a known URL.
+#[derive(Debug, Deserialize, Serialize)]
+pub struct DownloadablePackage {
+    /// The URL at which the package is located.
+    ///
+    /// The URL may define arbitrary parameters enclosed in braces, which will
+    /// be interpolated with values from the `url_parameters` field.
+    ///
+    /// For example, the URL `https://api.left-pad.io?str={string}&len={length}`
+    /// will substitute the values of `string` and `length` defined within
+    /// `url_parameters` into the appropriate query params.
+    pub url: String,
+
+    /// Variable substitutions for `url`. See its documentation for details.
+    #[serde(default)]
+    pub url_parameters: HashMap<String, String>,
+
+    /// Overrides for the `url_parameters` field. Overrides are processed in
+    /// order of definition, with the first match being applied.
+    #[serde(default)]
+    pub variants: Vec<DownloadVariants>,
+}
+
+/// An override of the default `url_parameters` for a downloadable package.
+#[derive(Debug, Deserialize, Serialize)]
+pub struct DownloadVariants {
+    /// Mapping of variable -> value against which this override is matched.
+    /// The matchable variables are:
+    ///
+    ///   * `os`: The system on which the build is running. One of `linux`,
+    ///     `macos`, or `windows`.
+    ///   * `arch`: System architecture. Either `x64` or `arm64`.
+    ///
+    #[serde(default, rename = "match")]
+    pub match_vars: HashMap<String, String>,
+
+    /// Parameter overrides to apply if this variant is matched.
+    #[serde(default)]
+    pub url_parameters: HashMap<String, String>,
+}
+
 impl Manifest {
     pub fn new(name: &str) -> Self {
         Self {
diff --git a/qg/src/project/mod.rs b/qg/src/project/mod.rs
index bbd2de8..68f32ec 100644
--- a/qg/src/project/mod.rs
+++ b/qg/src/project/mod.rs
@@ -168,6 +168,7 @@
             registry.add_target(crate::Target::from_manifest(
                 &name,
                 &root_manifest.project.name,
+                target.namespace.is_global(),
                 target,
             ));
         }
@@ -177,16 +178,25 @@
                 let provider_file = root_manifest_file.relative_file(manifest);
 
                 let provider_data = provider_file.deserialize_toml::<manifest::ProviderFile>()?;
-                let is_global = if let Some(desc) = &provider_data.provider {
-                    desc.namespace == manifest::ProviderNamespace::Global
+                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(), is_global));
+                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, target));
+                    registry.add_target(crate::Target::from_manifest(
+                        &name,
+                        provider,
+                        global_provider || target.namespace.is_global(),
+                        target,
+                    ));
                 }
             }
         }
diff --git a/qg/src/registry.rs b/qg/src/registry.rs
index 2ed229f..376e0f7 100644
--- a/qg/src/registry.rs
+++ b/qg/src/registry.rs
@@ -50,10 +50,8 @@
     }
 
     /// Returns an iterator over all known targets, in arbitrary order.
-    pub fn targets(&self) -> impl Iterator<Item = (&str, &Target)> {
-        self.targets
-            .iter()
-            .map(|(k, v)| (k.as_str(), Arc::as_ref(v)))
+    pub fn targets(&self) -> impl Iterator<Item = &Target> {
+        self.targets.values().map(Arc::as_ref)
     }
 
     pub(crate) fn add_provider(&mut self, provider: Provider) -> bool {
@@ -64,15 +62,11 @@
 
     /// Inserts a target into the registry.
     pub(crate) fn add_target(&mut self, target: Target) -> bool {
-        let Some(provider) = self.providers.get(target.provider()) else {
+        if !self.providers.contains_key(target.provider()) {
             return false;
         };
 
-        let slug = if provider.global {
-            target.name().to_string()
-        } else {
-            format!("{}:{}", provider.name, target.name())
-        };
+        let target_name = target.full_name();
 
         for dep in target
             .dependencies()
@@ -81,10 +75,10 @@
             self.unresolved_dependencies.insert(dep.into());
         }
 
-        self.unresolved_dependencies.remove(&slug);
+        self.unresolved_dependencies.remove(&target_name);
 
         let target = Arc::new(target);
-        self.targets.insert(slug, target);
+        self.targets.insert(target_name, target);
 
         true
     }
diff --git a/qg/src/target.rs b/qg/src/target.rs
index dfb6bc5..a746cd3 100644
--- a/qg/src/target.rs
+++ b/qg/src/target.rs
@@ -50,6 +50,9 @@
     /// The name of the target's provider.
     provider: String,
 
+    /// Whether the target lives in the global namespace.
+    global: bool,
+
     /// List of targets on which this one depends, storing their full namespaced
     /// paths.
     dependencies: Vec<String>,
@@ -64,22 +67,33 @@
 #[derive(Debug)]
 enum Metadata {
     DepOnly,
+
+    // TODO(frolv): These shouldn't store the manifest data directly but convert
+    // it into a more usable in-memory format.
     Cipd(manifest::CipdPackage),
+    Download(manifest::DownloadablePackage),
 }
 
 impl From<manifest::TargetType> for Metadata {
     fn from(tt: manifest::TargetType) -> Self {
         match tt {
             manifest::TargetType::Cipd(data) => Self::Cipd(data),
+            manifest::TargetType::Download(data) => Self::Download(data),
         }
     }
 }
 
 impl Target {
-    pub(crate) fn from_manifest(name: &str, provider: &str, target: manifest::Target) -> Self {
+    pub(crate) fn from_manifest(
+        name: &str,
+        provider: &str,
+        global: bool,
+        target: manifest::Target,
+    ) -> Self {
         Self {
             name: name.to_owned(),
             provider: provider.to_owned(),
+            global,
             dependencies: target.deps,
             metadata: target.desc.map_or(Metadata::DepOnly, Metadata::from),
         }
@@ -97,17 +111,34 @@
         &self.provider
     }
 
+    /// Returns true if the target is defined in the global namespace.
+    #[must_use]
+    pub fn is_global(&self) -> bool {
+        self.global
+    }
+
     /// Returns the package's dependencies.
     pub fn dependencies(&self) -> impl Iterator<Item = &str> {
         self.dependencies.iter().map(String::as_str)
     }
 
+    /// Returns the fully-qualified name of the target.
+    #[must_use]
+    pub fn full_name(&self) -> String {
+        if self.global {
+            self.name.clone()
+        } else {
+            format!("{}:{}", self.provider, self.name)
+        }
+    }
+
     /// Returns the type of the package as a string.
     #[must_use]
     pub fn type_string(&self) -> &str {
         match self.metadata {
             Metadata::DepOnly => "dep_only",
             Metadata::Cipd(_) => "cipd",
+            Metadata::Download(_) => "download",
         }
     }
 }