1use std::{collections::HashMap, path::PathBuf};
6use url::Url;
7
8#[derive(Debug)]
9#[non_exhaustive]
10pub enum FlakeRef {
11 File {
12 last_modified: Option<u64>,
13 nar_hash: Option<String>,
14 rev: Option<String>,
15 rev_count: Option<u64>,
16 url: Url,
17 },
18 Git {
19 all_refs: bool,
20 export_ignore: bool,
21 keytype: Option<String>,
22 public_key: Option<String>,
23 public_keys: Option<Vec<String>>,
24 r#ref: Option<String>,
25 rev: Option<String>,
26 shallow: bool,
27 submodules: bool,
28 url: Url,
29 verify_commit: bool,
30 },
31 GitHub {
32 owner: String,
33 repo: String,
34 host: Option<String>,
35 keytype: Option<String>,
36 public_key: Option<String>,
37 public_keys: Option<Vec<String>>,
38 r#ref: Option<String>,
39 rev: Option<String>,
40 },
41 GitLab {
42 owner: String,
43 repo: String,
44 host: Option<String>,
45 keytype: Option<String>,
46 public_key: Option<String>,
47 public_keys: Option<Vec<String>>,
48 r#ref: Option<String>,
49 rev: Option<String>,
50 },
51 Indirect {
52 id: String,
53 r#ref: Option<String>,
54 rev: Option<String>,
55 },
56 Mercurial {
57 r#ref: Option<String>,
58 rev: Option<String>,
59 },
60 Path {
61 last_modified: Option<u64>,
62 nar_hash: Option<String>,
63 path: PathBuf,
64 rev: Option<String>,
65 rev_count: Option<u64>,
66 },
67 SourceHut {
68 owner: String,
69 repo: String,
70 host: Option<String>,
71 keytype: Option<String>,
72 public_key: Option<String>,
73 public_keys: Option<Vec<String>>,
74 r#ref: Option<String>,
75 rev: Option<String>,
76 },
77 Tarball {
78 last_modified: Option<u64>,
79 nar_hash: Option<String>,
80 rev: Option<String>,
81 rev_count: Option<u64>,
82 url: Url,
83 },
84}
85
86#[derive(Debug, Default)]
87pub struct FlakeRefOutput {
88 pub out_path: String,
89 pub nar_hash: String,
90 pub last_modified: Option<i64>,
91 pub last_modified_date: Option<String>,
92 pub rev_count: Option<i64>,
93 pub rev: Option<String>,
94 pub short_rev: Option<String>,
95 pub submodules: Option<bool>,
96}
97
98impl FlakeRefOutput {
99 pub fn into_kv_tuples(self) -> Vec<(String, String)> {
100 let mut vec = vec![
101 ("outPath".into(), self.out_path),
102 ("narHash".into(), self.nar_hash),
103 ];
104
105 if let Some(lm) = self.last_modified {
106 vec.push(("lastModified".into(), lm.to_string()));
107 }
108 if let Some(lmd) = self.last_modified_date {
109 vec.push(("lastModifiedDate".into(), lmd));
110 }
111 if let Some(rc) = self.rev_count {
112 vec.push(("revCount".into(), rc.to_string()));
113 }
114 if let Some(rev) = self.rev {
115 vec.push(("rev".into(), rev));
116 }
117 if let Some(sr) = self.short_rev {
118 vec.push(("shortRev".into(), sr));
119 }
120 if let Some(sub) = self.submodules {
121 vec.push(("submodules".into(), sub.to_string()));
122 }
123
124 vec
125 }
126}
127
128#[derive(Debug, thiserror::Error)]
129pub enum FlakeRefError {
130 #[error("failed to parse URL: {0}")]
131 UrlParseError(#[from] url::ParseError),
132 #[error("unsupported input type: {0}")]
133 UnsupportedType(String),
134}
135
136impl std::str::FromStr for FlakeRef {
138 type Err = FlakeRefError;
139
140 fn from_str(s: &str) -> Result<Self, Self::Err> {
141 let mut url = Url::parse(s)?;
143 let mut new_protocol = None;
144
145 let fetch_type = if let Some((type_part, protocol)) = url.scheme().split_once('+') {
147 new_protocol = Some(protocol.to_string());
148 match type_part {
149 "path" => FetchType::Path,
150 "file" => FetchType::File,
151 "tarball" => FetchType::Tarball,
152 "git" => FetchType::Git,
153 "github" => FetchType::GitHub,
154 "gitlab" => FetchType::GitLab,
155 "sourcehut" => FetchType::SourceHut,
156 "indirect" => FetchType::Indirect,
157 _ => return Err(FlakeRefError::UnsupportedType(type_part.to_string())),
158 }
159 } else {
160 match url.scheme() {
161 "path" => FetchType::Path,
163 "github" => FetchType::GitHub,
164 "gitlab" => FetchType::GitLab,
165 "sourcehut" => FetchType::SourceHut,
166 "git" => FetchType::Git,
167 _ if is_tarball_extension(url.path()) => FetchType::Tarball,
169 _ => FetchType::File,
171 }
172 };
173
174 if let Some(protocol) = new_protocol {
177 let mut url_str = url.to_string();
178 url_str.replace_range(..url.scheme().len(), &protocol);
179 url = Url::parse(&url_str)?;
180 }
181
182 let query_pairs = extract_query_pairs(&url);
184
185 Ok(match fetch_type {
187 FetchType::File => {
188 let params = extract_common_file_params(&query_pairs);
189 FlakeRef::File {
190 url,
191 nar_hash: params.nar_hash,
192 rev: params.rev,
193 rev_count: params.rev_count,
194 last_modified: params.last_modified,
195 }
196 }
197 FetchType::Tarball => {
198 let params = extract_common_file_params(&query_pairs);
199 FlakeRef::Tarball {
200 url,
201 nar_hash: params.nar_hash,
202 rev: params.rev,
203 rev_count: params.rev_count,
204 last_modified: params.last_modified,
205 }
206 }
207 FetchType::Indirect => FlakeRef::Indirect {
208 id: url.path().to_string(),
209 r#ref: query_pairs.get("ref").cloned(),
210 rev: query_pairs.get("rev").cloned(),
211 },
212 FetchType::Git => {
213 let params = extract_git_params(&query_pairs);
214 FlakeRef::Git {
215 url,
216 r#ref: params.r#ref,
217 rev: params.rev,
218 keytype: params.keytype,
219 public_key: params.public_key,
220 public_keys: params.public_keys,
221 shallow: params.shallow,
222 submodules: params.submodules,
223 export_ignore: params.export_ignore,
224 all_refs: params.all_refs,
225 verify_commit: params.verify_commit,
226 }
227 }
228 FetchType::Path => {
229 let params = extract_common_file_params(&query_pairs);
230 FlakeRef::Path {
231 path: PathBuf::from(url.path()),
232 rev: params.rev,
233 nar_hash: params.nar_hash,
234 rev_count: params.rev_count,
235 last_modified: params.last_modified,
236 }
237 }
238 FetchType::GitHub => {
239 create_repo_host_args(&url, &query_pairs, |params| FlakeRef::GitHub {
240 owner: params.owner,
241 repo: params.repo,
242 r#ref: params.r#ref,
243 rev: params.rev,
244 host: params.host,
245 keytype: params.keytype,
246 public_key: params.public_key,
247 public_keys: params.public_keys,
248 })?
249 }
250 FetchType::GitLab => {
251 create_repo_host_args(&url, &query_pairs, |params| FlakeRef::GitLab {
252 owner: params.owner,
253 repo: params.repo,
254 r#ref: params.r#ref,
255 rev: params.rev,
256 host: params.host,
257 keytype: params.keytype,
258 public_key: params.public_key,
259 public_keys: params.public_keys,
260 })?
261 }
262 FetchType::SourceHut => {
263 create_repo_host_args(&url, &query_pairs, |params| FlakeRef::SourceHut {
264 owner: params.owner,
265 repo: params.repo,
266 r#ref: params.r#ref,
267 rev: params.rev,
268 host: params.host,
269 keytype: params.keytype,
270 public_key: params.public_key,
271 public_keys: params.public_keys,
272 })?
273 }
274 })
275 }
276}
277
278#[derive(Debug, Default, Clone)]
280struct FileParams {
281 nar_hash: Option<String>,
282 rev: Option<String>,
283 rev_count: Option<u64>,
284 last_modified: Option<u64>,
285}
286
287#[derive(Debug, Default)]
288struct GitParams {
289 r#ref: Option<String>,
290 rev: Option<String>,
291 keytype: Option<String>,
292 public_key: Option<String>,
293 public_keys: Option<Vec<String>>,
294 submodules: bool,
295 shallow: bool,
296 export_ignore: bool,
297 all_refs: bool,
298 verify_commit: bool,
299}
300
301#[derive(Debug, Default)]
302struct RepoHostParams {
303 owner: String,
304 repo: String,
305 host: Option<String>,
306 r#ref: Option<String>,
307 rev: Option<String>,
308 keytype: Option<String>,
309 public_key: Option<String>,
310 public_keys: Option<Vec<String>>,
311}
312
313enum FetchType {
315 File,
316 Git,
317 GitHub,
318 GitLab,
319 Indirect,
320 Path,
321 SourceHut,
322 Tarball,
323}
324
325fn extract_query_pairs(url: &Url) -> HashMap<String, String> {
327 url.query_pairs()
328 .map(|(k, v)| (k.to_string(), v.to_string()))
329 .collect()
330}
331
332fn get_param(query_pairs: &HashMap<String, String>, key: &str) -> Option<u64> {
333 query_pairs.get(key).and_then(|s| s.parse().ok())
334}
335
336fn get_bool_param(query_pairs: &HashMap<String, String>, key: &str) -> bool {
337 query_pairs
338 .get(key)
339 .map(|v| v == "1" || v.to_lowercase() == "true")
340 .unwrap_or(false)
341}
342
343fn extract_common_file_params(query_pairs: &HashMap<String, String>) -> FileParams {
345 FileParams {
346 nar_hash: query_pairs.get("narHash").cloned(),
347 rev: query_pairs.get("rev").cloned(),
348 rev_count: get_param(query_pairs, "revCount"),
349 last_modified: get_param(query_pairs, "lastModified"),
350 }
351}
352
353fn extract_git_params(query_pairs: &HashMap<String, String>) -> GitParams {
354 GitParams {
355 r#ref: query_pairs.get("ref").cloned(),
356 rev: query_pairs.get("rev").cloned(),
357 keytype: query_pairs.get("keytype").cloned(),
358 public_key: query_pairs.get("publicKey").cloned(),
359 public_keys: query_pairs
360 .get("publicKeys")
361 .map(|s| s.split(',').map(String::from).collect()),
362 submodules: get_bool_param(query_pairs, "submodules"),
363 shallow: get_bool_param(query_pairs, "shallow"),
364 export_ignore: get_bool_param(query_pairs, "exportIgnore"),
365 all_refs: get_bool_param(query_pairs, "allRefs"),
366 verify_commit: get_bool_param(query_pairs, "verifyCommit"),
367 }
368}
369
370fn extract_repo_params(
371 url: &Url,
372 query_pairs: &HashMap<String, String>,
373) -> Result<RepoHostParams, FlakeRefError> {
374 let (owner, repo, path_ref) = parse_path_segments(url)?;
375
376 if path_ref.is_some() && query_pairs.contains_key("ref") {
378 return Err(FlakeRefError::UnsupportedType(
379 "URL contains multiple branch/tag names".to_string(),
380 ));
381 }
382
383 let r#ref = path_ref.or_else(|| query_pairs.get("ref").cloned());
384
385 Ok(RepoHostParams {
386 owner,
387 repo,
388 r#ref,
389 rev: query_pairs.get("rev").cloned(),
390 host: query_pairs.get("host").cloned(),
391 keytype: query_pairs.get("keytype").cloned(),
392 public_key: query_pairs.get("publicKey").cloned(),
393 public_keys: query_pairs
394 .get("publicKeys")
395 .map(|s| s.split(',').map(String::from).collect()),
396 })
397}
398
399fn parse_path_segments(url: &Url) -> Result<(String, String, Option<String>), FlakeRefError> {
401 let path_segments: Vec<&str> = url.path().trim_start_matches('/').splitn(3, '/').collect();
402
403 if path_segments.len() < 2 {
404 return Err(FlakeRefError::UnsupportedType(
405 "URLs must contain owner and repo".to_string(),
406 ));
407 }
408
409 Ok((
410 path_segments[0].to_string(),
411 path_segments[1].to_string(),
412 path_segments.get(2).map(|&s| s.to_string()),
413 ))
414}
415
416fn is_tarball_extension(path: &str) -> bool {
418 const TARBALL_EXTENSIONS: [&str; 7] = [
419 ".zip", ".tar", ".tgz", ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst",
420 ];
421
422 TARBALL_EXTENSIONS.iter().any(|ext| path.ends_with(ext))
423}
424
425fn create_repo_host_args<F>(
426 url: &Url,
427 query_pairs: &HashMap<String, String>,
428 creator: F,
429) -> Result<FlakeRef, FlakeRefError>
430where
431 F: FnOnce(RepoHostParams) -> FlakeRef,
432{
433 let params = extract_repo_params(url, query_pairs)?;
434 Ok(creator(params))
435}
436
437fn append_param<T: ToString>(url: &mut Url, key: &str, value: &Option<T>) {
439 if let Some(val) = value {
440 url.query_pairs_mut().append_pair(key, &val.to_string());
441 }
442}
443
444fn append_bool_param(url: &mut Url, key: &str, value: bool) {
445 if value {
446 url.query_pairs_mut().append_pair(key, "1");
447 }
448}
449
450fn append_params(url: &mut Url, params: &[(&str, Option<String>)]) {
451 for &(key, ref value) in params {
452 append_param(url, key, value);
453 }
454}
455
456fn append_public_keys_param(url: &mut Url, public_keys: &Option<Vec<String>>) {
457 if let Some(keys) = public_keys {
458 url.query_pairs_mut()
459 .append_pair("publicKeys", &keys.join(","));
460 }
461}
462
463fn append_common_file_params(url: &mut Url, params: &FileParams) {
464 append_params(
465 url,
466 &[
467 ("narHash", params.nar_hash.clone()),
468 ("rev", params.rev.clone()),
469 ],
470 );
471 append_param(url, "revCount", ¶ms.rev_count);
472 append_param(url, "lastModified", ¶ms.last_modified);
473}
474
475fn append_git_params(url: &mut Url, params: &GitParams) {
476 append_params(
477 url,
478 &[
479 ("ref", params.r#ref.clone()),
480 ("rev", params.rev.clone()),
481 ("keytype", params.keytype.clone()),
482 ("publicKey", params.public_key.clone()),
483 ],
484 );
485 append_public_keys_param(url, ¶ms.public_keys);
486 append_bool_param(url, "shallow", params.shallow);
487 append_bool_param(url, "submodules", params.submodules);
488 append_bool_param(url, "exportIgnore", params.export_ignore);
489 append_bool_param(url, "allRefs", params.all_refs);
490 append_bool_param(url, "verifyCommit", params.verify_commit);
491}
492
493fn append_repo_host_params(url: &mut Url, params: &RepoHostParams) {
494 append_params(
495 url,
496 &[
497 ("ref", params.r#ref.clone()),
498 ("rev", params.rev.clone()),
499 ("keytype", params.keytype.clone()),
500 ("publicKey", params.public_key.clone()),
501 ],
502 );
503 append_public_keys_param(url, ¶ms.public_keys);
504}
505
506impl FlakeRef {
508 pub fn to_uri(&self) -> Url {
509 match self {
510 FlakeRef::File {
511 url,
512 nar_hash,
513 rev,
514 rev_count,
515 last_modified,
516 } => {
517 let mut url = url.clone();
518 let params = FileParams {
519 nar_hash: nar_hash.clone(),
520 rev: rev.clone(),
521 rev_count: *rev_count,
522 last_modified: *last_modified,
523 };
524 append_common_file_params(&mut url, ¶ms);
525 url
526 }
527 FlakeRef::Git {
528 url,
529 r#ref,
530 rev,
531 keytype,
532 public_key,
533 public_keys,
534 shallow,
535 submodules,
536 export_ignore,
537 all_refs,
538 verify_commit,
539 } => {
540 let mut url = url.clone();
541 let params = GitParams {
542 r#ref: r#ref.clone(),
543 rev: rev.clone(),
544 keytype: keytype.clone(),
545 public_key: public_key.clone(),
546 public_keys: public_keys.clone(),
547 shallow: *shallow,
548 submodules: *submodules,
549 export_ignore: *export_ignore,
550 all_refs: *all_refs,
551 verify_commit: *verify_commit,
552 };
553 append_git_params(&mut url, ¶ms);
554 Url::parse(&format!("git+{}", url.as_str())).unwrap()
555 }
556 FlakeRef::GitHub {
557 owner,
558 repo,
559 host,
560 keytype,
561 public_key,
562 public_keys,
563 r#ref,
564 rev,
565 }
566 | FlakeRef::GitLab {
567 owner,
568 repo,
569 host,
570 keytype,
571 public_key,
572 public_keys,
573 r#ref,
574 rev,
575 }
576 | FlakeRef::SourceHut {
577 owner,
578 repo,
579 host,
580 keytype,
581 public_key,
582 public_keys,
583 r#ref,
584 rev,
585 } => {
586 let scheme = match self {
587 FlakeRef::GitHub { .. } => "github",
588 FlakeRef::GitLab { .. } => "gitlab",
589 FlakeRef::SourceHut { .. } => "sourcehut",
590 _ => unreachable!(),
591 };
592
593 let mut url = Url::parse(&format!("{}://{}/{}", scheme, owner, repo)).unwrap();
594 if let Some(h) = host {
595 url.set_host(Some(h)).unwrap();
596 }
597
598 let params = RepoHostParams {
599 owner: owner.clone(),
600 repo: repo.clone(),
601 host: host.clone(),
602 r#ref: r#ref.clone(),
603 rev: rev.clone(),
604 keytype: keytype.clone(),
605 public_key: public_key.clone(),
606 public_keys: public_keys.clone(),
607 };
608 append_repo_host_params(&mut url, ¶ms);
609 url
610 }
611 FlakeRef::Indirect { id, r#ref, rev } => {
612 let mut url = Url::parse(&format!("indirect://{}", id)).unwrap();
613 append_params(&mut url, &[("ref", r#ref.clone()), ("rev", rev.clone())]);
614 url
615 }
616 FlakeRef::Path {
617 path,
618 rev,
619 nar_hash,
620 rev_count,
621 last_modified,
622 } => {
623 let mut url = Url::parse(&format!("path://{}", path.display())).unwrap();
624 let params = FileParams {
625 nar_hash: nar_hash.clone(),
626 rev: rev.clone(),
627 rev_count: *rev_count,
628 last_modified: *last_modified,
629 };
630 append_common_file_params(&mut url, ¶ms);
631 url
632 }
633 FlakeRef::Tarball {
634 url,
635 nar_hash,
636 rev,
637 rev_count,
638 last_modified,
639 } => {
640 let mut url = url.clone();
641 let params = FileParams {
642 nar_hash: nar_hash.clone(),
643 rev: rev.clone(),
644 rev_count: *rev_count,
645 last_modified: *last_modified,
646 };
647 append_common_file_params(&mut url, ¶ms);
648 url
649 }
650 FlakeRef::Mercurial { r#ref, rev } => {
651 let mut url = Url::parse("hg://").unwrap();
652 append_params(&mut url, &[("ref", r#ref.clone()), ("rev", rev.clone())]);
653 url
654 }
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn test_git_urls() {
665 let input = "git+https://github.com/lichess-org/fishnet?submodules=1";
666 pretty_assertions::assert_matches!(
667 input.parse::<FlakeRef>(),
668 Ok(FlakeRef::Git {
669 submodules: true,
670 shallow: false,
671 export_ignore: false,
672 all_refs: false,
673 verify_commit: false,
674 ..
675 })
676 );
677
678 let input = "git+file:///home/user/project?ref=fa1e2d23a22";
679 match input.parse::<FlakeRef>() {
680 Ok(FlakeRef::Git { r#ref, rev, .. }) => {
681 assert_eq!(r#ref, Some("fa1e2d23a22".to_string()));
682 assert_eq!(rev, None);
683 }
684 _ => panic!("Expected Git input type"),
685 }
686
687 let input = "git+git://github.com/someuser/my-repo?rev=v1.2.3";
688 match input.parse::<FlakeRef>() {
689 Ok(FlakeRef::Git { rev, .. }) => {
690 assert_eq!(rev, Some("v1.2.3".to_string()));
691 }
692 _ => panic!("Expected Git input type"),
693 }
694 }
695
696 #[test]
697 fn test_github_urls() {
698 let input = "github:snowfallorg/lib?ref=v2.1.1";
699 match input.parse::<FlakeRef>() {
700 Ok(FlakeRef::GitHub { r#ref, rev, .. }) => {
701 assert_eq!(r#ref, Some("v2.1.1".to_string()));
702 assert_eq!(rev, None);
703 }
704 _ => panic!("Expected GitHub input type"),
705 }
706
707 let input = "github:aarowill/base16-alacritty";
708 match input.parse::<FlakeRef>() {
709 Ok(FlakeRef::GitHub { r#ref, rev, .. }) => {
710 assert_eq!(r#ref, None);
711 assert_eq!(rev, None);
712 }
713 _ => panic!("Expected GitHub input type"),
714 }
715
716 let input = "github:a/b/c?ref=yyy";
717 match input.parse::<FlakeRef>() {
718 Ok(_) => panic!("Expected error for multiple identifiers"),
719 Err(FlakeRefError::UnsupportedType(_)) => (),
720 _ => panic!("Expected UnsupportedType error"),
721 }
722
723 let input = "github:a";
724 match input.parse::<FlakeRef>() {
725 Ok(_) => panic!("Expected error for missing repo"),
726 Err(FlakeRefError::UnsupportedType(_)) => (),
727 _ => panic!("Expected UnsupportedType error"),
728 }
729
730 let input = "github:a/b/master/extra";
731 match input.parse::<FlakeRef>() {
732 Ok(FlakeRef::GitHub { r#ref, rev, .. }) => {
733 assert_eq!(r#ref, Some("master/extra".to_string()));
734 assert_eq!(rev, None);
735 }
736 _ => panic!("Expected GitHub input type"),
737 }
738
739 let input = "github:a/b";
740 match input.parse::<FlakeRef>() {
741 Ok(FlakeRef::GitHub { r#ref, .. }) => {
742 assert_eq!(r#ref, None);
743 }
744 _ => panic!("Expected GitHub input type"),
745 }
746 }
747
748 #[test]
749 fn test_file_urls() {
750 let input = "https://www.shutterstock.com/image-photo/young-potato-isolated-on-white-260nw-630239534.jpg";
751 pretty_assertions::assert_matches!(
752 input.parse::<FlakeRef>(),
753 Ok(FlakeRef::File {
754 url,
755 nar_hash: None,
756 rev: None,
757 rev_count: None,
758 last_modified: None,
759 }) if url.to_string() == input
760 );
761 }
762
763 #[test]
764 fn test_path_urls() {
765 let input = "path:./go";
766 pretty_assertions::assert_matches!(
767 input.parse::<FlakeRef>(),
768 Ok(FlakeRef::Path {
769 path,
770 rev: None,
771 nar_hash: None,
772 rev_count: None,
773 last_modified: None,
774 }) if path.to_str().unwrap() == "./go"
775 );
776
777 let input = "~/Downloads/a.zip";
778 match input.parse::<FlakeRef>() {
779 Ok(_) => panic!("Expected error for invalid URL format"),
780 Err(FlakeRefError::UrlParseError(_)) => (),
781 _ => panic!("Expected UrlParseError error"),
782 }
783 }
784}