diff --git a/src/cli/cmd/apply/mod.rs b/src/cli/cmd/apply/mod.rs index ada2951..e7702ef 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 2f3d6e7..9789103 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 paths = res.json::>().await?; + + Ok(paths) + } + 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,46 @@ 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(), + }) + } + [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 new file mode 100644 index 0000000..6469202 --- /dev/null +++ b/src/cli/cmd/paths.rs @@ -0,0 +1,68 @@ +use std::{collections::HashMap, process::ExitCode}; + +use clap::Parser; +use serde::{Deserialize, Serialize}; + +use super::{parse_release_ref, print_json, CommandExecute, FlakeHubClient}; + +/// Display all output paths that are derivations in the specified flake release. +#[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 mut paths = FlakeHubClient::paths(self.api_addr.as_ref(), &release_ref).await?; + clear_nulls(&mut paths); + + tracing::debug!( + r#ref = release_ref.to_string(), + "Successfully fetched output paths for release" + ); + + if paths.is_empty() { + tracing::warn!("Flake release provides no output paths"); + } + + print_json(paths)?; + Ok(ExitCode::SUCCESS) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum PathNode { + Path(String), + PathMap(HashMap), +} + +// 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); + } +} diff --git a/src/cli/error.rs b/src/cli/error.rs index 94bd0c6..f55e1b2 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/cli/refs.rs b/src/cli/refs.rs new file mode 100644 index 0000000..a213183 --- /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(()) +} diff --git a/src/main.rs b/src/main.rs index 910f87d..76b3515 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,