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    /// CRAN template
97    Cran {
98        /// Package name
99        package: String,
100        /// Version type pattern to use
101        version_type: Option<String>,
102    },
103    /// Bioconductor template
104    Bioconductor {
105        /// Package name
106        package: String,
107        /// Version type pattern to use
108        version_type: Option<String>,
109    },
110}
111
112/// Expanded template fields
113#[derive(Debug, Clone, Default)]
114pub struct ExpandedTemplate {
115    /// Source URL
116    pub source: Option<String>,
117    /// Matching pattern
118    pub matching_pattern: Option<String>,
119    /// Search mode
120    pub searchmode: Option<String>,
121    /// Mode
122    pub mode: Option<String>,
123    /// PGP mode
124    pub pgpmode: Option<String>,
125    /// Download URL mangle
126    pub downloadurlmangle: Option<String>,
127}
128
129/// Expand a template into field values
130pub fn expand_template(template: Template) -> ExpandedTemplate {
131    match template {
132        Template::GitHub {
133            owner,
134            repository,
135            release_only,
136            version_type,
137        } => expand_github_template(owner, repository, release_only, version_type),
138        Template::GitLab {
139            dist,
140            release_only,
141            version_type,
142        } => expand_gitlab_template(dist, release_only, version_type),
143        Template::PyPI {
144            package,
145            version_type,
146        } => expand_pypi_template(package, version_type),
147        Template::Npmregistry {
148            package,
149            version_type,
150        } => expand_npmregistry_template(package, version_type),
151        Template::Metacpan { dist, version_type } => expand_metacpan_template(dist, version_type),
152        Template::Cran {
153            package,
154            version_type,
155        } => expand_cran_template(package, version_type),
156        Template::Bioconductor {
157            package,
158            version_type,
159        } => expand_bioconductor_template(package, version_type),
160    }
161}
162
163/// Expand GitHub template
164fn expand_github_template(
165    owner: String,
166    repository: String,
167    release_only: bool,
168    version_type: Option<String>,
169) -> ExpandedTemplate {
170    let version_pattern = version_type
171        .as_deref()
172        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
173        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
174
175    let source = if release_only {
176        format!("https://github.com/{}/{}/releases", owner, repository)
177    } else {
178        format!("https://github.com/{}/{}/tags", owner, repository)
179    };
180
181    let matching_pattern = format!(
182        r".*/(?:refs/tags/)?v?{}{}",
183        version_pattern, "@ARCHIVE_EXT@"
184    );
185
186    ExpandedTemplate {
187        source: Some(source),
188        matching_pattern: Some(matching_pattern),
189        searchmode: Some("html".to_string()),
190        ..Default::default()
191    }
192}
193
194/// Expand GitLab template
195fn expand_gitlab_template(
196    dist: String,
197    _release_only: bool,
198    version_type: Option<String>,
199) -> ExpandedTemplate {
200    let version_pattern = version_type
201        .as_deref()
202        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
203        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
204
205    // GitLab uses mode=gitlab
206    ExpandedTemplate {
207        source: Some(dist),
208        matching_pattern: Some(format!(r".*/v?{}{}", version_pattern, "@ARCHIVE_EXT@")),
209        mode: Some("gitlab".to_string()),
210        ..Default::default()
211    }
212}
213
214/// Expand PyPI template
215fn expand_pypi_template(package: String, version_type: Option<String>) -> ExpandedTemplate {
216    let version_pattern = version_type
217        .as_deref()
218        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
219        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
220
221    ExpandedTemplate {
222        source: Some(format!("https://pypi.debian.net/{}/", package)),
223        matching_pattern: Some(format!(
224            r"https://pypi\.debian\.net/{}/[^/]+\.tar\.gz#/.*-{}\.tar\.gz",
225            package, version_pattern
226        )),
227        searchmode: Some("plain".to_string()),
228        ..Default::default()
229    }
230}
231
232/// Expand Npmregistry template
233fn expand_npmregistry_template(package: 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    // npm package names might have @ prefix for scoped packages
240    let package_name = package.trim_start_matches('@');
241
242    ExpandedTemplate {
243        source: Some(format!("https://registry.npmjs.org/{}", package)),
244        matching_pattern: Some(format!(
245            r"https://registry\.npmjs\.org/{}/-/.*-{}@ARCHIVE_EXT@",
246            package_name.replace('/', r"\/"),
247            version_pattern
248        )),
249        searchmode: Some("plain".to_string()),
250        ..Default::default()
251    }
252}
253
254/// Expand Metacpan template
255fn expand_metacpan_template(dist: String, version_type: Option<String>) -> ExpandedTemplate {
256    let version_pattern = version_type
257        .as_deref()
258        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
259        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
260
261    // MetaCPAN dist names can use :: or -
262    let dist_name = dist.replace("::", "-");
263
264    ExpandedTemplate {
265        source: Some("https://cpan.metacpan.org/authors/id/".to_string()),
266        matching_pattern: Some(format!(r".*/{}{}@ARCHIVE_EXT@", dist_name, version_pattern)),
267        searchmode: Some("plain".to_string()),
268        ..Default::default()
269    }
270}
271
272/// Expand CRAN template
273fn expand_cran_template(package: String, version_type: Option<String>) -> ExpandedTemplate {
274    let version_pattern = version_type
275        .as_deref()
276        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
277        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
278
279    ExpandedTemplate {
280        source: Some(format!("https://cran.r-project.org/package={}", package)),
281        matching_pattern: Some(format!(".*_{}.tar.gz", version_pattern)),
282        downloadurlmangle: Some(
283            "s%.*/src/contrib/%https://cran.r-project.org/src/contrib/%".to_string(),
284        ),
285        ..Default::default()
286    }
287}
288
289/// Expand Bioconductor template
290fn expand_bioconductor_template(package: String, version_type: Option<String>) -> ExpandedTemplate {
291    let version_pattern = version_type
292        .as_deref()
293        .map(|v| format!("@{}_VERSION@", v.to_uppercase()))
294        .unwrap_or_else(|| "@ANY_VERSION@".to_string());
295
296    ExpandedTemplate {
297        source: Some(format!("https://bioconductor.org/packages/{}", package)),
298        matching_pattern: Some(format!(".*_{}.tar.gz", version_pattern)),
299        downloadurlmangle: Some(
300            "s%.*/src/contrib/%https://bioconductor.org/packages/release/bioc/src/contrib/%"
301                .to_string(),
302        ),
303        ..Default::default()
304    }
305}
306
307/// Try to detect if the given fields match a known template pattern
308/// and return the corresponding Template if a match is found.
309///
310/// This is the reverse of `expand_template` - it analyzes expanded fields
311/// and tries to identify which template would produce them.
312///
313/// # Arguments
314///
315/// * `source` - The Source URL
316/// * `matching_pattern` - The Matching-Pattern
317/// * `searchmode` - The Searchmode field (if any)
318/// * `mode` - The Mode field (if any)
319///
320/// # Returns
321///
322/// Returns `Some(Template)` if the fields match a known template pattern,
323/// `None` otherwise.
324pub fn detect_template(
325    source: Option<&str>,
326    matching_pattern: Option<&str>,
327    searchmode: Option<&str>,
328    mode: Option<&str>,
329) -> Option<Template> {
330    let source = source?;
331
332    // Try GitHub template detection
333    if let Some(template) = detect_github_template(source, matching_pattern, searchmode) {
334        return Some(template);
335    }
336
337    // Try GitLab template detection
338    if let Some(template) = detect_gitlab_template(source, matching_pattern, mode) {
339        return Some(template);
340    }
341
342    // Try PyPI template detection
343    if let Some(template) = detect_pypi_template(source, matching_pattern, searchmode) {
344        return Some(template);
345    }
346
347    // Try Npmregistry template detection
348    if let Some(template) = detect_npmregistry_template(source, matching_pattern, searchmode) {
349        return Some(template);
350    }
351
352    // Try Metacpan template detection
353    if let Some(template) = detect_metacpan_template(source, matching_pattern, searchmode) {
354        return Some(template);
355    }
356
357    // Try CRAN template detection
358    if let Some(template) = detect_cran_template(source, matching_pattern) {
359        return Some(template);
360    }
361
362    // Try CRAN detection from old-style source URL (e.g. converted from v4 where
363    // URL and pattern were combined, or from explicit Source/Matching-Pattern)
364    if let Some(template) = detect_cran_from_source_url(source, matching_pattern) {
365        return Some(template);
366    }
367
368    // Try CRAN detection from source URL with embedded pattern (no separate
369    // Matching-Pattern field, common after v4 → v5 conversion)
370    if matching_pattern.is_none() {
371        if let Some(template) = detect_cran_from_inline_url(source) {
372            return Some(template);
373        }
374        if let Some(template) = detect_bioconductor_from_inline_url(source) {
375            return Some(template);
376        }
377    }
378
379    // Try Bioconductor template detection
380    if let Some(template) = detect_bioconductor_template(source, matching_pattern) {
381        return Some(template);
382    }
383
384    None
385}
386
387/// Detect GitHub template
388fn detect_github_template(
389    source: &str,
390    matching_pattern: Option<&str>,
391    searchmode: Option<&str>,
392) -> Option<Template> {
393    // Check searchmode is html
394    if searchmode != Some("html") && searchmode.is_some() {
395        return None;
396    }
397
398    // Parse source URL to extract owner and repository
399    let release_only = if source.ends_with("/releases") {
400        true
401    } else if source.ends_with("/tags") {
402        false
403    } else {
404        return None;
405    };
406
407    // Extract owner/repo from URL
408    let url_without_suffix = if release_only {
409        source.strip_suffix("/releases")?
410    } else {
411        source.strip_suffix("/tags")?
412    };
413
414    let (owner, repository) = if let Ok(parsed) = url::Url::parse(url_without_suffix) {
415        if parsed.host_str() != Some("github.com") {
416            return None;
417        }
418        let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
419        let parts: Vec<&str> = path.split('/').collect();
420        if parts.len() != 2 {
421            return None;
422        }
423        (parts[0].to_string(), parts[1].to_string())
424    } else {
425        return None;
426    };
427
428    // Try to detect version_type from matching pattern
429    let version_type = if let Some(pattern) = matching_pattern {
430        extract_version_type(pattern)
431    } else {
432        None
433    };
434
435    Some(Template::GitHub {
436        owner,
437        repository,
438        release_only,
439        version_type,
440    })
441}
442
443/// Detect GitLab template
444fn detect_gitlab_template(
445    source: &str,
446    matching_pattern: Option<&str>,
447    mode: Option<&str>,
448) -> Option<Template> {
449    // Check mode is gitlab
450    if mode != Some("gitlab") {
451        return None;
452    }
453
454    // Try to detect version_type from matching pattern
455    let version_type = if let Some(pattern) = matching_pattern {
456        extract_version_type(pattern)
457    } else {
458        None
459    };
460
461    Some(Template::GitLab {
462        dist: source.to_string(),
463        release_only: false, // GitLab template doesn't use release_only
464        version_type,
465    })
466}
467
468/// Detect PyPI template
469fn detect_pypi_template(
470    source: &str,
471    matching_pattern: Option<&str>,
472    searchmode: Option<&str>,
473) -> Option<Template> {
474    // Check searchmode is plain
475    if searchmode != Some("plain") && searchmode.is_some() {
476        return None;
477    }
478
479    // Check if source matches PyPI pattern
480    if !source.starts_with("https://pypi.debian.net/") {
481        return None;
482    }
483
484    let remainder = source
485        .strip_prefix("https://pypi.debian.net/")?
486        .trim_end_matches('/');
487
488    // Extract only the package name (first path component)
489    let package = match remainder.split_once('/') {
490        Some((pkg, _)) => pkg,
491        None => remainder,
492    };
493
494    // Try to detect version_type from matching pattern
495    let version_type = if let Some(pattern) = matching_pattern {
496        extract_version_type(pattern)
497    } else {
498        None
499    };
500
501    Some(Template::PyPI {
502        package: package.to_string(),
503        version_type,
504    })
505}
506
507/// Detect Npmregistry template
508fn detect_npmregistry_template(
509    source: &str,
510    matching_pattern: Option<&str>,
511    searchmode: Option<&str>,
512) -> Option<Template> {
513    // Check searchmode is plain
514    if searchmode != Some("plain") && searchmode.is_some() {
515        return None;
516    }
517
518    // Check if source matches npm registry pattern
519    if !source.starts_with("https://registry.npmjs.org/") {
520        return None;
521    }
522
523    let package = source.strip_prefix("https://registry.npmjs.org/")?;
524
525    // Try to detect version_type from matching pattern
526    let version_type = if let Some(pattern) = matching_pattern {
527        extract_version_type(pattern)
528    } else {
529        None
530    };
531
532    Some(Template::Npmregistry {
533        package: package.to_string(),
534        version_type,
535    })
536}
537
538/// Detect Metacpan template
539fn detect_metacpan_template(
540    source: &str,
541    matching_pattern: Option<&str>,
542    searchmode: Option<&str>,
543) -> Option<Template> {
544    // Check searchmode is plain
545    if searchmode != Some("plain") && searchmode.is_some() {
546        return None;
547    }
548
549    if source == "https://cpan.metacpan.org/authors/id/" {
550        // Extract dist from matching pattern
551        let pattern = matching_pattern?;
552
553        // Pattern should be like: .*/DIST-NAME@VERSION@@ARCHIVE_EXT@
554        // We need to extract DIST-NAME
555        if !pattern.starts_with(".*/") {
556            return None;
557        }
558
559        let after_prefix = pattern.strip_prefix(".*/").unwrap();
560
561        // Find where the version pattern starts
562        let version_type = extract_version_type(pattern);
563
564        // Extract dist name - everything before the version pattern
565        // Strip optional trailing `-v?` or `-` before the version placeholder
566        let dist = if let Some(idx) = after_prefix.find('@') {
567            after_prefix[..idx]
568                .trim_end_matches("-v?")
569                .trim_end_matches('-')
570        } else {
571            return None;
572        };
573
574        Some(Template::Metacpan {
575            dist: dist.to_string(),
576            version_type,
577        })
578    } else if let Some(dist) = source
579        .strip_prefix("https://metacpan.org/release/")
580        .or_else(|| source.strip_prefix("https://metacpan.org/dist/"))
581    {
582        let dist = dist.trim_end_matches('/');
583        if dist.is_empty() {
584            return None;
585        }
586
587        let version_type = matching_pattern.and_then(extract_version_type);
588
589        Some(Template::Metacpan {
590            dist: dist.to_string(),
591            version_type,
592        })
593    } else {
594        None
595    }
596}
597
598/// Detect CRAN template
599fn detect_cran_template(source: &str, matching_pattern: Option<&str>) -> Option<Template> {
600    // Check if source matches CRAN package URL pattern
601    let package = source.strip_prefix("https://cran.r-project.org/package=")?;
602
603    if package.is_empty() {
604        return None;
605    }
606
607    let version_type = matching_pattern.and_then(extract_version_type);
608
609    Some(Template::Cran {
610        package: package.to_string(),
611        version_type,
612    })
613}
614
615/// Detect CRAN template from old-style source URL (pre-template format)
616///
617/// Matches URLs like `https://cran.r-project.org/src/contrib/` or
618/// `https://cloud.r-project.org/src/contrib/` with a matching pattern
619/// that contains the package name.
620pub fn detect_cran_from_source_url(
621    source: &str,
622    matching_pattern: Option<&str>,
623) -> Option<Template> {
624    // Check for CRAN contrib URLs (both cran.r-project.org and cloud.r-project.org)
625    if source != "https://cran.r-project.org/src/contrib/"
626        && source != "https://cloud.r-project.org/src/contrib/"
627    {
628        return None;
629    }
630
631    // Extract package name from matching pattern like "forecast_([-.\d]*)\.tar\.gz"
632    let pattern = matching_pattern?;
633    let package = pattern.split('_').next()?;
634    if package.is_empty() {
635        return None;
636    }
637
638    let version_type = extract_version_type(pattern);
639
640    Some(Template::Cran {
641        package: package.to_string(),
642        version_type,
643    })
644}
645
646/// Detect CRAN template from an inline URL where the source URL contains both
647/// the base URL and the matching pattern (common after v4 → v5 conversion).
648///
649/// Matches URLs like `https://cran.r-project.org/src/contrib/forecast_([-\d.]*)\\.tar\\.gz`
650/// or `https://cloud.r-project.org/src/contrib/forecast_([-\d.]*)\\.tar\\.gz`
651fn detect_cran_from_inline_url(source: &str) -> Option<Template> {
652    let remainder = source
653        .strip_prefix("https://cran.r-project.org/src/contrib/")
654        .or_else(|| source.strip_prefix("https://cloud.r-project.org/src/contrib/"))?;
655
656    // Extract package name: everything before the first underscore
657    let package = remainder.split('_').next()?;
658    if package.is_empty() {
659        return None;
660    }
661
662    Some(Template::Cran {
663        package: package.to_string(),
664        version_type: None,
665    })
666}
667
668/// Detect Bioconductor template from an inline URL where the source URL contains both
669/// the base URL and the matching pattern.
670fn detect_bioconductor_from_inline_url(source: &str) -> Option<Template> {
671    let remainder =
672        source.strip_prefix("https://bioconductor.org/packages/release/bioc/src/contrib/")?;
673
674    let package = remainder.split('_').next()?;
675    if package.is_empty() {
676        return None;
677    }
678
679    Some(Template::Bioconductor {
680        package: package.to_string(),
681        version_type: None,
682    })
683}
684
685/// Detect Bioconductor template
686fn detect_bioconductor_template(source: &str, matching_pattern: Option<&str>) -> Option<Template> {
687    // Check if source matches Bioconductor package URL pattern
688    let package = source.strip_prefix("https://bioconductor.org/packages/")?;
689
690    if package.is_empty() {
691        return None;
692    }
693
694    let version_type = matching_pattern.and_then(extract_version_type);
695
696    Some(Template::Bioconductor {
697        package: package.to_string(),
698        version_type,
699    })
700}
701
702/// Extract version type from a matching pattern
703/// Returns None for @ANY_VERSION@, Some(type) for specific types
704fn extract_version_type(pattern: &str) -> Option<String> {
705    // Look for @XXXXX_VERSION@ or @ANY_VERSION@ patterns
706    if pattern.contains("@ANY_VERSION@") {
707        None
708    } else if let Some(start) = pattern.find('@') {
709        if let Some(end) = pattern[start + 1..].find('@') {
710            let version_str = &pattern[start + 1..start + 1 + end];
711            if version_str.ends_with("_VERSION") {
712                let type_str = version_str.strip_suffix("_VERSION")?;
713                Some(type_str.to_lowercase())
714            } else {
715                None
716            }
717        } else {
718            None
719        }
720    } else {
721        None
722    }
723}
724
725/// Parse GitHub URL or owner/repository to extract owner and repository
726pub fn parse_github_url(url: &str) -> Result<(String, String), TemplateError> {
727    let url = url.trim_end_matches('/');
728
729    // Try to parse as URL
730    if let Ok(parsed) = url::Url::parse(url) {
731        if parsed.host_str() == Some("github.com") {
732            let path = parsed.path().trim_start_matches('/').trim_end_matches('/');
733            let parts: Vec<&str> = path.split('/').collect();
734            if parts.len() >= 2 {
735                return Ok((parts[0].to_string(), parts[1].to_string()));
736            }
737        }
738    }
739
740    // Try to parse as owner/repository
741    let parts: Vec<&str> = url.split('/').collect();
742    if parts.len() == 2 {
743        return Ok((parts[0].to_string(), parts[1].to_string()));
744    }
745
746    Err(TemplateError::InvalidValue {
747        field: "Dist".to_string(),
748        reason: format!("Could not parse GitHub URL: {}", url),
749    })
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn test_github_template_with_owner_repository() {
758        let template = Template::GitHub {
759            owner: "torvalds".to_string(),
760            repository: "linux".to_string(),
761            release_only: false,
762            version_type: None,
763        };
764
765        let result = expand_template(template);
766        assert_eq!(
767            result.source,
768            Some("https://github.com/torvalds/linux/tags".to_string())
769        );
770        assert_eq!(
771            result.matching_pattern,
772            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
773        );
774    }
775
776    #[test]
777    fn test_github_template_release_only() {
778        let template = Template::GitHub {
779            owner: "test".to_string(),
780            repository: "project".to_string(),
781            release_only: true,
782            version_type: None,
783        };
784
785        let result = expand_template(template);
786        assert_eq!(
787            result.source,
788            Some("https://github.com/test/project/releases".to_string())
789        );
790    }
791
792    #[test]
793    fn test_parse_github_url() {
794        let (owner, repo) = parse_github_url("https://github.com/guimard/llng-docker").unwrap();
795        assert_eq!(owner, "guimard");
796        assert_eq!(repo, "llng-docker");
797
798        let (owner, repo) = parse_github_url("torvalds/linux").unwrap();
799        assert_eq!(owner, "torvalds");
800        assert_eq!(repo, "linux");
801    }
802
803    #[test]
804    fn test_pypi_template() {
805        let template = Template::PyPI {
806            package: "bitbox02".to_string(),
807            version_type: None,
808        };
809
810        let result = expand_template(template);
811        assert_eq!(
812            result.source,
813            Some("https://pypi.debian.net/bitbox02/".to_string())
814        );
815        assert_eq!(result.searchmode, Some("plain".to_string()));
816    }
817
818    #[test]
819    fn test_npmregistry_template() {
820        let template = Template::Npmregistry {
821            package: "@lemonldapng/handler".to_string(),
822            version_type: None,
823        };
824
825        let result = expand_template(template);
826        assert_eq!(
827            result.source,
828            Some("https://registry.npmjs.org/@lemonldapng/handler".to_string())
829        );
830        assert_eq!(result.searchmode, Some("plain".to_string()));
831    }
832
833    #[test]
834    fn test_gitlab_template() {
835        let template = Template::GitLab {
836            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
837            release_only: false,
838            version_type: None,
839        };
840
841        let result = expand_template(template);
842        assert_eq!(
843            result.source,
844            Some("https://salsa.debian.org/debian/devscripts".to_string())
845        );
846        assert_eq!(result.mode, Some("gitlab".to_string()));
847    }
848
849    #[test]
850    fn test_metacpan_template() {
851        let template = Template::Metacpan {
852            dist: "MetaCPAN-Client".to_string(),
853            version_type: None,
854        };
855
856        let result = expand_template(template);
857        assert_eq!(
858            result.source,
859            Some("https://cpan.metacpan.org/authors/id/".to_string())
860        );
861    }
862
863    #[test]
864    fn test_detect_github_template() {
865        let template = detect_template(
866            Some("https://github.com/torvalds/linux/tags"),
867            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
868            Some("html"),
869            None,
870        );
871
872        assert_eq!(
873            template,
874            Some(Template::GitHub {
875                owner: "torvalds".to_string(),
876                repository: "linux".to_string(),
877                release_only: false,
878                version_type: None,
879            })
880        );
881    }
882
883    #[test]
884    fn test_detect_github_template_releases() {
885        let template = detect_template(
886            Some("https://github.com/test/project/releases"),
887            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
888            Some("html"),
889            None,
890        );
891
892        assert_eq!(
893            template,
894            Some(Template::GitHub {
895                owner: "test".to_string(),
896                repository: "project".to_string(),
897                release_only: true,
898                version_type: None,
899            })
900        );
901    }
902
903    #[test]
904    fn test_detect_github_template_with_version_type() {
905        let template = detect_template(
906            Some("https://github.com/foo/bar/tags"),
907            Some(r".*/(?:refs/tags/)?v?@SEMANTIC_VERSION@@ARCHIVE_EXT@"),
908            Some("html"),
909            None,
910        );
911
912        assert_eq!(
913            template,
914            Some(Template::GitHub {
915                owner: "foo".to_string(),
916                repository: "bar".to_string(),
917                release_only: false,
918                version_type: Some("semantic".to_string()),
919            })
920        );
921    }
922
923    #[test]
924    fn test_detect_pypi_template() {
925        let template = detect_template(
926            Some("https://pypi.debian.net/bitbox02/"),
927            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
928            Some("plain"),
929            None,
930        );
931
932        assert_eq!(
933            template,
934            Some(Template::PyPI {
935                package: "bitbox02".to_string(),
936                version_type: None,
937            })
938        );
939    }
940
941    #[test]
942    fn test_detect_gitlab_template() {
943        let template = detect_template(
944            Some("https://salsa.debian.org/debian/devscripts"),
945            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
946            None,
947            Some("gitlab"),
948        );
949
950        assert_eq!(
951            template,
952            Some(Template::GitLab {
953                dist: "https://salsa.debian.org/debian/devscripts".to_string(),
954                release_only: false,
955                version_type: None,
956            })
957        );
958    }
959
960    #[test]
961    fn test_detect_npmregistry_template() {
962        let template = detect_template(
963            Some("https://registry.npmjs.org/@lemonldapng/handler"),
964            Some(
965                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
966            ),
967            Some("plain"),
968            None,
969        );
970
971        assert_eq!(
972            template,
973            Some(Template::Npmregistry {
974                package: "@lemonldapng/handler".to_string(),
975                version_type: None,
976            })
977        );
978    }
979
980    #[test]
981    fn test_detect_metacpan_template() {
982        let template = detect_template(
983            Some("https://cpan.metacpan.org/authors/id/"),
984            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
985            Some("plain"),
986            None,
987        );
988
989        assert_eq!(
990            template,
991            Some(Template::Metacpan {
992                dist: "MetaCPAN-Client".to_string(),
993                version_type: None,
994            })
995        );
996    }
997
998    #[test]
999    fn test_detect_no_template() {
1000        // Non-template source
1001        let template = detect_template(
1002            Some("https://example.com/downloads/"),
1003            Some(r".*/v?(\d+\.\d+)\.tar\.gz"),
1004            Some("html"),
1005            None,
1006        );
1007
1008        assert_eq!(template, None);
1009    }
1010
1011    #[test]
1012    fn test_roundtrip_github_template() {
1013        // Expand template
1014        let original = Template::GitHub {
1015            owner: "torvalds".to_string(),
1016            repository: "linux".to_string(),
1017            release_only: false,
1018            version_type: None,
1019        };
1020        let expanded = expand_template(original.clone());
1021
1022        // Detect template from expanded fields
1023        let detected = detect_template(
1024            expanded.source.as_deref(),
1025            expanded.matching_pattern.as_deref(),
1026            expanded.searchmode.as_deref(),
1027            expanded.mode.as_deref(),
1028        );
1029
1030        assert_eq!(detected, Some(original));
1031    }
1032
1033    #[test]
1034    fn test_extract_version_type() {
1035        assert_eq!(extract_version_type("@ANY_VERSION@"), None);
1036        assert_eq!(
1037            extract_version_type("@SEMANTIC_VERSION@"),
1038            Some("semantic".to_string())
1039        );
1040        assert_eq!(
1041            extract_version_type("@STABLE_VERSION@"),
1042            Some("stable".to_string())
1043        );
1044        assert_eq!(extract_version_type("no-template-here"), None);
1045    }
1046
1047    #[test]
1048    fn test_detect_github_wrong_searchmode() {
1049        // GitHub template requires searchmode=html or None
1050        let template = detect_template(
1051            Some("https://github.com/torvalds/linux/tags"),
1052            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
1053            Some("plain"), // Wrong searchmode
1054            None,
1055        );
1056
1057        assert_eq!(template, None);
1058    }
1059
1060    #[test]
1061    fn test_detect_github_invalid_url() {
1062        // URL doesn't end with /tags or /releases
1063        let template = detect_template(
1064            Some("https://github.com/torvalds/linux"),
1065            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
1066            Some("html"),
1067            None,
1068        );
1069
1070        assert_eq!(template, None);
1071    }
1072
1073    #[test]
1074    fn test_detect_github_wrong_host() {
1075        // Not github.com
1076        let template = detect_template(
1077            Some("https://gitlab.com/foo/bar/tags"),
1078            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"),
1079            Some("html"),
1080            None,
1081        );
1082
1083        assert_eq!(template, None);
1084    }
1085
1086    #[test]
1087    fn test_detect_gitlab_without_mode() {
1088        // GitLab template requires mode=gitlab
1089        let template = detect_template(
1090            Some("https://salsa.debian.org/debian/devscripts"),
1091            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
1092            None,
1093            None, // Missing mode=gitlab
1094        );
1095
1096        assert_eq!(template, None);
1097    }
1098
1099    #[test]
1100    fn test_detect_pypi_source_with_inline_pattern() {
1101        // When the Source URL contains the matching pattern inline
1102        // (e.g., from old-style watch files), only the package name should be extracted
1103        let template = detect_template(
1104            Some("https://pypi.debian.net/dulwich/dulwich-(.*).tar.gz"),
1105            None,
1106            None,
1107            None,
1108        );
1109
1110        assert_eq!(
1111            template,
1112            Some(Template::PyPI {
1113                package: "dulwich".to_string(),
1114                version_type: None,
1115            })
1116        );
1117    }
1118
1119    #[test]
1120    fn test_detect_pypi_wrong_searchmode() {
1121        let template = detect_template(
1122            Some("https://pypi.debian.net/bitbox02/"),
1123            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
1124            Some("html"), // Wrong searchmode
1125            None,
1126        );
1127
1128        assert_eq!(template, None);
1129    }
1130
1131    #[test]
1132    fn test_detect_pypi_wrong_url() {
1133        let template = detect_template(
1134            Some("https://pypi.org/bitbox02/"), // Wrong domain
1135            Some(r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"),
1136            Some("plain"),
1137            None,
1138        );
1139
1140        assert_eq!(template, None);
1141    }
1142
1143    #[test]
1144    fn test_detect_npmregistry_wrong_url() {
1145        let template = detect_template(
1146            Some("https://npm.example.com/@lemonldapng/handler"), // Wrong domain
1147            Some(
1148                r"https://registry\.npmjs\.org/lemonldapng/handler/-/.*-@ANY_VERSION@@ARCHIVE_EXT@",
1149            ),
1150            Some("plain"),
1151            None,
1152        );
1153
1154        assert_eq!(template, None);
1155    }
1156
1157    #[test]
1158    fn test_detect_metacpan_wrong_source() {
1159        let template = detect_template(
1160            Some("https://cpan.example.com/authors/id/"), // Wrong URL
1161            Some(r".*/MetaCPAN-Client@ANY_VERSION@@ARCHIVE_EXT@"),
1162            Some("plain"),
1163            None,
1164        );
1165
1166        assert_eq!(template, None);
1167    }
1168
1169    #[test]
1170    fn test_detect_metacpan_missing_pattern() {
1171        let template = detect_template(
1172            Some("https://cpan.metacpan.org/authors/id/"),
1173            None, // Missing pattern needed to extract dist
1174            Some("plain"),
1175            None,
1176        );
1177
1178        assert_eq!(template, None);
1179    }
1180
1181    #[test]
1182    fn test_detect_metacpan_release_url() {
1183        let template = detect_template(
1184            Some("https://metacpan.org/release/Time-ParseDate"),
1185            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1186            None,
1187            None,
1188        );
1189
1190        assert_eq!(
1191            template,
1192            Some(Template::Metacpan {
1193                dist: "Time-ParseDate".to_string(),
1194                version_type: None,
1195            })
1196        );
1197    }
1198
1199    #[test]
1200    fn test_detect_metacpan_dist_url() {
1201        let template = detect_template(
1202            Some("https://metacpan.org/dist/Mail-AuthenticationResults"),
1203            Some(r".*/Mail-AuthenticationResults-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1204            None,
1205            None,
1206        );
1207
1208        assert_eq!(
1209            template,
1210            Some(Template::Metacpan {
1211                dist: "Mail-AuthenticationResults".to_string(),
1212                version_type: None,
1213            })
1214        );
1215    }
1216
1217    #[test]
1218    fn test_detect_metacpan_cpan_url_with_v_prefix() {
1219        // Pattern with `-v?` before version placeholder (common in the wild)
1220        let template = detect_template(
1221            Some("https://cpan.metacpan.org/authors/id/"),
1222            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@"),
1223            Some("plain"),
1224            None,
1225        );
1226
1227        assert_eq!(
1228            template,
1229            Some(Template::Metacpan {
1230                dist: "Time-ParseDate".to_string(),
1231                version_type: None,
1232            })
1233        );
1234    }
1235
1236    #[test]
1237    fn test_detect_metacpan_release_url_wrong_domain() {
1238        let template = detect_template(
1239            Some("https://example.org/release/Time-ParseDate"),
1240            Some(r".*/Time-ParseDate-v?@ANY_VERSION@@ARCHIVE_EXT@$"),
1241            None,
1242            None,
1243        );
1244
1245        assert_eq!(template, None);
1246    }
1247
1248    #[test]
1249    fn test_roundtrip_gitlab_template() {
1250        let original = Template::GitLab {
1251            dist: "https://salsa.debian.org/debian/devscripts".to_string(),
1252            release_only: false,
1253            version_type: None,
1254        };
1255        let expanded = expand_template(original.clone());
1256
1257        let detected = detect_template(
1258            expanded.source.as_deref(),
1259            expanded.matching_pattern.as_deref(),
1260            expanded.searchmode.as_deref(),
1261            expanded.mode.as_deref(),
1262        );
1263
1264        assert_eq!(detected, Some(original));
1265    }
1266
1267    #[test]
1268    fn test_roundtrip_pypi_template() {
1269        let original = Template::PyPI {
1270            package: "bitbox02".to_string(),
1271            version_type: None,
1272        };
1273        let expanded = expand_template(original.clone());
1274
1275        let detected = detect_template(
1276            expanded.source.as_deref(),
1277            expanded.matching_pattern.as_deref(),
1278            expanded.searchmode.as_deref(),
1279            expanded.mode.as_deref(),
1280        );
1281
1282        assert_eq!(detected, Some(original));
1283    }
1284
1285    #[test]
1286    fn test_roundtrip_npmregistry_template() {
1287        let original = Template::Npmregistry {
1288            package: "@scope/package".to_string(),
1289            version_type: None,
1290        };
1291        let expanded = expand_template(original.clone());
1292
1293        let detected = detect_template(
1294            expanded.source.as_deref(),
1295            expanded.matching_pattern.as_deref(),
1296            expanded.searchmode.as_deref(),
1297            expanded.mode.as_deref(),
1298        );
1299
1300        assert_eq!(detected, Some(original));
1301    }
1302
1303    #[test]
1304    fn test_roundtrip_metacpan_template() {
1305        let original = Template::Metacpan {
1306            dist: "MetaCPAN-Client".to_string(),
1307            version_type: None,
1308        };
1309        let expanded = expand_template(original.clone());
1310
1311        let detected = detect_template(
1312            expanded.source.as_deref(),
1313            expanded.matching_pattern.as_deref(),
1314            expanded.searchmode.as_deref(),
1315            expanded.mode.as_deref(),
1316        );
1317
1318        assert_eq!(detected, Some(original));
1319    }
1320
1321    #[test]
1322    fn test_roundtrip_github_with_version_type() {
1323        let original = Template::GitHub {
1324            owner: "foo".to_string(),
1325            repository: "bar".to_string(),
1326            release_only: true,
1327            version_type: Some("stable".to_string()),
1328        };
1329        let expanded = expand_template(original.clone());
1330
1331        let detected = detect_template(
1332            expanded.source.as_deref(),
1333            expanded.matching_pattern.as_deref(),
1334            expanded.searchmode.as_deref(),
1335            expanded.mode.as_deref(),
1336        );
1337
1338        assert_eq!(detected, Some(original));
1339    }
1340
1341    #[test]
1342    fn test_detect_with_none_source() {
1343        // Should return None if source is None
1344        let template = detect_template(
1345            None,
1346            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@"),
1347            Some("html"),
1348            None,
1349        );
1350
1351        assert_eq!(template, None);
1352    }
1353
1354    #[test]
1355    fn test_detect_github_partial_match() {
1356        // Right URL but wrong pattern
1357        let template = detect_template(
1358            Some("https://github.com/torvalds/linux/tags"),
1359            Some(r".*/v?(\d+\.\d+)\.tar\.gz"), // Not a template pattern
1360            Some("html"),
1361            None,
1362        );
1363
1364        // Should still detect GitHub but won't have version_type
1365        assert_eq!(
1366            template,
1367            Some(Template::GitHub {
1368                owner: "torvalds".to_string(),
1369                repository: "linux".to_string(),
1370                release_only: false,
1371                version_type: None,
1372            })
1373        );
1374    }
1375
1376    #[test]
1377    fn test_extract_version_type_edge_cases() {
1378        // Multiple @ symbols
1379        assert_eq!(extract_version_type("@FOO@@BAR@"), None);
1380
1381        // Only one @ symbol
1382        assert_eq!(extract_version_type("@INCOMPLETE"), None);
1383
1384        // Not ending with _VERSION
1385        assert_eq!(extract_version_type("@SOMETHING@"), None);
1386
1387        // Empty between @
1388        assert_eq!(extract_version_type("@@"), None);
1389    }
1390
1391    #[test]
1392    fn test_cran_template() {
1393        let template = Template::Cran {
1394            package: "forecast".to_string(),
1395            version_type: None,
1396        };
1397
1398        let result = expand_template(template);
1399        assert_eq!(
1400            result.source,
1401            Some("https://cran.r-project.org/package=forecast".to_string())
1402        );
1403        assert_eq!(
1404            result.matching_pattern,
1405            Some(".*_@ANY_VERSION@.tar.gz".to_string())
1406        );
1407        assert_eq!(
1408            result.downloadurlmangle,
1409            Some("s%.*/src/contrib/%https://cran.r-project.org/src/contrib/%".to_string())
1410        );
1411    }
1412
1413    #[test]
1414    fn test_bioconductor_template() {
1415        let template = Template::Bioconductor {
1416            package: "GenomicRanges".to_string(),
1417            version_type: None,
1418        };
1419
1420        let result = expand_template(template);
1421        assert_eq!(
1422            result.source,
1423            Some("https://bioconductor.org/packages/GenomicRanges".to_string())
1424        );
1425        assert_eq!(
1426            result.matching_pattern,
1427            Some(".*_@ANY_VERSION@.tar.gz".to_string())
1428        );
1429        assert_eq!(
1430            result.downloadurlmangle,
1431            Some(
1432                "s%.*/src/contrib/%https://bioconductor.org/packages/release/bioc/src/contrib/%"
1433                    .to_string()
1434            )
1435        );
1436    }
1437
1438    #[test]
1439    fn test_detect_cran_template() {
1440        let template = detect_template(
1441            Some("https://cran.r-project.org/package=forecast"),
1442            Some(".*_@ANY_VERSION@.tar.gz"),
1443            None,
1444            None,
1445        );
1446
1447        assert_eq!(
1448            template,
1449            Some(Template::Cran {
1450                package: "forecast".to_string(),
1451                version_type: None,
1452            })
1453        );
1454    }
1455
1456    #[test]
1457    fn test_detect_bioconductor_template() {
1458        let template = detect_template(
1459            Some("https://bioconductor.org/packages/GenomicRanges"),
1460            Some(".*_@ANY_VERSION@.tar.gz"),
1461            None,
1462            None,
1463        );
1464
1465        assert_eq!(
1466            template,
1467            Some(Template::Bioconductor {
1468                package: "GenomicRanges".to_string(),
1469                version_type: None,
1470            })
1471        );
1472    }
1473
1474    #[test]
1475    fn test_detect_cran_from_source_url() {
1476        let template = detect_cran_from_source_url(
1477            "https://cran.r-project.org/src/contrib/",
1478            Some(r"forecast_([-.\d]*)\.tar\.gz"),
1479        );
1480
1481        assert_eq!(
1482            template,
1483            Some(Template::Cran {
1484                package: "forecast".to_string(),
1485                version_type: None,
1486            })
1487        );
1488    }
1489
1490    #[test]
1491    fn test_detect_cran_from_cloud_url() {
1492        let template = detect_cran_from_source_url(
1493            "https://cloud.r-project.org/src/contrib/",
1494            Some(r"forecast_([-.\d]*)\.tar\.gz"),
1495        );
1496
1497        assert_eq!(
1498            template,
1499            Some(Template::Cran {
1500                package: "forecast".to_string(),
1501                version_type: None,
1502            })
1503        );
1504    }
1505
1506    #[test]
1507    fn test_detect_cran_from_source_url_no_match() {
1508        let template = detect_cran_from_source_url(
1509            "https://example.com/src/contrib/",
1510            Some(r"forecast_([-.\d]*)\.tar\.gz"),
1511        );
1512
1513        assert_eq!(template, None);
1514    }
1515
1516    #[test]
1517    fn test_roundtrip_cran_template() {
1518        let original = Template::Cran {
1519            package: "forecast".to_string(),
1520            version_type: None,
1521        };
1522        let expanded = expand_template(original.clone());
1523
1524        let detected = detect_template(
1525            expanded.source.as_deref(),
1526            expanded.matching_pattern.as_deref(),
1527            expanded.searchmode.as_deref(),
1528            expanded.mode.as_deref(),
1529        );
1530
1531        assert_eq!(detected, Some(original));
1532    }
1533
1534    #[test]
1535    fn test_roundtrip_bioconductor_template() {
1536        let original = Template::Bioconductor {
1537            package: "GenomicRanges".to_string(),
1538            version_type: None,
1539        };
1540        let expanded = expand_template(original.clone());
1541
1542        let detected = detect_template(
1543            expanded.source.as_deref(),
1544            expanded.matching_pattern.as_deref(),
1545            expanded.searchmode.as_deref(),
1546            expanded.mode.as_deref(),
1547        );
1548
1549        assert_eq!(detected, Some(original));
1550    }
1551
1552    #[test]
1553    fn test_detect_cran_from_inline_url() {
1554        // This is the exact case from Debian bug #1133114: a v4 watch file that gets
1555        // mechanically converted to v5 puts the entire URL+pattern into Source
1556        let template = detect_template(
1557            Some("https://cloud.r-project.org/src/contrib/forecast_([-\\d.]*)\\.tar\\.gz"),
1558            None,
1559            None,
1560            None,
1561        );
1562
1563        assert_eq!(
1564            template,
1565            Some(Template::Cran {
1566                package: "forecast".to_string(),
1567                version_type: None,
1568            })
1569        );
1570    }
1571
1572    #[test]
1573    fn test_detect_cran_from_inline_url_cran_domain() {
1574        let template = detect_template(
1575            Some("https://cran.r-project.org/src/contrib/gower_([-\\d.]*)\\.tar\\.gz"),
1576            None,
1577            None,
1578            None,
1579        );
1580
1581        assert_eq!(
1582            template,
1583            Some(Template::Cran {
1584                package: "gower".to_string(),
1585                version_type: None,
1586            })
1587        );
1588    }
1589}