use std::fmt::Display;
use serde::{Deserialize, Serialize};
use winnow::{
ModalResult, Parser,
combinator::{repeat, separated_pair},
error::{StrContext, StrContextValue},
token::{take_till, take_until},
};
use crate::{
error::NixUriError,
flakeref::{encoding, validators::parse_bool_param},
};
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(test, serde(deny_unknown_fields))]
#[non_exhaustive]
pub struct LocationParameters {
dir: Option<String>,
#[serde(rename = "narHash")]
nar_hash: Option<String>,
pub submodules: Option<bool>,
pub shallow: Option<bool>,
host: Option<String>,
#[serde(rename = "revCount")]
rev_count: Option<String>,
#[serde(rename = "lastModified")]
last_modified: Option<String>,
pub lfs: Option<bool>,
#[serde(rename = "exportIgnore")]
pub export_ignore: Option<bool>,
#[serde(rename = "allRefs")]
pub all_refs: Option<bool>,
#[serde(rename = "verifyCommit")]
pub verify_commit: Option<bool>,
pub keytype: Option<String>,
#[serde(rename = "publicKey")]
pub public_key: Option<String>,
#[serde(rename = "publicKeys")]
pub public_keys: Option<String>,
arbitrary: Vec<(String, String)>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ParamRefRev {
pub r#ref: Option<String>,
pub rev: Option<String>,
}
impl Display for LocationParameters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut entries = self.entries();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (i, (key, value)) in entries.iter().enumerate() {
if i > 0 {
write!(f, "&")?;
}
write!(
f,
"{key}={value}",
key = encoding::encode_query(key),
value = encoding::encode_query(value)
)?;
}
Ok(())
}
}
impl LocationParameters {
#[allow(dead_code)]
pub(crate) fn parse(input: &mut &str) -> ModalResult<(Self, ParamRefRev)> {
let param_values: Vec<(&str, &str)> = repeat(
0..,
separated_pair(
take_until(0.., "="),
'='.context(StrContext::Expected(StrContextValue::CharLiteral('='))),
take_till(0.., |c| c == '&' || c == '#'),
),
)
.context(StrContext::Label("location parameters"))
.parse_next(input)?;
let mut params = Self::default();
let mut ref_rev = ParamRefRev::default();
for (param, value) in param_values {
if let Ok(param) = param.parse() {
match param {
LocationParamKeys::Dir => params.set_dir(Some(value.into())),
LocationParamKeys::NarHash => params.set_nar_hash(Some(value.into())),
LocationParamKeys::LastModified => {
params.set_last_modified(Some(value.into()));
}
LocationParamKeys::RevCount => params.set_rev_count(Some(value.into())),
LocationParamKeys::Host => params.set_host(Some(value.into())),
LocationParamKeys::Ref => ref_rev.r#ref = Some(value.into()),
LocationParamKeys::Rev => ref_rev.rev = Some(value.into()),
LocationParamKeys::Submodules => {
params.set_submodules(parse_bool_param("submodules", value).ok());
}
LocationParamKeys::Shallow => {
params.set_shallow(parse_bool_param("shallow", value).ok());
}
LocationParamKeys::Lfs => {
params.set_lfs(parse_bool_param("lfs", value).ok());
}
LocationParamKeys::ExportIgnore => {
params.set_export_ignore(parse_bool_param("exportIgnore", value).ok());
}
LocationParamKeys::AllRefs => {
params.set_all_refs(parse_bool_param("allRefs", value).ok());
}
LocationParamKeys::VerifyCommit => {
params.set_verify_commit(parse_bool_param("verifyCommit", value).ok());
}
LocationParamKeys::Keytype => params.set_keytype(Some(value.into())),
LocationParamKeys::PublicKey => params.set_public_key(Some(value.into())),
LocationParamKeys::PublicKeys => params.set_public_keys(Some(value.into())),
LocationParamKeys::Arbitrary(param) => {
params.add_arbitrary((param, value.into()));
}
}
}
}
Ok((params, ref_rev))
}
pub fn dir(&mut self, dir: Option<String>) -> &mut Self {
self.dir = dir;
self
}
pub fn nar_hash(&mut self, nar_hash: Option<String>) -> &mut Self {
self.nar_hash = nar_hash;
self
}
pub fn host(&mut self, host: Option<String>) -> &mut Self {
self.host = host;
self
}
pub fn set_dir(&mut self, dir: Option<String>) {
self.dir = dir;
}
pub fn set_nar_hash(&mut self, nar_hash: Option<String>) {
self.nar_hash = nar_hash;
}
pub fn set_host(&mut self, host: Option<String>) {
self.host = host;
}
pub(crate) fn host_value(&self) -> Option<&str> {
self.host.as_deref()
}
pub(crate) fn nar_hash_value(&self) -> Option<&str> {
self.nar_hash.as_deref()
}
pub(crate) fn submodules_truthy(&self) -> bool {
self.submodules.unwrap_or(false)
}
pub(crate) fn shallow_truthy(&self) -> bool {
self.shallow.unwrap_or(false)
}
pub fn rev_count_mut(&mut self) -> &mut Option<String> {
&mut self.rev_count
}
pub fn set_last_modified(&mut self, last_modified: Option<String>) {
self.last_modified = last_modified;
}
pub fn set_rev_count(&mut self, rev_count: Option<String>) {
self.rev_count = rev_count;
}
pub fn set_submodules(&mut self, submodules: Option<bool>) {
self.submodules = submodules;
}
pub fn set_shallow(&mut self, shallow: Option<bool>) {
self.shallow = shallow;
}
pub fn set_lfs(&mut self, lfs: Option<bool>) {
self.lfs = lfs;
}
pub fn set_export_ignore(&mut self, export_ignore: Option<bool>) {
self.export_ignore = export_ignore;
}
pub fn set_all_refs(&mut self, all_refs: Option<bool>) {
self.all_refs = all_refs;
}
pub fn set_verify_commit(&mut self, verify_commit: Option<bool>) {
self.verify_commit = verify_commit;
}
pub fn set_keytype(&mut self, keytype: Option<String>) {
self.keytype = keytype;
}
pub fn set_public_key(&mut self, public_key: Option<String>) {
self.public_key = public_key;
}
pub fn set_public_keys(&mut self, public_keys: Option<String>) {
self.public_keys = public_keys;
}
pub fn add_arbitrary(&mut self, arbitrary: (String, String)) {
self.arbitrary.push(arbitrary);
}
pub(crate) fn entries(&self) -> Vec<(&str, &str)> {
let mut entries: Vec<(&str, &str)> = Vec::new();
if let Some(v) = &self.dir {
entries.push(("dir", v));
}
if let Some(v) = &self.host {
entries.push(("host", v));
}
if let Some(v) = &self.nar_hash {
entries.push(("narHash", v));
}
if let Some(v) = &self.last_modified {
entries.push(("lastModified", v));
}
if let Some(v) = &self.rev_count {
entries.push(("revCount", v));
}
if let Some(v) = self.submodules {
entries.push(("submodules", bool_repr(v)));
}
if let Some(v) = self.shallow {
entries.push(("shallow", bool_repr(v)));
}
if let Some(v) = self.lfs {
entries.push(("lfs", bool_repr(v)));
}
if let Some(v) = self.export_ignore {
entries.push(("exportIgnore", bool_repr(v)));
}
if let Some(v) = self.all_refs {
entries.push(("allRefs", bool_repr(v)));
}
if let Some(v) = self.verify_commit {
entries.push(("verifyCommit", bool_repr(v)));
}
if let Some(v) = &self.keytype {
entries.push(("keytype", v));
}
if let Some(v) = &self.public_key {
entries.push(("publicKey", v));
}
if let Some(v) = &self.public_keys {
entries.push(("publicKeys", v));
}
for (k, v) in &self.arbitrary {
entries.push((k.as_str(), v.as_str()));
}
entries
}
}
fn bool_repr(b: bool) -> &'static str {
if b { "1" } else { "0" }
}
#[non_exhaustive]
pub(crate) enum LocationParamKeys {
Dir,
NarHash,
LastModified,
RevCount,
Host,
Ref,
Rev,
Submodules,
Shallow,
Lfs,
ExportIgnore,
AllRefs,
VerifyCommit,
Keytype,
PublicKey,
PublicKeys,
Arbitrary(String),
}
impl std::str::FromStr for LocationParamKeys {
type Err = NixUriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"dir" | "&dir" => Ok(Self::Dir),
"narHash" | "&narHash" => Ok(Self::NarHash),
"lastModified" | "&lastModified" => Ok(Self::LastModified),
"revCount" | "&revCount" => Ok(Self::RevCount),
"host" | "&host" => Ok(Self::Host),
"rev" | "&rev" => Ok(Self::Rev),
"ref" | "&ref" => Ok(Self::Ref),
"submodules" | "&submodules" => Ok(Self::Submodules),
"shallow" | "&shallow" => Ok(Self::Shallow),
"lfs" | "&lfs" => Ok(Self::Lfs),
"exportIgnore" | "&exportIgnore" => Ok(Self::ExportIgnore),
"allRefs" | "&allRefs" => Ok(Self::AllRefs),
"verifyCommit" | "&verifyCommit" => Ok(Self::VerifyCommit),
"keytype" | "&keytype" => Ok(Self::Keytype),
"publicKey" | "&publicKey" => Ok(Self::PublicKey),
"publicKeys" | "&publicKeys" => Ok(Self::PublicKeys),
arbitrary => Ok(Self::Arbitrary(
arbitrary.strip_prefix('&').unwrap_or(arbitrary).into(),
)),
}
}
}
#[cfg(test)]
mod inc_parse {
use super::*;
#[test]
fn no_str() {
let expected = LocationParameters::default();
let in_str = "";
let (outstr, (parsed_param, ref_rev)) =
LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", outstr);
assert_eq!(expected, parsed_param);
assert_eq!(ref_rev, ParamRefRev::default());
}
#[test]
fn empty() {
let expected = LocationParameters::default();
let in_str = "";
let (rest, (output, ref_rev)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
assert_eq!(ref_rev, ParamRefRev::default());
}
#[test]
fn empty_hash_terminated() {
let expected = LocationParameters::default();
let in_str = "#";
let (rest, (output, ref_rev)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("#", rest);
assert_eq!(output, expected);
assert_eq!(ref_rev, ParamRefRev::default());
}
#[test]
fn dir() {
let mut expected = LocationParameters::default();
expected.dir(Some("foo".to_string()));
let in_str = "dir=foo";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
let in_str = "&dir=foo";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
let in_str = "dir=&dir=foo";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
expected.dir(Some(String::new()));
let in_str = "dir=";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
}
#[test]
fn dir_hash_term() {
let mut expected = LocationParameters::default();
expected.dir(Some("foo".to_string()));
let in_str = "dir=foo#fizz";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("#fizz", rest);
assert_eq!(output, expected);
let in_str = "&dir=foo#fizz";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("#fizz", rest);
assert_eq!(output, expected);
let in_str = "dir=&dir=foo#fizz";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("#fizz", rest);
assert_eq!(output, expected);
expected.dir(Some(String::new()));
let in_str = "dir=#fizz";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("#fizz", rest);
assert_eq!(output, expected);
}
#[test]
fn canonical_param_keys_round_trip() {
let mut expected = LocationParameters::default();
expected.set_nar_hash(Some("sha256-abc".into()));
expected.set_last_modified(Some("12345".into()));
expected.set_rev_count(Some("42".into()));
let in_str = "narHash=sha256-abc&lastModified=12345&revCount=42";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
assert_eq!(output, expected);
assert_eq!(
output.to_string(),
"lastModified=12345&narHash=sha256-abc&revCount=42"
);
}
#[test]
fn snake_case_falls_through_to_arbitrary() {
let in_str = "nar_hash=sha256-abc";
let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
assert_eq!("", rest);
let mut expected = LocationParameters::default();
expected.add_arbitrary(("nar_hash".into(), "sha256-abc".into()));
assert_eq!(output, expected);
}
}
#[cfg(test)]
mod git_typed_params {
use crate::{FlakeRef, NixUriError};
use rstest::rstest;
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn lfs_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?lfs={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().lfs, Some(expected));
}
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn export_ignore_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?exportIgnore={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().export_ignore, Some(expected));
}
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn all_refs_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?allRefs={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().all_refs, Some(expected));
}
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn verify_commit_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?verifyCommit={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().verify_commit, Some(expected));
}
#[rstest]
#[case("lfs", "yes")]
#[case("exportIgnore", "yes")]
#[case("allRefs", "yes")]
#[case("verifyCommit", "yes")]
#[case("lfs", "true")]
#[case("exportIgnore", "false")]
#[case("allRefs", "True")]
#[case("verifyCommit", "TRUE")]
fn bool_keys_reject_non_bool_values(#[case] key: &str, #[case] value: &str) {
let url = format!("git+ssh://example.com/repo?{key}={value}");
let err = url.parse::<FlakeRef>().unwrap_err();
match err {
NixUriError::InvalidValue { field, .. } => {
assert_eq!(field, key, "expected error.field to name the rejected key");
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn keytype_routes_to_typed_slot() {
let parsed: FlakeRef = "git+ssh://example.com/repo?keytype=ssh-ed25519"
.parse()
.unwrap();
assert_eq!(parsed.params().keytype.as_deref(), Some("ssh-ed25519"));
}
#[test]
fn public_key_routes_to_typed_slot() {
let parsed: FlakeRef = "git+ssh://example.com/repo?publicKey=abcdef"
.parse()
.unwrap();
assert_eq!(parsed.params().public_key.as_deref(), Some("abcdef"));
}
#[test]
fn public_keys_routes_to_typed_slot() {
let parsed: FlakeRef = "git+ssh://example.com/repo?publicKeys=k1.k2.k3"
.parse()
.unwrap();
assert_eq!(parsed.params().public_keys.as_deref(), Some("k1.k2.k3"));
}
#[test]
fn display_emits_seven_keys_alphabetically() {
let url = "git+ssh://example.com/repo?\
verifyCommit=1&publicKeys=k1.k2&publicKey=abc&\
narHash=sha256-x&lfs=1&keytype=ssh-ed25519&\
exportIgnore=0&allRefs=1";
let parsed: FlakeRef = url.parse().unwrap();
let expected = "git+ssh://example.com/repo?\
allRefs=1&exportIgnore=0&keytype=ssh-ed25519&\
lfs=1&narHash=sha256-x&publicKey=abc&\
publicKeys=k1.k2&verifyCommit=1";
assert_eq!(parsed.to_string(), expected);
}
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn submodules_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?submodules={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().submodules, Some(expected));
}
#[rstest]
#[case("1", true)]
#[case("0", false)]
fn shallow_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
let url = format!("git+ssh://example.com/repo?shallow={input}");
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().shallow, Some(expected));
}
#[rstest]
#[case("submodules", "garbage")]
#[case("submodules", "true")]
#[case("shallow", "garbage")]
#[case("shallow", "false")]
fn submodules_shallow_reject_non_bool_values(#[case] key: &str, #[case] value: &str) {
let url = format!("git+ssh://example.com/repo?{key}={value}");
let err = url.parse::<FlakeRef>().unwrap_err();
match err {
NixUriError::InvalidValue { field, .. } => assert_eq!(field, key),
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn submodules_round_trips_canonically() {
let url = "git+ssh://example.com/repo?submodules=1";
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.to_string(), url);
assert_eq!(parsed.params().submodules, Some(true));
}
#[test]
fn shallow_round_trips_canonically() {
let url = "git+ssh://example.com/repo?shallow=1";
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.to_string(), url);
assert_eq!(parsed.params().shallow, Some(true));
}
#[test]
fn round_trip_realistic_git_url() {
let url = "git+ssh://example.com/repo?allRefs=1&lfs=1&publicKey=abc";
let parsed: FlakeRef = url.parse().unwrap();
assert_eq!(parsed.params().all_refs, Some(true));
assert_eq!(parsed.params().lfs, Some(true));
assert_eq!(parsed.params().public_key.as_deref(), Some("abc"));
assert_eq!(parsed.to_string(), url);
}
}