From 066a31a03a18a988763e20792f73dbcb8e6aa61f Mon Sep 17 00:00:00 2001 From: Luc Perkins Date: Fri, 9 Aug 2024 16:41:07 +0200 Subject: [PATCH 1/5] Add initial paths command --- src/cli/cmd/apply/mod.rs | 2 +- src/cli/cmd/mod.rs | 78 +++++++++++++++++++++++++++++++++++++--- src/cli/cmd/paths.rs | 42 ++++++++++++++++++++++ src/cli/error.rs | 3 ++ src/main.rs | 1 + 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 src/cli/cmd/paths.rs diff --git a/src/cli/cmd/apply/mod.rs b/src/cli/cmd/apply/mod.rs index ada2951b..e7702efc 100644 --- a/src/cli/cmd/apply/mod.rs +++ b/src/cli/cmd/apply/mod.rs @@ -130,7 +130,7 @@ fn parse_output_ref( let output_ref = super::parse_flake_output_ref(frontend_addr, &with_default_output_path)?.to_string(); - let parsed = super::parse_release_ref(&output_ref)?; + let parsed = super::validate_release_ref(&output_ref)?; parsed.try_into() } diff --git a/src/cli/cmd/mod.rs b/src/cli/cmd/mod.rs index 2f3d6e7b..2fc5b9a6 100644 --- a/src/cli/cmd/mod.rs +++ b/src/cli/cmd/mod.rs @@ -6,14 +6,16 @@ pub(crate) mod eject; pub(crate) mod init; pub(crate) mod list; pub(crate) mod login; +pub(crate) mod paths; pub(crate) mod resolve; pub(crate) mod search; pub(crate) mod status; -use std::{fmt::Display, process::Stdio}; +use std::{collections::HashMap, fmt::Display, process::Stdio}; use color_eyre::eyre::WrapErr; use once_cell::sync::Lazy; +use paths::PathNode; use reqwest::{ header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION}, Client, @@ -72,6 +74,7 @@ pub(crate) enum FhSubcommands { Init(init::InitSubcommand), List(list::ListSubcommand), Login(login::LoginSubcommand), + Paths(paths::PathsSubcommand), Resolve(resolve::ResolveSubcommand), Search(search::SearchSubcommand), Status(status::StatusSubcommand), @@ -178,6 +181,36 @@ impl FlakeHubClient { get(url, true).await } + async fn paths( + api_addr: &str, + release_ref: &ReleaseRef, + ) -> Result, FhError> { + let ReleaseRef { + org, + project, + version_constraint, + } = release_ref; + + let url = flakehub_url!(api_addr, "f", org, project, version_constraint, "outputs"); + tracing::debug!( + url = url.to_string(), + r#ref = release_ref.to_string(), + "Fetching all output paths for flake release" + ); + let client = make_base_client(true).await?; + let res = client.get(&url.to_string()).send().await?; + + // Enrich the CLI error text with the error returned by FlakeHub + if let Err(e) = res.error_for_status_ref() { + let err_text = res.text().await?; + return Err(e).wrap_err(err_text)?; + }; + + let res = res.json::>().await?; + + Ok(res) + } + async fn project_and_url( api_addr: &str, org: &str, @@ -277,6 +310,22 @@ impl Display for FlakeOutputRef { } } +struct ReleaseRef { + org: String, + project: String, + version_constraint: String, +} + +impl Display for ReleaseRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}/{}/{}", + self.org, self.project, self.version_constraint + ) + } +} + impl TryFrom for FlakeOutputRef { type Error = FhError; @@ -442,17 +491,36 @@ fn parse_flake_output_ref( } // Ensure that release refs are of the form {org}/{project}/{version_req} -fn parse_release_ref(flake_ref: &str) -> Result { - match flake_ref.split('/').collect::>()[..] { +fn validate_release_ref(release_ref: &str) -> Result { + match release_ref.split('/').collect::>()[..] { [org, project, version_req] => { validate_segment(org)?; validate_segment(project)?; validate_segment(version_req)?; - Ok(flake_ref.to_string()) + Ok(release_ref.to_string()) } _ => Err(FhError::FlakeParse(format!( - "flake ref {flake_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" + "release ref {release_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" + ))), + } +} + +fn parse_release_ref(release_ref: &str) -> Result { + match release_ref.split('/').collect::>()[..] { + [org, project, version_constraint] => { + validate_segment(org)?; + validate_segment(project)?; + validate_segment(version_constraint)?; + + Ok(ReleaseRef { + org: org.to_string(), + project: project.to_string(), + version_constraint: version_constraint.to_string(), + }) + } + _ => Err(FhError::ReleaseRefParse(format!( + "release ref {release_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" ))), } } diff --git a/src/cli/cmd/paths.rs b/src/cli/cmd/paths.rs new file mode 100644 index 00000000..469daef5 --- /dev/null +++ b/src/cli/cmd/paths.rs @@ -0,0 +1,42 @@ +use std::{collections::HashMap, process::ExitCode}; + +use clap::Parser; +use serde::{Deserialize, Serialize}; + +use super::{parse_release_ref, print_json, CommandExecute, FlakeHubClient}; + +/// TODO +#[derive(Debug, Parser)] +pub(crate) struct PathsSubcommand { + /// TODO + release_ref: String, + + #[clap(from_global)] + api_addr: url::Url, +} + +#[async_trait::async_trait] +impl CommandExecute for PathsSubcommand { + #[tracing::instrument(skip_all)] + async fn execute(self) -> color_eyre::Result { + let release_ref = parse_release_ref(&self.release_ref)?; + + let paths = FlakeHubClient::paths(self.api_addr.as_ref(), &release_ref).await?; + + tracing::debug!( + r#ref = release_ref.to_string(), + "Successfully fetched output paths for release" + ); + + print_json(paths)?; + + Ok(ExitCode::SUCCESS) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum PathNode { + Path(String), + PathMap(HashMap), +} diff --git a/src/cli/error.rs b/src/cli/error.rs index 94bd0c67..f55e1b26 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -42,6 +42,9 @@ pub(crate) enum FhError { #[error("the flake has no inputs")] NoInputs, + #[error("release ref parse error: {0}")] + ReleaseRefParse(String), + #[error("template error: {0}")] Render(#[from] handlebars::RenderError), diff --git a/src/main.rs b/src/main.rs index 910f87d9..76b35153 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ async fn main() -> color_eyre::Result { FhSubcommands::Init(init) => init.execute().await, FhSubcommands::List(list) => list.execute().await, FhSubcommands::Login(login) => login.execute().await, + FhSubcommands::Paths(paths) => paths.execute().await, FhSubcommands::Resolve(resolve) => resolve.execute().await, FhSubcommands::Search(search) => search.execute().await, FhSubcommands::Status(status) => status.execute().await, From 47e426de3ea1f11dc3a8b4693e00c3728989325b Mon Sep 17 00:00:00 2001 From: Luc Perkins Date: Tue, 20 Aug 2024 18:23:59 +0200 Subject: [PATCH 2/5] Remove empty maps via custom serialization --- src/cli/cmd/mod.rs | 14 +++- src/cli/cmd/paths.rs | 25 +++++- src/cli/refs.rs | 195 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 src/cli/refs.rs diff --git a/src/cli/cmd/mod.rs b/src/cli/cmd/mod.rs index 2fc5b9a6..fd72ba6d 100644 --- a/src/cli/cmd/mod.rs +++ b/src/cli/cmd/mod.rs @@ -206,9 +206,9 @@ impl FlakeHubClient { return Err(e).wrap_err(err_text)?; }; - let res = res.json::>().await?; + let paths = res.json::>().await?; - Ok(res) + Ok(paths) } async fn project_and_url( @@ -519,6 +519,16 @@ fn parse_release_ref(release_ref: &str) -> Result { version_constraint: version_constraint.to_string(), }) } + [org, project] => { + validate_segment(org)?; + validate_segment(project)?; + + Ok(ReleaseRef { + org: org.to_string(), + project: project.to_string(), + version_constraint: "*".to_string(), + }) + } _ => Err(FhError::ReleaseRefParse(format!( "release ref {release_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" ))), diff --git a/src/cli/cmd/paths.rs b/src/cli/cmd/paths.rs index 469daef5..c82bff0b 100644 --- a/src/cli/cmd/paths.rs +++ b/src/cli/cmd/paths.rs @@ -1,11 +1,11 @@ use std::{collections::HashMap, process::ExitCode}; use clap::Parser; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use super::{parse_release_ref, print_json, CommandExecute, FlakeHubClient}; -/// TODO +/// Display all output paths that are derivations in the specified flake release. #[derive(Debug, Parser)] pub(crate) struct PathsSubcommand { /// TODO @@ -34,9 +34,28 @@ impl CommandExecute for PathsSubcommand { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize)] #[serde(untagged)] pub(crate) enum PathNode { Path(String), PathMap(HashMap), } + +// The custom serializer converts empty maps into nulls +impl Serialize for PathNode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + PathNode::Path(s) => s.serialize(serializer), + PathNode::PathMap(map) => { + if map.is_empty() { + serializer.serialize_none() + } else { + map.serialize(serializer) + } + } + } + } +} diff --git a/src/cli/refs.rs b/src/cli/refs.rs new file mode 100644 index 00000000..a2131839 --- /dev/null +++ b/src/cli/refs.rs @@ -0,0 +1,195 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +use crate::{cli::cmd::list::FLAKEHUB_WEB_ROOT, flakehub_url}; + +use super::error::FhError; + +// Parses a flake reference as a string to construct paths of the form: +// https://api.flakehub.com/f/{org}/{flake}/{version_constraint}/output/{attr_path} +pub(crate) struct FlakeOutputRef { + pub(crate) org: String, + pub(crate) project: String, + pub(crate) version_constraint: String, + pub(crate) attr_path: String, +} + +impl Display for FlakeOutputRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}/{}/{}#{}", + self.org, self.project, self.version_constraint, self.attr_path + ) + } +} + +impl TryFrom for FlakeOutputRef { + type Error = FhError; + + fn try_from(output_ref: String) -> Result { + let parts: Vec<&str> = output_ref.split('#').collect(); + + if let Some(release_parts) = parts.first() { + let Some(attr_path) = parts.get(1) else { + Err(FhError::MalformedFlakeOutputRef( + output_ref, + String::from("missing the output attribute path"), + ))? + }; + + match release_parts.split('/').collect::>()[..] { + [org, project, version_constraint] => { + validate_segment(org, "org")?; + validate_segment(project, "project")?; + validate_segment(version_constraint, "version constraint")?; + validate_segment(attr_path, "attribute path")?; + + Ok(FlakeOutputRef { + org: org.to_string(), + project: project.to_string(), + version_constraint: version_constraint.to_string(), + attr_path: attr_path.to_string(), + }) + } + _ => Err(FhError::MalformedFlakeOutputRef( + output_ref, + String::from( + "release reference must be of the form {{org}}/{{project}}/{{version_req}}", + ), + )), + } + } else { + Err(FhError::MalformedFlakeOutputRef( + output_ref, + String::from( + "must be of the form {{org}}/{{project}}/{{version_req}}#{{attr_path}}", + ), + )) + } + } +} + +pub(crate) fn parse_flake_output_ref_with_default_path( + frontend_addr: &url::Url, + output_ref: &str, + default_path: &str, +) -> Result { + let with_default_output_path = match output_ref.split('#').collect::>()[..] { + [_release, _output_path] => output_ref.to_string(), + [_release] => format!("{}#{}", output_ref, default_path), + _ => { + return Err(FhError::MalformedFlakeOutputRef( + output_ref.to_string(), + String::from( + "must be of the form {{org}}/{{project}}/{{version_req}}#{{attr_path}}", + ), + )) + } + }; + + parse_flake_output_ref(frontend_addr, &with_default_output_path) +} + +pub(crate) fn parse_flake_output_ref( + frontend_addr: &url::Url, + output_ref: &str, +) -> Result { + // Ensures that users can use both forms: + // 1. https://flakehub/f/{org}/{project}/{version_req}#{output} + // 2. {org}/{project}/{version_req}#{output} + let output_ref = String::from( + output_ref + .strip_prefix(frontend_addr.join("f/")?.as_str()) + .unwrap_or(output_ref), + ); + + output_ref.try_into() +} + +// Simple flake refs are of the form {org}/{project}, for example NixOS/nixpkgs +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct SimpleFlakeRef { + pub(crate) org: String, + pub(crate) project: String, +} + +impl SimpleFlakeRef { + pub(crate) fn url(&self) -> url::Url { + flakehub_url!(FLAKEHUB_WEB_ROOT, "flake", &self.org, &self.project) + } +} + +impl Display for SimpleFlakeRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.org, self.project) + } +} + +impl TryFrom for SimpleFlakeRef { + type Error = FhError; + + fn try_from(flake_ref: String) -> Result { + let (org, project) = match flake_ref.split('/').collect::>()[..] { + // `nixos/nixpkgs` + [org, repo] => (org, repo), + _ => { + return Err(FhError::Parse(format!( + "flake ref {flake_ref} invalid; must be of the form {{org}}/{{project}}" + ))) + } + }; + Ok(Self { + org: String::from(org), + project: String::from(project), + }) + } +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct VersionRef { + pub(crate) version: semver::Version, + pub(crate) simplified_version: semver::Version, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct OrgRef { + pub(crate) name: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ReleaseRef { + pub(crate) version: String, +} + +/* +// Ensure that release refs are of the form {org}/{project}/{version_req} +fn parse_release_ref(flake_ref: &str) -> Result { + match flake_ref.split('/').collect::>()[..] { + [org, project, version_req] => { + validate_segment(org)?; + validate_segment(project)?; + validate_segment(version_req)?; + + Ok(flake_ref.to_string()) + } + _ => Err(FhError::FlakeParse(format!( + "flake ref {flake_ref} invalid; must be of the form {{org}}/{{project}}/{{version_req}}" + ))), + } +} +*/ + +// Ensure that orgs, project names, and the like don't contain whitespace. +// This function may apply other validations in the future. +fn validate_segment(s: &str, field: &str) -> Result<(), FhError> { + if s.chars().any(char::is_whitespace) { + return Err(FhError::Parse(format!( + "{} in path segment contains whitespace: \"{}\"", + field, s + ))); + } + + Ok(()) +} From 72f193dbfeeb6b5381cae3c724196f38fbd149ec Mon Sep 17 00:00:00 2001 From: Luc Perkins Date: Wed, 21 Aug 2024 16:49:36 +0200 Subject: [PATCH 3/5] Fix clippy errors --- src/cli/cmd/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/cmd/mod.rs b/src/cli/cmd/mod.rs index fd72ba6d..9789103e 100644 --- a/src/cli/cmd/mod.rs +++ b/src/cli/cmd/mod.rs @@ -198,7 +198,7 @@ impl FlakeHubClient { "Fetching all output paths for flake release" ); let client = make_base_client(true).await?; - let res = client.get(&url.to_string()).send().await?; + let res = client.get(url.to_string()).send().await?; // Enrich the CLI error text with the error returned by FlakeHub if let Err(e) = res.error_for_status_ref() { From 6f01d9bf081a1e2ed2a0dedd0be1d311738fc6ea Mon Sep 17 00:00:00 2001 From: Luc Perkins Date: Wed, 21 Aug 2024 17:03:10 +0200 Subject: [PATCH 4/5] Remove all nulls from the output path tree --- src/cli/cmd/paths.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/cli/cmd/paths.rs b/src/cli/cmd/paths.rs index c82bff0b..a63dc43f 100644 --- a/src/cli/cmd/paths.rs +++ b/src/cli/cmd/paths.rs @@ -21,7 +21,8 @@ impl CommandExecute for PathsSubcommand { async fn execute(self) -> color_eyre::Result { let release_ref = parse_release_ref(&self.release_ref)?; - let paths = FlakeHubClient::paths(self.api_addr.as_ref(), &release_ref).await?; + let mut paths = FlakeHubClient::paths(self.api_addr.as_ref(), &release_ref).await?; + clear_nulls(&mut paths); tracing::debug!( r#ref = release_ref.to_string(), @@ -59,3 +60,25 @@ impl Serialize for PathNode { } } } + +// Recursively removes any nulls from the output path tree +fn clear_nulls(map: &mut HashMap) { + let keys_to_remove: Vec = map + .iter_mut() + .filter_map(|(key, value)| match value { + PathNode::PathMap(ref mut inner_map) => { + clear_nulls(inner_map); + if inner_map.is_empty() { + Some(key.clone()) + } else { + None + } + } + _ => None, + }) + .collect(); + + for key in keys_to_remove { + map.remove(&key); + } +} From 192718a9126d1aa7a86aba9dac0152654e5476d6 Mon Sep 17 00:00:00 2001 From: Luc Perkins Date: Wed, 21 Aug 2024 17:17:14 +0200 Subject: [PATCH 5/5] Provide warning if output paths map is empty --- src/cli/cmd/paths.rs | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/cli/cmd/paths.rs b/src/cli/cmd/paths.rs index a63dc43f..6469202e 100644 --- a/src/cli/cmd/paths.rs +++ b/src/cli/cmd/paths.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, process::ExitCode}; use clap::Parser; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; use super::{parse_release_ref, print_json, CommandExecute, FlakeHubClient}; @@ -29,38 +29,22 @@ impl CommandExecute for PathsSubcommand { "Successfully fetched output paths for release" ); - print_json(paths)?; + if paths.is_empty() { + tracing::warn!("Flake release provides no output paths"); + } + print_json(paths)?; Ok(ExitCode::SUCCESS) } } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum PathNode { Path(String), PathMap(HashMap), } -// The custom serializer converts empty maps into nulls -impl Serialize for PathNode { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - PathNode::Path(s) => s.serialize(serializer), - PathNode::PathMap(map) => { - if map.is_empty() { - serializer.serialize_none() - } else { - map.serialize(serializer) - } - } - } - } -} - // Recursively removes any nulls from the output path tree fn clear_nulls(map: &mut HashMap) { let keys_to_remove: Vec = map