Skip to main content

debian_watch/
templates.rs

1//! Template expansion for v5 watch files
2//!
3//! This module provides template expansion for common project hosting platforms,
4//! simplifying watch file creation by auto-generating Source URLs, matching patterns,
5//! and other configuration based on template type.
6//!
7//! # Supported Templates
8//!
9//! - `GitHub` - For GitHub-hosted projects
10//! - `GitLab` - For GitLab instances
11//! - `PyPI` - For Python packages on PyPI
12//! - `Npmregistry` - For npm packages
13//! - `Metacpan` - For Perl modules on MetaCPAN
14
15/// Error type for template expansion
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TemplateError {
18    /// Unknown template type
19    UnknownTemplate(String),
20    /// Missing required field
21    MissingField {
22        /// Template type
23        template: String,
24        /// Field name
25        field: String,
26    },
27    /// Invalid field value
28    InvalidValue {
29        /// Field name
30        field: String,
31        /// Reason for invalidity
32        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/// Template with variant-specific parameters
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Template {
55    /// GitHub template
56    GitHub {
57        /// Project owner
58        owner: String,
59        /// Project repository name
60        repository: String,
61        /// Search only releases (not all tags)
62        release_only: bool,
63        /// Version type pattern to use
64        version_type: Option<String>,
65    },
66    /// GitLab template
67    GitLab {
68        /// Project URL
69        dist: String,
70        /// Search only releases (not all tags)
71        release_only: bool,
72        /// Version type pattern to use
73        version_type: Option<String>,
74    },
75    /// PyPI template
76    PyPI {
77        /// Package name
78        package: String,
79        /// Version type pattern to use
80        version_type: Option<String>,
81    },
82    /// npm registry template
83    Npmregistry {
84        /// Package name (may include @scope/)
85        package: String,
86        /// Version type pattern to use
87        version_type: Option<String>,
88    },
89    /// MetaCPAN template
90    Metacpan {
91        /// Distribution name (using :: or -)
92        dist: String,
93        /// Version type pattern to use
94        version_type: Option<String>,
95    },
96}
97
98/// Expanded template fields
99#[derive(Debug, Clone, Default)]
100pub struct ExpandedTemplate {
101    /// Source URL
102    pub source: Option<String>,
103    /// Matching pattern
104    pub matching_pattern: Option<String>,
105    /// Search mode
106    pub searchmode: Option<String>,
107    /// Mode
108    pub mode: Option<String>,
109    /// PGP mode
110    pub pgpmode: Option<String>,
111    /// Download URL mangle
112    pub downloadurlmangle: Option<String>,
113}
114
115/// Expand a template into field values
116pub 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
141/// Expand GitHub template
142fn 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
172/// Expand GitLab template
173fn 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    // GitLab uses mode=gitlab
184    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
192/// Expand PyPI template
193fn 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
210/// Expand Npmregistry template
211fn 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    // npm package names might have @ prefix for scoped packages
218    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
232/// Expand Metacpan template
233fn 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    // MetaCPAN dist names can use :: or -
240    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
250/// Try to detect if the given fields match a known template pattern
251/// and return the corresponding Template if a match is found.
252///
253/// This is the reverse of `expand_template` - it analyzes expanded fields
254/// and tries to identify which template would produce them.
255///
256/// # Arguments
257///
258/// * `source` - The Source URL
259/// * `matching_pattern` - The Matching-Pattern
260/// * `searchmode` - The Searchmode field (if any)
261/// * `mode` - The Mode field (if any)
262///
263/// # Returns
264///
265/// Returns `Some(Template)` if the fields match a known template pattern,
266/// `None` otherwise.
267pub 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    // Try GitHub template detection
276    if let Some(template) = detect_github_template(source, matching_pattern, searchmode) {
277        return Some(template);
278    }
279
280    // Try GitLab template detection
281    if let Some(template) = detect_gitlab_template(source, matching_pattern, mode) {
282        return Some(template);
283    }
284
285    // Try PyPI template detection
286    if let Some(template) = detect_pypi_template(source, matching_pattern, searchmode) {
287        return Some(template);
288    }
289
290    // Try Npmregistry template detection
291    if let Some(template) = detect_npmregistry_template(source, matching_pattern, searchmode) {
292        return Some(template);
293    }
294
295    // Try Metacpan template detection
296    if let Some(template) = detect_metacpan_template(source, matching_pattern, searchmode) {
297        return Some(template);
298    }
299
300    None
301}
302
303/// Detect GitHub template
304fn detect_github_template(
305    source: &str,
306    matching_pattern: Option<&str>,
307    searchmode: Option<&str>,
308) -> Option<Template> {
309    // Check searchmode is html
310    if searchmode != Some("html") && searchmode.is_some() {
311        return None;
312    }
313
314    // Parse source URL to extract owner and repository
315    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    // Extract owner/repo from URL
324    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    // Try to detect version_type from matching pattern
345    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
359/// Detect GitLab template
360fn detect_gitlab_template(
361    source: &str,
362    matching_pattern: Option<&str>,
363    mode: Option<&str>,
364) -> Option<Template> {
365    // Check mode is gitlab
366    if mode != Some("gitlab") {
367        return None;
368    }
369
370    // Try to detect version_type from matching pattern
371    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, // GitLab template doesn't use release_only
380        version_type,
381    })
382}
383
384/// Detect PyPI template
385fn detect_pypi_template(
386    source: &str,
387    matching_pattern: Option<&str>,
388    searchmode: Option<&str>,
389) -> Option<Template> {
390    // Check searchmode is plain
391    if searchmode != Some("plain") && searchmode.is_some() {
392        return None;
393    }
394
395    // Check if source matches PyPI pattern
396    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    // Try to detect version_type from matching pattern
405    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
417/// Detect Npmregistry template
418fn detect_npmregistry_template(
419    source: &str,
420    matching_pattern: Option<&str>,
421    searchmode: Option<&str>,
422) -> Option<Template> {
423    // Check searchmode is plain
424    if searchmode != Some("plain") && searchmode.is_some() {
425        return None;
426    }
427
428    // Check if source matches npm registry pattern
429    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    // Try to detect version_type from matching pattern
436    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
448/// Detect Metacpan template
449fn detect_metacpan_template(
450    source: &str,
451    matching_pattern: Option<&str>,
452    searchmode: Option<&str>,
453) -> Option<Template> {
454    // Check searchmode is plain
455    if searchmode != Some("plain") && searchmode.is_some() {
456        return None;
457    }
458
459    if source == "https://cpan.metacpan.org/authors/id/" {
460        // Extract dist from matching pattern
461        let pattern = matching_pattern?;
462
463        // Pattern should be like: .*/DIST-NAME@VERSION@@ARCHIVE_EXT@
464        // We need to extract DIST-NAME
465        if !pattern.starts_with(".*/") {
466            return None;
467        }
468
469        let after_prefix = pattern.strip_prefix(".*/").unwrap();
470
471        // Find where the version pattern starts
472        let version_type = extract_version_type(pattern);
473
474        // Extract dist name - everything before the version pattern
475        // Strip optional trailing `-v?` or `-` before the version placeholder
476        let dist = if let Some(idx) = after_prefix.find('@') {
477            after_prefix[..idx].trim_end_matches("-v?").trim_end_matches('-')
478        } else {
479            return None;
480        };
481
482        Some(Template::Metacpan {
483            dist: dist.to_string(),
484            version_type,
485        })
486    } else if let Some(dist) = source
487        .strip_prefix("https://metacpan.org/release/")
488        .or_else(|| source.strip_prefix("https://metacpan.org/dist/"))
489    {
490        let dist = dist.trim_end_matches('/');
491        if dist.is_empty() {
492            return None;
493        }
494
495        let version_type = matching_pattern.and_then(extract_version_type);
496
497        Some(Template::Metacpan {
498            dist: dist.to_string(),
499            version_type,
500        })
501    } else {
502        None
503    }
504}
505
506/// Extract version type from a matching pattern
507/// Returns None for @ANY_VERSION@, Some(type) for specific types
508fn extract_version_type(pattern: &str) -> Option<String> {
509    // Look for @XXXXX_VERSION@ or @ANY_VERSION@ patterns
510    if pattern.contains("@ANY_VERSION@") {
511        None
512    } else if let Some(start) = pattern.find('@') {
513        if let Some(end) = pattern[start + 1..].find('@') {
514            let version_str = &pattern[start + 1..start + 1 + end];
515            if version_str.ends_with("_VERSION") {
516                let type_str = version_str.strip_suffix("_VERSION")?;
517                Some(type_str.to_lowercase())
518            } else {
519                None
520            }
521        } else {
522            None
523        }
524    } else {
525        None
526    }
527}
528
529/// Parse GitHub URL or owner/repository to extract owner and repository
530pub fn parse_github_url(url: &str) -> Result<(String, String), TemplateError> {
531    let url = url.trim_end_matches('/');
532
533    // Try to parse as URL
534    if let Ok(parsed) = url::Url::parse(url) {
535        if parsed.host_str() == Some("github.com") {
536            let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
537            let parts: Vec<&str> = path.split('/').collect();
538            if parts.len() >= 2 {
539                return Ok((parts[0].to_string(), parts[1].to_string()));
540            }
541        }
542    }
543
544    // Try to parse as owner/repository
545    let parts: Vec<&str> = url.split('/').collect();
546    if parts.len() == 2 {
547        return Ok((parts[0].to_string(), parts[1].to_string()));
548    }
549
550    Err(TemplateError::InvalidValue {
551        field: "Dist".to_string(),
552        reason: format!("Could not parse GitHub URL: {}", url),
553    })
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_github_template_with_owner_repository() {
562        let template = Template::GitHub {
563            owner: "torvalds".to_string(),
564            repository: "linux".to_string(),
565            release_only: false,
566            version_type: None,
567        };
568
569        let result = expand_template(template);
570        assert_eq!(
571            result.source,
572            Some("https://github.com/torvalds/linux/tags".to_string())
573        );
574        assert_eq!(
575            result.matching_pattern,
576            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
577        );
578    }
579
580    #[test]
581    fn test_github_template_release_only() {
582        let template = Template::GitHub {
583            owner: "test".to_string(),
584            repository: "project".to_string(),
585            release_only: true,
586            version_type: None,
587        };
588
589        let result = expand_template(template);
590        assert_eq!(
591            result.source,
592            Some("https://github.com/test/project/releases".to_string())
593        );
594    }
595
596    #[test]
597    fn test_parse_github_url() {
598        let (owner, repo) = parse_github_url("https://github.com/guimard/llng-docker").unwrap();
599        assert_eq!(owner, "guimard");
600        assert_eq!(repo, "llng-docker");
601
602        let (owner, repo) = parse_github_url("torvalds/linux").unwrap();
603        assert_eq!(owner, "torvalds");
604        assert_eq!(repo, "linux");
605    }
606
607    #[test]
608    fn test_pypi_template() {
609        let template = Template::PyPI {
610            package: "bitbox02".to_string(),
611            version_type: None,
612        };
613
614        let result = expand_template(template);
615        assert_eq!(
616            result.source,
617            Some("https://pypi.debian.net/bitbox02/".to_string())
618        );
619        assert_eq!(result.searchmode, Some("plain".to_string()));
620    }
621
622    #[test]
623    fn test_npmregistry_template() {
624        let template = Template::Npmregistry {
625            package: "@lemonldapng/handler".to_string(),
626            version_type: None,
627        };
628
629        let result = expand_template(template);
630        assert_eq!(
631            result.source,
632            Some("https://registry.npmjs.org/@lemonldapng/handler".to_string())
633        );
634        assert_eq!(result.searchmode, Some("plain".to_string()));
635    }
636
637    #[test]
638    fn test_gitlab_template() {
639        let template = Template::GitLab {
640            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
641            release_only: false,
642            version_type: None,
643        };
644
645        let result = expand_template(template);
646        assert_eq!(
647            result.source,
648            Some("https://salsa.debian.org/debian/devscripts".to_string())
649        );
650        assert_eq!(result.mode, Some("gitlab".to_string()));
651    }
652
653    #[test]
654    fn test_metacpan_template() {
655        let template = Template::Metacpan {
656            dist: "MetaCPAN-Client".to_string(),
657            version_type: None,
658        };
659
660        let result = expand_template(template);
661        assert_eq!(
662            result.source,
663            Some("https://cpan.metacpan.org/authors/id/".to_string())
664        );
665    }
666
667    #[test]
668    fn test_detect_github_template() {
669        let template = detect_template(
670            Some("https://github.com/torvalds/linux/tags"),
671            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
672            Some("html"),
673            None,
674        );
675
676        assert_eq!(
677            template,
678            Some(Template::GitHub {
679                owner: "torvalds".to_string(),
680                repository: "linux".to_string(),
681                release_only: false,
682                version_type: None,
683            })
684        );
685    }
686
687    #[test]
688    fn test_detect_github_template_releases() {
689        let template = detect_template(
690            Some("https://github.com/test/project/releases"),
691            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
692            Some("html"),
693            None,
694        );
695
696        assert_eq!(
697            template,
698            Some(Template::GitHub {
699                owner: "test".to_string(),
700                repository: "project".to_string(),
701                release_only: true,
702                version_type: None,
703            })
704        );
705    }
706
707    #[test]
708    fn test_detect_github_template_with_version_type() {
709        let template = detect_template(
710            Some("https://github.com/foo/bar/tags"),
711            Some(r".*/(?:refs/tags/)?v?@SEMANTIC_VERSION@@ARCHIVE_EXT@"),
712            Some("html"),
713            None,
714        );
715
716        assert_eq!(
717            template,
718            Some(Template::GitHub {
719                owner: "foo".to_string(),
720                repository: "bar".to_string(),
721                release_only: false,
722                version_type: Some("semantic".to_string()),
723            })
724        );
725    }
726
727    #[test]
728    fn test_detect_pypi_template() {
729        let template = detect_template(
730            Some("https://pypi.debian.net/bitbox02/"),
731            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
732            Some("plain"),
733            None,
734        );
735
736        assert_eq!(
737            template,
738            Some(Template::PyPI {
739                package: "bitbox02".to_string(),
740                version_type: None,
741            })
742        );
743    }
744
745    #[test]
746    fn test_detect_gitlab_template() {
747        let template = detect_template(
748            Some("https://salsa.debian.org/debian/devscripts"),
749            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
750            None,
751            Some("gitlab"),
752        );
753
754        assert_eq!(
755            template,
756            Some(Template::GitLab {
757                dist: "https://salsa.debian.org/debian/devscripts".to_string(),
758                release_only: false,
759                version_type: None,
760            })
761        );
762    }
763
764    #[test]
765    fn test_detect_npmregistry_template() {
766        let template = detect_template(
767            Some("https://registry.npmjs.org/@lemonldapng/handler"),
768            Some(
769                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
770            ),
771            Some("plain"),
772            None,
773        );
774
775        assert_eq!(
776            template,
777            Some(Template::Npmregistry {
778                package: "@lemonldapng/handler".to_string(),
779                version_type: None,
780            })
781        );
782    }
783
784    #[test]
785    fn test_detect_metacpan_template() {
786        let template = detect_template(
787            Some("https://cpan.metacpan.org/authors/id/"),
788            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
789            Some("plain"),
790            None,
791        );
792
793        assert_eq!(
794            template,
795            Some(Template::Metacpan {
796                dist: "MetaCPAN-Client".to_string(),
797                version_type: None,
798            })
799        );
800    }
801
802    #[test]
803    fn test_detect_no_template() {
804        // Non-template source
805        let template = detect_template(
806            Some("https://example.com/downloads/"),
807            Some(r".*/v?(\d+\.\d+)\.tar\.gz"),
808            Some("html"),
809            None,
810        );
811
812        assert_eq!(template, None);
813    }
814
815    #[test]
816    fn test_roundtrip_github_template() {
817        // Expand template
818        let original = Template::GitHub {
819            owner: "torvalds".to_string(),
820            repository: "linux".to_string(),
821            release_only: false,
822            version_type: None,
823        };
824        let expanded = expand_template(original.clone());
825
826        // Detect template from expanded fields
827        let detected = detect_template(
828            expanded.source.as_deref(),
829            expanded.matching_pattern.as_deref(),
830            expanded.searchmode.as_deref(),
831            expanded.mode.as_deref(),
832        );
833
834        assert_eq!(detected, Some(original));
835    }
836
837    #[test]
838    fn test_extract_version_type() {
839        assert_eq!(extract_version_type("@ANY_VERSION@"), None);
840        assert_eq!(
841            extract_version_type("@SEMANTIC_VERSION@"),
842            Some("semantic".to_string())
843        );
844        assert_eq!(
845            extract_version_type("@STABLE_VERSION@"),
846            Some("stable".to_string())
847        );
848        assert_eq!(extract_version_type("no-template-here"), None);
849    }
850
851    #[test]
852    fn test_detect_github_wrong_searchmode() {
853        // GitHub template requires searchmode=html or None
854        let template = detect_template(
855            Some("https://github.com/torvalds/linux/tags"),
856            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
857            Some("plain"), // Wrong searchmode
858            None,
859        );
860
861        assert_eq!(template, None);
862    }
863
864    #[test]
865    fn test_detect_github_invalid_url() {
866        // URL doesn't end with /tags or /releases
867        let template = detect_template(
868            Some("https://github.com/torvalds/linux"),
869            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
870            Some("html"),
871            None,
872        );
873
874        assert_eq!(template, None);
875    }
876
877    #[test]
878    fn test_detect_github_wrong_host() {
879        // Not github.com
880        let template = detect_template(
881            Some("https://gitlab.com/foo/bar/tags"),
882            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
883            Some("html"),
884            None,
885        );
886
887        assert_eq!(template, None);
888    }
889
890    #[test]
891    fn test_detect_gitlab_without_mode() {
892        // GitLab template requires mode=gitlab
893        let template = detect_template(
894            Some("https://salsa.debian.org/debian/devscripts"),
895            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
896            None,
897            None, // Missing mode=gitlab
898        );
899
900        assert_eq!(template, None);
901    }
902
903    #[test]
904    fn test_detect_pypi_wrong_searchmode() {
905        let template = detect_template(
906            Some("https://pypi.debian.net/bitbox02/"),
907            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
908            Some("html"), // Wrong searchmode
909            None,
910        );
911
912        assert_eq!(template, None);
913    }
914
915    #[test]
916    fn test_detect_pypi_wrong_url() {
917        let template = detect_template(
918            Some("https://pypi.org/bitbox02/"), // Wrong domain
919            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
920            Some("plain"),
921            None,
922        );
923
924        assert_eq!(template, None);
925    }
926
927    #[test]
928    fn test_detect_npmregistry_wrong_url() {
929        let template = detect_template(
930            Some("https://npm.example.com/@lemonldapng/handler"), // Wrong domain
931            Some(
932                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
933            ),
934            Some("plain"),
935            None,
936        );
937
938        assert_eq!(template, None);
939    }
940
941    #[test]
942    fn test_detect_metacpan_wrong_source() {
943        let template = detect_template(
944            Some("https://cpan.example.com/authors/id/"), // Wrong URL
945            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
946            Some("plain"),
947            None,
948        );
949
950        assert_eq!(template, None);
951    }
952
953    #[test]
954    fn test_detect_metacpan_missing_pattern() {
955        let template = detect_template(
956            Some("https://cpan.metacpan.org/authors/id/"),
957            None, // Missing pattern needed to extract dist
958            Some("plain"),
959            None,
960        );
961
962        assert_eq!(template, None);
963    }
964
965    #[test]
966    fn test_detect_metacpan_release_url() {
967        let template = detect_template(
968            Some("https://metacpan.org/release/Time-ParseDate"),
969            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
970            None,
971            None,
972        );
973
974        assert_eq!(
975            template,
976            Some(Template::Metacpan {
977                dist: "Time-ParseDate".to_string(),
978                version_type: None,
979            })
980        );
981    }
982
983    #[test]
984    fn test_detect_metacpan_dist_url() {
985        let template = detect_template(
986            Some("https://metacpan.org/dist/Mail-AuthenticationResults"),
987            Some(r".*/Mail-AuthenticationResults-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
988            None,
989            None,
990        );
991
992        assert_eq!(
993            template,
994            Some(Template::Metacpan {
995                dist: "Mail-AuthenticationResults".to_string(),
996                version_type: None,
997            })
998        );
999    }
1000
1001    #[test]
1002    fn test_detect_metacpan_cpan_url_with_v_prefix() {
1003        // Pattern with `-v?` before version placeholder (common in the wild)
1004        let template = detect_template(
1005            Some("https://cpan.metacpan.org/authors/id/"),
1006            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@"),
1007            Some("plain"),
1008            None,
1009        );
1010
1011        assert_eq!(
1012            template,
1013            Some(Template::Metacpan {
1014                dist: "Time-ParseDate".to_string(),
1015                version_type: None,
1016            })
1017        );
1018    }
1019
1020    #[test]
1021    fn test_detect_metacpan_release_url_wrong_domain() {
1022        let template = detect_template(
1023            Some("https://example.org/release/Time-ParseDate"),
1024            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1025            None,
1026            None,
1027        );
1028
1029        assert_eq!(template, None);
1030    }
1031
1032    #[test]
1033    fn test_roundtrip_gitlab_template() {
1034        let original = Template::GitLab {
1035            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
1036            release_only: false,
1037            version_type: None,
1038        };
1039        let expanded = expand_template(original.clone());
1040
1041        let detected = detect_template(
1042            expanded.source.as_deref(),
1043            expanded.matching_pattern.as_deref(),
1044            expanded.searchmode.as_deref(),
1045            expanded.mode.as_deref(),
1046        );
1047
1048        assert_eq!(detected, Some(original));
1049    }
1050
1051    #[test]
1052    fn test_roundtrip_pypi_template() {
1053        let original = Template::PyPI {
1054            package: "bitbox02".to_string(),
1055            version_type: None,
1056        };
1057        let expanded = expand_template(original.clone());
1058
1059        let detected = detect_template(
1060            expanded.source.as_deref(),
1061            expanded.matching_pattern.as_deref(),
1062            expanded.searchmode.as_deref(),
1063            expanded.mode.as_deref(),
1064        );
1065
1066        assert_eq!(detected, Some(original));
1067    }
1068
1069    #[test]
1070    fn test_roundtrip_npmregistry_template() {
1071        let original = Template::Npmregistry {
1072            package: "@scope/package".to_string(),
1073            version_type: None,
1074        };
1075        let expanded = expand_template(original.clone());
1076
1077        let detected = detect_template(
1078            expanded.source.as_deref(),
1079            expanded.matching_pattern.as_deref(),
1080            expanded.searchmode.as_deref(),
1081            expanded.mode.as_deref(),
1082        );
1083
1084        assert_eq!(detected, Some(original));
1085    }
1086
1087    #[test]
1088    fn test_roundtrip_metacpan_template() {
1089        let original = Template::Metacpan {
1090            dist: "MetaCPAN-Client".to_string(),
1091            version_type: None,
1092        };
1093        let expanded = expand_template(original.clone());
1094
1095        let detected = detect_template(
1096            expanded.source.as_deref(),
1097            expanded.matching_pattern.as_deref(),
1098            expanded.searchmode.as_deref(),
1099            expanded.mode.as_deref(),
1100        );
1101
1102        assert_eq!(detected, Some(original));
1103    }
1104
1105    #[test]
1106    fn test_roundtrip_github_with_version_type() {
1107        let original = Template::GitHub {
1108            owner: "foo".to_string(),
1109            repository: "bar".to_string(),
1110            release_only: true,
1111            version_type: Some("stable".to_string()),
1112        };
1113        let expanded = expand_template(original.clone());
1114
1115        let detected = detect_template(
1116            expanded.source.as_deref(),
1117            expanded.matching_pattern.as_deref(),
1118            expanded.searchmode.as_deref(),
1119            expanded.mode.as_deref(),
1120        );
1121
1122        assert_eq!(detected, Some(original));
1123    }
1124
1125    #[test]
1126    fn test_detect_with_none_source() {
1127        // Should return None if source is None
1128        let template = detect_template(
1129            None,
1130            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
1131            Some("html"),
1132            None,
1133        );
1134
1135        assert_eq!(template, None);
1136    }
1137
1138    #[test]
1139    fn test_detect_github_partial_match() {
1140        // Right URL but wrong pattern
1141        let template = detect_template(
1142            Some("https://github.com/torvalds/linux/tags"),
1143            Some(r".*/v?(\d+\.\d+)\.tar\.gz"), // Not a template pattern
1144            Some("html"),
1145            None,
1146        );
1147
1148        // Should still detect GitHub but won't have version_type
1149        assert_eq!(
1150            template,
1151            Some(Template::GitHub {
1152                owner: "torvalds".to_string(),
1153                repository: "linux".to_string(),
1154                release_only: false,
1155                version_type: None,
1156            })
1157        );
1158    }
1159
1160    #[test]
1161    fn test_extract_version_type_edge_cases() {
1162        // Multiple @ symbols
1163        assert_eq!(extract_version_type("@FOO@@BAR@"), None);
1164
1165        // Only one @ symbol
1166        assert_eq!(extract_version_type("@INCOMPLETE"), None);
1167
1168        // Not ending with _VERSION
1169        assert_eq!(extract_version_type("@SOMETHING@"), None);
1170
1171        // Empty between @
1172        assert_eq!(extract_version_type("@@"), None);
1173    }
1174}