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    // Check if source matches Metacpan pattern
460    if source != "https://cpan.metacpan.org/authors/id/" {
461        return None;
462    }
463
464    // Extract dist from matching pattern
465    let pattern = matching_pattern?;
466
467    // Pattern should be like: .*/DIST-NAME@VERSION@@ARCHIVE_EXT@
468    // We need to extract DIST-NAME
469    if !pattern.starts_with(".*/") {
470        return None;
471    }
472
473    let after_prefix = pattern.strip_prefix(".*/").unwrap();
474
475    // Find where the version pattern starts
476    let version_type = extract_version_type(pattern);
477
478    // Extract dist name - everything before the version pattern
479    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
491/// Extract version type from a matching pattern
492/// Returns None for @ANY_VERSION@, Some(type) for specific types
493fn extract_version_type(pattern: &str) -> Option<String> {
494    // Look for @XXXXX_VERSION@ or @ANY_VERSION@ patterns
495    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
514/// Parse GitHub URL or owner/repository to extract owner and repository
515pub fn parse_github_url(url: &str) -> Result<(String, String), TemplateError> {
516    let url = url.trim_end_matches('/');
517
518    // Try to parse as URL
519    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    // Try to parse as owner/repository
530    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        // Non-template source
790        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        // Expand template
803        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        // Detect template from expanded fields
812        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        // GitHub template requires searchmode=html or None
839        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"), // Wrong searchmode
843            None,
844        );
845
846        assert_eq!(template, None);
847    }
848
849    #[test]
850    fn test_detect_github_invalid_url() {
851        // URL doesn't end with /tags or /releases
852        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        // Not github.com
865        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        // GitLab template requires mode=gitlab
878        let template = detect_template(
879            Some("https://salsa.debian.org/debian/devscripts"),
880            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
881            None,
882            None, // Missing mode=gitlab
883        );
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"), // Wrong searchmode
894            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/"), // Wrong domain
904            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"), // Wrong domain
916            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/"), // Wrong URL
930            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, // Missing pattern needed to extract dist
943            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        // Should return None if source is None
1046        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        // Right URL but wrong pattern
1059        let template = detect_template(
1060            Some("https://github.com/torvalds/linux/tags"),
1061            Some(r".*/v?(\d+\.\d+)\.tar\.gz"), // Not a template pattern
1062            Some("html"),
1063            None,
1064        );
1065
1066        // Should still detect GitHub but won't have version_type
1067        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        // Multiple @ symbols
1081        assert_eq!(extract_version_type("@FOO@@BAR@"), None);
1082
1083        // Only one @ symbol
1084        assert_eq!(extract_version_type("@INCOMPLETE"), None);
1085
1086        // Not ending with _VERSION
1087        assert_eq!(extract_version_type("@SOMETHING@"), None);
1088
1089        // Empty between @
1090        assert_eq!(extract_version_type("@@"), None);
1091    }
1092}