Skip to main content

nix_uri/
flakeref.rs

1use std::fmt::Display;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{NixUriError, UnsupportedReason};
6
7pub(crate) mod encoding;
8mod fr_type;
9pub use fr_type::FlakeRefType;
10pub(crate) mod location_params;
11pub(crate) use location_params::LocationParamKeys;
12pub use location_params::LocationParameters;
13mod transport_layer;
14pub use transport_layer::TransportLayer;
15mod forge;
16pub use forge::{GitForge, GitForgePlatform};
17mod resource_url;
18pub use resource_url::{ResourceType, ResourceUrl};
19#[cfg(test)]
20mod proptest;
21pub(crate) mod validators;
22
23/// Names where a ref or rev is rendered in a `FlakeRef`.
24///
25/// `RefLocation` is a routing tag, not a presence flag: a `FlakeRef` whose
26/// kind has no ref and no rev still carries a `RefLocation` saying where one
27/// *would* be written if it were set. The "no ref or rev" state is encoded by
28/// `ref_ == None && rev == None` on the kind, so a `RefLocation::None`
29/// variant would be redundant and is therefore unrepresentable.
30///
31/// `Default` is `PathComponent` because that is the canonical form for the
32/// ref-bearing kinds (`GitForge`, `Indirect`); the parser flips this to
33/// `QueryParameter` when the value arrived via `?ref=` / `?rev=`, preserving
34/// the round-trip shape.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[non_exhaustive]
37pub enum RefLocation {
38    /// Rendered in the path component, e.g. `github:owner/repo/<value>`.
39    #[default]
40    PathComponent,
41    /// Rendered as a query parameter, e.g. `?ref=<value>` or `?rev=<value>`.
42    QueryParameter,
43}
44
45/// The General Flake Ref Schema
46#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[cfg_attr(test, serde(deny_unknown_fields))]
48#[non_exhaustive]
49pub struct FlakeRef {
50    /// The shape of the URL: a git forge, a resource (`git+https://...`,
51    /// `tarball+https://...`, ...), an indirect (registry) ref, or a path.
52    /// Reachable through [`Self::kind`] and the consuming [`Self::with_kind`]
53    /// builder; private so the public surface stays consistent with
54    /// `fragment` and `params`, which only expose accessors.
55    pub(crate) kind: FlakeRefType,
56    fragment: Option<String>,
57    params: Box<LocationParameters>,
58}
59
60/// Identity of a git-forge flake ref: `(platform, owner, repo, domain)`.
61///
62/// Returned by [`FlakeRef::forge_identity`] for kinds that resolve to a
63/// git forge (`GitForge` always; `Resource(Git)` does not; its
64/// owner/repo/domain are extracted ad-hoc from a URL string and not
65/// guaranteed to be present, so it returns `None` for `forge_identity`).
66///
67/// `#[non_exhaustive]` reserves room for future fields without breaking
68/// downstream match arms.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct ForgeIdentity {
72    pub platform: GitForgePlatform,
73    pub owner: String,
74    pub repo: String,
75    pub domain: String,
76}
77
78/// Discriminates the four ref/rev presence states without forcing callers
79/// to read both [`FlakeRef::ref_`] and [`FlakeRef::rev`] and reason about
80/// the cross product.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum RefKind {
84    /// Neither `ref_` nor `rev` is set.
85    None,
86    /// Only `ref_` is set (a branch or tag name, not a 40-hex commit hash).
87    Ref,
88    /// Only `rev` is set (pinned to a 40-hex commit hash).
89    Rev,
90    /// Both `ref_` and `rev` are set (canonical Nix's three-segment Indirect
91    /// form, `flake:id/ref/rev`).
92    Both,
93}
94
95impl FlakeRef {
96    /// Construct a `FlakeRef` around `kind` with no fragment and an empty
97    /// [`LocationParameters`] block. Use the consuming `with_*` builders or
98    /// the `set_*` mutators to fill in the rest.
99    pub fn new(kind: FlakeRefType) -> Self {
100        Self {
101            kind,
102            ..Self::default()
103        }
104    }
105
106    /// Read access to the kind. Pattern-matching consumers go through
107    /// this accessor rather than the (private) field directly.
108    pub fn kind(&self) -> &FlakeRefType {
109        &self.kind
110    }
111
112    /// Mutable access to the kind for in-place edits. The consuming
113    /// [`Self::with_kind`] builder is the right tool when chaining;
114    /// reach for `kind_mut` only when you must rewrite a field on a
115    /// borrowed `FlakeRef`.
116    pub fn kind_mut(&mut self) -> &mut FlakeRefType {
117        &mut self.kind
118    }
119
120    /// The repo identifier for the kind (`repo` for `GitForge`, the trailing
121    /// path segment of a `Resource(Git)` URL); `None` otherwise.
122    pub fn id(&self) -> Option<&str> {
123        self.kind().id()
124    }
125
126    /// Owner (organisation/user) for the kind, when applicable. Returns
127    /// `Some` for `GitForge` and for `Resource(Git)` URLs that look like
128    /// `domain/owner/repo`; `None` for `Path`, `Indirect`, and other
129    /// resources.
130    pub fn owner(&self) -> Option<&str> {
131        self.kind().owner()
132    }
133
134    /// Repository name for the kind, when applicable. See [`Self::owner`]
135    /// for the kinds that produce `Some`.
136    pub fn repo(&self) -> Option<&str> {
137        self.kind().repo()
138    }
139
140    /// Domain (host) for the kind, when applicable. Returns the canonical
141    /// host string for git-forge platforms (`github.com`, `gitlab.com`,
142    /// `git.sr.ht`) -- or the `?host=` override for self-hosted instances
143    /// -- and the host portion of a `Resource(Git)` URL (with `:port`
144    /// retained when the port is non-default for the scheme); `None`
145    /// otherwise.
146    ///
147    /// Matches Nix's per-scheme host resolution: the `host` attr
148    /// defaults to the canonical domain.
149    pub fn domain(&self) -> Option<&str> {
150        if matches!(self.kind(), FlakeRefType::GitForge(_)) {
151            if let Some(host) = self.params.host_value() {
152                return Some(host);
153            }
154        }
155        self.kind().domain()
156    }
157
158    /// Bundled identity for git-forge kinds. Returns `Some` only for
159    /// [`FlakeRefType::GitForge`]; `Resource(Git)` URLs do not have a
160    /// guaranteed-parseable owner/repo/domain triple, and `Path` /
161    /// `Indirect` have no identity at all.
162    ///
163    /// Honours the `?host=` override (matching Nix's per-scheme host
164    /// resolution); falls back to the platform's canonical domain when
165    /// no override is set. The `SourceHut` canonical default stays
166    /// `git.sr.ht` rather than the apex `sr.ht`, which does not serve
167    /// git over HTTPS.
168    pub fn forge_identity(&self) -> Option<ForgeIdentity> {
169        match self.kind() {
170            FlakeRefType::GitForge(forge) => {
171                let canonical = match forge.platform {
172                    GitForgePlatform::GitHub => "github.com",
173                    GitForgePlatform::GitLab => "gitlab.com",
174                    GitForgePlatform::SourceHut => "git.sr.ht",
175                };
176                let domain = self
177                    .params
178                    .host_value()
179                    .map_or_else(|| canonical.to_string(), str::to_owned);
180                Some(ForgeIdentity {
181                    platform: forge.platform.clone(),
182                    owner: forge.owner.clone(),
183                    repo: forge.repo.clone(),
184                    domain,
185                })
186            }
187            _ => None,
188        }
189    }
190
191    /// The `ref_` (branch or tag name) for kinds that carry one, borrowed.
192    pub fn ref_(&self) -> Option<&str> {
193        match self.kind() {
194            FlakeRefType::GitForge(GitForge { ref_, .. }) | FlakeRefType::Indirect { ref_, .. } => {
195                ref_.as_deref()
196            }
197            FlakeRefType::Resource(res) => res.ref_.as_deref(),
198            FlakeRefType::Path { .. } => None,
199        }
200    }
201
202    /// The `rev` (40-hex commit hash) for kinds that carry one, borrowed.
203    pub fn rev(&self) -> Option<&str> {
204        match self.kind() {
205            FlakeRefType::GitForge(GitForge { rev, .. })
206            | FlakeRefType::Indirect { rev, .. }
207            | FlakeRefType::Path { rev, .. } => rev.as_deref(),
208            FlakeRefType::Resource(res) => res.rev.as_deref(),
209        }
210    }
211
212    /// Whichever of `rev` / `ref_` is set, preferring `rev` when pinned;
213    /// the typical "what does this ref resolve to?" answer. Returns
214    /// borrowed `&str`; callers wanting an owned `String` use
215    /// `.map(str::to_owned)`.
216    pub fn ref_or_rev(&self) -> Option<&str> {
217        if self.is_pinned_to_rev() {
218            self.rev()
219        } else {
220            self.ref_().or_else(|| self.rev())
221        }
222    }
223
224    /// Discriminates which of `ref_` / `rev` are populated; useful when the
225    /// caller needs to switch on the four-way combination without writing
226    /// nested `Option` matches.
227    pub fn ref_kind(&self) -> RefKind {
228        match (self.ref_().is_some(), self.rev().is_some()) {
229            (false, false) => RefKind::None,
230            (true, false) => RefKind::Ref,
231            (false, true) => RefKind::Rev,
232            (true, true) => RefKind::Both,
233        }
234    }
235
236    /// `true` when the kind has a `rev` set (canonical Nix's "pinned to a
237    /// commit"). False for refs-by-name and for kinds that don't carry
238    /// rev at all.
239    pub fn is_pinned_to_rev(&self) -> bool {
240        matches!(
241            self.kind(),
242            FlakeRefType::GitForge(GitForge { rev: Some(_), .. })
243                | FlakeRefType::Indirect { rev: Some(_), .. }
244                | FlakeRefType::Path { rev: Some(_), .. }
245        ) || matches!(
246            self.kind(),
247            FlakeRefType::Resource(res) if res.rev.is_some()
248        )
249    }
250
251    /// Where the kind's ref/rev is rendered. Reads the kind's [`RefLocation`]
252    /// directly; `Path` is fixed at `QueryParameter` because its rev has no
253    /// path-component spelling in Nix's grammar.
254    pub fn ref_source_location(&self) -> RefLocation {
255        match self.kind() {
256            FlakeRefType::GitForge(forge) => forge.location,
257            FlakeRefType::Indirect { location, .. } => *location,
258            FlakeRefType::Resource(res) => res.ref_location,
259            FlakeRefType::Path { .. } => RefLocation::QueryParameter,
260        }
261    }
262
263    /// Read-only access to the query-string parameters.
264    pub fn params(&self) -> &LocationParameters {
265        &self.params
266    }
267
268    /// Trailing `#fragment` retained verbatim. Nix uses fragments to select
269    /// an attribute path inside a flake (e.g. `github:nixos/nixpkgs#hello`);
270    /// the raw string is preserved without interpretation.
271    pub fn fragment(&self) -> Option<&str> {
272        self.fragment.as_deref()
273    }
274
275    /// Write `new_ref` into the kind's typed `ref_` slot. Silently no-op for
276    /// kinds that do not carry ref/rev. For [`FlakeRefType::Resource`],
277    /// writing a non-`None` value also flips `ref_location` to
278    /// [`RefLocation::QueryParameter`]; Resource has no path-component
279    /// ref/rev representation, so leaving it as `PathComponent` would cause
280    /// `Display` to drop the value silently.
281    pub fn set_ref(&mut self, new_ref: Option<String>) {
282        let writing_some = new_ref.is_some();
283        self.kind_mut().set_ref(new_ref);
284        if writing_some && matches!(self.kind(), FlakeRefType::Resource(_)) {
285            self.kind_mut()
286                .set_ref_location(RefLocation::QueryParameter);
287        }
288    }
289
290    /// Write `new_rev` into the kind's typed `rev` slot. See [`Self::set_ref`]
291    /// for the Resource `ref_location` flip rationale.
292    pub fn set_rev(&mut self, new_rev: Option<String>) {
293        let writing_some = new_rev.is_some();
294        self.kind_mut().set_rev(new_rev);
295        if writing_some && matches!(self.kind(), FlakeRefType::Resource(_)) {
296            self.kind_mut()
297                .set_ref_location(RefLocation::QueryParameter);
298        }
299    }
300
301    /// Write `fragment` (the `#suffix`) into the typed slot.
302    pub fn set_fragment(&mut self, fragment: Option<String>) {
303        self.fragment = fragment;
304    }
305
306    /// Set [`RefLocation`] on the kind's `ref_location` slot. Renamed from
307    /// `set_location`, which was easy to misread as "modify the URL
308    /// location string"; this method writes the routing tag that controls
309    /// whether ref/rev render as `?ref=` or as a path component.
310    pub fn set_ref_location(&mut self, loc: RefLocation) {
311        self.kind_mut().set_ref_location(loc);
312    }
313
314    /// Typed mutator for the `dir` query parameter.
315    pub fn set_dir(&mut self, dir: Option<String>) {
316        self.params.set_dir(dir);
317    }
318
319    /// Typed mutator for the `host` query parameter.
320    pub fn set_host(&mut self, host: Option<String>) {
321        self.params.set_host(host);
322    }
323
324    /// Typed mutator for the `shallow` query parameter. `true` enables a
325    /// shallow clone (mapped to `?shallow=1` on Display); `false` writes
326    /// `?shallow=0`. Pass through [`LocationParameters::set_shallow`] to
327    /// clear the slot entirely.
328    pub fn set_shallow(&mut self, shallow: bool) {
329        self.params.set_shallow(Some(shallow));
330    }
331
332    /// Typed mutator for the `submodules` query parameter; behaves like
333    /// [`Self::set_shallow`].
334    pub fn set_submodules(&mut self, submodules: bool) {
335        self.params.set_submodules(Some(submodules));
336    }
337
338    /// Typed mutator for the `narHash` query parameter.
339    pub fn set_nar_hash(&mut self, hash: Option<String>) {
340        self.params.set_nar_hash(hash);
341    }
342
343    /// Typed mutator for the `lastModified` query parameter.
344    pub fn set_last_modified(&mut self, ts: Option<String>) {
345        self.params.set_last_modified(ts);
346    }
347
348    /// Typed mutator for the `revCount` query parameter.
349    pub fn set_rev_count(&mut self, count: Option<String>) {
350        self.params.set_rev_count(count);
351    }
352
353    /// Internal helper: replace the entire [`LocationParameters`] block.
354    /// Kept `pub(crate)` because batch replacement is an initialisation
355    /// pattern (the parser builds a fresh `LocationParameters` then
356    /// installs it); public consumers should reach for the typed
357    /// `set_*` mutators instead.
358    pub(crate) fn replace_params(&mut self, params: LocationParameters) {
359        *self.params = params;
360    }
361
362    /// Consuming builder variant of [`Self::set_ref`]. Silently no-ops
363    /// on kinds without a `ref_` slot ([`FlakeRefType::Path`]); see
364    /// [`Self::try_with_ref`] for the loud opt-in alternative that
365    /// surfaces a typed error instead.
366    pub fn with_ref(mut self, r: Option<String>) -> Self {
367        self.set_ref(r);
368        self
369    }
370
371    /// Consuming builder variant of [`Self::set_rev`]. See
372    /// [`Self::try_with_rev`] for the fallible variant kept for API
373    /// symmetry with [`Self::try_with_ref`].
374    pub fn with_rev(mut self, r: Option<String>) -> Self {
375        self.set_rev(r);
376        self
377    }
378
379    /// Fallible variant of [`Self::with_ref`]: returns
380    /// [`NixUriError::Unsupported`] when the kind cannot carry a ref per
381    /// Nix's per-scheme attribute rules.
382    ///
383    /// Surfaces [`UnsupportedReason::Field`] for kinds outside the
384    /// ref-bearing set ([`FlakeRefType::Path`], [`ResourceType::File`]
385    /// and [`ResourceType::Tarball`]). Setting a ref on these kinds via
386    /// [`Self::with_ref`] is a silent no-op (Path) or renders a string
387    /// the parser would reject (File/Tarball, breaking the round-trip
388    /// invariant); `try_with_ref` lets callers diagnose the mismatch at
389    /// the call site.
390    ///
391    /// `try_with_ref(None)` is always `Ok`: clearing has no Nix-level
392    /// implications.
393    pub fn try_with_ref(self, new_ref: Option<String>) -> Result<Self, NixUriError> {
394        if new_ref.is_some() && !self.kind().allows_ref() {
395            return Err(NixUriError::Unsupported(UnsupportedReason::Field {
396                field: "ref".into(),
397                only_supported_by: "github, gitlab, sourcehut, flake (indirect), git+, hg+".into(),
398            }));
399        }
400        Ok(self.with_ref(new_ref))
401    }
402
403    /// Fallible variant of [`Self::with_rev`]. All current kinds permit
404    /// rev per Nix's per-scheme attribute rules, so this method always
405    /// returns `Ok`. It exists for API symmetry with
406    /// [`Self::try_with_ref`] so callers can write the same
407    /// fallible-builder shape regardless of which slot they are
408    /// writing.
409    pub fn try_with_rev(self, new_rev: Option<String>) -> Result<Self, NixUriError> {
410        Ok(self.with_rev(new_rev))
411    }
412
413    /// Consuming builder variant of [`Self::set_fragment`].
414    pub fn with_fragment(mut self, fragment: Option<String>) -> Self {
415        self.set_fragment(fragment);
416        self
417    }
418
419    /// Consuming builder that sets the kind in a chain.
420    pub fn with_kind(mut self, kind: FlakeRefType) -> Self {
421        *self.kind_mut() = kind;
422        self
423    }
424
425    /// Consuming builder that installs a fresh [`LocationParameters`] block.
426    /// For incremental edits, use the typed `set_*` mutators instead.
427    pub fn with_params(mut self, params: LocationParameters) -> Self {
428        self.params = Box::new(params);
429        self
430    }
431
432    /// Clear the `rev` slot (the "pin"); leaves `ref_` alone. Useful when
433    /// the caller wants the named branch/tag rather than a specific
434    /// commit.
435    pub fn without_pin(mut self) -> Self {
436        self.set_rev(None);
437        self
438    }
439
440    /// Pin to a specific commit, clearing any pre-existing `ref_` first.
441    ///
442    /// `with_rev(Some(rev))` writes only the `rev` slot, leaving any
443    /// `ref_` already on the kind in place. The `GitForge` Display arm
444    /// renders `ref_.or(rev)` when [`RefLocation::PathComponent`] is
445    /// active, so a `ref_`-bearing forge URL with a freshly written
446    /// `rev` round-trips back to the named ref and silently drops the
447    /// pinned commit. `pin_to_rev` is the atomic builder that performs
448    /// the clear-ref-then-set-rev sequence callers want.
449    ///
450    /// For kinds without a `ref_` slot ([`FlakeRefType::Path`]), this
451    /// only writes the `rev`; there is no ref to clear.
452    pub fn pin_to_rev(mut self, rev: String) -> Self {
453        self.set_ref(None);
454        self.set_rev(Some(rev));
455        self
456    }
457
458    /// Consume and render to a `String`; the same output as `to_string`
459    /// but without an intermediate clone when the caller already owns
460    /// `self`.
461    pub fn into_uri(self) -> String {
462        self.to_string()
463    }
464
465    /// Canonical wire form of this `FlakeRef`, matching the URL Nix
466    /// would emit for the same input.
467    ///
468    /// Pairs with [`Display`] (and the equivalent [`Self::into_uri`]),
469    /// which preserves the input verbatim for byte-stable round-trips.
470    /// `to_canonical_string` instead emits the form Nix would produce,
471    /// normalising every shape that Nix collapses on the way out:
472    ///
473    /// - `GitForge` (`github:` / `gitlab:` / `sourcehut:`): renders as
474    ///   `<scheme>:<owner>/<repo>[/<ref-or-rev>]` regardless of where
475    ///   the parsed value came from. `rev` wins over `ref_` when both
476    ///   are populated, matching Nix's behaviour (Nix asserts that ref
477    ///   and rev are never both set on a git-archive URL, so the
478    ///   both-set case is nonsensical for consumers). The query carries
479    ///   only `host` and `narHash` when set.
480    /// - `Resource(Git)`: emits `ref` and `rev` always when set, and
481    ///   the typed booleans (`shallow`, `lfs`, `submodules`,
482    ///   `exportIgnore`, `verifyCommit`) only for the truthy branch.
483    ///   `allRefs` is never emitted because Nix's git scheme does not
484    ///   include it on canonical output. `narHash`, `lastModified`,
485    ///   `revCount`, `dir`, `host`, and arbitrary keys are dropped.
486    /// - `Resource(Mercurial)`: emits only `ref` and `rev`.
487    /// - `Indirect`, `Path`, `Resource(File)`, `Resource(Tarball)`:
488    ///   delegated to `Display`; their existing form already matches
489    ///   Nix byte-for-byte.
490    ///
491    /// Use this when handing a string to a Nix consumer that expects
492    /// the canonical spelling; reach for `Display` / [`Self::into_uri`]
493    /// when round-tripping a user-supplied URL byte-for-byte.
494    pub fn to_canonical_string(&self) -> String {
495        use std::fmt::Write;
496
497        let mut out = String::new();
498
499        match self.kind() {
500            FlakeRefType::GitForge(forge) => {
501                let owner_out = encoding::encode_path_segment(&forge.owner);
502                write!(&mut out, "{}:{}/{}", forge.platform, owner_out, forge.repo).unwrap();
503                // ref/rev always render in the path tail. `rev` wins
504                // when both happen to be populated (Nix asserts they
505                // cannot coexist on a git-archive URL; we still pick a
506                // deterministic answer rather than silently dropping
507                // one).
508                if let Some(value) = forge.rev.as_deref().or(forge.ref_.as_deref()) {
509                    write!(&mut out, "/{value}").unwrap();
510                }
511                let mut entries: Vec<(&str, &str)> = Vec::new();
512                if let Some(host) = self.params.host_value() {
513                    entries.push(("host", host));
514                }
515                if let Some(nar) = self.params.nar_hash_value() {
516                    entries.push(("narHash", nar));
517                }
518                entries.sort_by(|a, b| a.0.cmp(b.0));
519                write_canonical_query(&mut out, &entries);
520            }
521            FlakeRefType::Resource(res) if matches!(res.res_type, ResourceType::Git) => {
522                write_resource_base(&mut out, res);
523                let mut entries: Vec<(&str, &str)> = Vec::new();
524                if let Some(r) = res.ref_.as_deref() {
525                    entries.push(("ref", r));
526                }
527                if let Some(v) = res.rev.as_deref() {
528                    entries.push(("rev", v));
529                }
530                if self.params.shallow_truthy() {
531                    entries.push(("shallow", "1"));
532                }
533                if self.params.lfs == Some(true) {
534                    entries.push(("lfs", "1"));
535                }
536                if self.params.submodules_truthy() {
537                    entries.push(("submodules", "1"));
538                }
539                if self.params.export_ignore == Some(true) {
540                    entries.push(("exportIgnore", "1"));
541                }
542                if self.params.verify_commit == Some(true) {
543                    entries.push(("verifyCommit", "1"));
544                }
545                if let Some(kt) = self.params.keytype.as_deref() {
546                    entries.push(("keytype", kt));
547                }
548                if let Some(pk) = self.params.public_key.as_deref() {
549                    entries.push(("publicKey", pk));
550                }
551                if let Some(pks) = self.params.public_keys.as_deref() {
552                    entries.push(("publicKeys", pks));
553                }
554                entries.sort_by(|a, b| a.0.cmp(b.0));
555                write_canonical_query(&mut out, &entries);
556            }
557            FlakeRefType::Resource(res) if matches!(res.res_type, ResourceType::Mercurial) => {
558                write_resource_base(&mut out, res);
559                let mut entries: Vec<(&str, &str)> = Vec::new();
560                if let Some(r) = res.ref_.as_deref() {
561                    entries.push(("ref", r));
562                }
563                if let Some(v) = res.rev.as_deref() {
564                    entries.push(("rev", v));
565                }
566                entries.sort_by(|a, b| a.0.cmp(b.0));
567                write_canonical_query(&mut out, &entries);
568            }
569            _ => {
570                // Indirect, Path, Resource(File), Resource(Tarball):
571                // the existing Display form already matches Nix's
572                // canonical output byte-for-byte.
573                return self.to_string();
574            }
575        }
576
577        if let Some(fragment) = &self.fragment {
578            write!(&mut out, "#{}", encoding::encode_fragment(fragment)).unwrap();
579        }
580        out
581    }
582}
583
584fn write_resource_base(out: &mut String, res: &ResourceUrl) {
585    use std::fmt::Write;
586    // Git and Mercurial canonical forms keep the `<res_type>+` prefix
587    // verbatim (no Tarball/File-style stripping); they always carry a
588    // resource scheme tag.
589    write!(out, "{}", res.res_type).unwrap();
590    if let Some(transport) = &res.transport_type {
591        write!(out, "+{}", transport).unwrap();
592    }
593    write!(out, "://{}", res.location).unwrap();
594}
595
596fn write_canonical_query(out: &mut String, entries: &[(&str, &str)]) {
597    use std::fmt::Write;
598    if entries.is_empty() {
599        return;
600    }
601    out.push('?');
602    for (i, (key, value)) in entries.iter().enumerate() {
603        if i > 0 {
604            out.push('&');
605        }
606        write!(
607            out,
608            "{key}={value}",
609            key = encoding::encode_query(key),
610            value = encoding::encode_query(value)
611        )
612        .unwrap();
613    }
614}
615
616impl Display for FlakeRef {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        write!(f, "{}", self.kind())?;
619
620        // Collect every query key (typed param slots, the arbitrary bag, and
621        // the kind's ref/rev when they live in the query) into one list and
622        // emit it sorted by key. Nix emits query keys in alphabetical
623        // order; matching that lets a Display string compare
624        // byte-for-byte against a Nix-emitted form.
625        let mut entries: Vec<(&str, &str)> = self.params.entries();
626        if matches!(self.ref_source_location(), RefLocation::QueryParameter) {
627            // Resource only supports the query-parameter form (Nix's
628            // git/hg schemes have no path-component ref/rev shape), so
629            // it always emits here when ref/rev are set.
630            // Path is fixed at `QueryParameter` and has no ref slot;
631            // `ref_or_rev` returns `(None, rev)` for it.
632            let (ref_, rev) = match self.kind() {
633                FlakeRefType::GitForge(GitForge { ref_, rev, .. })
634                | FlakeRefType::Indirect { ref_, rev, .. } => (ref_.as_deref(), rev.as_deref()),
635                FlakeRefType::Resource(res) => (res.ref_.as_deref(), res.rev.as_deref()),
636                FlakeRefType::Path { rev, .. } => (None, rev.as_deref()),
637            };
638            if let Some(r) = ref_ {
639                entries.push(("ref", r));
640            }
641            if let Some(v) = rev {
642                entries.push(("rev", v));
643            }
644        }
645        entries.sort_by(|a, b| a.0.cmp(b.0));
646        if !entries.is_empty() {
647            write!(f, "?")?;
648            for (i, (key, value)) in entries.iter().enumerate() {
649                if i > 0 {
650                    write!(f, "&")?;
651                }
652                write!(
653                    f,
654                    "{key}={value}",
655                    key = encoding::encode_query(key),
656                    value = encoding::encode_query(value)
657                )?;
658            }
659        }
660        if let Some(fragment) = &self.fragment {
661            write!(f, "#{}", encoding::encode_fragment(fragment))?;
662        }
663        Ok(())
664    }
665}
666
667impl TryFrom<&str> for FlakeRef {
668    type Error = NixUriError;
669
670    fn try_from(value: &str) -> Result<Self, Self::Error> {
671        use crate::parser::parse_nix_uri;
672        parse_nix_uri(value)
673    }
674}
675
676impl std::str::FromStr for FlakeRef {
677    type Err = NixUriError;
678
679    fn from_str(s: &str) -> Result<Self, Self::Err> {
680        use crate::parser::parse_nix_uri;
681        parse_nix_uri(s)
682    }
683}
684
685#[cfg(test)]
686mod tests {
687
688    use cool_asserts::assert_matches;
689    use resource_url::{ResourceType, ResourceUrl};
690    use winnow::Parser;
691
692    use super::*;
693    use crate::{
694        NixUriResult,
695        parser::{parse_nix_uri, parse_params, route_location_params},
696    };
697
698    #[test]
699    fn parse_simple_uri() {
700        let uri = "github:nixos/nixpkgs";
701        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
702            platform: GitForgePlatform::GitHub,
703            owner: "nixos".into(),
704            repo: "nixpkgs".into(),
705            ref_: None,
706            rev: None,
707            location: RefLocation::PathComponent,
708        }));
709
710        let parsed: FlakeRef = uri.try_into().unwrap();
711        assert_eq!(expected, parsed);
712    }
713
714    #[test]
715    fn parse_simple_uri_parsed() {
716        let uri = "github:zellij-org/zellij";
717        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
718            platform: GitForgePlatform::GitHub,
719            owner: "zellij-org".into(),
720            repo: "zellij".into(),
721            ref_: None,
722            rev: None,
723            location: RefLocation::PathComponent,
724        }));
725
726        let parsed: FlakeRef = uri.parse().unwrap();
727        assert_eq!(expected, parsed);
728    }
729
730    #[test]
731    fn parse_simple_uri_no_params() {
732        let uri = "github:zellij-org/zellij";
733        let parsed = parse_params.parse_peek(uri).unwrap().1;
734        assert_eq!(("github:zellij-org/zellij", None), parsed);
735    }
736
737    #[test]
738    fn parse_simple_uri_attr_with_params() {
739        let uri = "github:zellij-org/zellij?dir=assets";
740        let mut location_params = LocationParameters::default();
741        location_params.dir(Some("assets".into()));
742        let (head, raw_values) = parse_params.parse_peek(uri).unwrap().1;
743        assert_eq!("github:zellij-org/zellij", head);
744        let (params, ref_rev) = route_location_params(raw_values.unwrap()).unwrap();
745        assert_eq!(location_params, params);
746        assert!(ref_rev.r#ref.is_none() && ref_rev.rev.is_none());
747    }
748
749    #[test]
750    fn parse_simple_uri_ref() {
751        let uri = "github:zellij-org/zellij?ref=main";
752        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
753            platform: GitForgePlatform::GitHub,
754            owner: "zellij-org".into(),
755            repo: "zellij".into(),
756            ref_: Some("main".into()),
757            rev: None,
758            location: RefLocation::QueryParameter,
759        }));
760
761        let parsed = parse_nix_uri(uri).unwrap();
762        assert_eq!(flake_ref, parsed);
763    }
764
765    #[test]
766    fn parse_simple_uri_rev() {
767        let uri = "github:zellij-org/zellij?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298";
768        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
769            platform: GitForgePlatform::GitHub,
770            owner: "zellij-org".into(),
771            repo: "zellij".into(),
772            ref_: None,
773            rev: Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298".into()),
774            location: RefLocation::QueryParameter,
775        }));
776
777        let parsed = parse_nix_uri(uri).unwrap();
778        assert_eq!(flake_ref, parsed);
779    }
780
781    #[test]
782    fn parse_simple_uri_ref_or_rev() {
783        let uri = "github:zellij-org/zellij/main";
784        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
785            platform: GitForgePlatform::GitHub,
786            owner: "zellij-org".into(),
787            repo: "zellij".into(),
788            ref_: Some("main".into()),
789            rev: None,
790            location: RefLocation::PathComponent,
791        }));
792
793        let parsed = parse_nix_uri(uri).unwrap();
794        assert_eq!(flake_ref, parsed);
795    }
796
797    #[test]
798    fn parse_simple_uri_ref_or_rev_attr() {
799        let uri = "github:zellij-org/zellij/main?dir=assets";
800        let mut params = LocationParameters::default();
801        params.dir(Some("assets".into()));
802        let flake_ref = FlakeRef::default()
803            .with_kind(FlakeRefType::GitForge(GitForge {
804                platform: GitForgePlatform::GitHub,
805                owner: "zellij-org".into(),
806                repo: "zellij".into(),
807                ref_: Some("main".into()),
808                rev: None,
809                location: RefLocation::PathComponent,
810            }))
811            .with_params(params);
812
813        let parsed = parse_nix_uri(uri).unwrap();
814        assert_eq!(flake_ref, parsed);
815    }
816
817    #[test]
818    fn parse_simple_uri_attr() {
819        let uri = "github:zellij-org/zellij?dir=assets";
820        let mut params = LocationParameters::default();
821        params.dir(Some("assets".into()));
822        let flake_ref = FlakeRef::default()
823            .with_kind(FlakeRefType::GitForge(GitForge {
824                platform: GitForgePlatform::GitHub,
825                owner: "zellij-org".into(),
826                repo: "zellij".into(),
827                ref_: None,
828                rev: None,
829                location: RefLocation::PathComponent,
830            }))
831            .with_params(params);
832
833        let parsed = parse_nix_uri(uri).unwrap();
834        assert_eq!(flake_ref, parsed);
835    }
836    #[test]
837    fn parse_simple_uri_attr_nom_alt() {
838        let uri = "github:zellij-org/zellij/?dir=assets";
839        let mut params = LocationParameters::default();
840        params.dir(Some("assets".into()));
841        let flake_ref = FlakeRef::default()
842            .with_kind(FlakeRefType::GitForge(GitForge {
843                platform: GitForgePlatform::GitHub,
844                owner: "zellij-org".into(),
845                repo: "zellij".into(),
846                ref_: None,
847                rev: None,
848                location: RefLocation::PathComponent,
849            }))
850            .with_params(params);
851
852        let parsed = parse_nix_uri(uri).unwrap();
853        assert_eq!(flake_ref, parsed);
854    }
855    #[test]
856    fn parse_simple_uri_params_nom_alt() {
857        let uri = "github:zellij-org/zellij/?dir=assets&narHash=fakeHash256";
858        let mut params = LocationParameters::default();
859        params.dir(Some("assets".into()));
860        params.nar_hash(Some("fakeHash256".into()));
861        let flake_ref = FlakeRef::default()
862            .with_kind(FlakeRefType::GitForge(GitForge {
863                platform: GitForgePlatform::GitHub,
864                owner: "zellij-org".into(),
865                repo: "zellij".into(),
866                ref_: None,
867                rev: None,
868                location: RefLocation::PathComponent,
869            }))
870            .with_params(params);
871
872        let parsed = parse_nix_uri(uri).unwrap();
873        assert_eq!(flake_ref, parsed);
874    }
875
876    #[test]
877    fn parse_simple_path_nom() {
878        let uri = "path:/home/kenji/.config/dotfiles/";
879        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::Path {
880            path: "/home/kenji/.config/dotfiles/".into(),
881            rev: None,
882        });
883
884        let parsed = parse_nix_uri(uri).unwrap();
885        assert_eq!(flake_ref, parsed, "{}", uri);
886    }
887
888    #[test]
889    fn parse_simple_path_params_nom() {
890        let uri = "path:/home/kenji/.config/dotfiles/?dir=assets";
891        let mut params = LocationParameters::default();
892        params.dir(Some("assets".into()));
893        let flake_ref = FlakeRef::default()
894            .with_kind(FlakeRefType::Path {
895                path: "/home/kenji/.config/dotfiles/".into(),
896                rev: None,
897            })
898            .with_params(params);
899
900        let parsed = parse_nix_uri(uri).unwrap();
901        assert_eq!(flake_ref, parsed, "{}", uri);
902    }
903
904    #[test]
905    fn parse_gitlab_simple() {
906        let uri = "gitlab:veloren/veloren";
907        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
908            platform: GitForgePlatform::GitLab,
909            owner: "veloren".into(),
910            repo: "veloren".into(),
911            ref_: None,
912            rev: None,
913            location: RefLocation::PathComponent,
914        }));
915
916        let parsed = parse_nix_uri(uri).unwrap();
917        assert_eq!(flake_ref, parsed);
918    }
919
920    #[test]
921    fn parse_gitlab_simple_ref_or_rev() {
922        let uri = "gitlab:veloren/veloren/master";
923        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
924            platform: GitForgePlatform::GitLab,
925            owner: "veloren".into(),
926            repo: "veloren".into(),
927            ref_: Some("master".into()),
928            rev: None,
929            location: RefLocation::PathComponent,
930        }));
931
932        let parsed = parse_nix_uri(uri).unwrap();
933        assert_eq!(flake_ref, parsed);
934    }
935
936    #[test]
937    fn parse_gitlab_simple_ref_or_rev_alt() {
938        let uri = "gitlab:veloren/veloren/19742bb9300fb0be9fdc92f30766c95230a8a371";
939        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
940            platform: GitForgePlatform::GitLab,
941            owner: "veloren".into(),
942            repo: "veloren".into(),
943            ref_: None,
944            rev: Some("19742bb9300fb0be9fdc92f30766c95230a8a371".into()),
945            location: RefLocation::PathComponent,
946        }));
947
948        let parsed = parse_nix_uri(uri).unwrap();
949        assert_eq!(flake_ref, parsed);
950    }
951
952    #[test]
953    fn parse_gitlab_nested_subgroup() {
954        let uri = "gitlab:veloren%2Fdev/rfcs";
955        let parsed = parse_nix_uri(uri).unwrap();
956        let flake_ref = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
957            platform: GitForgePlatform::GitLab,
958            owner: "veloren/dev".into(),
959            repo: "rfcs".into(),
960            ref_: None,
961            rev: None,
962            location: RefLocation::PathComponent,
963        }));
964        assert_eq!(flake_ref, parsed);
965        // Display re-encodes the subgroup `/` so the wire form is byte-stable.
966        assert_eq!(parsed.to_string(), uri);
967    }
968
969    #[test]
970    fn parse_gitlab_simple_host_param() {
971        let uri = "gitlab:openldap/openldap?host=git.openldap.org";
972        let mut params = LocationParameters::default();
973        params.host(Some("git.openldap.org".into()));
974        let flake_ref = FlakeRef::default()
975            .with_kind(FlakeRefType::GitForge(GitForge {
976                platform: GitForgePlatform::GitLab,
977                owner: "openldap".into(),
978                repo: "openldap".into(),
979                ref_: None,
980                rev: None,
981                location: RefLocation::PathComponent,
982            }))
983            .with_params(params);
984
985        let parsed = parse_nix_uri(uri).unwrap();
986        assert_eq!(flake_ref, parsed);
987    }
988
989    #[test]
990    fn parse_git_and_https_simple() {
991        let uri = "git+https://git.somehost.tld/user/path";
992        let expected = FlakeRef::default().with_kind(FlakeRefType::Resource(ResourceUrl {
993            res_type: ResourceType::Git,
994            location: "git.somehost.tld/user/path".into(),
995            transport_type: Some(TransportLayer::Https),
996            ref_: None,
997            rev: None,
998            ref_location: RefLocation::PathComponent,
999        }));
1000
1001        let parsed: FlakeRef = uri.try_into().unwrap();
1002        assert_eq!(expected, parsed);
1003    }
1004
1005    #[test]
1006    fn parse_git_and_https_params() {
1007        let uri = "git+https://git.somehost.tld/user/path?ref=branch&rev=fdc8ef970de2b4634e1b3dca296e1ed918459a9e";
1008        let parsed: FlakeRef = uri.try_into().unwrap();
1009        assert_eq!(parsed.to_string(), uri);
1010    }
1011
1012    #[test]
1013    fn parse_git_and_file_params() {
1014        let uri = "git+file:///nix/nixpkgs?ref=upstream/nixpkgs-unstable";
1015        let parsed: FlakeRef = uri.try_into().unwrap();
1016        assert_eq!(parsed.to_string(), uri);
1017    }
1018
1019    #[test]
1020    fn parse_git_and_file_simple() {
1021        let uri = "git+file:///nix/nixpkgs";
1022        let expected = FlakeRef::default().with_kind(FlakeRefType::Resource(ResourceUrl {
1023            res_type: ResourceType::Git,
1024            location: "/nix/nixpkgs".into(),
1025            transport_type: Some(TransportLayer::File),
1026            ref_: None,
1027            rev: None,
1028            ref_location: RefLocation::PathComponent,
1029        }));
1030
1031        let parsed: FlakeRef = uri.try_into().unwrap();
1032        assert_eq!(expected, parsed);
1033    }
1034
1035    #[test]
1036    fn parse_git_and_file_branch_query_routes_to_arbitrary() {
1037        // Nix's git scheme has no `branch` key; the branch slot is
1038        // `ref`. The parser absorbs the unrecognised key into the
1039        // arbitrary bag rather than rejecting, matching Nix's
1040        // permissive `parseFlakeRef`.
1041        let uri = "git+file:///home/user/forked-flake?branch=feat/myNewFeature";
1042        let parsed: FlakeRef = uri.parse().expect("unrecognised key must parse");
1043        assert!(
1044            parsed
1045                .params()
1046                .entries()
1047                .iter()
1048                .any(|(k, v)| *k == "branch" && *v == "feat/myNewFeature"),
1049            "branch=feat/myNewFeature must land in arbitrary",
1050        );
1051    }
1052
1053    #[test]
1054    fn parse_github_simple_tag_non_alphabetic_params() {
1055        let uri = "github:smunix/MyST-Parser?ref=fix.hls-docutils";
1056        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
1057            platform: GitForgePlatform::GitHub,
1058            owner: "smunix".into(),
1059            repo: "MyST-Parser".into(),
1060            ref_: Some("fix.hls-docutils".to_owned()),
1061            rev: None,
1062            location: RefLocation::QueryParameter,
1063        }));
1064
1065        let parsed: FlakeRef = uri.try_into().unwrap();
1066        assert_eq!(expected, parsed);
1067    }
1068
1069    #[test]
1070    fn parse_github_simple_tag() {
1071        let uri = "github:cachix/devenv/v0.5";
1072        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
1073            platform: GitForgePlatform::GitHub,
1074            owner: "cachix".into(),
1075            repo: "devenv".into(),
1076            ref_: Some("v0.5".into()),
1077            rev: None,
1078            location: RefLocation::PathComponent,
1079        }));
1080
1081        let parsed: FlakeRef = uri.try_into().unwrap();
1082        assert_eq!(expected, parsed);
1083    }
1084
1085    #[test]
1086    fn parse_gitlab_with_host_params_alt() {
1087        let uri = "gitlab:fpottier/menhir/20201216?host=gitlab.inria.fr";
1088        let mut params = LocationParameters::default();
1089        params.set_host(Some("gitlab.inria.fr".into()));
1090        let expected = FlakeRef::default()
1091            .with_kind(FlakeRefType::GitForge(GitForge {
1092                platform: GitForgePlatform::GitLab,
1093                owner: "fpottier".to_owned(),
1094                repo: "menhir".to_owned(),
1095                ref_: Some("20201216".to_owned()),
1096                rev: None,
1097                location: RefLocation::PathComponent,
1098            }))
1099            .with_params(params);
1100
1101        let parsed: FlakeRef = uri.try_into().unwrap();
1102        assert_eq!(expected, parsed);
1103    }
1104
1105    #[test]
1106    fn parse_git_and_https_params_submodules() {
1107        let uri = "git+https://www.github.com/ocaml/ocaml-lsp?submodules=1";
1108        let mut params = LocationParameters::default();
1109        params.set_submodules(Some(true));
1110        let expected = FlakeRef::default()
1111            .with_kind(FlakeRefType::Resource(ResourceUrl {
1112                res_type: ResourceType::Git,
1113                location: "www.github.com/ocaml/ocaml-lsp".to_owned(),
1114                transport_type: Some(TransportLayer::Https),
1115                ref_: None,
1116                rev: None,
1117                ref_location: RefLocation::PathComponent,
1118            }))
1119            .with_params(params);
1120
1121        let parsed: FlakeRef = uri.try_into().unwrap();
1122        assert_eq!(expected, parsed);
1123    }
1124
1125    #[test]
1126    fn parse_marcurial_and_https_simpe_uri() {
1127        let uri = "hg+https://www.github.com/ocaml/ocaml-lsp";
1128        let expected = FlakeRef::default().with_kind(FlakeRefType::Resource(ResourceUrl {
1129            res_type: ResourceType::Mercurial,
1130            location: "www.github.com/ocaml/ocaml-lsp".to_owned(),
1131            transport_type: Some(TransportLayer::Https),
1132            ref_: None,
1133            rev: None,
1134            ref_location: RefLocation::PathComponent,
1135        }));
1136
1137        let parsed: FlakeRef = uri.try_into().unwrap();
1138        assert_eq!(expected, parsed);
1139    }
1140
1141    #[test]
1142    #[should_panic(expected = "Unsupported(UriType { ty: \"gt+https\" })")]
1143    fn parse_git_and_https_params_submodules_wrong_type() {
1144        let uri = "gt+https://www.github.com/ocaml/ocaml-lsp?submodules=1";
1145        let mut params = LocationParameters::default();
1146        params.set_submodules(Some(true));
1147        let expected = FlakeRef::default()
1148            .with_kind(FlakeRefType::Resource(ResourceUrl {
1149                res_type: ResourceType::Git,
1150                location: "www.github.com/ocaml/ocaml-lsp".to_owned(),
1151                transport_type: Some(TransportLayer::Https),
1152                ref_: None,
1153                rev: None,
1154                ref_location: RefLocation::PathComponent,
1155            }))
1156            .with_params(params);
1157
1158        let parsed: FlakeRef = uri.try_into().unwrap();
1159        assert_eq!(expected, parsed);
1160    }
1161
1162    // TODO: https://github.com/a-kenji/nix-uri/issues/157
1163    #[test]
1164    fn parse_git_and_file_shallow() {
1165        let uri = "git+file:/path/to/repo?shallow=1";
1166        let mut params = LocationParameters::default();
1167        params.set_shallow(Some(true));
1168        let expected = FlakeRef::default()
1169            .with_kind(FlakeRefType::Resource(ResourceUrl {
1170                res_type: ResourceType::Git,
1171                location: "/path/to/repo".to_owned(),
1172                transport_type: Some(TransportLayer::File),
1173                ref_: None,
1174                rev: None,
1175                ref_location: RefLocation::PathComponent,
1176            }))
1177            .with_params(params);
1178
1179        let parsed: FlakeRef = uri.try_into().unwrap();
1180        assert_eq!(expected, parsed);
1181    }
1182
1183    #[test]
1184    fn parse_simple_path_uri_indirect() {
1185        let uri = "path:../.";
1186        let expected = FlakeRef::default().with_kind(FlakeRefType::Path {
1187            path: "../.".to_owned(),
1188            rev: None,
1189        });
1190        let parsed: FlakeRef = uri.try_into().unwrap();
1191        assert_eq!(expected, parsed);
1192    }
1193
1194    #[test]
1195    fn parse_path_uri_empty_body_rejected() {
1196        // The path: branch admits `path:.` and `path:../.`, so it does
1197        // not gate on `Path::is_absolute()`. An explicit guard on
1198        // empty / whitespace bodies keeps `path:` (and friends) from
1199        // parsing to `FlakeRefType::Path { path: "" }` and round-tripping
1200        // back into parse_nix_uri's trim-empty guard.
1201        for uri in ["path:", "path: ", "path:  "] {
1202            let result: Result<FlakeRef, _> = uri.try_into();
1203            assert!(
1204                matches!(result, Err(NixUriError::InvalidUrl(_))),
1205                "expected InvalidUrl for {uri:?}, got {result:?}"
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn parse_simple_path_uri_indirect_local() {
1212        let uri = "path:.";
1213        let expected = FlakeRef::default().with_kind(FlakeRefType::Path {
1214            path: ".".to_owned(),
1215            rev: None,
1216        });
1217        let parsed: FlakeRef = uri.try_into().unwrap();
1218        assert_eq!(expected, parsed);
1219    }
1220
1221    #[test]
1222    fn parse_simple_uri_sourcehut() {
1223        let uri = "sourcehut:~misterio/nix-colors";
1224        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
1225            platform: GitForgePlatform::SourceHut,
1226            owner: "~misterio".to_owned(),
1227            repo: "nix-colors".to_owned(),
1228            ref_: None,
1229            rev: None,
1230            location: RefLocation::PathComponent,
1231        }));
1232
1233        let parsed: FlakeRef = uri.try_into().unwrap();
1234        assert_eq!(expected, parsed);
1235    }
1236
1237    #[test]
1238    fn parse_simple_uri_sourcehut_rev() {
1239        let uri = "sourcehut:~misterio/nix-colors/main";
1240        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
1241            platform: GitForgePlatform::SourceHut,
1242            owner: "~misterio".to_owned(),
1243            repo: "nix-colors".to_owned(),
1244            ref_: Some("main".to_owned()),
1245            rev: None,
1246            location: RefLocation::PathComponent,
1247        }));
1248
1249        let parsed: FlakeRef = uri.try_into().unwrap();
1250        assert_eq!(expected, parsed);
1251    }
1252
1253    #[test]
1254    fn parse_simple_uri_sourcehut_host_param() {
1255        let uri = "sourcehut:~misterio/nix-colors?host=git.example.org";
1256        let mut params = LocationParameters::default();
1257        params.set_host(Some("git.example.org".into()));
1258        let expected = FlakeRef::default()
1259            .with_kind(FlakeRefType::GitForge(GitForge {
1260                platform: GitForgePlatform::SourceHut,
1261                owner: "~misterio".to_owned(),
1262                repo: "nix-colors".to_owned(),
1263                ref_: None,
1264                rev: None,
1265                location: RefLocation::PathComponent,
1266            }))
1267            .with_params(params);
1268
1269        let parsed: FlakeRef = uri.try_into().unwrap();
1270        assert_eq!(expected, parsed);
1271    }
1272
1273    #[test]
1274    fn parse_simple_uri_sourcehut_ref() {
1275        let uri = "sourcehut:~misterio/nix-colors/182b4b8709b8ffe4e9774a4c5d6877bf6bb9a21c";
1276        let expected = FlakeRef::default().with_kind(FlakeRefType::GitForge(GitForge {
1277            platform: GitForgePlatform::SourceHut,
1278            owner: "~misterio".to_owned(),
1279            repo: "nix-colors".to_owned(),
1280            ref_: None,
1281            rev: Some("182b4b8709b8ffe4e9774a4c5d6877bf6bb9a21c".to_owned()),
1282            location: RefLocation::PathComponent,
1283        }));
1284
1285        let parsed: FlakeRef = uri.try_into().unwrap();
1286        assert_eq!(expected, parsed);
1287    }
1288
1289    #[test]
1290    fn parse_simple_uri_sourcehut_ref_params() {
1291        let uri =
1292            "sourcehut:~misterio/nix-colors/21c1a380a6915d890d408e9f22203436a35bb2de?host=hg.sr.ht";
1293        let mut params = LocationParameters::default();
1294        params.set_host(Some("hg.sr.ht".into()));
1295        let expected = FlakeRef::default()
1296            .with_kind(FlakeRefType::GitForge(GitForge {
1297                platform: GitForgePlatform::SourceHut,
1298                owner: "~misterio".to_owned(),
1299                repo: "nix-colors".to_owned(),
1300                ref_: None,
1301                rev: Some("21c1a380a6915d890d408e9f22203436a35bb2de".to_owned()),
1302                location: RefLocation::PathComponent,
1303            }))
1304            .with_params(params);
1305
1306        let parsed: FlakeRef = uri.try_into().unwrap();
1307        assert_eq!(expected, parsed);
1308    }
1309
1310    #[test]
1311    fn display_simple_sourcehut_uri_ref_or_rev() {
1312        let expected = "sourcehut:~misterio/nix-colors/21c1a380a6915d890d408e9f22203436a35bb2de";
1313        let flake_ref = FlakeRef::default()
1314            .with_kind(FlakeRefType::GitForge(GitForge {
1315                platform: GitForgePlatform::SourceHut,
1316                owner: "~misterio".to_owned(),
1317                repo: "nix-colors".to_owned(),
1318                ref_: None,
1319                rev: Some("21c1a380a6915d890d408e9f22203436a35bb2de".to_owned()),
1320                location: RefLocation::PathComponent,
1321            }))
1322            .to_string();
1323
1324        assert_eq!(expected, flake_ref);
1325    }
1326
1327    #[test]
1328    fn display_simple_sourcehut_uri_ref_or_rev_host_param() {
1329        let expected =
1330            "sourcehut:~misterio/nix-colors/21c1a380a6915d890d408e9f22203436a35bb2de?host=hg.sr.ht";
1331        let mut params = LocationParameters::default();
1332        params.set_host(Some("hg.sr.ht".into()));
1333        let flake_ref = FlakeRef::default()
1334            .with_kind(FlakeRefType::GitForge(GitForge {
1335                platform: GitForgePlatform::SourceHut,
1336                owner: "~misterio".to_owned(),
1337                repo: "nix-colors".to_owned(),
1338                ref_: None,
1339                rev: Some("21c1a380a6915d890d408e9f22203436a35bb2de".to_owned()),
1340                location: RefLocation::PathComponent,
1341            }))
1342            .with_params(params)
1343            .to_string();
1344
1345        assert_eq!(expected, flake_ref);
1346    }
1347
1348    #[test]
1349    fn display_simple_github_uri_ref() {
1350        let expected = "github:zellij-org/zellij?ref=main";
1351        let flake_ref = FlakeRef::default()
1352            .with_kind(FlakeRefType::GitForge(GitForge {
1353                platform: GitForgePlatform::GitHub,
1354                owner: "zellij-org".into(),
1355                repo: "zellij".into(),
1356                ref_: Some("main".into()),
1357                rev: None,
1358                location: RefLocation::QueryParameter,
1359            }))
1360            .to_string();
1361
1362        assert_eq!(flake_ref, expected);
1363    }
1364
1365    #[test]
1366    fn display_simple_github_uri_rev() {
1367        let expected = "github:zellij-org/zellij?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298";
1368        let flake_ref = FlakeRef::default()
1369            .with_kind(FlakeRefType::GitForge(GitForge {
1370                platform: GitForgePlatform::GitHub,
1371                owner: "zellij-org".into(),
1372                repo: "zellij".into(),
1373                ref_: None,
1374                rev: Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298".into()),
1375                location: RefLocation::QueryParameter,
1376            }))
1377            .to_string();
1378
1379        assert_eq!(flake_ref, expected);
1380    }
1381
1382    #[test]
1383    fn parse_simple_path_uri_indirect_absolute_without_prefix() {
1384        let uri = "/home/kenji/git";
1385        let expected = FlakeRef::default().with_kind(FlakeRefType::Path {
1386            path: "/home/kenji/git".to_owned(),
1387            rev: None,
1388        });
1389
1390        let parsed: FlakeRef = uri.try_into().unwrap();
1391        assert_eq!(expected, parsed);
1392    }
1393
1394    #[test]
1395    fn parse_simple_path_uri_indirect_absolute_without_prefix_with_params() {
1396        let uri = "/home/kenji/git?dir=dev";
1397        let mut params = LocationParameters::default();
1398        params.set_dir(Some("dev".into()));
1399        let expected = FlakeRef::default()
1400            .with_kind(FlakeRefType::Path {
1401                path: "/home/kenji/git".to_owned(),
1402                rev: None,
1403            })
1404            .with_params(params);
1405
1406        let parsed: FlakeRef = uri.try_into().unwrap();
1407        assert_eq!(expected, parsed);
1408    }
1409
1410    #[test]
1411    fn parse_simple_path_uri_indirect_local_without_prefix() {
1412        let uri = ".";
1413        let expected = FlakeRef::default().with_kind(FlakeRefType::Path {
1414            path: ".".to_owned(),
1415            rev: None,
1416        });
1417        let parsed: FlakeRef = uri.try_into().unwrap();
1418        assert_eq!(expected, parsed);
1419    }
1420
1421    #[test]
1422    fn parse_wrong_git_uri_extension_type() {
1423        let uri = "git+(:z";
1424        let parsed: NixUriResult<FlakeRef> = uri.try_into();
1425        let parsed = parsed.unwrap_err();
1426        assert_matches!(
1427            parsed,
1428            NixUriError::Unsupported(UnsupportedReason::TransportLayer { ty })
1429                => assert_eq!("(", ty)
1430        );
1431    }
1432
1433    #[test]
1434    fn parse_github_missing_parameter_public_surface() {
1435        use crate::ParseExpected;
1436
1437        assert_matches!(
1438            parse_nix_uri("github:"),
1439            Err(NixUriError::Parse {
1440                position: 7,
1441                expected: ParseExpected::Label("TakeTill1"),
1442            })
1443        );
1444    }
1445
1446    #[test]
1447    fn parse_github_missing_parameter_repo_public_surface() {
1448        use crate::ParseExpected;
1449
1450        assert_matches!(
1451            parse_nix_uri("github:nixos/"),
1452            Err(NixUriError::Parse {
1453                position: 13,
1454                expected: ParseExpected::Label("TakeTill1"),
1455            })
1456        );
1457    }
1458
1459    #[test]
1460    fn parse_resource_missing_separator_pins_tag_variant() {
1461        use crate::ParseExpected;
1462
1463        assert_matches!(
1464            parse_nix_uri("git:x"),
1465            Err(NixUriError::Parse {
1466                position: 4,
1467                expected: ParseExpected::Tag("//"),
1468            })
1469        );
1470    }
1471
1472    #[test]
1473    fn parse_github_starts_with_whitespace() {
1474        let uri = " github:nixos/nixpkgs";
1475        assert_matches!(
1476            uri.parse::<FlakeRef>(),
1477            Err(NixUriError::InvalidUrl(uri_match)) => assert_eq!(uri, uri_match)
1478        );
1479    }
1480
1481    #[test]
1482    fn parse_github_ends_with_whitespace() {
1483        let uri = "github:nixos/nixpkgs ";
1484        assert_matches!(
1485            uri.parse::<FlakeRef>(),
1486            Err(NixUriError::InvalidUrl(uri_match)) => assert_eq!(uri, uri_match)
1487        );
1488    }
1489
1490    #[test]
1491    fn parse_empty_invalid_url() {
1492        let uri = "";
1493        assert_matches!(
1494            uri.parse::<FlakeRef>().unwrap_err(),
1495            NixUriError::InvalidUrl(uri) => assert_eq!("", uri)
1496        );
1497    }
1498
1499    #[test]
1500    fn parse_empty_trim_invalid_url() {
1501        let uri = "  ";
1502        assert_matches!(
1503            uri.parse::<FlakeRef>().unwrap_err(),
1504            NixUriError::InvalidUrl(uri_match) => assert_eq!(uri, uri_match)
1505        );
1506    }
1507
1508    #[test]
1509    fn parse_slash_trim_invalid_url() {
1510        let uri = "   /   ";
1511        assert_matches!(
1512            uri.parse::<FlakeRef>().unwrap_err(),
1513            NixUriError::InvalidUrl(uri_match) => assert_eq!(uri, uri_match)
1514        );
1515    }
1516
1517    #[test]
1518    fn parse_double_trim_invalid_url() {
1519        let uri = "   :   ";
1520        assert_matches!(
1521            uri.parse::<FlakeRef>().unwrap_err(),
1522            NixUriError::InvalidUrl(uri_match) => assert_eq!(uri, uri_match)
1523        );
1524    }
1525
1526    #[test]
1527    fn indirect_display_emits_flake_prefix() {
1528        // Indirect Display now emits `flake:` unconditionally; the bare-input
1529        // form parses but Display canonicalises to the explicit prefix.
1530        let parsed: FlakeRef = "flake:nixpkgs/release-23.05".parse().unwrap();
1531        assert_eq!(parsed.to_string(), "flake:nixpkgs/release-23.05");
1532
1533        let parsed: FlakeRef = "nixpkgs".parse().unwrap();
1534        assert_eq!(parsed.to_string(), "flake:nixpkgs");
1535    }
1536
1537    #[test]
1538    fn path_display_emits_path_prefix() {
1539        // Same canonicalisation for Path: the `path:` prefix is always
1540        // emitted, even when the input form was bare.
1541        let parsed: FlakeRef = "path:./foo".parse().unwrap();
1542        assert_eq!(parsed.to_string(), "path:./foo");
1543
1544        let parsed: FlakeRef = "/abs/path".parse().unwrap();
1545        assert_eq!(parsed.to_string(), "path:/abs/path");
1546    }
1547
1548    #[test]
1549    fn indirect_explicit_three_segment_round_trip() {
1550        // Canonical Nix's indirect form supports id/ref/rev as three path
1551        // segments; round-trip preserves both.
1552        let uri = "flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567";
1553        let parsed: FlakeRef = uri.parse().unwrap();
1554        assert_eq!(parsed.to_string(), uri);
1555    }
1556
1557    #[test]
1558    fn fragment_round_trip_github() {
1559        // The trailing `#fragment` is preserved on `FlakeRef.fragment`
1560        // and re-emitted by Display.
1561        let uri = "github:nixos/nixpkgs#default";
1562        let parsed: FlakeRef = uri.parse().unwrap();
1563        assert_eq!(parsed.fragment.as_deref(), Some("default"));
1564        assert_eq!(parsed.to_string(), uri);
1565    }
1566
1567    #[test]
1568    fn fragment_round_trip_with_params() {
1569        let uri = "github:nixos/nixpkgs?dir=foo#bar";
1570        let parsed: FlakeRef = uri.parse().unwrap();
1571        assert_eq!(parsed.fragment.as_deref(), Some("bar"));
1572        assert_eq!(parsed.to_string(), uri);
1573    }
1574
1575    /// Nix's bare-flake-id form matches `id[/ref[/rev]]` and routes it
1576    /// through the same indirect scheme as `flake:id/...`. So
1577    /// `nixos/nixpkgs` parses as
1578    /// `Indirect { id: "nixos", ref_: Some("nixpkgs"), .. }`.
1579    #[test]
1580    fn bare_two_segment_parses_as_indirect() {
1581        let parsed: FlakeRef = "nixos/nixpkgs".parse().unwrap();
1582        assert_eq!(
1583            *parsed.kind(),
1584            FlakeRefType::Indirect {
1585                id: "nixos".to_string(),
1586                ref_: Some("nixpkgs".to_string()),
1587                rev: None,
1588                location: RefLocation::PathComponent,
1589            },
1590        );
1591        assert_eq!(parsed.to_string(), "flake:nixos/nixpkgs");
1592    }
1593
1594    /// Three-segment bare with a 40-hex final segment routes through the
1595    /// indirect grammar's `id/ref/rev` form, matching Nix.
1596    #[test]
1597    fn bare_three_segment_with_hex_parses_as_indirect() {
1598        let rev = "abc1234567890123456789012345678901234567";
1599        let uri = format!("nixos/nixpkgs/{rev}");
1600        let parsed: FlakeRef = uri.parse().unwrap();
1601        assert_eq!(
1602            *parsed.kind(),
1603            FlakeRefType::Indirect {
1604                id: "nixos".to_string(),
1605                ref_: Some("nixpkgs".to_string()),
1606                rev: Some(rev.to_string()),
1607                location: RefLocation::PathComponent,
1608            },
1609        );
1610    }
1611
1612    /// Nix's bare-flake-id regex does not match four bare segments, so
1613    /// `nixos/nixpkgs/extra/parts` is rejected. nix-uri keeps surfacing
1614    /// that as `MissingScheme`.
1615    #[test]
1616    fn bare_four_segment_rejected() {
1617        let err = "nixos/nixpkgs/extra/parts"
1618            .parse::<FlakeRef>()
1619            .expect_err("bare four-segment must not parse");
1620        assert_matches!(err, NixUriError::MissingScheme { input } if input == "nixos/nixpkgs/extra/parts");
1621    }
1622
1623    /// Nix requires the third indirect segment to be a 40-hex commit
1624    /// hash and throws otherwise. Without the check,
1625    /// `release-23.05/notahex` was silently folded into a
1626    /// slash-containing ref.
1627    #[test]
1628    fn flake_three_segment_non_hex_rejects() {
1629        let err = "flake:nixpkgs/release-23.05/notahex"
1630            .parse::<FlakeRef>()
1631            .expect_err("non-hex third segment must reject");
1632        assert_matches!(err, NixUriError::InvalidValue { field: "rev", .. },);
1633    }
1634
1635    /// Nix's `flake:` URL form skips empty segments when splitting the
1636    /// path, so `flake:nixpkgs//main` collapses to `flake:nixpkgs/main`.
1637    #[test]
1638    fn flake_double_slash_collapses_skipempty() {
1639        let parsed: FlakeRef = "flake:nixpkgs//main".parse().unwrap();
1640        assert_eq!(
1641            *parsed.kind(),
1642            FlakeRefType::Indirect {
1643                id: "nixpkgs".to_string(),
1644                ref_: Some("main".to_string()),
1645                rev: None,
1646                location: RefLocation::PathComponent,
1647            },
1648        );
1649
1650        let parsed: FlakeRef = "flake:nixpkgs///main".parse().unwrap();
1651        assert_eq!(
1652            *parsed.kind(),
1653            FlakeRefType::Indirect {
1654                id: "nixpkgs".to_string(),
1655                ref_: Some("main".to_string()),
1656                rev: None,
1657                location: RefLocation::PathComponent,
1658            },
1659        );
1660    }
1661
1662    /// Bare `//host/path` (no `path:` prefix) was silently stored as
1663    /// `Path { path: "//host/path" }`, which Display emitted as
1664    /// `path://host/path` -- a string the parser then rejects on
1665    /// re-parse via the authority guard. Reject the malformed shape
1666    /// up-front instead.
1667    #[test]
1668    fn bare_double_slash_rejects() {
1669        let err = "//host/path"
1670            .parse::<FlakeRef>()
1671            .expect_err("bare //host/path must reject");
1672        assert_matches!(err, NixUriError::InvalidUrl(input) if input == "//host/path");
1673    }
1674
1675    /// Sanity: the legitimate bare-path shapes still round-trip after
1676    /// the bare-`//` guard. `path:` prefix is added on Display per the
1677    /// canonicalisation rule pinned in `path_display_emits_path_prefix`.
1678    #[test]
1679    fn bare_legitimate_paths_round_trip() {
1680        for (input, displayed) in [
1681            ("./relative", "path:./relative"),
1682            ("/abs/path", "path:/abs/path"),
1683        ] {
1684            let parsed: FlakeRef = input.parse().unwrap();
1685            assert!(matches!(parsed.kind(), FlakeRefType::Path { .. }));
1686            assert_eq!(parsed.to_string(), displayed);
1687        }
1688    }
1689
1690    /// Pins Nix's three-segment cap on the indirect form. Without the
1691    /// check the trailing segments collapse into `ref_` verbatim,
1692    /// producing a ref name that contains `/`.
1693    #[test]
1694    fn flake_scheme_four_segment_rejected() {
1695        let err = "flake:nixpkgs/main/abc/extra"
1696            .parse::<FlakeRef>()
1697            .expect_err("flake: 4+ segments must not parse");
1698        assert_matches!(err, NixUriError::TooManyIndirectSegments { count: 4 });
1699    }
1700
1701    #[test]
1702    fn bare_single_segment_still_parses() {
1703        let parsed: FlakeRef = "nixpkgs".parse().unwrap();
1704        assert_eq!(
1705            *parsed.kind(),
1706            FlakeRefType::Indirect {
1707                id: "nixpkgs".to_string(),
1708                ref_: None,
1709                rev: None,
1710                location: RefLocation::PathComponent,
1711            },
1712        );
1713    }
1714}
1715
1716#[cfg(test)]
1717mod ref_rev_methods {
1718    //! Exercises the typed ref/rev API on `FlakeRef`. The "no ref/rev" state
1719    //! is `ref_or_rev() == None`; there is no `RefLocation::None` variant,
1720    //! so `ref_source_location()` returns the kind's default `PathComponent`
1721    //! for kinds that simply have no value set.
1722    //!
1723    //! `Resource` kinds carry typed ref/rev slots and only support the
1724    //! query-parameter form; `set_ref`/`set_rev` write through and also flip
1725    //! `ref_location` to `QueryParameter` so the value round-trips through
1726    //! `Display`.
1727    use super::*;
1728    use rstest::rstest;
1729
1730    #[rstest]
1731    #[case(
1732        "github:nixos/nixpkgs/release-23.05",
1733        Some("release-23.05"),
1734        RefLocation::PathComponent
1735    )]
1736    #[case(
1737        "github:nixos/nixpkgs?ref=release-23.05",
1738        Some("release-23.05"),
1739        RefLocation::QueryParameter
1740    )]
1741    #[case(
1742        "github:nixos/nixpkgs?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298",
1743        Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298"),
1744        RefLocation::QueryParameter
1745    )]
1746    #[case("flake:nixpkgs/unstable", Some("unstable"), RefLocation::PathComponent)]
1747    #[case("github:nixos/nixpkgs", None, RefLocation::PathComponent)]
1748    fn typed_ref_or_rev_round_trip(
1749        #[case] url: &str,
1750        #[case] expected_ref: Option<&str>,
1751        #[case] expected_location: RefLocation,
1752    ) {
1753        let parsed: FlakeRef = url.parse().unwrap();
1754        assert_eq!(
1755            parsed.ref_or_rev(),
1756            expected_ref,
1757            "ref_or_rev mismatch for {url}",
1758        );
1759        assert_eq!(
1760            parsed.ref_source_location(),
1761            expected_location,
1762            "ref_source_location mismatch for {url}",
1763        );
1764    }
1765
1766    #[test]
1767    fn set_ref_preserves_path_component_location() {
1768        let url = "github:nixos/nixpkgs/release-23.05";
1769        let mut parsed: FlakeRef = url.parse().unwrap();
1770
1771        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1772        assert_eq!(parsed.ref_or_rev(), Some("release-23.05"));
1773
1774        parsed.set_ref(Some("release-24.05".to_string()));
1775
1776        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1777        assert_eq!(parsed.ref_or_rev(), Some("release-24.05"));
1778        assert_eq!(parsed.to_string(), "github:nixos/nixpkgs/release-24.05");
1779    }
1780
1781    #[test]
1782    fn set_ref_preserves_query_parameter_location() {
1783        let url = "github:nixos/nixpkgs?ref=release-23.05";
1784        let mut parsed: FlakeRef = url.parse().unwrap();
1785
1786        assert_eq!(parsed.ref_source_location(), RefLocation::QueryParameter);
1787        assert_eq!(parsed.ref_or_rev(), Some("release-23.05"));
1788
1789        parsed.set_ref(Some("release-24.05".to_string()));
1790
1791        assert_eq!(parsed.ref_source_location(), RefLocation::QueryParameter);
1792        assert_eq!(parsed.ref_or_rev(), Some("release-24.05"));
1793        assert_eq!(parsed.to_string(), "github:nixos/nixpkgs?ref=release-24.05");
1794    }
1795
1796    #[test]
1797    fn set_ref_on_resource_writes_to_typed_slot_and_flips_location() {
1798        // Resource kinds carry typed `ref_` / `rev` slots and only emit
1799        // those slots through the query string. set_ref / set_rev
1800        // therefore flip ref_location to QueryParameter so Display
1801        // preserves the form.
1802        let url = "git+https://github.com/nixos/nixpkgs";
1803        let mut parsed: FlakeRef = url.parse().unwrap();
1804
1805        parsed.set_ref(Some("v1.0.0".to_string()));
1806        assert_eq!(parsed.ref_or_rev(), Some("v1.0.0"));
1807        assert_eq!(parsed.ref_source_location(), RefLocation::QueryParameter);
1808        match parsed.kind() {
1809            FlakeRefType::Resource(res) => {
1810                assert_eq!(res.ref_.as_deref(), Some("v1.0.0"));
1811            }
1812            other => panic!("expected Resource, got {other:?}"),
1813        }
1814        // The full round-trip works now: Display renders ?ref=...
1815        assert_eq!(
1816            parsed.to_string(),
1817            "git+https://github.com/nixos/nixpkgs?ref=v1.0.0",
1818        );
1819    }
1820
1821    #[test]
1822    fn set_ref_on_github_without_existing_ref_uses_path_component() {
1823        let url = "github:nixos/nixpkgs";
1824        let mut parsed: FlakeRef = url.parse().unwrap();
1825
1826        // Default (no ref present) reports PathComponent; that's where a
1827        // value would be rendered if set.
1828        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1829
1830        parsed.set_ref(Some("release-23.05".to_string()));
1831
1832        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1833        assert_eq!(parsed.to_string(), "github:nixos/nixpkgs/release-23.05");
1834    }
1835
1836    #[test]
1837    fn set_rev_preserves_location() {
1838        // Path-based 40-hex rev.
1839        let url = "github:nixos/nixpkgs/b2df4e4e80e04cbb33a350f87717f4bd6140d298";
1840        let mut parsed: FlakeRef = url.parse().unwrap();
1841
1842        parsed.set_rev(Some("c3ee5f5f91f15dcb44b461g98828g5ce7251e399".to_string()));
1843        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1844        assert_eq!(
1845            parsed.to_string(),
1846            "github:nixos/nixpkgs/c3ee5f5f91f15dcb44b461g98828g5ce7251e399",
1847        );
1848
1849        // Query-parameter rev.
1850        let url2 = "github:nixos/nixpkgs?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298";
1851        let mut parsed2: FlakeRef = url2.parse().unwrap();
1852
1853        parsed2.set_rev(Some("c3ee5f5f91f15dcb44b461g98828g5ce7251e399".to_string()));
1854        assert_eq!(parsed2.ref_source_location(), RefLocation::QueryParameter);
1855        assert_eq!(
1856            parsed2.to_string(),
1857            "github:nixos/nixpkgs?rev=c3ee5f5f91f15dcb44b461g98828g5ce7251e399",
1858        );
1859    }
1860
1861    #[test]
1862    fn remove_ref_clears_value_and_drops_path_segment() {
1863        // Path-based.
1864        let url = "github:nixos/nixpkgs/release-23.05";
1865        let mut parsed: FlakeRef = url.parse().unwrap();
1866
1867        parsed.set_ref(None);
1868        assert_eq!(parsed.ref_or_rev(), None);
1869        assert_eq!(parsed.to_string(), "github:nixos/nixpkgs");
1870
1871        // Query-parameter.
1872        let url2 = "github:nixos/nixpkgs?ref=release-23.05";
1873        let mut parsed2: FlakeRef = url2.parse().unwrap();
1874
1875        parsed2.set_ref(None);
1876        assert_eq!(parsed2.ref_or_rev(), None);
1877        assert_eq!(parsed2.to_string(), "github:nixos/nixpkgs");
1878    }
1879
1880    #[test]
1881    fn indirect_set_ref_uses_path_component() {
1882        let url = "flake:nixpkgs";
1883        let mut parsed: FlakeRef = url.parse().unwrap();
1884
1885        parsed.set_ref(Some("unstable".to_string()));
1886        assert_eq!(parsed.ref_source_location(), RefLocation::PathComponent);
1887        assert_eq!(parsed.to_string(), "flake:nixpkgs/unstable");
1888    }
1889
1890    #[test]
1891    fn round_trip_path_component_ref() {
1892        let original = "github:nixos/nixpkgs/release-23.05";
1893        let parsed: FlakeRef = original.parse().unwrap();
1894        assert_eq!(parsed.to_string(), original);
1895    }
1896
1897    #[test]
1898    fn round_trip_query_parameter_ref() {
1899        let original = "github:nixos/nixpkgs?ref=release-23.05";
1900        let parsed: FlakeRef = original.parse().unwrap();
1901        assert_eq!(parsed.to_string(), original);
1902    }
1903
1904    #[test]
1905    fn round_trip_path_component_rev() {
1906        let original = "github:nixos/nixpkgs/b2df4e4e80e04cbb33a350f87717f4bd6140d298";
1907        let parsed: FlakeRef = original.parse().unwrap();
1908        assert_eq!(parsed.to_string(), original);
1909        // The 40-hex value classified as a rev, not a ref.
1910        match parsed.kind() {
1911            FlakeRefType::GitForge(forge) => {
1912                assert!(forge.ref_.is_none());
1913                assert_eq!(
1914                    forge.rev.as_deref(),
1915                    Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298"),
1916                );
1917            }
1918            _ => panic!("expected GitForge"),
1919        }
1920    }
1921
1922    #[test]
1923    fn resource_set_ref_none_keeps_ref_location_when_rev_remains() {
1924        // Clearing one slot must not silently flip ref_location.
1925        // While rev is still Some, the kind's ref_location is
1926        // load-bearing for `Display` (it is what makes ?rev=... appear).
1927        let url = "git+https://github.com/owner/repo?ref=main&rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298";
1928        let mut parsed: FlakeRef = url.parse().unwrap();
1929        assert_eq!(parsed.ref_source_location(), RefLocation::QueryParameter);
1930
1931        parsed.set_ref(None);
1932        assert_eq!(parsed.ref_(), None);
1933        assert_eq!(
1934            parsed.rev(),
1935            Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298"),
1936        );
1937        assert_eq!(
1938            parsed.ref_source_location(),
1939            RefLocation::QueryParameter,
1940            "clearing ref must not flip ref_location while rev is still set",
1941        );
1942
1943        // Clearing the remaining rev now reaches the (None, None) state. The
1944        // location slot is informational at that point; what matters is that
1945        // we did not see a spurious flip on the way here.
1946        parsed.set_rev(None);
1947        assert_eq!(parsed.ref_(), None);
1948        assert_eq!(parsed.rev(), None);
1949        assert_eq!(
1950            parsed.ref_source_location(),
1951            RefLocation::QueryParameter,
1952            "clearing rev must not flip ref_location either",
1953        );
1954    }
1955
1956    #[test]
1957    fn set_ref_and_rev_independently_on_gitforge() {
1958        let url = "github:owner/repo";
1959        let mut parsed: FlakeRef = url.parse().unwrap();
1960
1961        parsed.set_ref(Some("main".to_string()));
1962        match parsed.kind() {
1963            FlakeRefType::GitForge(forge) => {
1964                assert_eq!(forge.ref_.as_deref(), Some("main"));
1965                assert!(forge.rev.is_none());
1966            }
1967            _ => panic!("expected GitForge"),
1968        }
1969
1970        // Setting rev does not clear ref; the typed slots are independent.
1971        parsed.set_rev(Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298".to_string()));
1972        match parsed.kind() {
1973            FlakeRefType::GitForge(forge) => {
1974                assert_eq!(forge.ref_.as_deref(), Some("main"));
1975                assert_eq!(
1976                    forge.rev.as_deref(),
1977                    Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298"),
1978                );
1979            }
1980            _ => panic!("expected GitForge"),
1981        }
1982    }
1983}
1984
1985#[cfg(test)]
1986mod canonical_round_trip {
1987    //! Round-trip property: every URI listed here parses and `Display`s back
1988    //! to the original string, byte-for-byte. Each case pins a distinct
1989    //! grammar shape (typed Resource ref/rev, Indirect 3-segment,
1990    //! Path/Indirect prefix normalisation, fragment retention, canonical
1991    //! query keys).
1992    use super::*;
1993    use rstest::rstest;
1994
1995    #[rstest]
1996    #[case("github:nixos/nixpkgs/release-23.05")]
1997    #[case("github:nixos/nixpkgs?ref=release-23.05")]
1998    #[case("git+https://github.com/owner/repo?ref=v1.0.0")]
1999    #[case("flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567")]
2000    #[case("path:./foo")]
2001    #[case("github:nixos/nixpkgs#default")]
2002    // GitHub's URL parser narrows query keys to `ref/rev/host/narHash`;
2003    // the lastModified+revCount+narHash mix below rides the Git scheme
2004    // where all three are recognised.
2005    #[case("git+https://example.com/repo?lastModified=12345&narHash=sha256-abc&revCount=42")]
2006    fn round_trip(#[case] uri: &str) {
2007        let parsed: FlakeRef = uri.parse().unwrap();
2008        assert_eq!(parsed.to_string(), uri, "round-trip mismatch");
2009    }
2010
2011    #[test]
2012    fn query_keys_emit_alphabetical_across_typed_and_arbitrary() {
2013        // `name` is in Nix's git-scheme attribute set but does not have
2014        // a typed slot on `LocationParameters`, so it lands in the
2015        // arbitrary bag while `dir` and `narHash` ride typed slots.
2016        // The Display merge sorts alphabetically across both:
2017        // dir < name < narHash.
2018        let input = "git+https://example.com/repo?narHash=sha256-x&dir=foo&name=my-flake";
2019        let parsed: FlakeRef = input.parse().unwrap();
2020        assert_eq!(
2021            parsed.to_string(),
2022            "git+https://example.com/repo?dir=foo&name=my-flake&narHash=sha256-x"
2023        );
2024
2025        let reparsed: FlakeRef = parsed.to_string().parse().unwrap();
2026        assert_eq!(parsed, reparsed);
2027        assert_eq!(reparsed.to_string(), parsed.to_string());
2028    }
2029}
2030
2031#[cfg(test)]
2032mod resource_prefix_strip {
2033    //! `tarball+` and `file+` are accepted on parse but stripped on Display.
2034    //! This matches Nix's canonical output for the curl-based fetcher,
2035    //! so a Display string handed to Nix's parser stays string-equal to
2036    //! what Nix would emit itself.
2037    use cool_asserts::assert_matches;
2038
2039    use super::*;
2040    use crate::{ResourceType, TransportLayer, flakeref::resource_url::ResourceUrl};
2041
2042    #[test]
2043    fn tarball_explicit_prefix_strips_on_display() {
2044        let parsed: FlakeRef = "tarball+https://example.com/foo.tar.gz".parse().unwrap();
2045        assert_eq!(parsed.to_string(), "https://example.com/foo.tar.gz");
2046    }
2047
2048    #[test]
2049    fn tarball_bare_https_round_trips() {
2050        let input = "https://example.com/foo.tar.gz";
2051        let parsed: FlakeRef = input.parse().unwrap();
2052        assert_eq!(parsed.to_string(), input);
2053    }
2054
2055    #[test]
2056    fn file_explicit_prefix_strips_on_display() {
2057        let parsed: FlakeRef = "file+https://example.com/data.bin".parse().unwrap();
2058        assert_eq!(parsed.to_string(), "https://example.com/data.bin");
2059    }
2060
2061    #[test]
2062    fn file_bare_https_round_trips() {
2063        let input = "https://example.com/data.bin";
2064        let parsed: FlakeRef = input.parse().unwrap();
2065        assert_eq!(parsed.to_string(), input);
2066    }
2067
2068    #[test]
2069    fn bare_file_with_tarball_extension_parses_as_tarball() {
2070        // Nix accepts a bare `file://...tar.gz` as a tarball because the
2071        // tarball-extension classifier matches; the file scheme rejects
2072        // it for the same reason. nix-uri must match that decision so
2073        // the round-trip from the explicit `tarball+file://` shape is
2074        // stable across parse -> Display -> parse.
2075        let parsed: FlakeRef = "file:///tmp/foo.tar.gz".parse().unwrap();
2076        assert_matches!(
2077            *parsed.kind(),
2078            FlakeRefType::Resource(ResourceUrl {
2079                res_type: ResourceType::Tarball,
2080                transport_type: Some(TransportLayer::File),
2081                ..
2082            })
2083        );
2084    }
2085
2086    #[test]
2087    fn bare_file_no_extension_parses_as_file() {
2088        // Nix's file scheme accepts a bare `file://` URL with no
2089        // tarball extension; the tarball scheme rejects it for the same
2090        // reason. nix-uri's extension-based `is_tarball` classifier
2091        // matches that decision.
2092        let parsed: FlakeRef = "file:///tmp/data.bin".parse().unwrap();
2093        assert_matches!(
2094            *parsed.kind(),
2095            FlakeRefType::Resource(ResourceUrl {
2096                res_type: ResourceType::File,
2097                transport_type: Some(TransportLayer::File),
2098                ..
2099            })
2100        );
2101    }
2102
2103    #[test]
2104    fn tarball_plus_file_round_trips() {
2105        // Parsing `tarball+file:///path/to/file.tar.gz` produces
2106        // `Resource(Tarball, File)`; Display strips the `tarball+`
2107        // prefix to `file:///path/to/file.tar.gz`; re-parse must land
2108        // on the same `Resource(Tarball, File)` variant so the
2109        // round-trip is stable.
2110        let input = "tarball+file:///x/y.tar.gz";
2111        let parsed: FlakeRef = input.parse().unwrap();
2112        let displayed = parsed.to_string();
2113        assert_eq!(displayed, "file:///x/y.tar.gz");
2114        let reparsed: FlakeRef = displayed.parse().unwrap();
2115        assert_eq!(parsed, reparsed);
2116        assert_eq!(reparsed.to_string(), displayed);
2117    }
2118}
2119
2120#[cfg(test)]
2121mod accessors {
2122    //! Identity (owner / repo / domain / `forge_identity`) and ref/rev
2123    //! accessors on `FlakeRef`: the public surface that replaces
2124    //! triple-pattern-matches at the call site.
2125    use super::*;
2126    use rstest::rstest;
2127
2128    #[test]
2129    fn forge_identity_for_github() {
2130        let parsed: FlakeRef = "github:nixos/nixpkgs".parse().unwrap();
2131        let id = parsed.forge_identity().unwrap();
2132        assert_eq!(id.platform, GitForgePlatform::GitHub);
2133        assert_eq!(id.owner, "nixos");
2134        assert_eq!(id.repo, "nixpkgs");
2135        assert_eq!(id.domain, "github.com");
2136    }
2137
2138    #[test]
2139    fn forge_identity_for_sourcehut() {
2140        // Nix uses `git.sr.ht` for SourceHut clone URLs; the apex
2141        // `sr.ht` does not serve git over HTTPS, so a downstream that
2142        // hands the returned `domain` to a fetcher would 404.
2143        let parsed: FlakeRef = "sourcehut:~owner/repo".parse().unwrap();
2144        let id = parsed.forge_identity().unwrap();
2145        assert_eq!(id.platform, GitForgePlatform::SourceHut);
2146        assert_eq!(id.owner, "~owner");
2147        assert_eq!(id.repo, "repo");
2148        assert_eq!(id.domain, "git.sr.ht");
2149    }
2150
2151    #[test]
2152    fn sourcehut_round_trips() {
2153        let uri = "sourcehut:nix-community/foo";
2154        let parsed: FlakeRef = uri.parse().unwrap();
2155        assert_eq!(parsed.to_string(), uri, "round-trip mismatch");
2156        assert_eq!(parsed.domain(), Some("git.sr.ht"));
2157    }
2158
2159    #[test]
2160    fn gitlab_with_host_override_returns_overridden_domain() {
2161        // Self-hosted GitLab via `?host=` mirrors Nix's host-attr
2162        // resolution: the host attr overrides the canonical domain.
2163        // Without the override, fetch URLs target gitlab.com and
2164        // silently break for self-hosted instances.
2165        let parsed: FlakeRef = "gitlab:openldap/openldap?host=git.openldap.org"
2166            .parse()
2167            .unwrap();
2168        let id = parsed.forge_identity().unwrap();
2169        assert_eq!(id.domain, "git.openldap.org");
2170        assert_eq!(parsed.domain(), Some("git.openldap.org"));
2171    }
2172
2173    #[test]
2174    fn github_without_host_returns_canonical_domain() {
2175        let parsed: FlakeRef = "github:o/r".parse().unwrap();
2176        let id = parsed.forge_identity().unwrap();
2177        assert_eq!(id.domain, "github.com");
2178        assert_eq!(parsed.domain(), Some("github.com"));
2179    }
2180
2181    #[test]
2182    fn sourcehut_without_host_returns_git_sr_ht() {
2183        // SourceHut's canonical clone host is `git.sr.ht`; the apex
2184        // `sr.ht` does not serve git over HTTPS.
2185        let parsed: FlakeRef = "sourcehut:~user/repo".parse().unwrap();
2186        let id = parsed.forge_identity().unwrap();
2187        assert_eq!(id.domain, "git.sr.ht");
2188        assert_eq!(parsed.domain(), Some("git.sr.ht"));
2189    }
2190
2191    #[test]
2192    fn forge_identity_none_for_path_indirect_resource() {
2193        // Resource(Git) URLs do not have a guaranteed owner/repo/domain
2194        // triple; those are extracted ad hoc from the URL string and not
2195        // part of the kind's structure, so forge_identity returns None.
2196        for uri in [
2197            "path:./foo",
2198            "flake:nixpkgs",
2199            "git+https://example.com/owner/repo",
2200        ] {
2201            let parsed: FlakeRef = uri.parse().unwrap();
2202            assert!(parsed.forge_identity().is_none(), "expected None for {uri}",);
2203        }
2204    }
2205
2206    #[rstest]
2207    #[case(
2208        "github:nixos/nixpkgs",
2209        Some("nixos"),
2210        Some("nixpkgs"),
2211        Some("github.com")
2212    )]
2213    #[case("gitlab:owner/repo", Some("owner"), Some("repo"), Some("gitlab.com"))]
2214    #[case(
2215        "sourcehut:user/project",
2216        Some("user"),
2217        Some("project"),
2218        Some("git.sr.ht")
2219    )]
2220    #[case(
2221        "git+https://example.com/a/b",
2222        Some("a"),
2223        Some("b"),
2224        Some("example.com")
2225    )]
2226    #[case("path:./foo", None, None, None)]
2227    #[case("flake:nixpkgs", None, None, None)]
2228    fn identity_accessors(
2229        #[case] uri: &str,
2230        #[case] owner: Option<&str>,
2231        #[case] repo: Option<&str>,
2232        #[case] domain: Option<&str>,
2233    ) {
2234        let parsed: FlakeRef = uri.parse().unwrap();
2235        assert_eq!(parsed.owner(), owner, "owner mismatch for {uri}");
2236        assert_eq!(parsed.repo(), repo, "repo mismatch for {uri}");
2237        assert_eq!(parsed.domain(), domain, "domain mismatch for {uri}");
2238    }
2239
2240    #[rstest]
2241    #[case("github:nixos/nixpkgs", RefKind::None, false)]
2242    #[case("github:nixos/nixpkgs/release-23.05", RefKind::Ref, false)]
2243    #[case(
2244        "github:nixos/nixpkgs/abc1234567890123456789012345678901234567",
2245        RefKind::Rev,
2246        true
2247    )]
2248    #[case(
2249        "flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567",
2250        RefKind::Both,
2251        true
2252    )]
2253    #[case(
2254        "github:nixos/nixpkgs?rev=abc1234567890123456789012345678901234567",
2255        RefKind::Rev,
2256        true
2257    )]
2258    fn ref_kind_and_pinning(
2259        #[case] uri: &str,
2260        #[case] expected_kind: RefKind,
2261        #[case] pinned: bool,
2262    ) {
2263        let parsed: FlakeRef = uri.parse().unwrap();
2264        assert_eq!(
2265            parsed.ref_kind(),
2266            expected_kind,
2267            "ref_kind mismatch for {uri}"
2268        );
2269        assert_eq!(
2270            parsed.is_pinned_to_rev(),
2271            pinned,
2272            "is_pinned_to_rev mismatch for {uri}",
2273        );
2274    }
2275
2276    #[test]
2277    fn ref_or_rev_prefers_rev_when_pinned() {
2278        let parsed: FlakeRef =
2279            "flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567"
2280                .parse()
2281                .unwrap();
2282        // When both are populated, the "what does this resolve to?" answer
2283        // is the pinned rev; that's the canonical lookup.
2284        assert_eq!(
2285            parsed.ref_or_rev(),
2286            Some("abc1234567890123456789012345678901234567"),
2287        );
2288        assert_eq!(parsed.ref_(), Some("release-23.05"));
2289        assert_eq!(
2290            parsed.rev(),
2291            Some("abc1234567890123456789012345678901234567")
2292        );
2293    }
2294}
2295
2296#[cfg(test)]
2297mod builders {
2298    //! Consuming builders (`with_*`, `without_pin`, `into_uri`) collapse the
2299    //! parse -> set -> `to_string` pattern into a single expression.
2300    use super::*;
2301
2302    #[test]
2303    fn with_ref_round_trip_path_component() {
2304        let updated = "github:nixos/nixpkgs"
2305            .parse::<FlakeRef>()
2306            .unwrap()
2307            .with_ref(Some("release-23.05".into()))
2308            .into_uri();
2309        assert_eq!(updated, "github:nixos/nixpkgs/release-23.05");
2310    }
2311
2312    #[test]
2313    fn with_rev_promotes_path_component_to_three_segment_for_indirect() {
2314        // Adding a rev to an Indirect that already has a ref produces the
2315        // canonical Nix three-segment form.
2316        let updated = "flake:nixpkgs/release-23.05"
2317            .parse::<FlakeRef>()
2318            .unwrap()
2319            .with_rev(Some("abc1234567890123456789012345678901234567".into()))
2320            .into_uri();
2321        assert_eq!(
2322            updated,
2323            "flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567",
2324        );
2325    }
2326
2327    #[test]
2328    fn without_pin_clears_rev_keeps_ref() {
2329        let updated = "flake:nixpkgs/release-23.05/abc1234567890123456789012345678901234567"
2330            .parse::<FlakeRef>()
2331            .unwrap()
2332            .without_pin()
2333            .into_uri();
2334        assert_eq!(updated, "flake:nixpkgs/release-23.05");
2335    }
2336
2337    #[test]
2338    fn with_rev_on_resource_flips_to_query_parameter() {
2339        let updated = "git+https://github.com/owner/repo"
2340            .parse::<FlakeRef>()
2341            .unwrap()
2342            .with_rev(Some("abc1234567890123456789012345678901234567".into()))
2343            .into_uri();
2344        assert_eq!(
2345            updated,
2346            "git+https://github.com/owner/repo?rev=abc1234567890123456789012345678901234567",
2347        );
2348    }
2349
2350    #[test]
2351    fn with_fragment_round_trip() {
2352        let updated = "github:nixos/nixpkgs"
2353            .parse::<FlakeRef>()
2354            .unwrap()
2355            .with_fragment(Some("hello".into()))
2356            .into_uri();
2357        assert_eq!(updated, "github:nixos/nixpkgs#hello");
2358    }
2359
2360    #[test]
2361    fn with_ref_then_with_rev_chains_on_gitforge() {
2362        // Both setters compose: each writes to its own typed slot, neither
2363        // clobbers the other's. `Display` for `GitForge` in `PathComponent`
2364        // form renders ref preferentially, so this asserts on the typed
2365        // slots (the source of truth) rather than on the rendered URI.
2366        let updated = "github:nixos/nixpkgs"
2367            .parse::<FlakeRef>()
2368            .unwrap()
2369            .with_ref(Some("release-23.05".into()))
2370            .with_rev(Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298".into()));
2371
2372        assert_eq!(updated.ref_(), Some("release-23.05"));
2373        assert_eq!(
2374            updated.rev(),
2375            Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298")
2376        );
2377    }
2378
2379    #[test]
2380    fn with_ref_then_with_rev_chains_on_indirect() {
2381        // Indirect renders both via the three-segment `flake:id/ref/rev`
2382        // form.
2383        let updated = "flake:nixpkgs"
2384            .parse::<FlakeRef>()
2385            .unwrap()
2386            .with_ref(Some("release-23.05".into()))
2387            .with_rev(Some("b2df4e4e80e04cbb33a350f87717f4bd6140d298".into()))
2388            .into_uri();
2389
2390        assert_eq!(
2391            updated,
2392            "flake:nixpkgs/release-23.05/b2df4e4e80e04cbb33a350f87717f4bd6140d298",
2393        );
2394    }
2395}
2396
2397#[cfg(test)]
2398mod https_github_classification {
2399    //! Plain `https://github.com/...` URLs do NOT auto-promote to
2400    //! `GitForge(GitHub)`. They classify through the same
2401    //! tarball-extension auto-classifier that any other `https://` URL
2402    //! does. Matches Nix's behaviour: the github scheme is gated on
2403    //! `url.scheme == "github"`, so a bare HTTPS URL only ever reaches
2404    //! the curl-based opaque-tarball/file path.
2405    //!
2406    //! Regression: a `flake.lock` whose `original` is
2407    //! `{ type = "tarball"; url = "https://github.com/NixOS/nixpkgs/pull/483360.diff"; }`
2408    //! must NOT round-trip through nix-uri as a github forge: Nix
2409    //! cannot fetch back a `github:NixOS/nixpkgs/pull/483360.diff`
2410    //! shape.
2411    use super::*;
2412
2413    #[test]
2414    fn https_github_with_pull_path_does_not_reclassify_to_github_forge() {
2415        let url = "https://github.com/NixOS/nixpkgs/pull/483360.diff";
2416        let parsed: FlakeRef = url.parse().unwrap();
2417        assert!(
2418            !matches!(parsed.kind(), FlakeRefType::GitForge(_)),
2419            "expected non-GitForge classification for {url}, got {:?}",
2420            *parsed.kind(),
2421        );
2422        assert_eq!(parsed.to_string(), url);
2423    }
2424
2425    #[test]
2426    fn https_github_owner_repo_is_resource_not_gitforge() {
2427        let url = "https://github.com/nixos/nixpkgs";
2428        let parsed: FlakeRef = url.parse().unwrap();
2429        assert!(
2430            !matches!(parsed.kind(), FlakeRefType::GitForge(_)),
2431            "bare https://github.com/<o>/<r> must not auto-promote to GitForge, got {:?}",
2432            *parsed.kind(),
2433        );
2434        assert_eq!(parsed.to_string(), url);
2435    }
2436
2437    #[test]
2438    fn https_github_archive_tarball_remains_resource() {
2439        let url = "https://github.com/user/repo/archive/main.tar.gz";
2440        let parsed: FlakeRef = url.parse().unwrap();
2441        assert!(matches!(parsed.kind(), FlakeRefType::Resource(_)));
2442        assert_eq!(parsed.to_string(), url);
2443    }
2444}
2445
2446#[cfg(test)]
2447mod ref_rev_validation {
2448    //! Public-surface coverage for two `GitForge` / query-rev validation
2449    //! contracts:
2450    //!
2451    //! - `GitForge` (`github` / `gitlab` / `sourcehut`) inputs that combine
2452    //!   `ref` and `rev` in any path-component or query-string combination
2453    //!   are rejected with `FieldConflict { left: "ref", right: "rev" }`,
2454    //!   matching Nix's rejection of the same shape on git-archive URLs.
2455    //!   Indirect's three-segment form and `Resource(Git)`'s
2456    //!   `?ref=&rev=` shape stay legitimate.
2457    //! - A `?rev=` value that is not exactly 40 ASCII hex digits is
2458    //!   rejected with `InvalidValue { field: "rev", .. }`. The
2459    //!   path-component side already validated via `looks_like_rev`; the
2460    //!   query side was the unguarded ingestion path.
2461    use super::*;
2462    use crate::error::NixUriError;
2463    use cool_asserts::assert_matches;
2464    use rstest::rstest;
2465
2466    const HEX40: &str = "b2df4e4e80e04cbb33a350f87717f4bd6140d298";
2467
2468    #[rstest]
2469    #[case::github_both_in_query(
2470        "github:owner/repo?ref=main&rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2471    )]
2472    #[case::github_ref_path_rev_query(
2473        "github:owner/repo/main?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2474    )]
2475    #[case::github_rev_path_ref_query(
2476        "github:owner/repo/b2df4e4e80e04cbb33a350f87717f4bd6140d298?ref=main"
2477    )]
2478    #[case::gitlab_both_in_query(
2479        "gitlab:owner/repo?ref=main&rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2480    )]
2481    #[case::gitlab_ref_path_rev_query(
2482        "gitlab:owner/repo/main?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2483    )]
2484    #[case::gitlab_rev_path_ref_query(
2485        "gitlab:owner/repo/b2df4e4e80e04cbb33a350f87717f4bd6140d298?ref=main"
2486    )]
2487    #[case::sourcehut_both_in_query(
2488        "sourcehut:~owner/repo?ref=main&rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2489    )]
2490    #[case::sourcehut_ref_path_rev_query(
2491        "sourcehut:~owner/repo/main?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d298"
2492    )]
2493    #[case::sourcehut_rev_path_ref_query(
2494        "sourcehut:~owner/repo/b2df4e4e80e04cbb33a350f87717f4bd6140d298?ref=main"
2495    )]
2496    fn gitforge_rejects_ref_and_rev_together(#[case] uri: &str) {
2497        // Matches Nix's rejection of git-archive URLs that combine ref
2498        // and rev. FieldConflict surfaces the structural relationship
2499        // (two mutually exclusive fields were both populated), distinct
2500        // from a value-shape failure.
2501        assert_matches!(
2502            uri.parse::<FlakeRef>(),
2503            Err(NixUriError::FieldConflict {
2504                left: "ref",
2505                right: "rev",
2506            }),
2507            "expected mutual-exclusion rejection for {uri}",
2508        );
2509    }
2510
2511    #[rstest]
2512    #[case::github("github:owner/repo?rev=not-a-hash")]
2513    #[case::git_https("git+https://example.com/owner/repo?rev=not-a-hash")]
2514    #[case::hg_https("hg+https://example.com/repo?rev=zzzz")]
2515    #[case::indirect("flake:nixpkgs?rev=main")]
2516    #[case::gitlab("gitlab:owner/repo?rev=not-a-hash")]
2517    #[case::sourcehut("sourcehut:~owner/repo?rev=not-a-hash")]
2518    #[case::short_hex("github:owner/repo?rev=abc123")]
2519    #[case::between_40_and_64_hex(
2520        "github:owner/repo?rev=b2df4e4e80e04cbb33a350f87717f4bd6140d29800000"
2521    )]
2522    #[case::sixty_five_hex(
2523        "github:owner/repo?rev=00000000000000000000000000000000000000000000000000000000000000000"
2524    )]
2525    #[case::sixty_three_hex(
2526        "github:owner/repo?rev=000000000000000000000000000000000000000000000000000000000000000"
2527    )]
2528    fn query_rev_must_be_40_or_64_hex(#[case] uri: &str) {
2529        // Matches Nix's accepted commit-hash shapes: SHA-1 (40 hex) or
2530        // SHA-256 (64 hex). Anything else surfaces as InvalidValue with
2531        // the algorithm-aware diagnostic.
2532        assert_matches!(
2533            uri.parse::<FlakeRef>(),
2534            Err(NixUriError::InvalidValue {
2535                field: "rev",
2536                reason,
2537            }) if reason == "expected 40-hex (SHA-1) or 64-hex (SHA-256) commit",
2538            "expected hex-validation rejection for {uri}",
2539        );
2540    }
2541
2542    // GitLab and SourceHut `?ref=...` (the ref-only query shape) have no
2543    // existing positive coverage; the github/git+ siblings are already
2544    // pinned by `set_ref_preserves_query_parameter_location`,
2545    // `set_rev_preserves_location`, and the `canonical_round_trip`
2546    // rstest. Pin the two missing forges here so the new check is shown
2547    // not to over-reject them.
2548    #[rstest]
2549    #[case::gitlab_query_ref("gitlab:owner/repo?ref=main")]
2550    #[case::sourcehut_query_ref("sourcehut:~owner/repo?ref=main")]
2551    fn ref_only_query_still_parses_for_each_forge(#[case] uri: &str) {
2552        let parsed = uri
2553            .parse::<FlakeRef>()
2554            .expect("input must continue to parse cleanly");
2555        assert_eq!(parsed.to_string(), uri, "round-trip mismatch for {uri}");
2556    }
2557
2558    #[test]
2559    fn indirect_path_component_three_segment_still_parses() {
2560        // Nix's `flake:id/ref/rev` form populates BOTH ref and rev
2561        // legitimately; the GitForge exclusion does not extend to
2562        // Indirect. Pin that.
2563        let uri = format!("flake:nixpkgs/release-23.05/{HEX40}");
2564        let parsed: FlakeRef = uri.parse().unwrap();
2565        assert_eq!(parsed.ref_(), Some("release-23.05"));
2566        assert_eq!(parsed.rev(), Some(HEX40));
2567    }
2568
2569    #[test]
2570    fn resource_git_with_ref_and_rev_still_parses() {
2571        // Resource(Git) explicitly supports `?ref=branch&rev=hex` (you
2572        // can pin a rev and remember which branch it came from). This
2573        // is NOT the GitForge case; do not reject.
2574        let uri = "git+https://git.somehost.tld/user/path?ref=branch&rev=fdc8ef970de2b4634e1b3dca296e1ed918459a9e";
2575        let parsed: FlakeRef = uri.parse().unwrap();
2576        assert_eq!(parsed.to_string(), uri);
2577    }
2578}
2579
2580#[cfg(test)]
2581mod path_authority_and_rev {
2582    //! Public-surface coverage for the `Path` arm:
2583    //!
2584    //! - `path://...` is a malformed shape Nix rejects with a
2585    //!   "path URL should not have an authority" diagnostic; nix-uri
2586    //!   refuses any `path:` input where the body begins with `//`.
2587    //! - `?rev=<40hex>` on a `path:` input routes into the typed `rev`
2588    //!   slot and Display re-emits it; locked store-path inputs of the
2589    //!   form `path:/nix/store/...?rev=...` round-trip cleanly.
2590    use super::*;
2591    use crate::error::{NixUriError, UnsupportedReason};
2592    use cool_asserts::assert_matches;
2593    use rstest::rstest;
2594
2595    const HEX40: &str = "b2df4e4e80e04cbb33a350f87717f4bd6140d298";
2596
2597    #[rstest]
2598    #[case::host("path://somehost/abs/path")]
2599    #[case::host_no_path("path://x")]
2600    fn path_authority_rejected(#[case] uri: &str) {
2601        assert_matches!(
2602            uri.parse::<FlakeRef>(),
2603            Err(NixUriError::Unsupported(UnsupportedReason::Authority {
2604                scheme: "path",
2605            })),
2606            "expected authority rejection for {uri}",
2607        );
2608    }
2609
2610    /// Nix rejects `path://` only when the URL authority's host is
2611    /// non-empty. An empty authority (the body literally begins `///`
2612    /// or is just `//`) is accepted and decodes to the trailing path.
2613    /// Pin both the parse acceptance and the Display round-trip.
2614    #[rstest]
2615    #[case::triple_slash("path:///abs/path")]
2616    #[case::quad_slash("path:////a")]
2617    fn path_triple_slash_parses_as_absolute_path(#[case] uri: &str) {
2618        let parsed: FlakeRef = uri
2619            .parse()
2620            .unwrap_or_else(|e| panic!("empty-authority path must parse: {uri} -> {e}"));
2621        assert_matches!(parsed.kind(), FlakeRefType::Path { rev: None, .. });
2622        assert_eq!(parsed.to_string(), uri, "round-trip mismatch for {uri}");
2623        let reparsed: FlakeRef = parsed.to_string().parse().unwrap();
2624        assert_eq!(parsed, reparsed, "parse-Display-parse not stable for {uri}");
2625    }
2626
2627    #[test]
2628    fn path_with_authority_host_rejects() {
2629        assert_matches!(
2630            "path://host/abs".parse::<FlakeRef>(),
2631            Err(NixUriError::Unsupported(UnsupportedReason::Authority {
2632                scheme: "path",
2633            })),
2634        );
2635    }
2636
2637    #[rstest]
2638    #[case::abs("path:/foo/bar")]
2639    #[case::abs_trailing("path:/home/kenji/.config/dotfiles/")]
2640    #[case::cwd("path:./relative")]
2641    #[case::parent("path:..")]
2642    #[case::single_dot("path:.")]
2643    fn path_non_authority_still_parses(#[case] uri: &str) {
2644        let parsed: FlakeRef = uri
2645            .parse()
2646            .unwrap_or_else(|e| panic!("path body must continue to parse: {uri} -> {e}"));
2647        assert_eq!(parsed.to_string(), uri, "round-trip mismatch for {uri}");
2648    }
2649
2650    #[rstest]
2651    #[case::store_path(&format!("path:/nix/store/abc?rev={HEX40}"))]
2652    #[case::abs(&format!("path:/var/cache?rev={HEX40}"))]
2653    #[case::with_trailing_slash(&format!("path:/home/kenji/?rev={HEX40}"))]
2654    fn path_rev_query_round_trips(#[case] uri: &str) {
2655        let parsed: FlakeRef = uri.parse().expect("path with ?rev= must parse");
2656        assert_eq!(parsed.rev(), Some(HEX40), "rev not stored for {uri}");
2657        assert_eq!(parsed.to_string(), uri, "round-trip mismatch for {uri}");
2658        let reparsed: FlakeRef = parsed.to_string().parse().unwrap();
2659        assert_eq!(parsed, reparsed, "parse-Display-parse not stable for {uri}");
2660    }
2661
2662    #[test]
2663    fn path_rev_with_dir_keeps_alphabetical_query() {
2664        // `dir` typed slot + `rev` from kind both land in the alphabetical
2665        // query block; dir < rev lexicographically.
2666        let uri = format!("path:/abs/path?dir=sub&rev={HEX40}");
2667        let parsed: FlakeRef = uri.parse().expect("must parse");
2668        assert_eq!(parsed.to_string(), uri);
2669    }
2670}
2671
2672#[cfg(test)]
2673mod percent_encoding_round_trip {
2674    //! Pin the encoder/decoder pair against Nix's two encoding contracts:
2675    //! query strings keep `unreserved + ":@/?"` raw, while the fragment
2676    //! encodes the four extra bytes `:@/?` too. The tests cover both
2677    //! sides plus the strict-decode contract (a stray `%` not followed
2678    //! by two hex digits rejects rather than passing through).
2679    use crate::{FlakeRef, NixUriError};
2680    use rstest::rstest;
2681
2682    #[rstest]
2683    #[case::space("github:o/r?dir=foo%20bar", "foo bar")]
2684    #[case::percent("github:o/r?dir=foo%25bar", "foo%bar")]
2685    #[case::semicolon("github:o/r?dir=foo%3Bbar", "foo;bar")]
2686    #[case::plus("github:o/r?dir=foo%2Bbar", "foo+bar")]
2687    #[case::ampersand("github:o/r?dir=foo%26bar", "foo&bar")]
2688    #[case::equals("github:o/r?dir=foo%3Dbar", "foo=bar")]
2689    #[case::hash("github:o/r?dir=foo%23bar", "foo#bar")]
2690    #[case::non_ascii("github:o/r?dir=f%C3%96%C3%B6", "fÖö")]
2691    fn query_value_round_trips_for_encoded_byte(#[case] input: &str, #[case] decoded: &str) {
2692        let parsed: FlakeRef = input.parse().expect("input must parse");
2693        let dir_value = parsed
2694            .params()
2695            .entries()
2696            .into_iter()
2697            .find(|(k, _)| *k == "dir")
2698            .map(|(_, v)| v.to_string());
2699        assert_eq!(dir_value, Some(decoded.to_string()));
2700        assert_eq!(parsed.to_string(), input);
2701    }
2702
2703    #[rstest]
2704    #[case::colon_in_value("github:o/r?dir=foo:bar")]
2705    #[case::at_in_value("github:o/r?dir=foo@bar")]
2706    #[case::slash_in_value("github:o/r?dir=foo/bar")]
2707    fn allowed_query_chars_remain_unencoded(#[case] input: &str) {
2708        let parsed: FlakeRef = input.parse().expect("input must parse");
2709        assert_eq!(parsed.to_string(), input);
2710    }
2711
2712    #[rstest]
2713    #[case::space("github:o/r#default%20package", "default package")]
2714    #[case::percent("github:o/r#a%25b", "a%b")]
2715    #[case::non_ascii("github:o/r#f%C3%96%C3%B6", "fÖö")]
2716    #[case::question_mark_in_fragment("github:o/r#a%3Fb", "a?b")]
2717    #[case::slash_in_fragment("github:o/r#a%2Fb", "a/b")]
2718    fn fragment_round_trips_for_encoded_byte(#[case] input: &str, #[case] decoded: &str) {
2719        let parsed: FlakeRef = input.parse().expect("input must parse");
2720        assert_eq!(parsed.fragment(), Some(decoded));
2721        assert_eq!(parsed.to_string(), input);
2722    }
2723
2724    #[rstest]
2725    #[case::truncated_one_hex("github:o/r?dir=%2")]
2726    #[case::truncated_no_hex("github:o/r?dir=%")]
2727    #[case::non_hex("github:o/r?dir=%XY")]
2728    #[case::non_hex_partial("github:o/r?dir=%2Z")]
2729    fn malformed_query_value_percent_encoding_rejected(#[case] input: &str) {
2730        match input.parse::<FlakeRef>() {
2731            Err(NixUriError::InvalidUrl(_)) => {}
2732            other => panic!("expected InvalidUrl for {input}, got {other:?}"),
2733        }
2734    }
2735
2736    #[rstest]
2737    #[case::truncated("github:o/r#a%2")]
2738    #[case::non_hex("github:o/r#a%XY")]
2739    fn malformed_fragment_percent_encoding_rejected(#[case] input: &str) {
2740        match input.parse::<FlakeRef>() {
2741            Err(NixUriError::InvalidUrl(_)) => {}
2742            other => panic!("expected InvalidUrl for {input}, got {other:?}"),
2743        }
2744    }
2745
2746    #[test]
2747    fn arbitrary_param_value_round_trips_with_space() {
2748        // `name` is a real Git allowedAttr (no typed slot in
2749        // `LocationParameters`), so it lands in the arbitrary bag where
2750        // the encoder pair is exercised end-to-end.
2751        let input = "git+https://example.com/repo?name=hello%20world";
2752        let parsed: FlakeRef = input.parse().unwrap();
2753        assert_eq!(parsed.to_string(), input);
2754    }
2755}
2756
2757#[cfg(test)]
2758mod ref_rev_builders {
2759    //! Tests for the `pin_to_rev` and `try_with_ref` / `try_with_rev`
2760    //! ergonomic builders. The `pin_to_rev` shape exists because
2761    //! `with_rev(Some(_))` on a `RefLocation::PathComponent` `GitForge`
2762    //! that already carries a `ref_` silently drops the rev (see the
2763    //! `ref_.or(rev)` Display arm in `fr_type.rs`). The `try_*` shape
2764    //! is the loud opt-in alternative to the silent no-op `with_*`
2765    //! builders for callers who want the round-trip mismatch surfaced
2766    //! at the call site.
2767    use super::*;
2768    use crate::{NixUriError, UnsupportedReason};
2769
2770    const HEX40: &str = "b2df4e4e80e04cbb33a350f87717f4bd6140d298";
2771
2772    #[test]
2773    fn pin_to_rev_clears_path_component_ref() {
2774        // Regression: with_rev(Some(rev)) on a parsed `github:foo/bar/main`
2775        // (which carries ref_=Some("main")) leaves both ref_ and rev set;
2776        // the GitForge Display arm at fr_type.rs renders `ref_.or(rev)` for
2777        // RefLocation::PathComponent, so the rev silently disappears from
2778        // the round-trip string. `pin_to_rev` clears the ref slot first so
2779        // the rev wins.
2780        let parsed: FlakeRef = "github:foo/bar/main".parse().unwrap();
2781        let pinned = parsed.pin_to_rev(HEX40.to_string());
2782        assert_eq!(pinned.ref_(), None);
2783        assert_eq!(pinned.rev(), Some(HEX40));
2784        let rendered = pinned.to_string();
2785        assert!(rendered.contains(HEX40), "expected rev in {rendered}");
2786        assert!(!rendered.contains("main"), "ref leaked into {rendered}");
2787    }
2788
2789    #[test]
2790    fn pin_to_rev_replaces_query_param_ref() {
2791        let parsed: FlakeRef = "github:foo/bar?ref=main".parse().unwrap();
2792        let pinned = parsed.pin_to_rev(HEX40.to_string());
2793        assert_eq!(pinned.ref_(), None);
2794        assert_eq!(pinned.rev(), Some(HEX40));
2795        let rendered = pinned.to_string();
2796        assert!(rendered.contains(HEX40), "expected rev in {rendered}");
2797        assert!(!rendered.contains("ref="), "ref= leaked into {rendered}");
2798    }
2799
2800    #[test]
2801    fn pin_to_rev_sets_rev_on_path() {
2802        // `Path` has a typed `rev` slot (`Path { path, rev }`), so pinning
2803        // is meaningful: it writes the rev. There is no ref slot to clear.
2804        let parsed: FlakeRef = "path:/x/y".parse().unwrap();
2805        let pinned = parsed.pin_to_rev(HEX40.to_string());
2806        assert_eq!(pinned.rev(), Some(HEX40));
2807    }
2808
2809    #[test]
2810    fn try_with_ref_path_returns_unsupported() {
2811        // Nix's path scheme does not accept `ref`; setting one would
2812        // render a string the parser would reject (round-trip break).
2813        // The loud opt-in surfaces this as a typed error.
2814        let parsed: FlakeRef = "path:/x/y".parse().unwrap();
2815        let result = parsed.try_with_ref(Some("main".into()));
2816        match result {
2817            Err(NixUriError::Unsupported(UnsupportedReason::Field { field, .. })) => {
2818                assert_eq!(field, "ref");
2819            }
2820            other => panic!("expected Unsupported(Field {{ field: \"ref\" }}), got {other:?}"),
2821        }
2822    }
2823
2824    #[test]
2825    fn try_with_ref_tarball_returns_unsupported() {
2826        // Nix's tarball/file schemes do not accept `ref`.
2827        let parsed: FlakeRef = "tarball+https://example.com/foo.tar.gz".parse().unwrap();
2828        let result = parsed.try_with_ref(Some("v1".into()));
2829        match result {
2830            Err(NixUriError::Unsupported(UnsupportedReason::Field { field, .. })) => {
2831                assert_eq!(field, "ref");
2832            }
2833            other => panic!("expected Unsupported(Field {{ field: \"ref\" }}), got {other:?}"),
2834        }
2835    }
2836
2837    #[test]
2838    fn try_with_ref_github_succeeds() {
2839        let parsed: FlakeRef = "github:foo/bar".parse().unwrap();
2840        let updated = parsed
2841            .try_with_ref(Some("main".into()))
2842            .expect("github accepts ref");
2843        assert_eq!(updated.ref_(), Some("main"));
2844    }
2845
2846    #[test]
2847    fn try_with_ref_clear_is_always_ok() {
2848        // Clearing has no Nix-level implications, so try_with_ref(None)
2849        // succeeds even on kinds without a ref slot.
2850        let parsed: FlakeRef = "path:/x/y".parse().unwrap();
2851        let cleared = parsed.try_with_ref(None).expect("clear is a no-op");
2852        assert_eq!(cleared.ref_(), None);
2853    }
2854
2855    #[test]
2856    fn try_with_rev_path_succeeds() {
2857        // Nix's path scheme accepts `rev`, so try_with_rev surfaces
2858        // no error.
2859        let parsed: FlakeRef = "path:/x/y".parse().unwrap();
2860        let pinned = parsed
2861            .try_with_rev(Some(HEX40.into()))
2862            .expect("path accepts rev");
2863        assert_eq!(pinned.rev(), Some(HEX40));
2864    }
2865
2866    #[test]
2867    fn with_ref_silent_noop_path() {
2868        // Regression guard for the existing infallible builder: setting
2869        // a ref on Path is silently dropped today (set_ref's Path arm is
2870        // a no-op). The try_* variant is the loud opt-in alternative.
2871        let parsed: FlakeRef = "path:/x/y".parse().unwrap();
2872        let updated = parsed.with_ref(Some("main".into()));
2873        assert_eq!(updated.ref_(), None, "with_ref must remain a no-op on Path");
2874    }
2875}
2876
2877#[cfg(test)]
2878mod canonical_string {
2879    //! Tests for [`FlakeRef::to_canonical_string`].
2880    //!
2881    //! Pins the per-scheme canonicalisation rules and guards `Display`
2882    //! from drifting along with them.
2883
2884    use super::*;
2885
2886    const HEX40: &str = "0000000000000000000000000000000000000000";
2887
2888    /// Round-trip helper: every canonical string this library produces
2889    /// must re-parse as a `FlakeRef`. (It need not equal the input
2890    /// `FlakeRef` byte-for-byte; canonical-form may drop fields like
2891    /// `?ref=` location or `?allRefs=1`.)
2892    fn assert_canonical_reparses(input: &str) {
2893        let parsed: FlakeRef = input.parse().expect("input parses");
2894        let canonical = parsed.to_canonical_string();
2895        let _: FlakeRef = canonical
2896            .parse()
2897            .unwrap_or_else(|e| panic!("canonical {canonical:?} failed to re-parse: {e}"));
2898    }
2899
2900    // GitForge `?ref=` / `?rev=` canonicalises to path-component form.
2901
2902    #[test]
2903    fn github_ref_query_canonicalises_to_path_component() {
2904        let input = "github:nixos/nixpkgs?ref=nixos-23.11";
2905        let parsed: FlakeRef = input.parse().unwrap();
2906        assert_eq!(
2907            parsed.to_canonical_string(),
2908            "github:nixos/nixpkgs/nixos-23.11"
2909        );
2910        // Display-unchanged regression guard.
2911        assert_eq!(parsed.to_string(), input);
2912        assert_canonical_reparses(input);
2913    }
2914
2915    #[test]
2916    fn github_rev_query_canonicalises_to_path_component() {
2917        let input = "github:nixos/nixpkgs?rev=0000000000000000000000000000000000000000";
2918        let parsed: FlakeRef = input.parse().unwrap();
2919        assert_eq!(
2920            parsed.to_canonical_string(),
2921            "github:nixos/nixpkgs/0000000000000000000000000000000000000000"
2922        );
2923        assert_eq!(parsed.to_string(), input);
2924        assert_canonical_reparses(input);
2925    }
2926
2927    #[test]
2928    fn github_path_component_ref_unchanged() {
2929        // Already-canonical input survives canonicalisation as-is.
2930        let input = "github:nixos/nixpkgs/main";
2931        let parsed: FlakeRef = input.parse().unwrap();
2932        assert_eq!(parsed.to_canonical_string(), input);
2933        assert_eq!(parsed.to_string(), input);
2934    }
2935
2936    #[test]
2937    fn gitlab_ref_query_canonicalises() {
2938        let input = "gitlab:foo/bar?ref=v1.0";
2939        let parsed: FlakeRef = input.parse().unwrap();
2940        assert_eq!(parsed.to_canonical_string(), "gitlab:foo/bar/v1.0");
2941        assert_eq!(parsed.to_string(), input);
2942    }
2943
2944    #[test]
2945    fn sourcehut_ref_query_canonicalises() {
2946        let input = "sourcehut:~user/repo?ref=branch";
2947        let parsed: FlakeRef = input.parse().unwrap();
2948        assert_eq!(parsed.to_canonical_string(), "sourcehut:~user/repo/branch");
2949        assert_eq!(parsed.to_string(), input);
2950    }
2951
2952    #[test]
2953    fn github_canonical_keeps_host_and_nar_hash() {
2954        // `host` and `narHash` are the two query keys Nix emits on a
2955        // canonical git-archive URL. Everything else (dir,
2956        // lastModified, revCount, arbitrary) is dropped.
2957        let input = "github:nixos/nixpkgs/main?host=ghe.example.com&narHash=sha256-abc";
2958        let parsed: FlakeRef = input.parse().unwrap();
2959        assert_eq!(
2960            parsed.to_canonical_string(),
2961            "github:nixos/nixpkgs/main?host=ghe.example.com&narHash=sha256-abc"
2962        );
2963    }
2964
2965    #[test]
2966    fn github_canonical_picks_rev_over_ref() {
2967        // Construct a pathological state where both ref and rev are
2968        // populated. Nix asserts they cannot coexist on a git-archive
2969        // URL, so this is nonsensical, but we need a deterministic
2970        // answer if a caller ever wires both via the builder; pick rev
2971        // (rev is the more-specific pin).
2972        let mut forge = GitForge {
2973            platform: GitForgePlatform::GitHub,
2974            owner: "nixos".into(),
2975            repo: "nixpkgs".into(),
2976            ref_: Some("main".into()),
2977            rev: Some(HEX40.into()),
2978            location: RefLocation::PathComponent,
2979        };
2980        // The proptest generator deliberately avoids this shape; we
2981        // synthesise it directly.
2982        forge.location = RefLocation::PathComponent;
2983        let f = FlakeRef::default().with_kind(FlakeRefType::GitForge(forge));
2984        assert_eq!(
2985            f.to_canonical_string(),
2986            format!("github:nixos/nixpkgs/{HEX40}")
2987        );
2988    }
2989
2990    // Typed Git booleans only emit on the truthy branch, and
2991    // `allRefs` is never emitted.
2992
2993    #[test]
2994    fn git_all_refs_dropped_on_canonical() {
2995        let input = "git+https://github.com/nixos/nixpkgs?allRefs=1";
2996        let parsed: FlakeRef = input.parse().unwrap();
2997        // Nix's canonical git URL does not include allRefs in the
2998        // serialised query.
2999        assert_eq!(
3000            parsed.to_canonical_string(),
3001            "git+https://github.com/nixos/nixpkgs"
3002        );
3003        // Display still preserves the round-trip.
3004        assert_eq!(parsed.to_string(), input);
3005        assert_canonical_reparses(input);
3006    }
3007
3008    #[test]
3009    fn git_lfs_truthy_kept() {
3010        let input = "git+https://example.com/repo?lfs=1";
3011        let parsed: FlakeRef = input.parse().unwrap();
3012        assert_eq!(parsed.to_canonical_string(), input);
3013        assert_eq!(parsed.to_string(), input);
3014    }
3015
3016    #[test]
3017    fn git_lfs_falsy_dropped() {
3018        let input = "git+https://example.com/repo?lfs=0";
3019        let parsed: FlakeRef = input.parse().unwrap();
3020        assert_eq!(parsed.to_canonical_string(), "git+https://example.com/repo");
3021        assert_eq!(parsed.to_string(), input);
3022    }
3023
3024    #[test]
3025    fn git_submodules_truthy_kept() {
3026        let input = "git+https://example.com/repo?submodules=1";
3027        let parsed: FlakeRef = input.parse().unwrap();
3028        assert_eq!(parsed.to_canonical_string(), input);
3029        assert_eq!(parsed.to_string(), input);
3030    }
3031
3032    #[test]
3033    fn git_shallow_truthy_kept() {
3034        let input = "git+https://example.com/repo?shallow=1";
3035        let parsed: FlakeRef = input.parse().unwrap();
3036        assert_eq!(parsed.to_canonical_string(), input);
3037        assert_eq!(parsed.to_string(), input);
3038    }
3039
3040    #[test]
3041    fn git_export_ignore_truthy_kept() {
3042        let input = "git+https://example.com/repo?exportIgnore=1";
3043        let parsed: FlakeRef = input.parse().unwrap();
3044        assert_eq!(parsed.to_canonical_string(), input);
3045    }
3046
3047    #[test]
3048    fn git_verify_commit_truthy_kept() {
3049        let input = "git+https://example.com/repo?verifyCommit=1";
3050        let parsed: FlakeRef = input.parse().unwrap();
3051        assert_eq!(parsed.to_canonical_string(), input);
3052    }
3053
3054    #[test]
3055    fn git_locked_attrs_dropped() {
3056        // narHash, lastModified, revCount are not part of Nix's
3057        // canonical git URL output; canonical drops them.
3058        let input = "git+https://example.com/repo?lastModified=42&narHash=sha256-x&revCount=7";
3059        let parsed: FlakeRef = input.parse().unwrap();
3060        assert_eq!(parsed.to_canonical_string(), "git+https://example.com/repo");
3061    }
3062
3063    #[test]
3064    fn git_ref_and_rev_canonical_alphabetised() {
3065        // Nix emits both ref and rev on the same canonical git URL
3066        // when set; canonical sort keeps ref before rev.
3067        let input = format!("git+https://example.com/repo?ref=main&rev={HEX40}");
3068        let parsed: FlakeRef = input.parse().unwrap();
3069        assert_eq!(parsed.to_canonical_string(), input);
3070    }
3071
3072    // Resource(Mercurial): only ref/rev survive canonicalisation.
3073
3074    #[test]
3075    fn hg_canonical_keeps_only_ref_and_rev() {
3076        let input = "hg+https://example.com/repo?ref=main";
3077        let parsed: FlakeRef = input.parse().unwrap();
3078        assert_eq!(parsed.to_canonical_string(), input);
3079    }
3080
3081    // Indirect / Path / File / Tarball: canonical delegates to Display.
3082
3083    #[test]
3084    fn indirect_canonical_matches_display() {
3085        for input in ["flake:nixpkgs", "flake:nixpkgs/main", "flake:nixos/nixpkgs"] {
3086            let parsed: FlakeRef = input.parse().unwrap();
3087            assert_eq!(parsed.to_canonical_string(), parsed.to_string(), "{input}");
3088            assert_eq!(parsed.to_canonical_string(), input);
3089        }
3090    }
3091
3092    #[test]
3093    fn path_canonical_matches_display() {
3094        for input in ["path:./foo", "path:/abs/path"] {
3095            let parsed: FlakeRef = input.parse().unwrap();
3096            assert_eq!(parsed.to_canonical_string(), parsed.to_string(), "{input}");
3097            assert_eq!(parsed.to_canonical_string(), input);
3098        }
3099    }
3100
3101    #[test]
3102    fn fragment_survives_canonicalisation() {
3103        let input = "github:nixos/nixpkgs/main#hello";
3104        let parsed: FlakeRef = input.parse().unwrap();
3105        assert_eq!(
3106            parsed.to_canonical_string(),
3107            "github:nixos/nixpkgs/main#hello"
3108        );
3109        assert_eq!(parsed.to_string(), input);
3110    }
3111}
3112
3113#[cfg(test)]
3114mod historical_seed_round_trip {
3115    //! Round-trip pins for shapes once captured in
3116    //! `proptest-regressions/flakeref/proptest.txt`. Each test builds
3117    //! the value and asserts the round-trip property
3118    //! `flake_ref.to_string().parse() == Ok(flake_ref)`.
3119    use super::*;
3120
3121    fn assert_round_trip(value: &FlakeRef) {
3122        let displayed = value.to_string();
3123        let parsed: FlakeRef = displayed
3124            .parse()
3125            .unwrap_or_else(|e| panic!("Display output {displayed:?} failed to parse: {e}"));
3126        assert_eq!(*value, parsed, "round-trip mismatch for {displayed:?}");
3127    }
3128
3129    /// `path://` with an empty authority parses to a `Path` whose body
3130    /// is the literal `//`. Nix's URL parser admits the empty-authority
3131    /// form (it only rejects an authority with a non-empty host). The
3132    /// internal byte-for-byte round-trip must hold so a value
3133    /// constructed this way comes back identical.
3134    #[test]
3135    fn path_double_slash_round_trips() {
3136        let value = FlakeRef::new(FlakeRefType::Path {
3137            path: "//".to_string(),
3138            rev: None,
3139        });
3140        assert_eq!(value.to_string(), "path://");
3141        assert_round_trip(&value);
3142    }
3143}