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 remainder = source
401        .strip_prefix("https://pypi.debian.net/")?
402        .trim_end_matches('/');
403
404    // Extract only the package name (first path component)
405    let package = match remainder.split_once('/') {
406        Some((pkg, _)) => pkg,
407        None => remainder,
408    };
409
410    // Try to detect version_type from matching pattern
411    let version_type = if let Some(pattern) = matching_pattern {
412        extract_version_type(pattern)
413    } else {
414        None
415    };
416
417    Some(Template::PyPI {
418        package: package.to_string(),
419        version_type,
420    })
421}
422
423/// Detect Npmregistry template
424fn detect_npmregistry_template(
425    source: &str,
426    matching_pattern: Option<&str>,
427    searchmode: Option<&str>,
428) -> Option<Template> {
429    // Check searchmode is plain
430    if searchmode != Some("plain") && searchmode.is_some() {
431        return None;
432    }
433
434    // Check if source matches npm registry pattern
435    if !source.starts_with("https://registry.npmjs.org/") {
436        return None;
437    }
438
439    let package = source.strip_prefix("https://registry.npmjs.org/")?;
440
441    // Try to detect version_type from matching pattern
442    let version_type = if let Some(pattern) = matching_pattern {
443        extract_version_type(pattern)
444    } else {
445        None
446    };
447
448    Some(Template::Npmregistry {
449        package: package.to_string(),
450        version_type,
451    })
452}
453
454/// Detect Metacpan template
455fn detect_metacpan_template(
456    source: &str,
457    matching_pattern: Option<&str>,
458    searchmode: Option<&str>,
459) -> Option<Template> {
460    // Check searchmode is plain
461    if searchmode != Some("plain") && searchmode.is_some() {
462        return None;
463    }
464
465    if source == "https://cpan.metacpan.org/authors/id/" {
466        // Extract dist from matching pattern
467        let pattern = matching_pattern?;
468
469        // Pattern should be like: .*/DIST-NAME@VERSION@@ARCHIVE_EXT@
470        // We need to extract DIST-NAME
471        if !pattern.starts_with(".*/") {
472            return None;
473        }
474
475        let after_prefix = pattern.strip_prefix(".*/").unwrap();
476
477        // Find where the version pattern starts
478        let version_type = extract_version_type(pattern);
479
480        // Extract dist name - everything before the version pattern
481        // Strip optional trailing `-v?` or `-` before the version placeholder
482        let dist = if let Some(idx) = after_prefix.find('@') {
483            after_prefix[..idx]
484                .trim_end_matches("-v?")
485                .trim_end_matches('-')
486        } else {
487            return None;
488        };
489
490        Some(Template::Metacpan {
491            dist: dist.to_string(),
492            version_type,
493        })
494    } else if let Some(dist) = source
495        .strip_prefix("https://metacpan.org/release/")
496        .or_else(|| source.strip_prefix("https://metacpan.org/dist/"))
497    {
498        let dist = dist.trim_end_matches('/');
499        if dist.is_empty() {
500            return None;
501        }
502
503        let version_type = matching_pattern.and_then(extract_version_type);
504
505        Some(Template::Metacpan {
506            dist: dist.to_string(),
507            version_type,
508        })
509    } else {
510        None
511    }
512}
513
514/// Extract version type from a matching pattern
515/// Returns None for @ANY_VERSION@, Some(type) for specific types
516fn extract_version_type(pattern: &str) -> Option<String> {
517    // Look for @XXXXX_VERSION@ or @ANY_VERSION@ patterns
518    if pattern.contains("@ANY_VERSION@") {
519        None
520    } else if let Some(start) = pattern.find('@') {
521        if let Some(end) = pattern[start + 1..].find('@') {
522            let version_str = &pattern[start + 1..start + 1 + end];
523            if version_str.ends_with("_VERSION") {
524                let type_str = version_str.strip_suffix("_VERSION")?;
525                Some(type_str.to_lowercase())
526            } else {
527                None
528            }
529        } else {
530            None
531        }
532    } else {
533        None
534    }
535}
536
537/// Parse GitHub URL or owner/repository to extract owner and repository
538pub fn parse_github_url(url: &str) -> Result<(String, String), TemplateError> {
539    let url = url.trim_end_matches('/');
540
541    // Try to parse as URL
542    if let Ok(parsed) = url::Url::parse(url) {
543        if parsed.host_str() == Some("github.com") {
544            let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
545            let parts: Vec<&str> = path.split('/').collect();
546            if parts.len() >= 2 {
547                return Ok((parts[0].to_string(), parts[1].to_string()));
548            }
549        }
550    }
551
552    // Try to parse as owner/repository
553    let parts: Vec<&str> = url.split('/').collect();
554    if parts.len() == 2 {
555        return Ok((parts[0].to_string(), parts[1].to_string()));
556    }
557
558    Err(TemplateError::InvalidValue {
559        field: "Dist".to_string(),
560        reason: format!("Could not parse GitHub URL: {}", url),
561    })
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_github_template_with_owner_repository() {
570        let template = Template::GitHub {
571            owner: "torvalds".to_string(),
572            repository: "linux".to_string(),
573            release_only: false,
574            version_type: None,
575        };
576
577        let result = expand_template(template);
578        assert_eq!(
579            result.source,
580            Some("https://github.com/torvalds/linux/tags".to_string())
581        );
582        assert_eq!(
583            result.matching_pattern,
584            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
585        );
586    }
587
588    #[test]
589    fn test_github_template_release_only() {
590        let template = Template::GitHub {
591            owner: "test".to_string(),
592            repository: "project".to_string(),
593            release_only: true,
594            version_type: None,
595        };
596
597        let result = expand_template(template);
598        assert_eq!(
599            result.source,
600            Some("https://github.com/test/project/releases".to_string())
601        );
602    }
603
604    #[test]
605    fn test_parse_github_url() {
606        let (owner, repo) = parse_github_url("https://github.com/guimard/llng-docker").unwrap();
607        assert_eq!(owner, "guimard");
608        assert_eq!(repo, "llng-docker");
609
610        let (owner, repo) = parse_github_url("torvalds/linux").unwrap();
611        assert_eq!(owner, "torvalds");
612        assert_eq!(repo, "linux");
613    }
614
615    #[test]
616    fn test_pypi_template() {
617        let template = Template::PyPI {
618            package: "bitbox02".to_string(),
619            version_type: None,
620        };
621
622        let result = expand_template(template);
623        assert_eq!(
624            result.source,
625            Some("https://pypi.debian.net/bitbox02/".to_string())
626        );
627        assert_eq!(result.searchmode, Some("plain".to_string()));
628    }
629
630    #[test]
631    fn test_npmregistry_template() {
632        let template = Template::Npmregistry {
633            package: "@lemonldapng/handler".to_string(),
634            version_type: None,
635        };
636
637        let result = expand_template(template);
638        assert_eq!(
639            result.source,
640            Some("https://registry.npmjs.org/@lemonldapng/handler".to_string())
641        );
642        assert_eq!(result.searchmode, Some("plain".to_string()));
643    }
644
645    #[test]
646    fn test_gitlab_template() {
647        let template = Template::GitLab {
648            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
649            release_only: false,
650            version_type: None,
651        };
652
653        let result = expand_template(template);
654        assert_eq!(
655            result.source,
656            Some("https://salsa.debian.org/debian/devscripts".to_string())
657        );
658        assert_eq!(result.mode, Some("gitlab".to_string()));
659    }
660
661    #[test]
662    fn test_metacpan_template() {
663        let template = Template::Metacpan {
664            dist: "MetaCPAN-Client".to_string(),
665            version_type: None,
666        };
667
668        let result = expand_template(template);
669        assert_eq!(
670            result.source,
671            Some("https://cpan.metacpan.org/authors/id/".to_string())
672        );
673    }
674
675    #[test]
676    fn test_detect_github_template() {
677        let template = detect_template(
678            Some("https://github.com/torvalds/linux/tags"),
679            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
680            Some("html"),
681            None,
682        );
683
684        assert_eq!(
685            template,
686            Some(Template::GitHub {
687                owner: "torvalds".to_string(),
688                repository: "linux".to_string(),
689                release_only: false,
690                version_type: None,
691            })
692        );
693    }
694
695    #[test]
696    fn test_detect_github_template_releases() {
697        let template = detect_template(
698            Some("https://github.com/test/project/releases"),
699            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
700            Some("html"),
701            None,
702        );
703
704        assert_eq!(
705            template,
706            Some(Template::GitHub {
707                owner: "test".to_string(),
708                repository: "project".to_string(),
709                release_only: true,
710                version_type: None,
711            })
712        );
713    }
714
715    #[test]
716    fn test_detect_github_template_with_version_type() {
717        let template = detect_template(
718            Some("https://github.com/foo/bar/tags"),
719            Some(r".*/(?:refs/tags/)?v?@SEMANTIC_VERSION@@ARCHIVE_EXT@"),
720            Some("html"),
721            None,
722        );
723
724        assert_eq!(
725            template,
726            Some(Template::GitHub {
727                owner: "foo".to_string(),
728                repository: "bar".to_string(),
729                release_only: false,
730                version_type: Some("semantic".to_string()),
731            })
732        );
733    }
734
735    #[test]
736    fn test_detect_pypi_template() {
737        let template = detect_template(
738            Some("https://pypi.debian.net/bitbox02/"),
739            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
740            Some("plain"),
741            None,
742        );
743
744        assert_eq!(
745            template,
746            Some(Template::PyPI {
747                package: "bitbox02".to_string(),
748                version_type: None,
749            })
750        );
751    }
752
753    #[test]
754    fn test_detect_gitlab_template() {
755        let template = detect_template(
756            Some("https://salsa.debian.org/debian/devscripts"),
757            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
758            None,
759            Some("gitlab"),
760        );
761
762        assert_eq!(
763            template,
764            Some(Template::GitLab {
765                dist: "https://salsa.debian.org/debian/devscripts".to_string(),
766                release_only: false,
767                version_type: None,
768            })
769        );
770    }
771
772    #[test]
773    fn test_detect_npmregistry_template() {
774        let template = detect_template(
775            Some("https://registry.npmjs.org/@lemonldapng/handler"),
776            Some(
777                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
778            ),
779            Some("plain"),
780            None,
781        );
782
783        assert_eq!(
784            template,
785            Some(Template::Npmregistry {
786                package: "@lemonldapng/handler".to_string(),
787                version_type: None,
788            })
789        );
790    }
791
792    #[test]
793    fn test_detect_metacpan_template() {
794        let template = detect_template(
795            Some("https://cpan.metacpan.org/authors/id/"),
796            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
797            Some("plain"),
798            None,
799        );
800
801        assert_eq!(
802            template,
803            Some(Template::Metacpan {
804                dist: "MetaCPAN-Client".to_string(),
805                version_type: None,
806            })
807        );
808    }
809
810    #[test]
811    fn test_detect_no_template() {
812        // Non-template source
813        let template = detect_template(
814            Some("https://example.com/downloads/"),
815            Some(r".*/v?(\d+\.\d+)\.tar\.gz"),
816            Some("html"),
817            None,
818        );
819
820        assert_eq!(template, None);
821    }
822
823    #[test]
824    fn test_roundtrip_github_template() {
825        // Expand template
826        let original = Template::GitHub {
827            owner: "torvalds".to_string(),
828            repository: "linux".to_string(),
829            release_only: false,
830            version_type: None,
831        };
832        let expanded = expand_template(original.clone());
833
834        // Detect template from expanded fields
835        let detected = detect_template(
836            expanded.source.as_deref(),
837            expanded.matching_pattern.as_deref(),
838            expanded.searchmode.as_deref(),
839            expanded.mode.as_deref(),
840        );
841
842        assert_eq!(detected, Some(original));
843    }
844
845    #[test]
846    fn test_extract_version_type() {
847        assert_eq!(extract_version_type("@ANY_VERSION@"), None);
848        assert_eq!(
849            extract_version_type("@SEMANTIC_VERSION@"),
850            Some("semantic".to_string())
851        );
852        assert_eq!(
853            extract_version_type("@STABLE_VERSION@"),
854            Some("stable".to_string())
855        );
856        assert_eq!(extract_version_type("no-template-here"), None);
857    }
858
859    #[test]
860    fn test_detect_github_wrong_searchmode() {
861        // GitHub template requires searchmode=html or None
862        let template = detect_template(
863            Some("https://github.com/torvalds/linux/tags"),
864            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
865            Some("plain"), // Wrong searchmode
866            None,
867        );
868
869        assert_eq!(template, None);
870    }
871
872    #[test]
873    fn test_detect_github_invalid_url() {
874        // URL doesn't end with /tags or /releases
875        let template = detect_template(
876            Some("https://github.com/torvalds/linux"),
877            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
878            Some("html"),
879            None,
880        );
881
882        assert_eq!(template, None);
883    }
884
885    #[test]
886    fn test_detect_github_wrong_host() {
887        // Not github.com
888        let template = detect_template(
889            Some("https://gitlab.com/foo/bar/tags"),
890            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
891            Some("html"),
892            None,
893        );
894
895        assert_eq!(template, None);
896    }
897
898    #[test]
899    fn test_detect_gitlab_without_mode() {
900        // GitLab template requires mode=gitlab
901        let template = detect_template(
902            Some("https://salsa.debian.org/debian/devscripts"),
903            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
904            None,
905            None, // Missing mode=gitlab
906        );
907
908        assert_eq!(template, None);
909    }
910
911    #[test]
912    fn test_detect_pypi_source_with_inline_pattern() {
913        // When the Source URL contains the matching pattern inline
914        // (e.g., from old-style watch files), only the package name should be extracted
915        let template = detect_template(
916            Some("https://pypi.debian.net/dulwich/dulwich-(.*).tar.gz"),
917            None,
918            None,
919            None,
920        );
921
922        assert_eq!(
923            template,
924            Some(Template::PyPI {
925                package: "dulwich".to_string(),
926                version_type: None,
927            })
928        );
929    }
930
931    #[test]
932    fn test_detect_pypi_wrong_searchmode() {
933        let template = detect_template(
934            Some("https://pypi.debian.net/bitbox02/"),
935            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
936            Some("html"), // Wrong searchmode
937            None,
938        );
939
940        assert_eq!(template, None);
941    }
942
943    #[test]
944    fn test_detect_pypi_wrong_url() {
945        let template = detect_template(
946            Some("https://pypi.org/bitbox02/"), // Wrong domain
947            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
948            Some("plain"),
949            None,
950        );
951
952        assert_eq!(template, None);
953    }
954
955    #[test]
956    fn test_detect_npmregistry_wrong_url() {
957        let template = detect_template(
958            Some("https://npm.example.com/@lemonldapng/handler"), // Wrong domain
959            Some(
960                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
961            ),
962            Some("plain"),
963            None,
964        );
965
966        assert_eq!(template, None);
967    }
968
969    #[test]
970    fn test_detect_metacpan_wrong_source() {
971        let template = detect_template(
972            Some("https://cpan.example.com/authors/id/"), // Wrong URL
973            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
974            Some("plain"),
975            None,
976        );
977
978        assert_eq!(template, None);
979    }
980
981    #[test]
982    fn test_detect_metacpan_missing_pattern() {
983        let template = detect_template(
984            Some("https://cpan.metacpan.org/authors/id/"),
985            None, // Missing pattern needed to extract dist
986            Some("plain"),
987            None,
988        );
989
990        assert_eq!(template, None);
991    }
992
993    #[test]
994    fn test_detect_metacpan_release_url() {
995        let template = detect_template(
996            Some("https://metacpan.org/release/Time-ParseDate"),
997            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
998            None,
999            None,
1000        );
1001
1002        assert_eq!(
1003            template,
1004            Some(Template::Metacpan {
1005                dist: "Time-ParseDate".to_string(),
1006                version_type: None,
1007            })
1008        );
1009    }
1010
1011    #[test]
1012    fn test_detect_metacpan_dist_url() {
1013        let template = detect_template(
1014            Some("https://metacpan.org/dist/Mail-AuthenticationResults"),
1015            Some(r".*/Mail-AuthenticationResults-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1016            None,
1017            None,
1018        );
1019
1020        assert_eq!(
1021            template,
1022            Some(Template::Metacpan {
1023                dist: "Mail-AuthenticationResults".to_string(),
1024                version_type: None,
1025            })
1026        );
1027    }
1028
1029    #[test]
1030    fn test_detect_metacpan_cpan_url_with_v_prefix() {
1031        // Pattern with `-v?` before version placeholder (common in the wild)
1032        let template = detect_template(
1033            Some("https://cpan.metacpan.org/authors/id/"),
1034            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@"),
1035            Some("plain"),
1036            None,
1037        );
1038
1039        assert_eq!(
1040            template,
1041            Some(Template::Metacpan {
1042                dist: "Time-ParseDate".to_string(),
1043                version_type: None,
1044            })
1045        );
1046    }
1047
1048    #[test]
1049    fn test_detect_metacpan_release_url_wrong_domain() {
1050        let template = detect_template(
1051            Some("https://example.org/release/Time-ParseDate"),
1052            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1053            None,
1054            None,
1055        );
1056
1057        assert_eq!(template, None);
1058    }
1059
1060    #[test]
1061    fn test_roundtrip_gitlab_template() {
1062        let original = Template::GitLab {
1063            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
1064            release_only: false,
1065            version_type: None,
1066        };
1067        let expanded = expand_template(original.clone());
1068
1069        let detected = detect_template(
1070            expanded.source.as_deref(),
1071            expanded.matching_pattern.as_deref(),
1072            expanded.searchmode.as_deref(),
1073            expanded.mode.as_deref(),
1074        );
1075
1076        assert_eq!(detected, Some(original));
1077    }
1078
1079    #[test]
1080    fn test_roundtrip_pypi_template() {
1081        let original = Template::PyPI {
1082            package: "bitbox02".to_string(),
1083            version_type: None,
1084        };
1085        let expanded = expand_template(original.clone());
1086
1087        let detected = detect_template(
1088            expanded.source.as_deref(),
1089            expanded.matching_pattern.as_deref(),
1090            expanded.searchmode.as_deref(),
1091            expanded.mode.as_deref(),
1092        );
1093
1094        assert_eq!(detected, Some(original));
1095    }
1096
1097    #[test]
1098    fn test_roundtrip_npmregistry_template() {
1099        let original = Template::Npmregistry {
1100            package: "@scope/package".to_string(),
1101            version_type: None,
1102        };
1103        let expanded = expand_template(original.clone());
1104
1105        let detected = detect_template(
1106            expanded.source.as_deref(),
1107            expanded.matching_pattern.as_deref(),
1108            expanded.searchmode.as_deref(),
1109            expanded.mode.as_deref(),
1110        );
1111
1112        assert_eq!(detected, Some(original));
1113    }
1114
1115    #[test]
1116    fn test_roundtrip_metacpan_template() {
1117        let original = Template::Metacpan {
1118            dist: "MetaCPAN-Client".to_string(),
1119            version_type: None,
1120        };
1121        let expanded = expand_template(original.clone());
1122
1123        let detected = detect_template(
1124            expanded.source.as_deref(),
1125            expanded.matching_pattern.as_deref(),
1126            expanded.searchmode.as_deref(),
1127            expanded.mode.as_deref(),
1128        );
1129
1130        assert_eq!(detected, Some(original));
1131    }
1132
1133    #[test]
1134    fn test_roundtrip_github_with_version_type() {
1135        let original = Template::GitHub {
1136            owner: "foo".to_string(),
1137            repository: "bar".to_string(),
1138            release_only: true,
1139            version_type: Some("stable".to_string()),
1140        };
1141        let expanded = expand_template(original.clone());
1142
1143        let detected = detect_template(
1144            expanded.source.as_deref(),
1145            expanded.matching_pattern.as_deref(),
1146            expanded.searchmode.as_deref(),
1147            expanded.mode.as_deref(),
1148        );
1149
1150        assert_eq!(detected, Some(original));
1151    }
1152
1153    #[test]
1154    fn test_detect_with_none_source() {
1155        // Should return None if source is None
1156        let template = detect_template(
1157            None,
1158            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
1159            Some("html"),
1160            None,
1161        );
1162
1163        assert_eq!(template, None);
1164    }
1165
1166    #[test]
1167    fn test_detect_github_partial_match() {
1168        // Right URL but wrong pattern
1169        let template = detect_template(
1170            Some("https://github.com/torvalds/linux/tags"),
1171            Some(r".*/v?(\d+\.\d+)\.tar\.gz"), // Not a template pattern
1172            Some("html"),
1173            None,
1174        );
1175
1176        // Should still detect GitHub but won't have version_type
1177        assert_eq!(
1178            template,
1179            Some(Template::GitHub {
1180                owner: "torvalds".to_string(),
1181                repository: "linux".to_string(),
1182                release_only: false,
1183                version_type: None,
1184            })
1185        );
1186    }
1187
1188    #[test]
1189    fn test_extract_version_type_edge_cases() {
1190        // Multiple @ symbols
1191        assert_eq!(extract_version_type("@FOO@@BAR@"), None);
1192
1193        // Only one @ symbol
1194        assert_eq!(extract_version_type("@INCOMPLETE"), None);
1195
1196        // Not ending with _VERSION
1197        assert_eq!(extract_version_type("@SOMETHING@"), None);
1198
1199        // Empty between @
1200        assert_eq!(extract_version_type("@@"), None);
1201    }
1202}