Skip to main content

nix_uri/flakeref/
fr_type.rs

1use std::{fmt::Display, path::Path};
2
3use serde::{Deserialize, Serialize};
4use winnow::{
5    ModalResult, Parser,
6    combinator::{alt, opt, peek, preceded, separated_pair, terminated},
7    error::{StrContext, StrContextValue},
8    token::{rest, take_till, take_until},
9};
10
11use crate::{
12    error::{NixUriError, NixUriResult, UnsupportedReason, run_partial, tag},
13    flakeref::{
14        RefLocation, TransportLayer,
15        encoding::decode_percent,
16        forge::{GitForge, validate_owner_repo},
17        validators::{looks_like_rev, validated_ref_name},
18    },
19    parser::parse_transport_type,
20};
21
22use super::{
23    GitForgePlatform,
24    resource_url::{ResourceType, ResourceUrl},
25};
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum FlakeRefType {
29    /// A resource-style URL (`git+`, `hg+`, `file+`, `tarball+`); see
30    /// [`ResourceUrl`] for the typed shape.
31    Resource(ResourceUrl),
32    /// A git-forge shorthand (`github:`, `gitlab:`, `sourcehut:`); see
33    /// [`GitForge`] for the typed shape.
34    GitForge(GitForge),
35    /// Indirect (registry) flake reference, e.g. `flake:nixpkgs` or
36    /// `flake:nixpkgs/release-23.05`.
37    ///
38    /// Like [`GitForge`], `ref_` and `rev` are typed slots filled at parse
39    /// time by inspecting the path-component value: 40-hex goes to `rev`,
40    /// everything else to `ref_`. Nix's indirect form accepts up to three
41    /// segments `flake:id/ref/rev`; when both are present, both slots
42    /// populate.
43    /// `location` records whether a present value would render as
44    /// `flake:id/<value>` or `flake:id?ref=<value>`.
45    Indirect {
46        id: String,
47        ref_: Option<String>,
48        rev: Option<String>,
49        location: RefLocation,
50    },
51    /// Path must be a directory in the filesystem containing a `flake.nix`.
52    /// Path must be an absolute path.
53    ///
54    /// `rev` carries the optional 40-hex commit pin from a `?rev=` query
55    /// parameter. Nix accepts `rev`, `narHash`, `revCount`, and
56    /// `lastModified` on `path:` URLs; `narHash` and the counts ride on
57    /// [`crate::LocationParameters`], but `rev` is a typed slot so locked
58    /// store-path inputs round-trip without losing their pin. There is
59    /// no path-component form for the rev (Path has no `/<rev>` shape in
60    /// Nix's grammar), so it always renders as `?rev=`.
61    ///
62    /// `path:` URLs may use the empty-authority form (`path:///abs`,
63    /// equivalent to `path:/abs`) -- Nix rejects only when the URL
64    /// authority's host is non-empty, so `path://host/...` errors but
65    /// `path:///abs` parses. To preserve the internal byte-for-byte
66    /// round-trip the empty-authority form is stored verbatim (leading
67    /// `//` kept on `path`); the slash-collapse normalisation Nix
68    /// performs at Display time is intentionally deferred.
69    Path { path: String, rev: Option<String> },
70}
71
72/// `Default` is only the seed for the in-progress `FlakeRef` inside
73/// `parse_nix_uri`; an empty-path value is never round-trippable on its own
74/// (the empty-input guard rejects it before it can escape).
75impl Default for FlakeRefType {
76    fn default() -> Self {
77        Self::Path {
78            path: String::new(),
79            rev: None,
80        }
81    }
82}
83
84impl FlakeRefType {
85    #[allow(dead_code)]
86    pub(crate) fn parse_path(input: &mut &str) -> ModalResult<Self> {
87        preceded(
88            opt(alt((tag("path://"), tag("path:")))),
89            Self::path_parser.map(|path_str| Self::Path {
90                path: path_str.to_string(),
91                rev: None,
92            }),
93        )
94        .parse_next(input)
95    }
96
97    // TODO: #158
98    #[allow(dead_code)]
99    pub(crate) fn parse_file(input: &mut &str) -> ModalResult<Self> {
100        alt((
101            // Handle file+http[s]:// as Resource with proper transport.
102            Self::parse_file_with_http_transport,
103            Self::parse_explicit_file_scheme.map(|path| {
104                Self::Resource(ResourceUrl::new(
105                    ResourceType::File,
106                    path.display().to_string(),
107                    None,
108                ))
109            }),
110            Self::parse_naked.map(|path| Self::Path {
111                path: format!("{}", path.display()),
112                rev: None,
113            }),
114        ))
115        .context(StrContext::Label("path resource"))
116        .parse_next(input)
117    }
118
119    #[allow(dead_code)]
120    pub(crate) fn parse_naked<'i>(input: &mut &'i str) -> ModalResult<&'i Path> {
121        // Check that input starts with `.` or `/`.
122        peek(alt(('.', '/')))
123            .context(StrContext::Label("path location"))
124            .parse_next(input)?;
125        let path_str = Self::path_parser.parse_next(input)?;
126        Ok(Path::new(path_str))
127    }
128
129    #[allow(dead_code)]
130    pub(crate) fn path_parser<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
131        preceded(peek(alt(('.', '/'))), Self::path_verifier).parse_next(input)
132    }
133
134    #[allow(dead_code)]
135    pub(crate) fn path_verifier<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
136        take_till(0.., |c| c == '#' || c == '?')
137            .verify(|c: &&str| !c.contains('[') && !c.contains(']'))
138            .context(StrContext::Label("path validation"))
139            .parse_next(input)
140    }
141
142    #[allow(dead_code)]
143    pub(crate) fn parse_explicit_file_scheme<'i>(input: &mut &'i str) -> ModalResult<&'i Path> {
144        preceded(
145            tag("file"),
146            preceded(
147                opt(tag("+file")),
148                terminated(
149                    ':'.context(StrContext::Expected(StrContextValue::CharLiteral(':'))),
150                    opt(tag("//")),
151                ),
152            ),
153        )
154        .context(StrContext::Label("file resource"))
155        .parse_next(input)?;
156        let path_str = Self::path_parser.parse_next(input)?;
157        Ok(Path::new(path_str))
158    }
159
160    #[allow(dead_code)]
161    pub(crate) fn parse_file_with_http_transport(input: &mut &str) -> ModalResult<Self> {
162        let scheme = alt((tag("file+https"), tag("file+http"))).parse_next(input)?;
163        let _ = tag("://").parse_next(input)?;
164        let location = take_till(0.., |c| c == '#' || c == '?').parse_next(input)?;
165
166        let transport_type = match scheme {
167            "file+https" => Some(TransportLayer::Https),
168            "file+http" => Some(TransportLayer::Http),
169            _ => unreachable!(),
170        };
171
172        Ok(Self::Resource(ResourceUrl::new(
173            ResourceType::File,
174            location.to_string(),
175            transport_type,
176        )))
177    }
178
179    /// TODO: different platforms have different rules about owner/repo/ref
180    /// strings; not enforced today.
181    /// `<github | gitlab | sourcehut>:<owner>/<repo>[/<rev | ref>]...`
182    #[allow(dead_code)]
183    pub(crate) fn parse_git_forge(input: &mut &str) -> ModalResult<Self> {
184        GitForge::parse.map(Self::GitForge).parse_next(input)
185    }
186
187    /// `<git | hg>[+<transport-type>]://...`
188    #[allow(dead_code)]
189    pub(crate) fn parse_resource(input: &mut &str) -> ModalResult<Self> {
190        ResourceUrl::parse.map(Self::Resource).parse_next(input)
191    }
192
193    /// Parse plain HTTP/HTTPS URLs with auto-detection.
194    #[allow(dead_code)]
195    pub(crate) fn parse_plain_url(input: &mut &str) -> ModalResult<Self> {
196        use crate::parser::is_tarball;
197
198        let scheme = alt((tag("https"), tag("http"))).parse_next(input)?;
199        let _ = tag("://").parse_next(input)?;
200        let location = take_till(0.., |c| c == '#' || c == '?')
201            .context(StrContext::Label("url location"))
202            .parse_next(input)?;
203
204        let res_type = if is_tarball(location) {
205            ResourceType::Tarball
206        } else {
207            ResourceType::File
208        };
209
210        let transport_type = match scheme {
211            "https" => Some(TransportLayer::Https),
212            "http" => Some(TransportLayer::Http),
213            _ => None,
214        };
215
216        Ok(Self::Resource(ResourceUrl::new(
217            res_type,
218            location.to_string(),
219            transport_type,
220        )))
221    }
222
223    /// Parse type-specific information; the production entry point for
224    /// classifying a URI's kind.
225    #[allow(dead_code)]
226    pub(crate) fn parse_type(input: &str) -> NixUriResult<Self> {
227        let (_, maybe_explicit_type) = run_partial(
228            input,
229            input,
230            opt(separated_pair(take_until(0.., ":"), ':', rest)),
231        )?;
232        if let Some((flake_ref_type_str, rest_input)) = maybe_explicit_type {
233            match flake_ref_type_str {
234                "github" | "gitlab" | "sourcehut" => {
235                    let (_input, owner_and_repo_or_ref) =
236                        run_partial(input, rest_input, GitForge::parse_owner_repo_ref)?;
237                    // Match Nix's per-segment percent-decode: split on raw
238                    // `/` boundaries, then percent-decode each segment.
239                    // Decoding *after* the split is what lets a `%2F` inside an
240                    // owner survive as a literal `/` without colliding with the
241                    // owner-vs-repo separator. The validator then accepts that
242                    // `/` only when the platform is `gitlab:` (subgroup form);
243                    // see [`validate_owner_repo`].
244                    let owner = decode_percent(owner_and_repo_or_ref.0)?.into_owned();
245                    let repo = decode_percent(owner_and_repo_or_ref.1)?.into_owned();
246                    let (ref_, rev) = match owner_and_repo_or_ref.2 {
247                        Some(v) => {
248                            let v = decode_percent(v)?.into_owned();
249                            if looks_like_rev(&v) {
250                                (None, Some(v))
251                            } else {
252                                (Some(validated_ref_name(&v)?), None)
253                            }
254                        }
255                        None => (None, None),
256                    };
257                    let platform = match flake_ref_type_str {
258                        "github" => GitForgePlatform::GitHub,
259                        "gitlab" => GitForgePlatform::GitLab,
260                        "sourcehut" => GitForgePlatform::SourceHut,
261                        _ => unreachable!(),
262                    };
263                    validate_owner_repo(&platform, &owner, &repo)?;
264                    let res = Self::GitForge(GitForge {
265                        platform,
266                        owner,
267                        repo,
268                        ref_,
269                        rev,
270                        location: RefLocation::PathComponent,
271                    });
272                    Ok(res)
273                }
274                "path" => {
275                    // Nix rejects `path://` only when the URL authority's
276                    // host is non-empty. An *empty* authority (the body
277                    // begins `///` or is just `//`) decodes to the
278                    // trailing path and is accepted; e.g. `path:///abs`
279                    // parses as the absolute path `/abs`. So inspect
280                    // what follows the second `/` rather than rejecting
281                    // any `//` prefix outright. The body is stored
282                    // verbatim (leading slashes preserved) so Display
283                    // round-trips; the slash-collapse normalisation
284                    // Nix performs is intentionally deferred.
285                    if let Some(after) = rest_input.strip_prefix("//") {
286                        let host_end = after.find('/').unwrap_or(after.len());
287                        if !after[..host_end].is_empty() {
288                            return Err(NixUriError::Unsupported(UnsupportedReason::Authority {
289                                scheme: "path",
290                            }));
291                        }
292                    }
293                    if rest_input.contains(']') || rest_input.contains('[') {
294                        return Err(NixUriError::InvalidUrl(rest_input.into()));
295                    }
296                    if rest_input.is_empty() || rest_input.trim().is_empty() {
297                        return Err(NixUriError::InvalidUrl(rest_input.into()));
298                    }
299                    if rest_input.contains('#') || rest_input.contains('?') {
300                        return Err(NixUriError::InvalidUrl(rest_input.into()));
301                    }
302                    let flake_ref_type = Self::Path {
303                        path: rest_input.into(),
304                        rev: None,
305                    };
306                    Ok(flake_ref_type)
307                }
308                "flake" => {
309                    // Nix skips empty segments when splitting the URL
310                    // path, so `flake:nixpkgs//main` collapses to
311                    // `flake:nixpkgs/main`. Match that here.
312                    let segments: Vec<&str> =
313                        rest_input.split('/').filter(|s| !s.is_empty()).collect();
314                    if segments.is_empty() {
315                        return Err(NixUriError::InvalidUrl(rest_input.into()));
316                    }
317                    if segments.len() > INDIRECT_MAX_SEGMENTS {
318                        return Err(NixUriError::TooManyIndirectSegments {
319                            count: segments.len(),
320                        });
321                    }
322                    let (id, ref_, rev) = classify_indirect_segments(&segments, rest_input)?;
323                    Ok(Self::Indirect {
324                        id,
325                        ref_,
326                        rev,
327                        location: RefLocation::PathComponent,
328                    })
329                }
330
331                _ => {
332                    if flake_ref_type_str.starts_with("git+") {
333                        let transport_type = parse_transport_type(flake_ref_type_str)?;
334                        let (rest_input, _tag) = run_partial(input, rest_input, opt(tag("//")))?;
335                        let flake_ref_type = Self::Resource(ResourceUrl::new(
336                            ResourceType::Git,
337                            rest_input.into(),
338                            Some(transport_type),
339                        ));
340                        Ok(flake_ref_type)
341                    } else if flake_ref_type_str.starts_with("hg+") {
342                        let transport_type = parse_transport_type(flake_ref_type_str)?;
343                        let (rest_input, _tag) = run_partial(input, rest_input, tag("//"))?;
344                        let flake_ref_type = Self::Resource(ResourceUrl::new(
345                            ResourceType::Mercurial,
346                            rest_input.into(),
347                            Some(transport_type),
348                        ));
349                        Ok(flake_ref_type)
350                    } else if flake_ref_type_str.starts_with("tarball+") {
351                        let transport_type = parse_transport_type(flake_ref_type_str)?;
352                        let (rest_input, _tag) = run_partial(input, rest_input, opt(tag("//")))?;
353                        let flake_ref_type = Self::Resource(ResourceUrl::new(
354                            ResourceType::Tarball,
355                            rest_input.into(),
356                            Some(transport_type),
357                        ));
358                        Ok(flake_ref_type)
359                    } else if flake_ref_type_str.starts_with("file+") {
360                        let transport_type = parse_transport_type(flake_ref_type_str)?;
361                        let (rest_input, _tag) = run_partial(input, rest_input, opt(tag("//")))?;
362                        let flake_ref_type = Self::Resource(ResourceUrl::new(
363                            ResourceType::File,
364                            rest_input.into(),
365                            Some(transport_type),
366                        ));
367                        Ok(flake_ref_type)
368                    } else if flake_ref_type_str == "https" || flake_ref_type_str == "http" {
369                        // Plain HTTP/HTTPS URL - auto-detect type based on extension.
370                        use crate::parser::is_tarball;
371
372                        let (rest_input, _tag) = run_partial(input, rest_input, tag("//"))?;
373                        let res_type = if is_tarball(rest_input) {
374                            ResourceType::Tarball
375                        } else {
376                            ResourceType::File
377                        };
378                        let transport_type = match flake_ref_type_str {
379                            "https" => Some(TransportLayer::Https),
380                            "http" => Some(TransportLayer::Http),
381                            _ => None,
382                        };
383
384                        let flake_ref_type = Self::Resource(ResourceUrl::new(
385                            res_type,
386                            rest_input.into(),
387                            transport_type,
388                        ));
389                        Ok(flake_ref_type)
390                    } else if flake_ref_type_str == "file" {
391                        // Bare `file://` URL: Nix routes plain `file:`
392                        // through the same tarball-extension classifier
393                        // as `http(s)://`, splitting between the file
394                        // (no extension) and tarball (extension present)
395                        // shapes. The transport is always `file`, so a
396                        // round-trip from the explicit `tarball+file://`
397                        // shape (whose Display strips `tarball+`)
398                        // lands back on the same kind.
399                        use crate::parser::is_tarball;
400
401                        let (rest_input, _tag) = run_partial(input, rest_input, tag("//"))?;
402                        let res_type = if is_tarball(rest_input) {
403                            ResourceType::Tarball
404                        } else {
405                            ResourceType::File
406                        };
407                        let flake_ref_type = Self::Resource(ResourceUrl::new(
408                            res_type,
409                            rest_input.into(),
410                            Some(TransportLayer::File),
411                        ));
412                        Ok(flake_ref_type)
413                    } else if flake_ref_type_str == "git" {
414                        // Bare git:// protocol: native git over the wire,
415                        // no `+<transport>` layer.
416                        let (rest_input, _tag) = run_partial(input, rest_input, tag("//"))?;
417                        let flake_ref_type = Self::Resource(ResourceUrl::new(
418                            ResourceType::Git,
419                            rest_input.into(),
420                            None,
421                        ));
422                        Ok(flake_ref_type)
423                    } else {
424                        Err(NixUriError::Unsupported(UnsupportedReason::UriType {
425                            ty: flake_ref_type_str.into(),
426                        }))
427                    }
428                }
429            }
430        } else {
431            // Implicit types can be paths or indirect flake-ids. The bare
432            // form matches Nix's bare-flake-id shape, which routes
433            // matching shapes through the same indirect scheme as
434            // `flake:`.
435            if input.starts_with('/')
436                || input.starts_with("./")
437                || input.starts_with("../")
438                || input == "."
439                || input == ".."
440            {
441                // Bare `//host/path` has no Nix grammar (Nix only
442                // recognises single-slash absolute and relative
443                // shapes for bare path flake-refs). Without this
444                // guard the body was stored verbatim and Display
445                // emitted `path://host/path`, which the `path:` arm
446                // then rejects on re-parse via the authority guard.
447                if input.starts_with("//") {
448                    return Err(NixUriError::InvalidUrl(input.into()));
449                }
450                let flake_ref_type = Self::Path {
451                    path: input.into(),
452                    rev: None,
453                };
454                if input.contains(']')
455                    || input.contains('[')
456                    || !input.is_ascii()
457                    || input.contains('#')
458                    || input.contains('?')
459                {
460                    return Err(NixUriError::InvalidUrl(input.into()));
461                }
462                return Ok(flake_ref_type);
463            }
464
465            // Bare indirect form. Nix's bare-flake-id regex does not
466            // permit empty path segments (the `flake:` URL form skips
467            // empties, but the bare form is matched by a regex that
468            // requires non-empty content), so reject any.
469            let segments: Vec<&str> = input.split('/').collect();
470            if segments.iter().any(|s| s.is_empty()) {
471                return Err(NixUriError::InvalidUrl(input.into()));
472            }
473            if segments.len() > INDIRECT_MAX_SEGMENTS {
474                return Err(NixUriError::MissingScheme {
475                    input: input.into(),
476                });
477            }
478            let (id, ref_, rev) = classify_indirect_segments(&segments, input)?;
479            Ok(Self::Indirect {
480                id,
481                ref_,
482                rev,
483                location: RefLocation::PathComponent,
484            })
485        }
486    }
487    /// Repository identifier for the kind: the `repo` of a `GitForge` or the
488    /// trailing path segment of a `Resource(Git)` URL (with any `.git`
489    /// suffix stripped). The public entry point is [`crate::FlakeRef::id`].
490    pub(crate) fn id(&self) -> Option<&str> {
491        match self {
492            Self::GitForge(GitForge { repo, .. }) => Some(repo.as_str()),
493            Self::Resource(ResourceUrl {
494                res_type: ResourceType::Git,
495                location,
496                ..
497            }) => {
498                // Extract repo from "domain.com/owner/repo" or "domain.com/owner/repo.git".
499                location
500                    .split('/')
501                    .nth(2)
502                    .map(|s| s.strip_suffix(".git").unwrap_or(s))
503            }
504            _ => None,
505        }
506    }
507
508    /// Repository name for the kind. The public entry point is
509    /// [`crate::FlakeRef::repo`].
510    pub(crate) fn repo(&self) -> Option<&str> {
511        match self {
512            Self::GitForge(GitForge { repo, .. }) => Some(repo.as_str()),
513            Self::Resource(ResourceUrl {
514                res_type: ResourceType::Git,
515                location,
516                ..
517            }) => {
518                // Parse "domain.com/owner/repo" or "domain.com/owner/repo.git".
519                location
520                    .split('/')
521                    .nth(2)
522                    .map(|s| s.strip_suffix(".git").unwrap_or(s))
523            }
524            _ => None,
525        }
526    }
527
528    /// Owner (user/organisation) for the kind. The public entry point is
529    /// [`crate::FlakeRef::owner`].
530    pub(crate) fn owner(&self) -> Option<&str> {
531        match self {
532            Self::GitForge(GitForge { owner, .. }) => Some(owner.as_str()),
533            Self::Resource(ResourceUrl {
534                res_type: ResourceType::Git,
535                location,
536                ..
537            }) => {
538                // Parse "domain.com/owner/repo" -> "owner".
539                location.split('/').nth(1)
540            }
541            _ => None,
542        }
543    }
544
545    /// Domain (host) for the kind. Returns the canonical host string for
546    /// git-forge platforms and the host portion of a `Resource(Git)` URL,
547    /// retaining `:port` when the port is non-default for the scheme
548    /// (mirrors HTTP-library `Authority` semantics; flake-edit consumes
549    /// `domain()` directly as `api_host_for(domain)` input). The public
550    /// entry point is [`crate::FlakeRef::domain`].
551    pub(crate) fn domain(&self) -> Option<&str> {
552        match self {
553            Self::GitForge(GitForge { platform, .. }) => match platform {
554                GitForgePlatform::GitHub => Some("github.com"),
555                GitForgePlatform::GitLab => Some("gitlab.com"),
556                GitForgePlatform::SourceHut => Some("git.sr.ht"),
557            },
558            Self::Resource(ResourceUrl {
559                res_type: ResourceType::Git,
560                location,
561                transport_type,
562                ..
563            }) => {
564                // URL form is `[user@]host[:port]/owner/repo`; the
565                // SCP-like SSH form (handled by `parse_scp_style` at
566                // entry, but a tolerant path also accepts the inline
567                // `host:owner/repo` shape) reuses `:` as a path
568                // separator, not a port. Strip any leading `user@`,
569                // take everything before the first `/`, then split on
570                // the first `:` and discriminate: a numeric segment is
571                // a port (kept verbatim when non-default for the
572                // scheme, dropped when it matches the default), a
573                // non-numeric segment is the SCP-style path separator
574                // (drop everything from `:` onwards).
575                let after_user = location
576                    .split_once('@')
577                    .map_or(location.as_str(), |(_, rest)| rest);
578                let path_start = after_user.find('/').unwrap_or(after_user.len());
579                let authority = &after_user[..path_start];
580                if authority.is_empty() {
581                    return None;
582                }
583                let Some((host, port_str)) = authority.split_once(':') else {
584                    return Some(authority);
585                };
586                if host.is_empty() {
587                    return None;
588                }
589                let is_numeric_port =
590                    !port_str.is_empty() && port_str.bytes().all(|b| b.is_ascii_digit());
591                if !is_numeric_port {
592                    return Some(host);
593                }
594                let default_port = match transport_type {
595                    Some(TransportLayer::Https) => Some("443"),
596                    Some(TransportLayer::Http) => Some("80"),
597                    Some(TransportLayer::Ssh) => Some("22"),
598                    Some(TransportLayer::File) | None => None,
599                };
600                if default_port == Some(port_str) {
601                    Some(host)
602                } else {
603                    Some(authority)
604                }
605            }
606            _ => None,
607        }
608    }
609    /// Whether this kind admits a `ref` per Nix's per-scheme attribute
610    /// rules: yes for [`Self::GitForge`], [`Self::Indirect`], and
611    /// [`Self::Resource`] of [`ResourceType::Git`] /
612    /// [`ResourceType::Mercurial`]; no for [`Self::Path`] and
613    /// `Resource(File | Tarball)`. Backs
614    /// [`crate::FlakeRef::try_with_ref`]'s loud-failure decision; the
615    /// silent-no-op [`Self::set_ref`] does not consult it.
616    pub(crate) fn allows_ref(&self) -> bool {
617        match self {
618            Self::GitForge(_) | Self::Indirect { .. } => true,
619            Self::Resource(res) => {
620                matches!(res.res_type, ResourceType::Git | ResourceType::Mercurial)
621            }
622            Self::Path { .. } => false,
623        }
624    }
625
626    /// Set the typed `ref_` slot. `Path` has no ref slot in Nix's grammar
627    /// (only `rev`, `narHash`, `revCount`, `lastModified` are recognised
628    /// on `path:`), so the Path arm is a no-op; callers that want to
629    /// refuse `?ref=` on `path:` do so before reaching this method.
630    pub(crate) fn set_ref(&mut self, new_ref: Option<String>) {
631        match self {
632            Self::GitForge(forge) => forge.ref_ = new_ref,
633            Self::Indirect { ref_, .. } => *ref_ = new_ref,
634            Self::Resource(res) => res.ref_ = new_ref,
635            Self::Path { .. } => {}
636        }
637    }
638
639    /// Set the typed `rev` slot. Path's slot has no path-component
640    /// spelling in Nix's grammar and always renders as `?rev=` via the
641    /// `FlakeRef` Display block.
642    pub(crate) fn set_rev(&mut self, new_rev: Option<String>) {
643        match self {
644            Self::GitForge(forge) => forge.rev = new_rev,
645            Self::Resource(res) => res.rev = new_rev,
646            Self::Indirect { rev, .. } | Self::Path { rev, .. } => *rev = new_rev,
647        }
648    }
649
650    /// Set the [`RefLocation`] on kinds that carry one. `Path` is a no-op:
651    /// its rev has no path-component representation, so the routing tag
652    /// is fixed to `QueryParameter` and not stored on the variant. The
653    /// caller does not have to discriminate by kind.
654    pub(crate) fn set_ref_location(&mut self, loc: RefLocation) {
655        match self {
656            Self::GitForge(forge) => forge.location = loc,
657            Self::Indirect { location, .. } => *location = loc,
658            Self::Resource(res) => res.ref_location = loc,
659            Self::Path { .. } => {}
660        }
661    }
662}
663
664/// Maximum path-segment count for the indirect grammar
665/// (`id[/ref[/rev]]`). Matches Nix's three-segment cap; the bare
666/// (no-scheme) form uses the same cap because Nix's bare-flake-id form
667/// routes through the same indirect scheme.
668const INDIRECT_MAX_SEGMENTS: usize = 3;
669
670/// Validate a non-empty, non-overflowing slice of indirect path segments
671/// and project them onto `(id, ref_, rev)` per Nix's indirect scheme
672/// rules.
673///
674/// Caller is responsible for the segment-count cap and for filtering
675/// empty segments where appropriate (the `flake:` URL form filters
676/// empties; the bare form does not, since Nix's bare-flake-id regex does
677/// not permit empties). Caller also picks the right "too many" error:
678/// `TooManyIndirectSegments` from the `flake:` arm, `MissingScheme` from
679/// the bare arm.
680///
681/// `raw_input` is forwarded into [`NixUriError::InvalidUrl`] when the id
682/// segment is malformed so the diagnostic carries the original surface
683/// text.
684fn classify_indirect_segments(
685    segments: &[&str],
686    raw_input: &str,
687) -> Result<(String, Option<String>, Option<String>), NixUriError> {
688    let id = segments
689        .first()
690        .copied()
691        .ok_or_else(|| NixUriError::InvalidUrl(raw_input.into()))?;
692    if id.is_empty()
693        || !id.chars().next().unwrap().is_ascii_alphabetic()
694        || !id
695            .chars()
696            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
697    {
698        return Err(NixUriError::InvalidUrl(raw_input.into()));
699    }
700
701    match segments.len() {
702        1 => Ok((id.to_string(), None, None)),
703        2 => {
704            let v = segments[1];
705            if looks_like_rev(v) {
706                Ok((id.to_string(), None, Some(v.to_string())))
707            } else {
708                Ok((id.to_string(), Some(validated_ref_name(v)?), None))
709            }
710        }
711        3 => {
712            let r = validated_ref_name(segments[1])?;
713            // Nix requires the third segment to be a commit hash;
714            // without this check a non-hex value was silently folded
715            // back into the ref name (the `rsplit_once` path would yield
716            // e.g. `ref_=Some("release-23.05/notahex")`).
717            // `looks_like_rev` accepts 40-hex (SHA-1) or 64-hex
718            // (SHA-256).
719            if !looks_like_rev(segments[2]) {
720                return Err(NixUriError::InvalidValue {
721                    field: "rev",
722                    reason: "expected 40-hex (SHA-1) or 64-hex (SHA-256) commit in third indirect segment".to_string(),
723                });
724            }
725            Ok((id.to_string(), Some(r), Some(segments[2].to_string())))
726        }
727        _ => unreachable!("caller must enforce segment count <= INDIRECT_MAX_SEGMENTS"),
728    }
729}
730
731impl Display for FlakeRefType {
732    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        match self {
734            // The ref/rev/ref_location fields on `ResourceUrl` are rendered
735            // by `FlakeRef::Display`'s query-segment block (ref/rev only ever
736            // serialises as a query parameter for Resource), so they are
737            // intentionally not destructured here.
738            //
739            // `tarball+<transport>://...` and `file+<transport>://...` Display
740            // as the bare `<transport>://...` form, matching Nix's behaviour
741            // of stripping the application prefix on output. The parser still
742            // accepts both spellings on input; auto-classification on parse
743            // routes a bare `https://...tar.gz` back into `ResourceType::Tarball`.
744            Self::Resource(res) => {
745                let strip_res_type =
746                    matches!(res.res_type, ResourceType::Tarball | ResourceType::File,)
747                        && res.transport_type.is_some();
748                if !strip_res_type {
749                    write!(f, "{}", res.res_type)?;
750                }
751                if let Some(transport_type) = &res.transport_type {
752                    if strip_res_type {
753                        write!(f, "{}", transport_type)?;
754                    } else {
755                        write!(f, "+{}", transport_type)?;
756                    }
757                }
758                write!(f, "://{}", res.location)
759            }
760            Self::GitForge(GitForge {
761                platform,
762                owner,
763                repo,
764                ref_,
765                rev,
766                location,
767            }) => {
768                let owner_out = super::encoding::encode_path_segment(owner);
769                write!(f, "{platform}:{owner_out}/{repo}")?;
770                if matches!(location, RefLocation::PathComponent) {
771                    if let Some(value) = ref_.as_deref().or(rev.as_deref()) {
772                        write!(f, "/{value}")?;
773                    }
774                }
775                Ok(())
776            }
777            Self::Indirect {
778                id,
779                ref_,
780                rev,
781                location,
782            } => {
783                write!(f, "flake:{id}")?;
784                if matches!(location, RefLocation::PathComponent) {
785                    match (ref_.as_deref(), rev.as_deref()) {
786                        (Some(r), Some(v)) => write!(f, "/{r}/{v}")?,
787                        (Some(v), None) | (None, Some(v)) => write!(f, "/{v}")?,
788                        (None, None) => {}
789                    }
790                }
791                Ok(())
792            }
793            // `rev` is rendered by `FlakeRef::Display`'s alphabetical
794            // query block (Path's rev only has a `?rev=` form), so it is
795            // intentionally not destructured here.
796            Self::Path { path, .. } => write!(f, "path:{path}"),
797        }
798    }
799}
800
801#[cfg(test)]
802mod inc_parse_vc {
803    use crate::TransportLayer;
804
805    use super::*;
806
807    #[test]
808    fn parse_git_github_collision() {
809        let hub = "github:foo/bar";
810        let git = "git:///foo/bar";
811        let parsed_hub = FlakeRefType::parse_type(hub).unwrap();
812        let parsed_git = FlakeRefType::parse_type(git).unwrap();
813        let expected_hub = FlakeRefType::GitForge(GitForge {
814            platform: GitForgePlatform::GitHub,
815            owner: "foo".to_string(),
816            repo: "bar".to_string(),
817            ref_: None,
818            rev: None,
819            location: RefLocation::PathComponent,
820        });
821        let expected_git = FlakeRefType::Resource(ResourceUrl {
822            res_type: ResourceType::Git,
823            location: "/foo/bar".to_string(),
824            transport_type: None,
825            ref_: None,
826            rev: None,
827            ref_location: RefLocation::PathComponent,
828        });
829
830        assert_eq!(expected_git, parsed_git);
831        assert_eq!(expected_hub, parsed_hub);
832    }
833
834    #[test]
835    fn git_file() {
836        let uri = "git:///foo/bar";
837        let file_uri = "git+file:///foo/bar";
838
839        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
840            res_type: ResourceType::Git,
841            location: "/foo/bar".to_string(),
842            transport_type: None,
843            ref_: None,
844            rev: None,
845            ref_location: RefLocation::PathComponent,
846        });
847        let expected_filerefpath = FlakeRefType::Resource(ResourceUrl {
848            res_type: ResourceType::Git,
849            location: "/foo/bar".to_string(),
850            transport_type: Some(TransportLayer::File),
851            ref_: None,
852            rev: None,
853            ref_location: RefLocation::PathComponent,
854        });
855
856        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
857        let file_parsed_refpath = FlakeRefType::parse_type(file_uri).unwrap();
858
859        assert_eq!(expected_refpath, parsed_refpath);
860        assert_eq!(expected_filerefpath, file_parsed_refpath);
861    }
862
863    #[test]
864    fn git_http() {
865        let uri = "git+http:///foo/bar";
866        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
867            res_type: ResourceType::Git,
868            location: "/foo/bar".to_string(),
869            transport_type: Some(TransportLayer::Http),
870            ref_: None,
871            rev: None,
872            ref_location: RefLocation::PathComponent,
873        });
874
875        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
876
877        assert_eq!(expected_refpath, parsed_refpath);
878    }
879
880    #[test]
881    fn git_https() {
882        let uri = "git+https:///foo/bar";
883        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
884            res_type: ResourceType::Git,
885            location: "/foo/bar".to_string(),
886            transport_type: Some(TransportLayer::Https),
887            ref_: None,
888            rev: None,
889            ref_location: RefLocation::PathComponent,
890        });
891
892        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
893
894        assert_eq!(expected_refpath, parsed_refpath);
895    }
896
897    #[test]
898    fn hg_file() {
899        let file_uri = "hg+file:///foo/bar";
900        let file_expected_refpath = FlakeRefType::Resource(ResourceUrl {
901            res_type: ResourceType::Mercurial,
902            location: "/foo/bar".to_string(),
903            transport_type: Some(TransportLayer::File),
904            ref_: None,
905            rev: None,
906            ref_location: RefLocation::PathComponent,
907        });
908
909        let file_parsed_refpath = FlakeRefType::parse_type(file_uri).unwrap();
910
911        assert_eq!(file_expected_refpath, file_parsed_refpath);
912    }
913
914    #[test]
915    fn hg_http() {
916        let uri = "hg+http:///foo/bar";
917        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
918            res_type: ResourceType::Mercurial,
919            location: "/foo/bar".to_string(),
920            transport_type: Some(TransportLayer::Http),
921            ref_: None,
922            rev: None,
923            ref_location: RefLocation::PathComponent,
924        });
925
926        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
927
928        assert_eq!(expected_refpath, parsed_refpath);
929    }
930
931    #[test]
932    fn hg_https() {
933        let uri = "hg+https:///foo/bar";
934        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
935            res_type: ResourceType::Mercurial,
936            location: "/foo/bar".to_string(),
937            transport_type: Some(TransportLayer::Https),
938            ref_: None,
939            rev: None,
940            ref_location: RefLocation::PathComponent,
941        });
942
943        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
944
945        assert_eq!(expected_refpath, parsed_refpath);
946    }
947
948    #[test]
949    fn tarball_https_transport() {
950        let uri = "tarball+https://example.com/file.tar.gz";
951        let expected = FlakeRefType::Resource(ResourceUrl {
952            res_type: ResourceType::Tarball,
953            location: "example.com/file.tar.gz".to_string(),
954            transport_type: Some(TransportLayer::Https),
955            ref_: None,
956            rev: None,
957            ref_location: RefLocation::PathComponent,
958        });
959
960        let result = FlakeRefType::parse_type(uri).unwrap();
961        assert_eq!(expected, result);
962    }
963
964    #[test]
965    fn tarball_http_transport() {
966        let uri = "tarball+http://example.com/file.zip";
967        let expected = FlakeRefType::Resource(ResourceUrl {
968            res_type: ResourceType::Tarball,
969            location: "example.com/file.zip".to_string(),
970            transport_type: Some(TransportLayer::Http),
971            ref_: None,
972            rev: None,
973            ref_location: RefLocation::PathComponent,
974        });
975
976        let result = FlakeRefType::parse_type(uri).unwrap();
977        assert_eq!(expected, result);
978    }
979
980    #[test]
981    fn file_https_transport() {
982        let uri = "file+https://example.com/file.txt";
983        let expected = FlakeRefType::Resource(ResourceUrl {
984            res_type: ResourceType::File,
985            location: "example.com/file.txt".to_string(),
986            transport_type: Some(TransportLayer::Https),
987            ref_: None,
988            rev: None,
989            ref_location: RefLocation::PathComponent,
990        });
991
992        let result = FlakeRefType::parse_type(uri).unwrap();
993        assert_eq!(expected, result);
994    }
995
996    #[test]
997    fn file_http_transport() {
998        let uri = "file+http://example.com/file.txt";
999        let expected = FlakeRefType::Resource(ResourceUrl {
1000            res_type: ResourceType::File,
1001            location: "example.com/file.txt".to_string(),
1002            transport_type: Some(TransportLayer::Http),
1003            ref_: None,
1004            rev: None,
1005            ref_location: RefLocation::PathComponent,
1006        });
1007
1008        let result = FlakeRefType::parse_type(uri).unwrap();
1009        assert_eq!(expected, result);
1010    }
1011
1012    #[test]
1013    fn bare_git_protocol() {
1014        let uri = "git://github.com/user/repo.git";
1015        let expected = FlakeRefType::Resource(ResourceUrl {
1016            res_type: ResourceType::Git,
1017            location: "github.com/user/repo.git".to_string(),
1018            transport_type: None,
1019            ref_: None,
1020            rev: None,
1021            ref_location: RefLocation::PathComponent,
1022        });
1023
1024        let result = FlakeRefType::parse_type(uri).unwrap();
1025        assert_eq!(expected, result);
1026    }
1027
1028    #[test]
1029    fn plain_https_tarball_autodetect() {
1030        let uri = "https://example.com/file.tar.gz";
1031        let expected = FlakeRefType::Resource(ResourceUrl {
1032            res_type: ResourceType::Tarball,
1033            location: "example.com/file.tar.gz".to_string(),
1034            transport_type: Some(TransportLayer::Https),
1035            ref_: None,
1036            rev: None,
1037            ref_location: RefLocation::PathComponent,
1038        });
1039
1040        let result = FlakeRefType::parse_type(uri).unwrap();
1041        assert_eq!(expected, result);
1042    }
1043
1044    #[test]
1045    fn plain_https_file_autodetect() {
1046        let uri = "https://example.com/file.txt";
1047        let expected = FlakeRefType::Resource(ResourceUrl {
1048            res_type: ResourceType::File,
1049            location: "example.com/file.txt".to_string(),
1050            transport_type: Some(TransportLayer::Https),
1051            ref_: None,
1052            rev: None,
1053            ref_location: RefLocation::PathComponent,
1054        });
1055
1056        let result = FlakeRefType::parse_type(uri).unwrap();
1057        assert_eq!(expected, result);
1058    }
1059
1060    #[test]
1061    fn plain_http_tarball_autodetect() {
1062        let uri = "http://example.com/archive.zip";
1063        let expected = FlakeRefType::Resource(ResourceUrl {
1064            res_type: ResourceType::Tarball,
1065            location: "example.com/archive.zip".to_string(),
1066            transport_type: Some(TransportLayer::Http),
1067            ref_: None,
1068            rev: None,
1069            ref_location: RefLocation::PathComponent,
1070        });
1071
1072        let result = FlakeRefType::parse_type(uri).unwrap();
1073        assert_eq!(expected, result);
1074    }
1075
1076    #[test]
1077    fn plain_http_file_autodetect() {
1078        let uri = "http://example.com/README.md";
1079        let expected = FlakeRefType::Resource(ResourceUrl {
1080            res_type: ResourceType::File,
1081            location: "example.com/README.md".to_string(),
1082            transport_type: Some(TransportLayer::Http),
1083            ref_: None,
1084            rev: None,
1085            ref_location: RefLocation::PathComponent,
1086        });
1087
1088        let result = FlakeRefType::parse_type(uri).unwrap();
1089        assert_eq!(expected, result);
1090    }
1091
1092    #[test]
1093    fn different_tarball_extensions() {
1094        let test_cases = vec![
1095            "https://example.com/file.tar.gz",
1096            "https://example.com/file.tar.bz2",
1097            "https://example.com/file.tar.xz",
1098            "https://example.com/file.tgz",
1099            "https://example.com/file.zip",
1100        ];
1101
1102        for uri in test_cases {
1103            let result = FlakeRefType::parse_type(uri).unwrap();
1104            match result {
1105                FlakeRefType::Resource(ResourceUrl {
1106                    res_type: ResourceType::Tarball,
1107                    ..
1108                }) => {
1109                    // Expected.
1110                }
1111                _ => panic!("Expected tarball for URI: {}", uri),
1112            }
1113        }
1114    }
1115
1116    #[test]
1117    fn case_sensitive_extensions() {
1118        let uri_lowercase = "https://example.com/file.tar.gz";
1119        let result_lowercase = FlakeRefType::parse_type(uri_lowercase).unwrap();
1120        match result_lowercase {
1121            FlakeRefType::Resource(ResourceUrl {
1122                res_type: ResourceType::Tarball,
1123                ..
1124            }) => {
1125                // Expected.
1126            }
1127            _ => panic!("Expected tarball for lowercase extension"),
1128        }
1129
1130        // Uppercase extension should be treated as File, not Tarball.
1131        let uri_uppercase = "https://example.com/file.TAR.GZ";
1132        let result_uppercase = FlakeRefType::parse_type(uri_uppercase).unwrap();
1133        match result_uppercase {
1134            FlakeRefType::Resource(ResourceUrl {
1135                res_type: ResourceType::File,
1136                ..
1137            }) => {
1138                // Expected.
1139            }
1140            _ => panic!("Expected file for uppercase extension"),
1141        }
1142    }
1143}
1144
1145#[cfg(test)]
1146mod inc_parse_flake_id {
1147    use super::*;
1148
1149    #[test]
1150    fn flake_explicit_scheme_simple() {
1151        let uri = "flake:nixpkgs";
1152        let expected = FlakeRefType::Indirect {
1153            id: "nixpkgs".to_string(),
1154            ref_: None,
1155            rev: None,
1156            location: RefLocation::PathComponent,
1157        };
1158
1159        let result = FlakeRefType::parse_type(uri).unwrap();
1160        assert_eq!(expected, result);
1161    }
1162
1163    #[test]
1164    fn flake_explicit_scheme_with_ref() {
1165        let uri = "flake:nixpkgs/release-23.05";
1166        let expected = FlakeRefType::Indirect {
1167            id: "nixpkgs".to_string(),
1168            ref_: Some("release-23.05".to_string()),
1169            rev: None,
1170            location: RefLocation::PathComponent,
1171        };
1172
1173        let result = FlakeRefType::parse_type(uri).unwrap();
1174        assert_eq!(expected, result);
1175    }
1176
1177    #[test]
1178    fn flake_explicit_scheme_with_hyphens() {
1179        let uri = "flake:my-flake";
1180        let expected = FlakeRefType::Indirect {
1181            id: "my-flake".to_string(),
1182            ref_: None,
1183            rev: None,
1184            location: RefLocation::PathComponent,
1185        };
1186
1187        let result = FlakeRefType::parse_type(uri).unwrap();
1188        assert_eq!(expected, result);
1189    }
1190
1191    #[test]
1192    fn flake_explicit_scheme_with_underscores() {
1193        let uri = "flake:my_flake";
1194        let expected = FlakeRefType::Indirect {
1195            id: "my_flake".to_string(),
1196            ref_: None,
1197            rev: None,
1198            location: RefLocation::PathComponent,
1199        };
1200
1201        let result = FlakeRefType::parse_type(uri).unwrap();
1202        assert_eq!(expected, result);
1203    }
1204
1205    #[test]
1206    fn flake_explicit_scheme_invalid_start_with_number() {
1207        let uri = "flake:123invalid";
1208        let result = FlakeRefType::parse_type(uri);
1209        assert!(result.is_err());
1210    }
1211
1212    #[test]
1213    fn flake_explicit_scheme_empty() {
1214        let uri = "flake:";
1215        let result = FlakeRefType::parse_type(uri);
1216        assert!(result.is_err());
1217    }
1218
1219    #[test]
1220    fn flake_explicit_scheme_invalid_characters() {
1221        let uri = "flake:invalid!";
1222        let result = FlakeRefType::parse_type(uri);
1223        assert!(result.is_err());
1224    }
1225
1226    #[test]
1227    fn simple_flake_id() {
1228        let uri = "simple-flake";
1229        let expected = FlakeRefType::Indirect {
1230            id: "simple-flake".to_string(),
1231            ref_: None,
1232            rev: None,
1233            location: RefLocation::PathComponent,
1234        };
1235
1236        let result = FlakeRefType::parse_type(uri).unwrap();
1237        assert_eq!(expected, result);
1238    }
1239
1240    #[test]
1241    fn flake_id_with_underscores() {
1242        let uri = "flake_with_underscores";
1243        let expected = FlakeRefType::Indirect {
1244            id: "flake_with_underscores".to_string(),
1245            ref_: None,
1246            rev: None,
1247            location: RefLocation::PathComponent,
1248        };
1249
1250        let result = FlakeRefType::parse_type(uri).unwrap();
1251        assert_eq!(expected, result);
1252    }
1253
1254    #[test]
1255    fn bare_flake_id_with_numbers() {
1256        let uri = "nixpkgs23";
1257        let expected = FlakeRefType::Indirect {
1258            id: "nixpkgs23".to_string(),
1259            ref_: None,
1260            rev: None,
1261            location: RefLocation::PathComponent,
1262        };
1263
1264        let result = FlakeRefType::parse_type(uri).unwrap();
1265        assert_eq!(expected, result);
1266
1267        // Test via parse() method too.
1268        let result = FlakeRefType::parse_type(uri).unwrap();
1269        assert_eq!(expected, result);
1270    }
1271
1272    #[test]
1273    fn bare_flake_id_edge_cases() {
1274        // Test with too many slashes - should fail as indirect, no fallback should work.
1275        let uri = "my-flake/branch/deep/reference";
1276        // This should fail because it has too many slashes - only id/ref is allowed for bare IDs.
1277        let result = FlakeRefType::parse_type(uri);
1278        assert!(
1279            result.is_err(),
1280            "Multi-slash URIs should fail when not matching any scheme"
1281        );
1282
1283        // Test single character ID.
1284        let uri = "a";
1285        let expected = FlakeRefType::Indirect {
1286            id: "a".to_string(),
1287            ref_: None,
1288            rev: None,
1289            location: RefLocation::PathComponent,
1290        };
1291        let result = FlakeRefType::parse_type(uri).unwrap();
1292        assert_eq!(expected, result);
1293    }
1294
1295    #[test]
1296    fn flake_scheme_validation_edge_cases() {
1297        // Empty ID after flake:.
1298        let uri = "flake:";
1299        let result = FlakeRefType::parse_type(uri);
1300        assert!(result.is_err());
1301
1302        // ID starting with number.
1303        let uri = "flake:123invalid";
1304        let result = FlakeRefType::parse_type(uri);
1305        assert!(result.is_err());
1306
1307        // ID with invalid characters.
1308        let uri = "flake:invalid!";
1309        let result = FlakeRefType::parse_type(uri);
1310        assert!(result.is_err());
1311
1312        // Very long but valid ID.
1313        let uri = "flake:very-long-flake-name-with-many-dashes-and_underscores_123";
1314        let expected = FlakeRefType::Indirect {
1315            id: "very-long-flake-name-with-many-dashes-and_underscores_123".to_string(),
1316            ref_: None,
1317            rev: None,
1318            location: RefLocation::PathComponent,
1319        };
1320        let result = FlakeRefType::parse_type(uri).unwrap();
1321        assert_eq!(expected, result);
1322    }
1323
1324    #[test]
1325    fn protocol_collision_edge_cases() {
1326        // Ensure git:// doesn't collide with github:.
1327        let git_uri = "git://example.com/repo.git";
1328        let github_uri = "github:user/repo";
1329
1330        let git_result = FlakeRefType::parse_type(git_uri).unwrap();
1331        let github_result = FlakeRefType::parse_type(github_uri).unwrap();
1332
1333        match git_result {
1334            FlakeRefType::Resource(ResourceUrl {
1335                res_type: ResourceType::Git,
1336                ..
1337            }) => {
1338                // Expected.
1339            }
1340            _ => panic!("Expected git resource for git:// URL"),
1341        }
1342
1343        match github_result {
1344            FlakeRefType::GitForge(_) => {
1345                // Expected.
1346            }
1347            _ => panic!("Expected git forge for github: URL"),
1348        }
1349    }
1350
1351    #[test]
1352    fn http_https_autodetection_edge_cases() {
1353        let test_cases = vec![
1354            // Valid tarball extensions.
1355            ("https://example.com/file.tar.gz", ResourceType::Tarball),
1356            ("https://example.com/file.tar.bz2", ResourceType::Tarball),
1357            ("https://example.com/file.tar.xz", ResourceType::Tarball),
1358            ("https://example.com/file.tar.zst", ResourceType::Tarball),
1359            ("https://example.com/file.tgz", ResourceType::Tarball),
1360            ("https://example.com/file.zip", ResourceType::Tarball),
1361            ("https://example.com/file.tar", ResourceType::Tarball),
1362            // Extensions that are NOT tarball (bare compression formats).
1363            ("https://example.com/file.gz", ResourceType::File),
1364            ("https://example.com/file.bz2", ResourceType::File),
1365            ("https://example.com/file.xz", ResourceType::File),
1366            // Other file types.
1367            ("https://example.com/file.txt", ResourceType::File),
1368            ("https://example.com/README.md", ResourceType::File),
1369            ("https://example.com/file", ResourceType::File), // No extension.
1370        ];
1371
1372        for (uri, expected_type) in test_cases {
1373            let result = FlakeRefType::parse_type(uri).unwrap();
1374            match result {
1375                FlakeRefType::Resource(ResourceUrl { res_type, .. }) => {
1376                    assert_eq!(expected_type, res_type, "Failed for URI: {}", uri);
1377                }
1378                _ => panic!("Expected resource for URI: {}", uri),
1379            }
1380        }
1381    }
1382
1383    #[test]
1384    fn transport_scheme_combinations() {
1385        // Test all transport combinations work.
1386        let test_cases = vec![
1387            (
1388                "git+https://example.com/repo.git",
1389                ResourceType::Git,
1390                Some(TransportLayer::Https),
1391            ),
1392            (
1393                "git+http://example.com/repo.git",
1394                ResourceType::Git,
1395                Some(TransportLayer::Http),
1396            ),
1397            (
1398                "git+file://path/to/repo",
1399                ResourceType::Git,
1400                Some(TransportLayer::File),
1401            ),
1402            (
1403                "hg+https://example.com/repo",
1404                ResourceType::Mercurial,
1405                Some(TransportLayer::Https),
1406            ),
1407            (
1408                "hg+http://example.com/repo",
1409                ResourceType::Mercurial,
1410                Some(TransportLayer::Http),
1411            ),
1412            (
1413                "hg+file://path/to/repo",
1414                ResourceType::Mercurial,
1415                Some(TransportLayer::File),
1416            ),
1417            (
1418                "tarball+https://example.com/file.tar.gz",
1419                ResourceType::Tarball,
1420                Some(TransportLayer::Https),
1421            ),
1422            (
1423                "tarball+http://example.com/file.zip",
1424                ResourceType::Tarball,
1425                Some(TransportLayer::Http),
1426            ),
1427            (
1428                "file+https://example.com/file.txt",
1429                ResourceType::File,
1430                Some(TransportLayer::Https),
1431            ),
1432            (
1433                "file+http://example.com/file.txt",
1434                ResourceType::File,
1435                Some(TransportLayer::Http),
1436            ),
1437        ];
1438
1439        for (uri, expected_res_type, expected_transport) in test_cases {
1440            let result = FlakeRefType::parse_type(uri).unwrap();
1441            match result {
1442                FlakeRefType::Resource(ResourceUrl {
1443                    res_type,
1444                    transport_type,
1445                    ..
1446                }) => {
1447                    assert_eq!(
1448                        expected_res_type, res_type,
1449                        "Resource type mismatch for: {}",
1450                        uri
1451                    );
1452                    assert_eq!(
1453                        expected_transport, transport_type,
1454                        "Transport type mismatch for: {}",
1455                        uri
1456                    );
1457                }
1458                _ => panic!("Expected resource for URI: {}", uri),
1459            }
1460        }
1461    }
1462
1463    #[test]
1464    fn relative_path_edge_cases() {
1465        let test_cases = vec![
1466            "./",
1467            "../",
1468            "./path",
1469            "../path",
1470            "./path/to/flake",
1471            "../path/to/flake",
1472            "../../deeply/nested/path",
1473        ];
1474
1475        for uri in test_cases {
1476            let result = FlakeRefType::parse_type(uri).unwrap();
1477            match result {
1478                FlakeRefType::Path { path, rev } => {
1479                    assert_eq!(uri, path, "Path should match input for: {}", uri);
1480                    assert_eq!(rev, None, "rev should be None for plain path");
1481                }
1482                _ => panic!("Expected path for URI: {}", uri),
1483            }
1484        }
1485    }
1486
1487    #[test]
1488    fn flake_id_boundary_cases() {
1489        // Single character flake ID.
1490        let uri = "a";
1491        let result = FlakeRefType::parse_type(uri).unwrap();
1492        assert_eq!(
1493            result,
1494            FlakeRefType::Indirect {
1495                id: "a".to_string(),
1496                ref_: None,
1497                rev: None,
1498                location: RefLocation::PathComponent,
1499            }
1500        );
1501
1502        // Flake ID with maximum allowed characters.
1503        let uri = "abcDEF123-_";
1504        let result = FlakeRefType::parse_type(uri).unwrap();
1505        assert_eq!(
1506            result,
1507            FlakeRefType::Indirect {
1508                id: "abcDEF123-_".to_string(),
1509                ref_: None,
1510                rev: None,
1511                location: RefLocation::PathComponent,
1512            }
1513        );
1514    }
1515}
1516
1517#[cfg(test)]
1518mod inc_parse_errors {
1519    use super::*;
1520
1521    #[test]
1522    fn error_unsupported_scheme() {
1523        let uri = "unsupported://example.com";
1524        let result = FlakeRefType::parse_type(uri);
1525        assert!(result.is_err());
1526    }
1527
1528    #[test]
1529    fn error_malformed_url() {
1530        let uri = "://invalid";
1531        let result = FlakeRefType::parse_type(uri);
1532        assert!(result.is_err());
1533    }
1534
1535    #[test]
1536    fn path_with_invalid_characters() {
1537        let uri = "/path/with[brackets]";
1538        let result = FlakeRefType::parse_type(uri);
1539        assert!(result.is_err());
1540    }
1541
1542    #[test]
1543    fn path_with_query_fragment() {
1544        let uri = "/path/with?query#fragment";
1545        let result = FlakeRefType::parse_type(uri);
1546        assert!(result.is_err());
1547    }
1548
1549    #[test]
1550    fn file_extension_edge_cases() {
1551        // File without extension should be treated as File, not Tarball.
1552        let uri = "https://example.com/README";
1553        let result = FlakeRefType::parse_type(uri).unwrap();
1554        match result {
1555            FlakeRefType::Resource(ResourceUrl {
1556                res_type: ResourceType::File,
1557                ..
1558            }) => {
1559                // Expected.
1560            }
1561            _ => panic!("Expected file resource for extensionless file"),
1562        }
1563    }
1564
1565    #[test]
1566    fn url_with_port() {
1567        let uri = "https://example.com:8080/file.tar.gz";
1568        let result = FlakeRefType::parse_type(uri).unwrap();
1569        match result {
1570            FlakeRefType::Resource(ResourceUrl {
1571                res_type: ResourceType::Tarball,
1572                location,
1573                transport_type: Some(TransportLayer::Https),
1574                ..
1575            }) => {
1576                assert_eq!(location, "example.com:8080/file.tar.gz");
1577            }
1578            _ => panic!("Expected tarball resource with HTTPS transport"),
1579        }
1580    }
1581
1582    #[test]
1583    fn mixed_case_domain() {
1584        let uri = "https://Example.COM/file.tar.gz";
1585        let result = FlakeRefType::parse_type(uri).unwrap();
1586        match result {
1587            FlakeRefType::Resource(ResourceUrl { location, .. }) => {
1588                assert_eq!(location, "Example.COM/file.tar.gz");
1589            }
1590            _ => panic!("Expected resource"),
1591        }
1592    }
1593
1594    #[test]
1595    fn very_long_url() {
1596        let long_path = "a".repeat(1000);
1597        let uri = format!("https://example.com/{}.tar.gz", long_path);
1598        let result = FlakeRefType::parse_type(&uri);
1599
1600        // Should parse successfully even with very long URLs.
1601        assert!(result.is_ok());
1602    }
1603
1604    #[test]
1605    fn transport_scheme_combinations() {
1606        // All valid combinations for tarball.
1607        let valid_tarballs = vec![
1608            "tarball+https://example.com/file.tar.gz",
1609            "tarball+http://example.com/file.tar.gz",
1610            "tarball+file:///path/to/file.tar.gz",
1611        ];
1612
1613        for uri in valid_tarballs {
1614            let result = FlakeRefType::parse_type(uri);
1615            assert!(result.is_ok(), "Failed to parse valid tarball URI: {}", uri);
1616        }
1617
1618        // All valid combinations for file.
1619        let valid_files = vec![
1620            "file+https://example.com/file.txt",
1621            "file+http://example.com/file.txt",
1622            "file+file:///path/to/file.txt",
1623        ];
1624
1625        for uri in valid_files {
1626            let result = FlakeRefType::parse_type(uri);
1627            assert!(result.is_ok(), "Failed to parse valid file URI: {}", uri);
1628        }
1629    }
1630
1631    #[test]
1632    fn real_world_github_archive() {
1633        let uri = "https://github.com/user/repo/archive/main.tar.gz";
1634        let expected = FlakeRefType::Resource(ResourceUrl {
1635            res_type: ResourceType::Tarball,
1636            location: "github.com/user/repo/archive/main.tar.gz".to_string(),
1637            transport_type: Some(TransportLayer::Https),
1638            ref_: None,
1639            rev: None,
1640            ref_location: RefLocation::PathComponent,
1641        });
1642
1643        let result = FlakeRefType::parse_type(uri).unwrap();
1644        assert_eq!(expected, result);
1645    }
1646}
1647
1648#[cfg(test)]
1649mod inc_parse_file {
1650    use super::*;
1651
1652    #[test]
1653    fn path_leader() {
1654        let uri = "path:/foo/bar";
1655        let expected_refpath = FlakeRefType::Path {
1656            path: "/foo/bar".to_string(),
1657            rev: None,
1658        };
1659
1660        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
1661
1662        assert_eq!(expected_refpath, parsed_refpath);
1663    }
1664
1665    #[test]
1666    fn naked_abs() {
1667        let uri = "/foo/bar";
1668        let expected_refpath = FlakeRefType::Path {
1669            path: "/foo/bar".to_string(),
1670            rev: None,
1671        };
1672
1673        let parsed_refpath = FlakeRefType::parse_type(uri).unwrap();
1674
1675        assert_eq!(expected_refpath, parsed_refpath);
1676    }
1677
1678    #[test]
1679    fn relative_path_current_dir() {
1680        let uri = ".";
1681        let expected = FlakeRefType::Path {
1682            path: ".".to_string(),
1683            rev: None,
1684        };
1685
1686        let result = FlakeRefType::parse_type(uri).unwrap();
1687        assert_eq!(expected, result);
1688    }
1689
1690    #[test]
1691    fn relative_path_parent_dir() {
1692        let uri = "..";
1693        let expected = FlakeRefType::Path {
1694            path: "..".to_string(),
1695            rev: None,
1696        };
1697
1698        let result = FlakeRefType::parse_type(uri).unwrap();
1699        assert_eq!(expected, result);
1700    }
1701
1702    #[test]
1703    fn relative_path_current_subdir() {
1704        let uri = "./relative/path";
1705        let expected = FlakeRefType::Path {
1706            path: "./relative/path".to_string(),
1707            rev: None,
1708        };
1709
1710        let result = FlakeRefType::parse_type(uri).unwrap();
1711        assert_eq!(expected, result);
1712    }
1713
1714    #[test]
1715    fn relative_path_parent_subdir() {
1716        let uri = "../parent/path";
1717        let expected = FlakeRefType::Path {
1718            path: "../parent/path".to_string(),
1719            rev: None,
1720        };
1721
1722        let result = FlakeRefType::parse_type(uri).unwrap();
1723        assert_eq!(expected, result);
1724    }
1725
1726    #[test]
1727    fn complex_path_with_dots() {
1728        let uri = "./path/with/../../complex/structure";
1729        let expected = FlakeRefType::Path {
1730            path: "./path/with/../../complex/structure".to_string(),
1731            rev: None,
1732        };
1733
1734        let result = FlakeRefType::parse_type(uri).unwrap();
1735        assert_eq!(expected, result);
1736    }
1737
1738    #[test]
1739    fn naked_cwd() {
1740        let uri = "./foo/bar";
1741        let expected_refpath = FlakeRefType::Path {
1742            path: "./foo/bar".to_string(),
1743            rev: None,
1744        };
1745
1746        let (_rest, parsed_refpath) = FlakeRefType::parse_file.parse_peek(uri).unwrap();
1747
1748        assert_eq!(expected_refpath, parsed_refpath);
1749    }
1750
1751    #[test]
1752    fn http_layer() {
1753        let uri = "file+http://example.com/file.txt";
1754        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
1755            res_type: ResourceType::File,
1756            location: "example.com/file.txt".to_string(),
1757            transport_type: Some(TransportLayer::Http),
1758            ref_: None,
1759            rev: None,
1760            ref_location: RefLocation::PathComponent,
1761        });
1762
1763        let (_rest, parsed_refpath) = FlakeRefType::parse_file.parse_peek(uri).unwrap();
1764
1765        assert_eq!(expected_refpath, parsed_refpath);
1766    }
1767
1768    #[test]
1769    fn https_layer() {
1770        let uri = "file+https://example.com/file.txt";
1771        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
1772            res_type: ResourceType::File,
1773            location: "example.com/file.txt".to_string(),
1774            transport_type: Some(TransportLayer::Https),
1775            ref_: None,
1776            rev: None,
1777            ref_location: RefLocation::PathComponent,
1778        });
1779
1780        let (_rest, parsed_refpath) = FlakeRefType::parse_file.parse_peek(uri).unwrap();
1781
1782        assert_eq!(expected_refpath, parsed_refpath);
1783    }
1784
1785    #[test]
1786    fn file_layer() {
1787        let uri = "file+file:///foo/bar";
1788        let expected_refpath = FlakeRefType::Resource(ResourceUrl {
1789            res_type: ResourceType::File,
1790            location: "/foo/bar".to_string(),
1791            transport_type: None,
1792            ref_: None,
1793            rev: None,
1794            ref_location: RefLocation::PathComponent,
1795        });
1796
1797        let (_rest, parsed_refpath) = FlakeRefType::parse_file.parse_peek(uri).unwrap();
1798
1799        assert_eq!(expected_refpath, parsed_refpath);
1800    }
1801
1802    #[test]
1803    fn file_then_path() {
1804        let path_uri = "file:///wheres/wally";
1805        let path_uri2 = "file:///wheres/wally/";
1806
1807        let mut expected_ref = ResourceUrl {
1808            res_type: ResourceType::File,
1809            location: "/wheres/wally".to_string(),
1810            transport_type: None,
1811            ref_: None,
1812            rev: None,
1813            ref_location: RefLocation::PathComponent,
1814        };
1815
1816        let (_rest, parsed_ref) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1817        let (_rest, parsed_ref2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1818
1819        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_ref);
1820        expected_ref.location = "/wheres/wally/".to_string();
1821        assert_eq!(FlakeRefType::Resource(expected_ref), parsed_ref2);
1822    }
1823
1824    #[test]
1825    fn empty_param_term() {
1826        let path_uri = "file:///wheres/wally?";
1827        let path_uri2 = "file:///wheres/wally/?";
1828
1829        let mut expected_ref = ResourceUrl {
1830            res_type: ResourceType::File,
1831            location: "/wheres/wally".to_string(),
1832            transport_type: None,
1833            ref_: None,
1834            rev: None,
1835            ref_location: RefLocation::PathComponent,
1836        };
1837
1838        let (rest, parsed_file) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1839        assert_eq!(rest, "?");
1840        let (rest, parsed_file2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1841
1842        assert_eq!(rest, "?");
1843        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file);
1844        expected_ref.location = "/wheres/wally/".to_string();
1845        assert_eq!(FlakeRefType::Resource(expected_ref), parsed_file2);
1846    }
1847
1848    #[test]
1849    fn param_term() {
1850        let path_uri = "file:///wheres/wally?foo=bar#fizz";
1851        let path_uri2 = "file:///wheres/wally/?foo=bar#fizz";
1852
1853        let (rest, parsed_file) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1854        assert_eq!(rest, "?foo=bar#fizz");
1855        let (rest, parsed_file2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1856        assert_eq!(rest, "?foo=bar#fizz");
1857
1858        let mut expected_ref = ResourceUrl {
1859            res_type: ResourceType::File,
1860            location: "/wheres/wally".to_string(),
1861            transport_type: None,
1862            ref_: None,
1863            rev: None,
1864            ref_location: RefLocation::PathComponent,
1865        };
1866        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file);
1867        expected_ref.location = "/wheres/wally/".to_string();
1868        assert_eq!(FlakeRefType::Resource(expected_ref), parsed_file2);
1869    }
1870
1871    #[test]
1872    fn empty_param_attr_term() {
1873        let path_uri = "file:///wheres/wally?#";
1874        let path_uri2 = "file:///wheres/wally/?#";
1875
1876        let (rest, parsed_file) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1877        assert_eq!(rest, "?#");
1878        let (rest, parsed_file2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1879        assert_eq!(rest, "?#");
1880
1881        let mut expected_ref = ResourceUrl {
1882            res_type: ResourceType::File,
1883            location: "/wheres/wally".to_string(),
1884            transport_type: None,
1885            ref_: None,
1886            rev: None,
1887            ref_location: RefLocation::PathComponent,
1888        };
1889        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file);
1890        expected_ref.location = "/wheres/wally/".to_string();
1891        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file2);
1892
1893        let path_uri = "file:///wheres/wally#?";
1894        let path_uri2 = "file:///wheres/wally/#?";
1895
1896        let (rest, parsed_file) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1897        assert_eq!(rest, "#?");
1898        let (rest, parsed_file2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1899        assert_eq!(rest, "#?");
1900
1901        expected_ref.location = "/wheres/wally".to_string();
1902        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file);
1903        expected_ref.location = "/wheres/wally/".to_string();
1904        assert_eq!(FlakeRefType::Resource(expected_ref), parsed_file2);
1905    }
1906
1907    #[test]
1908    fn attr_term() {
1909        let path_uri = "file:///wheres/wally#";
1910        let path_uri2 = "file:///wheres/wally/#";
1911
1912        let (rest, parsed_file) = FlakeRefType::parse_file.parse_peek(path_uri).unwrap();
1913        assert_eq!(rest, "#");
1914        let (rest, parsed_file2) = FlakeRefType::parse_file.parse_peek(path_uri2).unwrap();
1915        assert_eq!(rest, "#");
1916
1917        let mut expected_ref = ResourceUrl {
1918            res_type: ResourceType::File,
1919            location: "/wheres/wally".to_string(),
1920            transport_type: None,
1921            ref_: None,
1922            rev: None,
1923            ref_location: RefLocation::PathComponent,
1924        };
1925        assert_eq!(FlakeRefType::Resource(expected_ref.clone()), parsed_file);
1926        expected_ref.location = "/wheres/wally/".to_string();
1927        assert_eq!(FlakeRefType::Resource(expected_ref), parsed_file2);
1928        assert_eq!(rest, "#");
1929    }
1930}
1931
1932#[cfg(test)]
1933mod resource_type_methods {
1934    use crate::FlakeRef;
1935    use rstest::rstest;
1936
1937    #[rstest]
1938    #[case("git+https://github.com/owner/repo", "github.com", "owner", "repo")]
1939    #[case(
1940        "git+https://git.clan.lol/kenji/test-release",
1941        "git.clan.lol",
1942        "kenji",
1943        "test-release"
1944    )]
1945    #[case(
1946        "git+https://codeberg.org/forgejo/forgejo",
1947        "codeberg.org",
1948        "forgejo",
1949        "forgejo"
1950    )]
1951    #[case("git+https://gitlab.com/user/project", "gitlab.com", "user", "project")]
1952    #[case("git+http://example.com/org/myrepo", "example.com", "org", "myrepo")]
1953    fn test_resource_git_url_extraction(
1954        #[case] url: &str,
1955        #[case] expected_domain: &str,
1956        #[case] expected_owner: &str,
1957        #[case] expected_repo: &str,
1958    ) {
1959        let parsed: FlakeRef = url.parse().unwrap();
1960
1961        assert_eq!(
1962            parsed.domain(),
1963            Some(expected_domain),
1964            "Domain mismatch for {}",
1965            url
1966        );
1967        assert_eq!(
1968            parsed.owner(),
1969            Some(expected_owner),
1970            "Owner mismatch for {}",
1971            url
1972        );
1973        assert_eq!(
1974            parsed.repo(),
1975            Some(expected_repo),
1976            "Repo mismatch for {}",
1977            url
1978        );
1979        assert_eq!(parsed.id(), Some(expected_repo), "ID mismatch for {}", url);
1980    }
1981
1982    #[rstest]
1983    #[case("git+https://github.com/owner/repo.git", "repo")]
1984    #[case("git+https://git.clan.lol/kenji/test-release.git", "test-release")]
1985    fn test_resource_git_url_with_git_suffix(#[case] url: &str, #[case] expected_repo: &str) {
1986        let parsed: FlakeRef = url.parse().unwrap();
1987
1988        assert_eq!(
1989            parsed.repo(),
1990            Some(expected_repo),
1991            ".git suffix should be stripped"
1992        );
1993        assert_eq!(
1994            parsed.id(),
1995            Some(expected_repo),
1996            ".git suffix should be stripped from ID"
1997        );
1998    }
1999
2000    #[rstest]
2001    #[case("github:nixos/nixpkgs", "github.com", "nixos", "nixpkgs")]
2002    #[case("gitlab:owner/repo", "gitlab.com", "owner", "repo")]
2003    #[case("sourcehut:user/project", "git.sr.ht", "user", "project")]
2004    fn test_gitforge_domain_extraction(
2005        #[case] url: &str,
2006        #[case] expected_domain: &str,
2007        #[case] expected_owner: &str,
2008        #[case] expected_repo: &str,
2009    ) {
2010        let parsed: FlakeRef = url.parse().unwrap();
2011
2012        assert_eq!(
2013            parsed.domain(),
2014            Some(expected_domain),
2015            "Domain mismatch for {}",
2016            url
2017        );
2018        assert_eq!(
2019            parsed.owner(),
2020            Some(expected_owner),
2021            "Owner mismatch for {}",
2022            url
2023        );
2024        assert_eq!(
2025            parsed.repo(),
2026            Some(expected_repo),
2027            "Repo mismatch for {}",
2028            url
2029        );
2030    }
2031
2032    #[rstest]
2033    #[case("path:/foo/bar")]
2034    #[case("/foo/bar")]
2035    #[case("./relative/path")]
2036    #[case("flake:nixpkgs")]
2037    #[case("https://example.com/file.tar.gz")]
2038    fn test_non_git_resource_returns_none(#[case] url: &str) {
2039        let parsed: FlakeRef = url.parse().unwrap();
2040
2041        assert_eq!(
2042            parsed.domain(),
2043            None,
2044            "Non-git resources should return None for domain"
2045        );
2046        assert_eq!(
2047            parsed.owner(),
2048            None,
2049            "Non-git resources should return None for owner"
2050        );
2051        assert_eq!(
2052            parsed.repo(),
2053            None,
2054            "Non-git resources should return None for repo"
2055        );
2056    }
2057
2058    #[rstest]
2059    #[case(
2060        "git+https://example.com/a/b",
2061        Some("example.com"),
2062        Some("a"),
2063        Some("b")
2064    )]
2065    #[case("git+https://x.y.z/org/repo", Some("x.y.z"), Some("org"), Some("repo"))]
2066    #[case("git+https://host/o/r.git", Some("host"), Some("o"), Some("r"))]
2067    fn test_resource_url_minimal_parsing(
2068        #[case] url: &str,
2069        #[case] expected_domain: Option<&str>,
2070        #[case] expected_owner: Option<&str>,
2071        #[case] expected_repo: Option<&str>,
2072    ) {
2073        let parsed: FlakeRef = url.parse().unwrap();
2074        assert_eq!(parsed.domain(), expected_domain);
2075        assert_eq!(parsed.owner(), expected_owner);
2076        assert_eq!(parsed.repo(), expected_repo);
2077    }
2078
2079    #[rstest]
2080    #[case("git+https://domain.com/owner")] // Missing repo.
2081    #[case("git+https://domain.com")] // Missing owner and repo.
2082    fn test_resource_url_insufficient_components_returns_none(#[case] url: &str) {
2083        let parsed: FlakeRef = url.parse().unwrap();
2084        // With insufficient path components, should return None.
2085        assert!(
2086            parsed.repo().is_none() || parsed.owner().is_none(),
2087            "URLs with insufficient path components should return None for missing parts"
2088        );
2089    }
2090
2091    #[rstest]
2092    #[case::git_https_default_port_returns_host_only("git+https://example.com/o/r", "example.com")]
2093    #[case::git_https_non_default_port_returns_host_with_port(
2094        "git+https://localhost:3000/o/r",
2095        "localhost:3000"
2096    )]
2097    #[case::git_https_explicit_default_port_strips(
2098        "git+https://example.com:443/o/r",
2099        "example.com"
2100    )]
2101    #[case::git_ssh_default_port_returns_host_only("git+ssh://example.com/o/r", "example.com")]
2102    #[case::git_ssh_non_default_port_returns_host_with_port(
2103        "git+ssh://example.com:2222/o/r",
2104        "example.com:2222"
2105    )]
2106    #[case::git_ssh_explicit_default_port_strips("git+ssh://example.com:22/o/r", "example.com")]
2107    #[case::git_http_non_default_port_returns_host_with_port(
2108        "git+http://example.com:8080/o/r",
2109        "example.com:8080"
2110    )]
2111    fn domain_retains_non_default_port(#[case] url: &str, #[case] expected: &str) {
2112        // Mirrors upstream HTTP-library `Authority` semantics: the public
2113        // `domain()` accessor reflects what a downstream fetcher must
2114        // target, so a non-default `:port` survives. `flake-edit` consumes
2115        // `domain()` directly as `api_host_for(domain)` input; without
2116        // this, self-hosted forges on non-default ports silently fetched
2117        // from the scheme's default port.
2118        let parsed: FlakeRef = url.parse().unwrap();
2119        assert_eq!(parsed.domain(), Some(expected), "domain mismatch for {url}");
2120    }
2121
2122    #[rstest]
2123    #[case("git+ssh://git@host:owner/repo", "host")]
2124    #[case("git+ssh://host:owner/repo", "host")]
2125    #[case("git+ssh://git@host/owner/repo", "host")]
2126    #[case("git+https://host/owner/repo", "host")]
2127    fn domain_strips_user_and_path(#[case] url: &str, #[case] expected_host: &str) {
2128        // Pre-fix `domain` did `location.split('/').next()`, which for an
2129        // SCP-style SSH location like `git@host:owner/repo` returned
2130        // `git@host:owner` rather than `host`. The fix strips any leading
2131        // `user@` and stops at the first `:` or `/`.
2132        let parsed: FlakeRef = url.parse().unwrap();
2133        assert_eq!(
2134            parsed.domain(),
2135            Some(expected_host),
2136            "domain mismatch for {url}"
2137        );
2138    }
2139}