Skip to main content

nix_uri/flakeref/
location_params.rs

1use std::fmt::Display;
2
3use serde::{Deserialize, Serialize};
4use winnow::{
5    ModalResult, Parser,
6    combinator::{repeat, separated_pair},
7    error::{StrContext, StrContextValue},
8    token::{take_till, take_until},
9};
10
11use crate::{
12    error::NixUriError,
13    flakeref::{encoding, validators::parse_bool_param},
14};
15
16/// Query-string parameters that decorate a `FlakeRef`.
17///
18/// Notably absent: `ref` and `rev`. Those are typed slots on the `FlakeRef`'s
19/// kind ([`crate::FlakeRefType::GitForge`] / [`crate::FlakeRefType::Indirect`])
20/// there is one source of truth for ref/rev, and it is not here. The parser
21/// extracts `?ref=` / `?rev=` values out of the query string and routes them
22/// into those typed slots, setting the kind's `RefLocation` to
23/// `QueryParameter` so round-trip Display preserves the form.
24#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[cfg_attr(test, serde(deny_unknown_fields))]
26#[non_exhaustive]
27pub struct LocationParameters {
28    /// The subdirectory of the flake in which flake.nix is located. This parameter
29    /// enables having multiple flakes in a repository or tarball. The default is the
30    /// root directory of the flake.
31    dir: Option<String>,
32    /// The hash of the NAR serialisation (in SRI format) of the contents of the flake.
33    /// This is useful for flake types such as tarballs that lack a unique content
34    /// identifier such as a Git commit hash.
35    #[serde(rename = "narHash")]
36    nar_hash: Option<String>,
37    /// Fetch git submodules during clone. Mirrors Nix's git fetcher
38    /// `submodules` setting; URL-time coercion is strict (`value == "1"`),
39    /// so the parser accepts `"1"` / `"0"` and rejects anything else.
40    pub submodules: Option<bool>,
41    /// Use a shallow clone (skip git history). Mirrors Nix's git fetcher
42    /// `shallow` setting; URL-time coercion follows the same rule as
43    /// [`Self::submodules`].
44    pub shallow: Option<bool>,
45    // Only available to certain types.
46    host: Option<String>,
47    // Not available to user.
48    #[serde(rename = "revCount")]
49    rev_count: Option<String>,
50    // Not available to user.
51    #[serde(rename = "lastModified")]
52    last_modified: Option<String>,
53    /// Git-LFS support. Boolean values follow Nix's URL-time coercion:
54    /// `"1"` -> `true`, `"0"` -> `false`; anything else (including
55    /// `"true"` / `"false"`) is rejected at parse time so the diagnostic
56    /// stays visible.
57    pub lfs: Option<bool>,
58    /// Honour `.gitattributes` `export-ignore` directives during fetch.
59    #[serde(rename = "exportIgnore")]
60    pub export_ignore: Option<bool>,
61    /// Fetch all refs, not just the requested one.
62    #[serde(rename = "allRefs")]
63    pub all_refs: Option<bool>,
64    /// Verify the commit signature against the configured key set.
65    #[serde(rename = "verifyCommit")]
66    pub verify_commit: Option<bool>,
67    /// Signature key type (e.g. `ssh-ed25519`). Stored as a free-form
68    /// string because Nix does not enumerate valid values.
69    pub keytype: Option<String>,
70    /// Public key bytes used to verify commit signatures.
71    #[serde(rename = "publicKey")]
72    pub public_key: Option<String>,
73    /// Multi-key bag (Nix uses a single string with platform-specific
74    /// delimiters).
75    #[serde(rename = "publicKeys")]
76    pub public_keys: Option<String>,
77    /// Unrecognised query parameters preserved verbatim so `Display` can
78    /// round-trip them. Recognised keys (canonical Nix camelCase spellings,
79    /// plus `ref`/`rev` which route to the kind's typed slots) are pulled
80    /// out by the parser before anything reaches this vec.
81    arbitrary: Vec<(String, String)>,
82}
83
84/// Ref/rev pulled out of a `?ref=`/`?rev=` query string. Threaded out of the
85/// param parsers as a side-channel so the caller can route the value into the
86/// `FlakeRef`'s typed `kind.ref_` / `kind.rev` slots instead of stashing it
87/// in [`LocationParameters`].
88#[derive(Debug, Default, Clone, PartialEq, Eq)]
89pub(crate) struct ParamRefRev {
90    pub r#ref: Option<String>,
91    pub rev: Option<String>,
92}
93
94impl Display for LocationParameters {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        let mut entries = self.entries();
97        entries.sort_by(|a, b| a.0.cmp(b.0));
98        for (i, (key, value)) in entries.iter().enumerate() {
99            if i > 0 {
100                write!(f, "&")?;
101            }
102            write!(
103                f,
104                "{key}={value}",
105                key = encoding::encode_query(key),
106                value = encoding::encode_query(value)
107            )?;
108        }
109        Ok(())
110    }
111}
112
113impl LocationParameters {
114    #[allow(dead_code)]
115    pub(crate) fn parse(input: &mut &str) -> ModalResult<(Self, ParamRefRev)> {
116        let param_values: Vec<(&str, &str)> = repeat(
117            0..,
118            separated_pair(
119                take_until(0.., "="),
120                '='.context(StrContext::Expected(StrContextValue::CharLiteral('='))),
121                take_till(0.., |c| c == '&' || c == '#'),
122            ),
123        )
124        .context(StrContext::Label("location parameters"))
125        .parse_next(input)?;
126
127        let mut params = Self::default();
128        let mut ref_rev = ParamRefRev::default();
129        for (param, value) in param_values {
130            // param can start with "&"
131            // TODO: actual error handling instead of unwrapping
132            // TODO: allow check of the parameters
133            if let Ok(param) = param.parse() {
134                match param {
135                    LocationParamKeys::Dir => params.set_dir(Some(value.into())),
136                    LocationParamKeys::NarHash => params.set_nar_hash(Some(value.into())),
137                    LocationParamKeys::LastModified => {
138                        params.set_last_modified(Some(value.into()));
139                    }
140                    LocationParamKeys::RevCount => params.set_rev_count(Some(value.into())),
141                    LocationParamKeys::Host => params.set_host(Some(value.into())),
142                    LocationParamKeys::Ref => ref_rev.r#ref = Some(value.into()),
143                    LocationParamKeys::Rev => ref_rev.rev = Some(value.into()),
144                    LocationParamKeys::Submodules => {
145                        params.set_submodules(parse_bool_param("submodules", value).ok());
146                    }
147                    LocationParamKeys::Shallow => {
148                        params.set_shallow(parse_bool_param("shallow", value).ok());
149                    }
150                    LocationParamKeys::Lfs => {
151                        params.set_lfs(parse_bool_param("lfs", value).ok());
152                    }
153                    LocationParamKeys::ExportIgnore => {
154                        params.set_export_ignore(parse_bool_param("exportIgnore", value).ok());
155                    }
156                    LocationParamKeys::AllRefs => {
157                        params.set_all_refs(parse_bool_param("allRefs", value).ok());
158                    }
159                    LocationParamKeys::VerifyCommit => {
160                        params.set_verify_commit(parse_bool_param("verifyCommit", value).ok());
161                    }
162                    LocationParamKeys::Keytype => params.set_keytype(Some(value.into())),
163                    LocationParamKeys::PublicKey => params.set_public_key(Some(value.into())),
164                    LocationParamKeys::PublicKeys => params.set_public_keys(Some(value.into())),
165                    LocationParamKeys::Arbitrary(param) => {
166                        params.add_arbitrary((param, value.into()));
167                    }
168                }
169            }
170        }
171        Ok((params, ref_rev))
172    }
173
174    /// Chainable setter for the `dir` parameter; returns `&mut Self` so the
175    /// builder form (`params.dir(Some(...)).host(Some(...))`) reads naturally.
176    pub fn dir(&mut self, dir: Option<String>) -> &mut Self {
177        self.dir = dir;
178        self
179    }
180
181    /// Chainable setter for the `narHash` parameter. See [`Self::dir`].
182    pub fn nar_hash(&mut self, nar_hash: Option<String>) -> &mut Self {
183        self.nar_hash = nar_hash;
184        self
185    }
186
187    /// Chainable setter for the `host` parameter. See [`Self::dir`].
188    pub fn host(&mut self, host: Option<String>) -> &mut Self {
189        self.host = host;
190        self
191    }
192
193    /// Replace the `dir` parameter (a flake's subdirectory inside its repo).
194    pub fn set_dir(&mut self, dir: Option<String>) {
195        self.dir = dir;
196    }
197
198    /// Replace the `narHash` parameter (SRI-format NAR hash).
199    pub fn set_nar_hash(&mut self, nar_hash: Option<String>) {
200        self.nar_hash = nar_hash;
201    }
202
203    /// Replace the `host` parameter (used to override the default host for
204    /// kinds that resolve to a forge or remote URL).
205    pub fn set_host(&mut self, host: Option<String>) {
206        self.host = host;
207    }
208
209    /// Borrow the `host` query value, when set. The canonical-default
210    /// fallback (`github.com` / `gitlab.com` / `git.sr.ht`) is the
211    /// `FlakeRef::domain` accessor's job, not this one.
212    pub(crate) fn host_value(&self) -> Option<&str> {
213        self.host.as_deref()
214    }
215
216    /// Borrow the `narHash` query value, when set. Used by
217    /// [`crate::FlakeRef::to_canonical_string`] to match the Nix schemes
218    /// that emit the SRI hash on canonical URLs (git-archive forges and
219    /// the curl-based tarball/file scheme).
220    pub(crate) fn nar_hash_value(&self) -> Option<&str> {
221        self.nar_hash.as_deref()
222    }
223
224    /// Whether `?submodules=` carries the truthy `"1"` value. Used to
225    /// gate canonical emission: Nix writes `?submodules=1` only for
226    /// the truthy branch. The slot is typed `Option<bool>` because
227    /// URL-time bool coercion follows the strict `value == "1"` rule.
228    pub(crate) fn submodules_truthy(&self) -> bool {
229        self.submodules.unwrap_or(false)
230    }
231
232    /// Whether `?shallow=` carries the truthy `"1"` value. Companion to
233    /// [`Self::submodules_truthy`].
234    pub(crate) fn shallow_truthy(&self) -> bool {
235        self.shallow.unwrap_or(false)
236    }
237
238    /// Mutable handle to the `revCount` slot for in-place edits.
239    pub fn rev_count_mut(&mut self) -> &mut Option<String> {
240        &mut self.rev_count
241    }
242
243    /// Replace the `lastModified` parameter (Unix timestamp of the source).
244    pub fn set_last_modified(&mut self, last_modified: Option<String>) {
245        self.last_modified = last_modified;
246    }
247
248    /// Replace the `revCount` parameter (commit count from the repo root).
249    pub fn set_rev_count(&mut self, rev_count: Option<String>) {
250        self.rev_count = rev_count;
251    }
252
253    /// Replace the `submodules` boolean parameter.
254    pub fn set_submodules(&mut self, submodules: Option<bool>) {
255        self.submodules = submodules;
256    }
257
258    /// Replace the `shallow` boolean parameter.
259    pub fn set_shallow(&mut self, shallow: Option<bool>) {
260        self.shallow = shallow;
261    }
262
263    /// Replace the `lfs` boolean parameter.
264    pub fn set_lfs(&mut self, lfs: Option<bool>) {
265        self.lfs = lfs;
266    }
267
268    /// Replace the `exportIgnore` boolean parameter.
269    pub fn set_export_ignore(&mut self, export_ignore: Option<bool>) {
270        self.export_ignore = export_ignore;
271    }
272
273    /// Replace the `allRefs` boolean parameter.
274    pub fn set_all_refs(&mut self, all_refs: Option<bool>) {
275        self.all_refs = all_refs;
276    }
277
278    /// Replace the `verifyCommit` boolean parameter.
279    pub fn set_verify_commit(&mut self, verify_commit: Option<bool>) {
280        self.verify_commit = verify_commit;
281    }
282
283    /// Replace the `keytype` parameter (signature key type).
284    pub fn set_keytype(&mut self, keytype: Option<String>) {
285        self.keytype = keytype;
286    }
287
288    /// Replace the `publicKey` parameter.
289    pub fn set_public_key(&mut self, public_key: Option<String>) {
290        self.public_key = public_key;
291    }
292
293    /// Replace the `publicKeys` parameter.
294    pub fn set_public_keys(&mut self, public_keys: Option<String>) {
295        self.public_keys = public_keys;
296    }
297
298    /// Append a `(key, value)` pair to the unrecognised-parameter vec.
299    /// Used by the parser for keys that do not match a typed slot; rendered
300    /// verbatim by `Display` to keep round-trip parity.
301    pub fn add_arbitrary(&mut self, arbitrary: (String, String)) {
302        self.arbitrary.push(arbitrary);
303    }
304
305    /// Every set query parameter as a `(key, value)` pair: the populated
306    /// typed slots followed by the arbitrary key/value bag, in storage order.
307    /// Callers that emit a query string (`Display` here, `FlakeRef`'s combined
308    /// ref/rev + params block) sort the merged list by key to match Nix's
309    /// alphabetical emission order.
310    pub(crate) fn entries(&self) -> Vec<(&str, &str)> {
311        let mut entries: Vec<(&str, &str)> = Vec::new();
312        if let Some(v) = &self.dir {
313            entries.push(("dir", v));
314        }
315        if let Some(v) = &self.host {
316            entries.push(("host", v));
317        }
318        if let Some(v) = &self.nar_hash {
319            entries.push(("narHash", v));
320        }
321        if let Some(v) = &self.last_modified {
322            entries.push(("lastModified", v));
323        }
324        if let Some(v) = &self.rev_count {
325            entries.push(("revCount", v));
326        }
327        if let Some(v) = self.submodules {
328            entries.push(("submodules", bool_repr(v)));
329        }
330        if let Some(v) = self.shallow {
331            entries.push(("shallow", bool_repr(v)));
332        }
333        if let Some(v) = self.lfs {
334            entries.push(("lfs", bool_repr(v)));
335        }
336        if let Some(v) = self.export_ignore {
337            entries.push(("exportIgnore", bool_repr(v)));
338        }
339        if let Some(v) = self.all_refs {
340            entries.push(("allRefs", bool_repr(v)));
341        }
342        if let Some(v) = self.verify_commit {
343            entries.push(("verifyCommit", bool_repr(v)));
344        }
345        if let Some(v) = &self.keytype {
346            entries.push(("keytype", v));
347        }
348        if let Some(v) = &self.public_key {
349            entries.push(("publicKey", v));
350        }
351        if let Some(v) = &self.public_keys {
352            entries.push(("publicKeys", v));
353        }
354        for (k, v) in &self.arbitrary {
355            entries.push((k.as_str(), v.as_str()));
356        }
357        entries
358    }
359}
360
361/// Canonical wire form for a boolean param: `"1"` for true, `"0"` for false.
362/// Matches Nix's URL-time coercion, which treats only `"1"` as true. The
363/// parser accepts the same two literals; Display picks the canonical
364/// spelling so a re-parse is byte-stable.
365fn bool_repr(b: bool) -> &'static str {
366    if b { "1" } else { "0" }
367}
368
369#[non_exhaustive]
370pub(crate) enum LocationParamKeys {
371    Dir,
372    NarHash,
373    LastModified,
374    RevCount,
375    Host,
376    Ref,
377    Rev,
378    Submodules,
379    Shallow,
380    Lfs,
381    ExportIgnore,
382    AllRefs,
383    VerifyCommit,
384    Keytype,
385    PublicKey,
386    PublicKeys,
387    Arbitrary(String),
388}
389
390impl std::str::FromStr for LocationParamKeys {
391    type Err = NixUriError;
392
393    fn from_str(s: &str) -> Result<Self, Self::Err> {
394        match s {
395            "dir" | "&dir" => Ok(Self::Dir),
396            "narHash" | "&narHash" => Ok(Self::NarHash),
397            "lastModified" | "&lastModified" => Ok(Self::LastModified),
398            "revCount" | "&revCount" => Ok(Self::RevCount),
399            "host" | "&host" => Ok(Self::Host),
400            "rev" | "&rev" => Ok(Self::Rev),
401            "ref" | "&ref" => Ok(Self::Ref),
402            "submodules" | "&submodules" => Ok(Self::Submodules),
403            "shallow" | "&shallow" => Ok(Self::Shallow),
404            "lfs" | "&lfs" => Ok(Self::Lfs),
405            "exportIgnore" | "&exportIgnore" => Ok(Self::ExportIgnore),
406            "allRefs" | "&allRefs" => Ok(Self::AllRefs),
407            "verifyCommit" | "&verifyCommit" => Ok(Self::VerifyCommit),
408            "keytype" | "&keytype" => Ok(Self::Keytype),
409            "publicKey" | "&publicKey" => Ok(Self::PublicKey),
410            "publicKeys" | "&publicKeys" => Ok(Self::PublicKeys),
411            // The typed arms above strip the leading `&` that the param
412            // parser leaves in place between adjacent k=v pairs; do the
413            // same for the arbitrary fallback so `?xA=&xB=` round-trips.
414            arbitrary => Ok(Self::Arbitrary(
415                arbitrary.strip_prefix('&').unwrap_or(arbitrary).into(),
416            )),
417        }
418    }
419}
420
421#[cfg(test)]
422mod inc_parse {
423    use super::*;
424    #[test]
425    fn no_str() {
426        let expected = LocationParameters::default();
427        let in_str = "";
428        let (outstr, (parsed_param, ref_rev)) =
429            LocationParameters::parse.parse_peek(in_str).unwrap();
430        assert_eq!("", outstr);
431        assert_eq!(expected, parsed_param);
432        assert_eq!(ref_rev, ParamRefRev::default());
433    }
434    #[test]
435    fn empty() {
436        let expected = LocationParameters::default();
437        let in_str = "";
438        let (rest, (output, ref_rev)) = LocationParameters::parse.parse_peek(in_str).unwrap();
439        assert_eq!("", rest);
440        assert_eq!(output, expected);
441        assert_eq!(ref_rev, ParamRefRev::default());
442    }
443    #[test]
444    fn empty_hash_terminated() {
445        let expected = LocationParameters::default();
446        let in_str = "#";
447        let (rest, (output, ref_rev)) = LocationParameters::parse.parse_peek(in_str).unwrap();
448        assert_eq!("#", rest);
449        assert_eq!(output, expected);
450        assert_eq!(ref_rev, ParamRefRev::default());
451    }
452    #[test]
453    fn dir() {
454        let mut expected = LocationParameters::default();
455        expected.dir(Some("foo".to_string()));
456
457        let in_str = "dir=foo";
458        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
459        assert_eq!("", rest);
460        assert_eq!(output, expected);
461
462        let in_str = "&dir=foo";
463        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
464        assert_eq!("", rest);
465        assert_eq!(output, expected);
466        let in_str = "dir=&dir=foo";
467        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
468        assert_eq!("", rest);
469        assert_eq!(output, expected);
470
471        expected.dir(Some(String::new()));
472        let in_str = "dir=";
473        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
474        assert_eq!("", rest);
475        assert_eq!(output, expected);
476    }
477    #[test]
478    fn dir_hash_term() {
479        let mut expected = LocationParameters::default();
480        expected.dir(Some("foo".to_string()));
481
482        let in_str = "dir=foo#fizz";
483        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
484        assert_eq!("#fizz", rest);
485        assert_eq!(output, expected);
486
487        let in_str = "&dir=foo#fizz";
488        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
489        assert_eq!("#fizz", rest);
490        assert_eq!(output, expected);
491        let in_str = "dir=&dir=foo#fizz";
492        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
493        assert_eq!("#fizz", rest);
494        assert_eq!(output, expected);
495
496        expected.dir(Some(String::new()));
497        let in_str = "dir=#fizz";
498        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
499        assert_eq!("#fizz", rest);
500        assert_eq!(output, expected);
501    }
502
503    #[test]
504    fn canonical_param_keys_round_trip() {
505        // Canonical Nix spells these in camelCase; only the camelCase
506        // form routes into the typed slots.
507        let mut expected = LocationParameters::default();
508        expected.set_nar_hash(Some("sha256-abc".into()));
509        expected.set_last_modified(Some("12345".into()));
510        expected.set_rev_count(Some("42".into()));
511
512        let in_str = "narHash=sha256-abc&lastModified=12345&revCount=42";
513        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
514        assert_eq!("", rest);
515        assert_eq!(output, expected);
516
517        // Display emits the canonical (camelCase) spelling in alphabetical
518        // key order: lastModified, narHash, revCount.
519        assert_eq!(
520            output.to_string(),
521            "lastModified=12345&narHash=sha256-abc&revCount=42"
522        );
523    }
524
525    #[test]
526    fn snake_case_falls_through_to_arbitrary() {
527        // The snake_case spellings (`nar_hash`, `last_modified`, `rev_count`)
528        // do not match a typed slot; they fall through to `arbitrary`.
529        let in_str = "nar_hash=sha256-abc";
530        let (rest, (output, _)) = LocationParameters::parse.parse_peek(in_str).unwrap();
531        assert_eq!("", rest);
532        // The typed nar_hash slot is still empty; the value lives in arbitrary.
533        let mut expected = LocationParameters::default();
534        expected.add_arbitrary(("nar_hash".into(), "sha256-abc".into()));
535        assert_eq!(output, expected);
536    }
537}
538
539#[cfg(test)]
540mod git_typed_params {
541    //! Pin the seven Git-flavoured params from Nix's git fetcher settings
542    //! to typed slots on `LocationParameters` so they route into the
543    //! typed slots rather than the arbitrary bag.
544
545    use crate::{FlakeRef, NixUriError};
546    use rstest::rstest;
547
548    #[rstest]
549    #[case("1", true)]
550    #[case("0", false)]
551    fn lfs_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
552        let url = format!("git+ssh://example.com/repo?lfs={input}");
553        let parsed: FlakeRef = url.parse().unwrap();
554        assert_eq!(parsed.params().lfs, Some(expected));
555    }
556
557    #[rstest]
558    #[case("1", true)]
559    #[case("0", false)]
560    fn export_ignore_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
561        let url = format!("git+ssh://example.com/repo?exportIgnore={input}");
562        let parsed: FlakeRef = url.parse().unwrap();
563        assert_eq!(parsed.params().export_ignore, Some(expected));
564    }
565
566    #[rstest]
567    #[case("1", true)]
568    #[case("0", false)]
569    fn all_refs_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
570        let url = format!("git+ssh://example.com/repo?allRefs={input}");
571        let parsed: FlakeRef = url.parse().unwrap();
572        assert_eq!(parsed.params().all_refs, Some(expected));
573    }
574
575    #[rstest]
576    #[case("1", true)]
577    #[case("0", false)]
578    fn verify_commit_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
579        let url = format!("git+ssh://example.com/repo?verifyCommit={input}");
580        let parsed: FlakeRef = url.parse().unwrap();
581        assert_eq!(parsed.params().verify_commit, Some(expected));
582    }
583
584    #[rstest]
585    #[case("lfs", "yes")]
586    #[case("exportIgnore", "yes")]
587    #[case("allRefs", "yes")]
588    #[case("verifyCommit", "yes")]
589    // Nix's URL-time coercion is strictly `value == "1"`; `"true"` /
590    // `"false"` look right but Nix maps anything other than `"1"` to
591    // false. Reject them at parse time so the diagnostic surfaces
592    // instead of silently flipping the bool.
593    #[case("lfs", "true")]
594    #[case("exportIgnore", "false")]
595    #[case("allRefs", "True")]
596    #[case("verifyCommit", "TRUE")]
597    fn bool_keys_reject_non_bool_values(#[case] key: &str, #[case] value: &str) {
598        let url = format!("git+ssh://example.com/repo?{key}={value}");
599        let err = url.parse::<FlakeRef>().unwrap_err();
600        match err {
601            NixUriError::InvalidValue { field, .. } => {
602                assert_eq!(field, key, "expected error.field to name the rejected key");
603            }
604            other => panic!("expected InvalidValue, got {other:?}"),
605        }
606    }
607
608    #[test]
609    fn keytype_routes_to_typed_slot() {
610        let parsed: FlakeRef = "git+ssh://example.com/repo?keytype=ssh-ed25519"
611            .parse()
612            .unwrap();
613        assert_eq!(parsed.params().keytype.as_deref(), Some("ssh-ed25519"));
614    }
615
616    #[test]
617    fn public_key_routes_to_typed_slot() {
618        let parsed: FlakeRef = "git+ssh://example.com/repo?publicKey=abcdef"
619            .parse()
620            .unwrap();
621        assert_eq!(parsed.params().public_key.as_deref(), Some("abcdef"));
622    }
623
624    #[test]
625    fn public_keys_routes_to_typed_slot() {
626        let parsed: FlakeRef = "git+ssh://example.com/repo?publicKeys=k1.k2.k3"
627            .parse()
628            .unwrap();
629        assert_eq!(parsed.params().public_keys.as_deref(), Some("k1.k2.k3"));
630    }
631
632    #[test]
633    fn display_emits_seven_keys_alphabetically() {
634        // Alphabetical (ASCII) order across the seven new keys plus a
635        // pre-existing `narHash` entry: allRefs, exportIgnore, keytype,
636        // lfs, narHash, publicKey, publicKeys, verifyCommit.
637        let url = "git+ssh://example.com/repo?\
638                   verifyCommit=1&publicKeys=k1.k2&publicKey=abc&\
639                   narHash=sha256-x&lfs=1&keytype=ssh-ed25519&\
640                   exportIgnore=0&allRefs=1";
641        let parsed: FlakeRef = url.parse().unwrap();
642        let expected = "git+ssh://example.com/repo?\
643                        allRefs=1&exportIgnore=0&keytype=ssh-ed25519&\
644                        lfs=1&narHash=sha256-x&publicKey=abc&\
645                        publicKeys=k1.k2&verifyCommit=1";
646        assert_eq!(parsed.to_string(), expected);
647    }
648
649    #[rstest]
650    #[case("1", true)]
651    #[case("0", false)]
652    fn submodules_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
653        let url = format!("git+ssh://example.com/repo?submodules={input}");
654        let parsed: FlakeRef = url.parse().unwrap();
655        assert_eq!(parsed.params().submodules, Some(expected));
656    }
657
658    #[rstest]
659    #[case("1", true)]
660    #[case("0", false)]
661    fn shallow_accepts_bool_forms(#[case] input: &str, #[case] expected: bool) {
662        let url = format!("git+ssh://example.com/repo?shallow={input}");
663        let parsed: FlakeRef = url.parse().unwrap();
664        assert_eq!(parsed.params().shallow, Some(expected));
665    }
666
667    #[rstest]
668    #[case("submodules", "garbage")]
669    #[case("submodules", "true")]
670    #[case("shallow", "garbage")]
671    #[case("shallow", "false")]
672    fn submodules_shallow_reject_non_bool_values(#[case] key: &str, #[case] value: &str) {
673        // submodules and shallow are bool-coerced by Nix; inputs that
674        // are not `"1"` / `"0"` surface as InvalidValue with a field
675        // tag.
676        let url = format!("git+ssh://example.com/repo?{key}={value}");
677        let err = url.parse::<FlakeRef>().unwrap_err();
678        match err {
679            NixUriError::InvalidValue { field, .. } => assert_eq!(field, key),
680            other => panic!("expected InvalidValue, got {other:?}"),
681        }
682    }
683
684    #[test]
685    fn submodules_round_trips_canonically() {
686        let url = "git+ssh://example.com/repo?submodules=1";
687        let parsed: FlakeRef = url.parse().unwrap();
688        assert_eq!(parsed.to_string(), url);
689        assert_eq!(parsed.params().submodules, Some(true));
690    }
691
692    #[test]
693    fn shallow_round_trips_canonically() {
694        let url = "git+ssh://example.com/repo?shallow=1";
695        let parsed: FlakeRef = url.parse().unwrap();
696        assert_eq!(parsed.to_string(), url);
697        assert_eq!(parsed.params().shallow, Some(true));
698    }
699
700    #[test]
701    fn round_trip_realistic_git_url() {
702        let url = "git+ssh://example.com/repo?allRefs=1&lfs=1&publicKey=abc";
703        let parsed: FlakeRef = url.parse().unwrap();
704        assert_eq!(parsed.params().all_refs, Some(true));
705        assert_eq!(parsed.params().lfs, Some(true));
706        assert_eq!(parsed.params().public_key.as_deref(), Some("abc"));
707        assert_eq!(parsed.to_string(), url);
708    }
709}