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 Resource(ResourceUrl),
32 GitForge(GitForge),
35 Indirect {
46 id: String,
47 ref_: Option<String>,
48 rev: Option<String>,
49 location: RefLocation,
50 },
51 Path { path: String, rev: Option<String> },
70}
71
72impl 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 #[allow(dead_code)]
99 pub(crate) fn parse_file(input: &mut &str) -> ModalResult<Self> {
100 alt((
101 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 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 if input.starts_with('/')
436 || input.starts_with("./")
437 || input.starts_with("../")
438 || input == "."
439 || input == ".."
440 {
441 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 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 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 location
500 .split('/')
501 .nth(2)
502 .map(|s| s.strip_suffix(".git").unwrap_or(s))
503 }
504 _ => None,
505 }
506 }
507
508 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 location
520 .split('/')
521 .nth(2)
522 .map(|s| s.strip_suffix(".git").unwrap_or(s))
523 }
524 _ => None,
525 }
526 }
527
528 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 location.split('/').nth(1)
540 }
541 _ => None,
542 }
543 }
544
545 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 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 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 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 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 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
664const INDIRECT_MAX_SEGMENTS: usize = 3;
669
670fn 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 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 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 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 }
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 }
1127 _ => panic!("Expected tarball for lowercase extension"),
1128 }
1129
1130 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 }
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 let result = FlakeRefType::parse_type(uri).unwrap();
1269 assert_eq!(expected, result);
1270 }
1271
1272 #[test]
1273 fn bare_flake_id_edge_cases() {
1274 let uri = "my-flake/branch/deep/reference";
1276 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 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 let uri = "flake:";
1299 let result = FlakeRefType::parse_type(uri);
1300 assert!(result.is_err());
1301
1302 let uri = "flake:123invalid";
1304 let result = FlakeRefType::parse_type(uri);
1305 assert!(result.is_err());
1306
1307 let uri = "flake:invalid!";
1309 let result = FlakeRefType::parse_type(uri);
1310 assert!(result.is_err());
1311
1312 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 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 }
1340 _ => panic!("Expected git resource for git:// URL"),
1341 }
1342
1343 match github_result {
1344 FlakeRefType::GitForge(_) => {
1345 }
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 ("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 ("https://example.com/file.gz", ResourceType::File),
1364 ("https://example.com/file.bz2", ResourceType::File),
1365 ("https://example.com/file.xz", ResourceType::File),
1366 ("https://example.com/file.txt", ResourceType::File),
1368 ("https://example.com/README.md", ResourceType::File),
1369 ("https://example.com/file", ResourceType::File), ];
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 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 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 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 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 }
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 assert!(result.is_ok());
1602 }
1603
1604 #[test]
1605 fn transport_scheme_combinations() {
1606 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 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")] #[case("git+https://domain.com")] fn test_resource_url_insufficient_components_returns_none(#[case] url: &str) {
2083 let parsed: FlakeRef = url.parse().unwrap();
2084 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 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 let parsed: FlakeRef = url.parse().unwrap();
2133 assert_eq!(
2134 parsed.domain(),
2135 Some(expected_host),
2136 "domain mismatch for {url}"
2137 );
2138 }
2139}