1#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TemplateError {
18 UnknownTemplate(String),
20 MissingField {
22 template: String,
24 field: String,
26 },
27 InvalidValue {
29 field: String,
31 reason: String,
33 },
34}
35
36impl std::fmt::Display for TemplateError {
37 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
38 match self {
39 TemplateError::UnknownTemplate(t) => write!(f, "Unknown template type: {}", t),
40 TemplateError::MissingField { template, field } => {
41 write!(f, "{} template requires '{}' field", template, field)
42 }
43 TemplateError::InvalidValue { field, reason } => {
44 write!(f, "Invalid value for '{}': {}", field, reason)
45 }
46 }
47 }
48}
49
50impl std::error::Error for TemplateError {}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Template {
55 GitHub {
57 owner: String,
59 repository: String,
61 release_only: bool,
63 version_type: Option<String>,
65 },
66 GitLab {
68 dist: String,
70 release_only: bool,
72 version_type: Option<String>,
74 },
75 PyPI {
77 package: String,
79 version_type: Option<String>,
81 },
82 Npmregistry {
84 package: String,
86 version_type: Option<String>,
88 },
89 Metacpan {
91 dist: String,
93 version_type: Option<String>,
95 },
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct ExpandedTemplate {
101 pub source: Option<String>,
103 pub matching_pattern: Option<String>,
105 pub searchmode: Option<String>,
107 pub mode: Option<String>,
109 pub pgpmode: Option<String>,
111 pub downloadurlmangle: Option<String>,
113}
114
115pub fn expand_template(template: Template) -> ExpandedTemplate {
117 match template {
118 Template::GitHub {
119 owner,
120 repository,
121 release_only,
122 version_type,
123 } => expand_github_template(owner, repository, release_only, version_type),
124 Template::GitLab {
125 dist,
126 release_only,
127 version_type,
128 } => expand_gitlab_template(dist, release_only, version_type),
129 Template::PyPI {
130 package,
131 version_type,
132 } => expand_pypi_template(package, version_type),
133 Template::Npmregistry {
134 package,
135 version_type,
136 } => expand_npmregistry_template(package, version_type),
137 Template::Metacpan { dist, version_type } => expand_metacpan_template(dist, version_type),
138 }
139}
140
141fn expand_github_template(
143 owner: String,
144 repository: String,
145 release_only: bool,
146 version_type: Option<String>,
147) -> ExpandedTemplate {
148 let version_pattern = version_type
149 .as_deref()
150 .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
151 .unwrap_or_else(|| "@ANY_VERSION@".to_string());
152
153 let source = if release_only {
154 format!("https://github.com/{}/{}/releases", owner, repository)
155 } else {
156 format!("https://github.com/{}/{}/tags", owner, repository)
157 };
158
159 let matching_pattern = format!(
160 r".*/(?:refs/tags/)?v?{}{}",
161 version_pattern, "@ARCHIVE_EXT@"
162 );
163
164 ExpandedTemplate {
165 source: Some(source),
166 matching_pattern: Some(matching_pattern),
167 searchmode: Some("html".to_string()),
168 ..Default::default()
169 }
170}
171
172fn expand_gitlab_template(
174 dist: String,
175 _release_only: bool,
176 version_type: Option<String>,
177) -> ExpandedTemplate {
178 let version_pattern = version_type
179 .as_deref()
180 .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
181 .unwrap_or_else(|| "@ANY_VERSION@".to_string());
182
183 ExpandedTemplate {
185 source: Some(dist),
186 matching_pattern: Some(format!(r".*/v?{}{}", version_pattern, "@ARCHIVE_EXT@")),
187 mode: Some("gitlab".to_string()),
188 ..Default::default()
189 }
190}
191
192fn expand_pypi_template(package: String, version_type: Option<String>) -> ExpandedTemplate {
194 let version_pattern = version_type
195 .as_deref()
196 .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
197 .unwrap_or_else(|| "@ANY_VERSION@".to_string());
198
199 ExpandedTemplate {
200 source: Some(format!("https://pypi.debian.net/{}/", package)),
201 matching_pattern: Some(format!(
202 r"https://pypi\.debian\.net/{}/[^/]+\.tar\.gz#/.*-{}\.tar\.gz",
203 package, version_pattern
204 )),
205 searchmode: Some("plain".to_string()),
206 ..Default::default()
207 }
208}
209
210fn expand_npmregistry_template(package: String, version_type: Option<String>) -> ExpandedTemplate {
212 let version_pattern = version_type
213 .as_deref()
214 .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
215 .unwrap_or_else(|| "@ANY_VERSION@".to_string());
216
217 let package_name = package.trim_start_matches('@');
219
220 ExpandedTemplate {
221 source: Some(format!("https://registry.npmjs.org/{}", package)),
222 matching_pattern: Some(format!(
223 r"https://registry\.npmjs\.org/{}/-/.*-{}@ARCHIVE_EXT@",
224 package_name.replace('/', r"\/"),
225 version_pattern
226 )),
227 searchmode: Some("plain".to_string()),
228 ..Default::default()
229 }
230}
231
232fn expand_metacpan_template(dist: String, version_type: Option<String>) -> ExpandedTemplate {
234 let version_pattern = version_type
235 .as_deref()
236 .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
237 .unwrap_or_else(|| "@ANY_VERSION@".to_string());
238
239 let dist_name = dist.replace("::", "-");
241
242 ExpandedTemplate {
243 source: Some("https://cpan.metacpan.org/authors/id/".to_string()),
244 matching_pattern: Some(format!(r".*/{}{}@ARCHIVE_EXT@", dist_name, version_pattern)),
245 searchmode: Some("plain".to_string()),
246 ..Default::default()
247 }
248}
249
250pub fn detect_template(
268 source: Option<&str>,
269 matching_pattern: Option<&str>,
270 searchmode: Option<&str>,
271 mode: Option<&str>,
272) -> Option<Template> {
273 let source = source?;
274
275 if let Some(template) = detect_github_template(source, matching_pattern, searchmode) {
277 return Some(template);
278 }
279
280 if let Some(template) = detect_gitlab_template(source, matching_pattern, mode) {
282 return Some(template);
283 }
284
285 if let Some(template) = detect_pypi_template(source, matching_pattern, searchmode) {
287 return Some(template);
288 }
289
290 if let Some(template) = detect_npmregistry_template(source, matching_pattern, searchmode) {
292 return Some(template);
293 }
294
295 if let Some(template) = detect_metacpan_template(source, matching_pattern, searchmode) {
297 return Some(template);
298 }
299
300 None
301}
302
303fn detect_github_template(
305 source: &str,
306 matching_pattern: Option<&str>,
307 searchmode: Option<&str>,
308) -> Option<Template> {
309 if searchmode != Some("html") && searchmode.is_some() {
311 return None;
312 }
313
314 let release_only = if source.ends_with("/releases") {
316 true
317 } else if source.ends_with("/tags") {
318 false
319 } else {
320 return None;
321 };
322
323 let url_without_suffix = if release_only {
325 source.strip_suffix("/releases")?
326 } else {
327 source.strip_suffix("/tags")?
328 };
329
330 let (owner, repository) = if let Ok(parsed) = url::Url::parse(url_without_suffix) {
331 if parsed.host_str() != Some("github.com") {
332 return None;
333 }
334 let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
335 let parts: Vec<&str> = path.split('/').collect();
336 if parts.len() != 2 {
337 return None;
338 }
339 (parts[0].to_string(), parts[1].to_string())
340 } else {
341 return None;
342 };
343
344 let version_type = if let Some(pattern) = matching_pattern {
346 extract_version_type(pattern)
347 } else {
348 None
349 };
350
351 Some(Template::GitHub {
352 owner,
353 repository,
354 release_only,
355 version_type,
356 })
357}
358
359fn detect_gitlab_template(
361 source: &str,
362 matching_pattern: Option<&str>,
363 mode: Option<&str>,
364) -> Option<Template> {
365 if mode != Some("gitlab") {
367 return None;
368 }
369
370 let version_type = if let Some(pattern) = matching_pattern {
372 extract_version_type(pattern)
373 } else {
374 None
375 };
376
377 Some(Template::GitLab {
378 dist: source.to_string(),
379 release_only: false, version_type,
381 })
382}
383
384fn detect_pypi_template(
386 source: &str,
387 matching_pattern: Option<&str>,
388 searchmode: Option<&str>,
389) -> Option<Template> {
390 if searchmode != Some("plain") && searchmode.is_some() {
392 return None;
393 }
394
395 if !source.starts_with("https://pypi.debian.net/") {
397 return None;
398 }
399
400 let package = source
401 .strip_prefix("https://pypi.debian.net/")?
402 .trim_end_matches('/');
403
404 let version_type = if let Some(pattern) = matching_pattern {
406 extract_version_type(pattern)
407 } else {
408 None
409 };
410
411 Some(Template::PyPI {
412 package: package.to_string(),
413 version_type,
414 })
415}
416
417fn detect_npmregistry_template(
419 source: &str,
420 matching_pattern: Option<&str>,
421 searchmode: Option<&str>,
422) -> Option<Template> {
423 if searchmode != Some("plain") && searchmode.is_some() {
425 return None;
426 }
427
428 if !source.starts_with("https://registry.npmjs.org/") {
430 return None;
431 }
432
433 let package = source.strip_prefix("https://registry.npmjs.org/")?;
434
435 let version_type = if let Some(pattern) = matching_pattern {
437 extract_version_type(pattern)
438 } else {
439 None
440 };
441
442 Some(Template::Npmregistry {
443 package: package.to_string(),
444 version_type,
445 })
446}
447
448fn detect_metacpan_template(
450 source: &str,
451 matching_pattern: Option<&str>,
452 searchmode: Option<&str>,
453) -> Option<Template> {
454 if searchmode != Some("plain") && searchmode.is_some() {
456 return None;
457 }
458
459 if source != "https://cpan.metacpan.org/authors/id/" {
461 return None;
462 }
463
464 let pattern = matching_pattern?;
466
467 if !pattern.starts_with(".*/") {
470 return None;
471 }
472
473 let after_prefix = pattern.strip_prefix(".*/").unwrap();
474
475 let version_type = extract_version_type(pattern);
477
478 let dist = if let Some(idx) = after_prefix.find('@') {
480 &after_prefix[..idx]
481 } else {
482 return None;
483 };
484
485 Some(Template::Metacpan {
486 dist: dist.to_string(),
487 version_type,
488 })
489}
490
491fn extract_version_type(pattern: &str) -> Option<String> {
494 if pattern.contains("@ANY_VERSION@") {
496 None
497 } else if let Some(start) = pattern.find('@') {
498 if let Some(end) = pattern[start + 1..].find('@') {
499 let version_str = &pattern[start + 1..start + 1 + end];
500 if version_str.ends_with("_VERSION") {
501 let type_str = version_str.strip_suffix("_VERSION")?;
502 Some(type_str.to_lowercase())
503 } else {
504 None
505 }
506 } else {
507 None
508 }
509 } else {
510 None
511 }
512}
513
514pub fn parse_github_url(url: &str) -> Result<(String, String), TemplateError> {
516 let url = url.trim_end_matches('/');
517
518 if let Ok(parsed) = url::Url::parse(url) {
520 if parsed.host_str() == Some("github.com") {
521 let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
522 let parts: Vec<&str> = path.split('/').collect();
523 if parts.len() >= 2 {
524 return Ok((parts[0].to_string(), parts[1].to_string()));
525 }
526 }
527 }
528
529 let parts: Vec<&str> = url.split('/').collect();
531 if parts.len() == 2 {
532 return Ok((parts[0].to_string(), parts[1].to_string()));
533 }
534
535 Err(TemplateError::InvalidValue {
536 field: "Dist".to_string(),
537 reason: format!("Could not parse GitHub URL: {}", url),
538 })
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_github_template_with_owner_repository() {
547 let template = Template::GitHub {
548 owner: "torvalds".to_string(),
549 repository: "linux".to_string(),
550 release_only: false,
551 version_type: None,
552 };
553
554 let result = expand_template(template);
555 assert_eq!(
556 result.source,
557 Some("https://github.com/torvalds/linux/tags".to_string())
558 );
559 assert_eq!(
560 result.matching_pattern,
561 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
562 );
563 }
564
565 #[test]
566 fn test_github_template_release_only() {
567 let template = Template::GitHub {
568 owner: "test".to_string(),
569 repository: "project".to_string(),
570 release_only: true,
571 version_type: None,
572 };
573
574 let result = expand_template(template);
575 assert_eq!(
576 result.source,
577 Some("https://github.com/test/project/releases".to_string())
578 );
579 }
580
581 #[test]
582 fn test_parse_github_url() {
583 let (owner, repo) = parse_github_url("https://github.com/guimard/llng-docker").unwrap();
584 assert_eq!(owner, "guimard");
585 assert_eq!(repo, "llng-docker");
586
587 let (owner, repo) = parse_github_url("torvalds/linux").unwrap();
588 assert_eq!(owner, "torvalds");
589 assert_eq!(repo, "linux");
590 }
591
592 #[test]
593 fn test_pypi_template() {
594 let template = Template::PyPI {
595 package: "bitbox02".to_string(),
596 version_type: None,
597 };
598
599 let result = expand_template(template);
600 assert_eq!(
601 result.source,
602 Some("https://pypi.debian.net/bitbox02/".to_string())
603 );
604 assert_eq!(result.searchmode, Some("plain".to_string()));
605 }
606
607 #[test]
608 fn test_npmregistry_template() {
609 let template = Template::Npmregistry {
610 package: "@lemonldapng/handler".to_string(),
611 version_type: None,
612 };
613
614 let result = expand_template(template);
615 assert_eq!(
616 result.source,
617 Some("https://registry.npmjs.org/@lemonldapng/handler".to_string())
618 );
619 assert_eq!(result.searchmode, Some("plain".to_string()));
620 }
621
622 #[test]
623 fn test_gitlab_template() {
624 let template = Template::GitLab {
625 dist: "https://salsa.debian.org/debian/devscripts".to_string(),
626 release_only: false,
627 version_type: None,
628 };
629
630 let result = expand_template(template);
631 assert_eq!(
632 result.source,
633 Some("https://salsa.debian.org/debian/devscripts".to_string())
634 );
635 assert_eq!(result.mode, Some("gitlab".to_string()));
636 }
637
638 #[test]
639 fn test_metacpan_template() {
640 let template = Template::Metacpan {
641 dist: "MetaCPAN-Client".to_string(),
642 version_type: None,
643 };
644
645 let result = expand_template(template);
646 assert_eq!(
647 result.source,
648 Some("https://cpan.metacpan.org/authors/id/".to_string())
649 );
650 }
651
652 #[test]
653 fn test_detect_github_template() {
654 let template = detect_template(
655 Some("https://github.com/torvalds/linux/tags"),
656 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
657 Some("html"),
658 None,
659 );
660
661 assert_eq!(
662 template,
663 Some(Template::GitHub {
664 owner: "torvalds".to_string(),
665 repository: "linux".to_string(),
666 release_only: false,
667 version_type: None,
668 })
669 );
670 }
671
672 #[test]
673 fn test_detect_github_template_releases() {
674 let template = detect_template(
675 Some("https://github.com/test/project/releases"),
676 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
677 Some("html"),
678 None,
679 );
680
681 assert_eq!(
682 template,
683 Some(Template::GitHub {
684 owner: "test".to_string(),
685 repository: "project".to_string(),
686 release_only: true,
687 version_type: None,
688 })
689 );
690 }
691
692 #[test]
693 fn test_detect_github_template_with_version_type() {
694 let template = detect_template(
695 Some("https://github.com/foo/bar/tags"),
696 Some(r".*/(?:refs/tags/)?v?@SEMANTIC_VERSION@@ARCHIVE_EXT@"),
697 Some("html"),
698 None,
699 );
700
701 assert_eq!(
702 template,
703 Some(Template::GitHub {
704 owner: "foo".to_string(),
705 repository: "bar".to_string(),
706 release_only: false,
707 version_type: Some("semantic".to_string()),
708 })
709 );
710 }
711
712 #[test]
713 fn test_detect_pypi_template() {
714 let template = detect_template(
715 Some("https://pypi.debian.net/bitbox02/"),
716 Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
717 Some("plain"),
718 None,
719 );
720
721 assert_eq!(
722 template,
723 Some(Template::PyPI {
724 package: "bitbox02".to_string(),
725 version_type: None,
726 })
727 );
728 }
729
730 #[test]
731 fn test_detect_gitlab_template() {
732 let template = detect_template(
733 Some("https://salsa.debian.org/debian/devscripts"),
734 Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
735 None,
736 Some("gitlab"),
737 );
738
739 assert_eq!(
740 template,
741 Some(Template::GitLab {
742 dist: "https://salsa.debian.org/debian/devscripts".to_string(),
743 release_only: false,
744 version_type: None,
745 })
746 );
747 }
748
749 #[test]
750 fn test_detect_npmregistry_template() {
751 let template = detect_template(
752 Some("https://registry.npmjs.org/@lemonldapng/handler"),
753 Some(
754 r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
755 ),
756 Some("plain"),
757 None,
758 );
759
760 assert_eq!(
761 template,
762 Some(Template::Npmregistry {
763 package: "@lemonldapng/handler".to_string(),
764 version_type: None,
765 })
766 );
767 }
768
769 #[test]
770 fn test_detect_metacpan_template() {
771 let template = detect_template(
772 Some("https://cpan.metacpan.org/authors/id/"),
773 Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
774 Some("plain"),
775 None,
776 );
777
778 assert_eq!(
779 template,
780 Some(Template::Metacpan {
781 dist: "MetaCPAN-Client".to_string(),
782 version_type: None,
783 })
784 );
785 }
786
787 #[test]
788 fn test_detect_no_template() {
789 let template = detect_template(
791 Some("https://example.com/downloads/"),
792 Some(r".*/v?(\d+\.\d+)\.tar\.gz"),
793 Some("html"),
794 None,
795 );
796
797 assert_eq!(template, None);
798 }
799
800 #[test]
801 fn test_roundtrip_github_template() {
802 let original = Template::GitHub {
804 owner: "torvalds".to_string(),
805 repository: "linux".to_string(),
806 release_only: false,
807 version_type: None,
808 };
809 let expanded = expand_template(original.clone());
810
811 let detected = detect_template(
813 expanded.source.as_deref(),
814 expanded.matching_pattern.as_deref(),
815 expanded.searchmode.as_deref(),
816 expanded.mode.as_deref(),
817 );
818
819 assert_eq!(detected, Some(original));
820 }
821
822 #[test]
823 fn test_extract_version_type() {
824 assert_eq!(extract_version_type("@ANY_VERSION@"), None);
825 assert_eq!(
826 extract_version_type("@SEMANTIC_VERSION@"),
827 Some("semantic".to_string())
828 );
829 assert_eq!(
830 extract_version_type("@STABLE_VERSION@"),
831 Some("stable".to_string())
832 );
833 assert_eq!(extract_version_type("no-template-here"), None);
834 }
835
836 #[test]
837 fn test_detect_github_wrong_searchmode() {
838 let template = detect_template(
840 Some("https://github.com/torvalds/linux/tags"),
841 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
842 Some("plain"), None,
844 );
845
846 assert_eq!(template, None);
847 }
848
849 #[test]
850 fn test_detect_github_invalid_url() {
851 let template = detect_template(
853 Some("https://github.com/torvalds/linux"),
854 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
855 Some("html"),
856 None,
857 );
858
859 assert_eq!(template, None);
860 }
861
862 #[test]
863 fn test_detect_github_wrong_host() {
864 let template = detect_template(
866 Some("https://gitlab.com/foo/bar/tags"),
867 Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
868 Some("html"),
869 None,
870 );
871
872 assert_eq!(template, None);
873 }
874
875 #[test]
876 fn test_detect_gitlab_without_mode() {
877 let template = detect_template(
879 Some("https://salsa.debian.org/debian/devscripts"),
880 Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
881 None,
882 None, );
884
885 assert_eq!(template, None);
886 }
887
888 #[test]
889 fn test_detect_pypi_wrong_searchmode() {
890 let template = detect_template(
891 Some("https://pypi.debian.net/bitbox02/"),
892 Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
893 Some("html"), None,
895 );
896
897 assert_eq!(template, None);
898 }
899
900 #[test]
901 fn test_detect_pypi_wrong_url() {
902 let template = detect_template(
903 Some("https://pypi.org/bitbox02/"), Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
905 Some("plain"),
906 None,
907 );
908
909 assert_eq!(template, None);
910 }
911
912 #[test]
913 fn test_detect_npmregistry_wrong_url() {
914 let template = detect_template(
915 Some("https://npm.example.com/@lemonldapng/handler"), Some(
917 r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
918 ),
919 Some("plain"),
920 None,
921 );
922
923 assert_eq!(template, None);
924 }
925
926 #[test]
927 fn test_detect_metacpan_wrong_source() {
928 let template = detect_template(
929 Some("https://cpan.example.com/authors/id/"), Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
931 Some("plain"),
932 None,
933 );
934
935 assert_eq!(template, None);
936 }
937
938 #[test]
939 fn test_detect_metacpan_missing_pattern() {
940 let template = detect_template(
941 Some("https://cpan.metacpan.org/authors/id/"),
942 None, Some("plain"),
944 None,
945 );
946
947 assert_eq!(template, None);
948 }
949
950 #[test]
951 fn test_roundtrip_gitlab_template() {
952 let original = Template::GitLab {
953 dist: "https://salsa.debian.org/debian/devscripts".to_string(),
954 release_only: false,
955 version_type: None,
956 };
957 let expanded = expand_template(original.clone());
958
959 let detected = detect_template(
960 expanded.source.as_deref(),
961 expanded.matching_pattern.as_deref(),
962 expanded.searchmode.as_deref(),
963 expanded.mode.as_deref(),
964 );
965
966 assert_eq!(detected, Some(original));
967 }
968
969 #[test]
970 fn test_roundtrip_pypi_template() {
971 let original = Template::PyPI {
972 package: "bitbox02".to_string(),
973 version_type: None,
974 };
975 let expanded = expand_template(original.clone());
976
977 let detected = detect_template(
978 expanded.source.as_deref(),
979 expanded.matching_pattern.as_deref(),
980 expanded.searchmode.as_deref(),
981 expanded.mode.as_deref(),
982 );
983
984 assert_eq!(detected, Some(original));
985 }
986
987 #[test]
988 fn test_roundtrip_npmregistry_template() {
989 let original = Template::Npmregistry {
990 package: "@scope/package".to_string(),
991 version_type: None,
992 };
993 let expanded = expand_template(original.clone());
994
995 let detected = detect_template(
996 expanded.source.as_deref(),
997 expanded.matching_pattern.as_deref(),
998 expanded.searchmode.as_deref(),
999 expanded.mode.as_deref(),
1000 );
1001
1002 assert_eq!(detected, Some(original));
1003 }
1004
1005 #[test]
1006 fn test_roundtrip_metacpan_template() {
1007 let original = Template::Metacpan {
1008 dist: "MetaCPAN-Client".to_string(),
1009 version_type: None,
1010 };
1011 let expanded = expand_template(original.clone());
1012
1013 let detected = detect_template(
1014 expanded.source.as_deref(),
1015 expanded.matching_pattern.as_deref(),
1016 expanded.searchmode.as_deref(),
1017 expanded.mode.as_deref(),
1018 );
1019
1020 assert_eq!(detected, Some(original));
1021 }
1022
1023 #[test]
1024 fn test_roundtrip_github_with_version_type() {
1025 let original = Template::GitHub {
1026 owner: "foo".to_string(),
1027 repository: "bar".to_string(),
1028 release_only: true,
1029 version_type: Some("stable".to_string()),
1030 };
1031 let expanded = expand_template(original.clone());
1032
1033 let detected = detect_template(
1034 expanded.source.as_deref(),
1035 expanded.matching_pattern.as_deref(),
1036 expanded.searchmode.as_deref(),
1037 expanded.mode.as_deref(),
1038 );
1039
1040 assert_eq!(detected, Some(original));
1041 }
1042
1043 #[test]
1044 fn test_detect_with_none_source() {
1045 let template = detect_template(
1047 None,
1048 Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
1049 Some("html"),
1050 None,
1051 );
1052
1053 assert_eq!(template, None);
1054 }
1055
1056 #[test]
1057 fn test_detect_github_partial_match() {
1058 let template = detect_template(
1060 Some("https://github.com/torvalds/linux/tags"),
1061 Some(r".*/v?(\d+\.\d+)\.tar\.gz"), Some("html"),
1063 None,
1064 );
1065
1066 assert_eq!(
1068 template,
1069 Some(Template::GitHub {
1070 owner: "torvalds".to_string(),
1071 repository: "linux".to_string(),
1072 release_only: false,
1073 version_type: None,
1074 })
1075 );
1076 }
1077
1078 #[test]
1079 fn test_extract_version_type_edge_cases() {
1080 assert_eq!(extract_version_type("@FOO@@BAR@"), None);
1082
1083 assert_eq!(extract_version_type("@INCOMPLETE"), None);
1085
1086 assert_eq!(extract_version_type("@SOMETHING@"), None);
1088
1089 assert_eq!(extract_version_type("@@"), None);
1091 }
1092}