Skip to main content

debian_watch/
deb822.rs

1//! Watch file implementation for format 5 (RFC822/deb822 style)
2use crate::types::ParseError as TypesParseError;
3use crate::VersionPolicy;
4use deb822_lossless::{Deb822, Paragraph};
5use std::str::FromStr;
6
7/// Get the deb822 field name for a WatchOption variant
8fn watch_option_to_key(option: &crate::types::WatchOption) -> &'static str {
9    use crate::types::WatchOption;
10
11    match option {
12        WatchOption::Component(_) => "Component",
13        WatchOption::Compression(_) => "Compression",
14        WatchOption::UserAgent(_) => "User-Agent",
15        WatchOption::Pagemangle(_) => "Pagemangle",
16        WatchOption::Uversionmangle(_) => "Uversionmangle",
17        WatchOption::Dversionmangle(_) => "Dversionmangle",
18        WatchOption::Dirversionmangle(_) => "Dirversionmangle",
19        WatchOption::Oversionmangle(_) => "Oversionmangle",
20        WatchOption::Downloadurlmangle(_) => "Downloadurlmangle",
21        WatchOption::Pgpsigurlmangle(_) => "Pgpsigurlmangle",
22        WatchOption::Filenamemangle(_) => "Filenamemangle",
23        WatchOption::VersionPolicy(_) => "Version-Policy",
24        WatchOption::Searchmode(_) => "Searchmode",
25        WatchOption::Mode(_) => "Mode",
26        WatchOption::Pgpmode(_) => "Pgpmode",
27        WatchOption::Gitexport(_) => "Gitexport",
28        WatchOption::Gitmode(_) => "Gitmode",
29        WatchOption::Pretty(_) => "Pretty",
30        WatchOption::Ctype(_) => "Ctype",
31        WatchOption::Repacksuffix(_) => "Repacksuffix",
32        WatchOption::Unzipopt(_) => "Unzipopt",
33        WatchOption::Script(_) => "Script",
34        WatchOption::Decompress => "Decompress",
35        WatchOption::Bare => "Bare",
36        WatchOption::Repack => "Repack",
37    }
38}
39
40#[derive(Debug)]
41/// Parse error for watch file parsing
42pub struct ParseError(String);
43
44impl std::error::Error for ParseError {}
45
46impl std::fmt::Display for ParseError {
47    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
48        write!(f, "ParseError: {}", self.0)
49    }
50}
51
52/// A watch file in format 5 (RFC822/deb822 style)
53#[derive(Debug)]
54pub struct WatchFile(Deb822);
55
56/// An entry in a format 5 watch file
57#[derive(Debug)]
58pub struct Entry {
59    paragraph: Paragraph,
60    defaults: Option<Paragraph>,
61}
62
63impl WatchFile {
64    /// Returns a reference to the underlying deb822 document.
65    pub fn as_deb822(&self) -> &Deb822 {
66        &self.0
67    }
68
69    /// Construct a WatchFile from an already-parsed Deb822 document.
70    ///
71    /// This avoids re-parsing when the caller already has a parsed tree.
72    /// No version validation is performed — the caller is responsible for
73    /// ensuring the document is a valid version 5 watch file.
74    pub(crate) fn from_deb822(deb822: Deb822) -> Self {
75        WatchFile(deb822)
76    }
77
78    /// Create a new empty format 5 watch file
79    pub fn new() -> Self {
80        // Create a minimal format 5 watch file from a string
81        let content = "Version: 5\n";
82        WatchFile::from_str(content).expect("Failed to create empty watch file")
83    }
84
85    /// Returns the version of the watch file (always 5 for this type)
86    pub fn version(&self) -> u32 {
87        5
88    }
89
90    /// Returns the defaults paragraph if it exists.
91    /// The defaults paragraph is the second paragraph (after Version) if it has no Source field.
92    pub fn defaults(&self) -> Option<Paragraph> {
93        let paragraphs: Vec<_> = self.0.paragraphs().collect();
94
95        if paragraphs.len() > 1 {
96            // Check if second paragraph looks like defaults (no Source field)
97            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
98                return Some(paragraphs[1].clone());
99            }
100        }
101
102        None
103    }
104
105    /// Returns an iterator over all entries in the watch file.
106    /// The first paragraph contains defaults, subsequent paragraphs are entries.
107    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
108        let paragraphs: Vec<_> = self.0.paragraphs().collect();
109        let defaults = self.defaults();
110
111        // Skip the first paragraph (version)
112        // The second paragraph (if it exists and has specific fields) contains defaults
113        // Otherwise all paragraphs are entries
114        let start_index = if paragraphs.len() > 1 {
115            // Check if second paragraph looks like defaults (no Source or Template field)
116            let has_source =
117                paragraphs[1].contains_key("Source") || paragraphs[1].contains_key("source");
118            let has_template =
119                paragraphs[1].contains_key("Template") || paragraphs[1].contains_key("template");
120
121            if !has_source && !has_template {
122                2 // Skip version and defaults
123            } else {
124                1 // Skip only version
125            }
126        } else {
127            1
128        };
129
130        paragraphs
131            .into_iter()
132            .skip(start_index)
133            .map(move |p| Entry {
134                paragraph: p,
135                defaults: defaults.clone(),
136            })
137    }
138
139    /// Get the underlying Deb822 object
140    pub fn inner(&self) -> &Deb822 {
141        &self.0
142    }
143
144    /// Get a mutable reference to the underlying Deb822 object
145    pub fn inner_mut(&mut self) -> &mut Deb822 {
146        &mut self.0
147    }
148
149    /// Add a new entry to the watch file with the given source and matching pattern.
150    /// Returns the newly created Entry.
151    ///
152    /// # Example
153    ///
154    /// ```
155    /// # #[cfg(feature = "deb822")]
156    /// # {
157    /// use debian_watch::deb822::WatchFile;
158    /// use debian_watch::WatchOption;
159    ///
160    /// let mut wf = WatchFile::new();
161    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
162    /// entry.set_option(WatchOption::Component("upstream".to_string()));
163    /// # }
164    /// ```
165    pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry {
166        let mut para = self.0.add_paragraph();
167        para.set("Source", source);
168        para.set("Matching-Pattern", matching_pattern);
169
170        // Create an Entry from the paragraph we just added
171        // Get the defaults paragraph if it exists
172        let defaults = self.defaults();
173
174        Entry {
175            paragraph: para.clone(),
176            defaults,
177        }
178    }
179}
180
181impl Default for WatchFile {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl FromStr for WatchFile {
188    type Err = ParseError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        match Deb822::from_str(s) {
192            Ok(deb822) => {
193                // Verify it's version 5
194                let version = deb822
195                    .paragraphs()
196                    .next()
197                    .and_then(|p| p.get("Version"))
198                    .unwrap_or_else(|| "1".to_string());
199
200                if version != "5" {
201                    return Err(ParseError(format!("Expected version 5, got {}", version)));
202                }
203
204                Ok(WatchFile(deb822))
205            }
206            Err(e) => Err(ParseError(e.to_string())),
207        }
208    }
209}
210
211impl std::fmt::Display for WatchFile {
212    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
213        write!(f, "{}", self.0)
214    }
215}
216
217impl Entry {
218    /// Get a field value from the entry, with fallback to defaults paragraph.
219    /// First checks the entry's own fields, then falls back to the defaults paragraph if present.
220    pub(crate) fn get_field(&self, key: &str) -> Option<String> {
221        // Try the key as-is first in the entry
222        if let Some(value) = self.paragraph.get(key) {
223            return Some(value);
224        }
225
226        // If not found, try with different case variations in the entry
227        // deb822-lossless is case-preserving, so we need to check all field names
228        let normalized_key = normalize_key(key);
229
230        // Iterate through all keys in the paragraph and check for normalized match
231        for (k, v) in self.paragraph.items() {
232            if normalize_key(&k) == normalized_key {
233                return Some(v);
234            }
235        }
236
237        // If not found in entry, check the defaults paragraph
238        if let Some(ref defaults) = self.defaults {
239            // Try the key as-is first in defaults
240            if let Some(value) = defaults.get(key) {
241                return Some(value);
242            }
243
244            // Try with case variations in defaults
245            for (k, v) in defaults.items() {
246                if normalize_key(&k) == normalized_key {
247                    return Some(v);
248                }
249            }
250        }
251
252        None
253    }
254
255    /// Returns the source URL, expanding templates if present
256    ///
257    /// Returns `Ok(None)` if no Source field is set and no template is present.
258    /// Returns `Err` if template expansion fails.
259    pub fn source(&self) -> Result<Option<String>, crate::templates::TemplateError> {
260        // First check if explicitly set
261        if let Some(source) = self.get_field("Source") {
262            return Ok(Some(source));
263        }
264
265        // If not set, check if there's a template to expand
266        if self.get_field("Template").is_none() {
267            return Ok(None);
268        }
269
270        // Template exists, expand it (propagate any errors)
271        self.expand_template().map(|t| t.source)
272    }
273
274    /// Returns the matching pattern, expanding templates if present
275    ///
276    /// Returns `Ok(None)` if no Matching-Pattern field is set and no template is present.
277    /// Returns `Err` if template expansion fails.
278    pub fn matching_pattern(&self) -> Result<Option<String>, crate::templates::TemplateError> {
279        // First check if explicitly set
280        if let Some(pattern) = self.get_field("Matching-Pattern") {
281            return Ok(Some(pattern));
282        }
283
284        // If not set, check if there's a template to expand
285        if self.get_field("Template").is_none() {
286            return Ok(None);
287        }
288
289        // Template exists, expand it (propagate any errors)
290        self.expand_template().map(|t| t.matching_pattern)
291    }
292
293    /// Get the underlying paragraph
294    pub fn as_deb822(&self) -> &Paragraph {
295        &self.paragraph
296    }
297
298    /// Name of the component, if specified
299    pub fn component(&self) -> Option<String> {
300        self.get_field("Component")
301    }
302
303    /// Get the an option value from the entry, with fallback to defaults paragraph.
304    pub fn get_option(&self, key: &str) -> Option<String> {
305        match key {
306            "Source" => None,           // Source is not an option
307            "Matching-Pattern" => None, // Matching-Pattern is not an option
308            "Component" => None,        // Component is not an option
309            "Version" => None,          // Version is not an option
310            key => self.get_field(key),
311        }
312    }
313
314    /// Set an option value in the entry using a WatchOption enum
315    pub fn set_option(&mut self, option: crate::types::WatchOption) {
316        use crate::types::WatchOption;
317
318        let (key, value) = match option {
319            WatchOption::Component(v) => ("Component", Some(v)),
320            WatchOption::Compression(v) => ("Compression", Some(v.to_string())),
321            WatchOption::UserAgent(v) => ("User-Agent", Some(v)),
322            WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)),
323            WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)),
324            WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)),
325            WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)),
326            WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)),
327            WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)),
328            WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)),
329            WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)),
330            WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())),
331            WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())),
332            WatchOption::Mode(v) => ("Mode", Some(v.to_string())),
333            WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())),
334            WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())),
335            WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())),
336            WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())),
337            WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())),
338            WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)),
339            WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)),
340            WatchOption::Script(v) => ("Script", Some(v)),
341            WatchOption::Decompress => ("Decompress", None),
342            WatchOption::Bare => ("Bare", None),
343            WatchOption::Repack => ("Repack", None),
344        };
345
346        if let Some(v) = value {
347            self.paragraph.set(key, &v);
348        } else {
349            // For boolean flags, set the key with empty value
350            self.paragraph.set(key, "");
351        }
352    }
353
354    /// Set an option value in the entry using string key and value (for backward compatibility)
355    pub fn set_option_str(&mut self, key: &str, value: &str) {
356        self.paragraph.set(key, value);
357    }
358
359    /// Delete an option from the entry using a WatchOption enum
360    pub fn delete_option(&mut self, option: crate::types::WatchOption) {
361        let key = watch_option_to_key(&option);
362        self.paragraph.remove(key);
363    }
364
365    /// Delete an option from the entry using a string key (for backward compatibility)
366    pub fn delete_option_str(&mut self, key: &str) {
367        self.paragraph.remove(key);
368    }
369
370    /// Get the URL (same as source() but named url() for consistency)
371    pub fn url(&self) -> String {
372        self.source().unwrap_or(None).unwrap_or_default()
373    }
374
375    /// Get the version policy
376    pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
377        match self.get_field("Version-Policy") {
378            Some(policy) => Ok(Some(policy.parse()?)),
379            None => Ok(None),
380        }
381    }
382
383    /// Get the script
384    pub fn script(&self) -> Option<String> {
385        self.get_field("Script")
386    }
387
388    /// Set the source URL
389    pub fn set_source(&mut self, url: &str) {
390        self.paragraph.set("Source", url);
391    }
392
393    /// Set the matching pattern
394    pub fn set_matching_pattern(&mut self, pattern: &str) {
395        self.paragraph.set("Matching-Pattern", pattern);
396    }
397
398    /// Get the line number (0-indexed) where this entry starts
399    pub fn line(&self) -> usize {
400        self.paragraph.line()
401    }
402
403    /// Retrieve the mode of the watch file entry with detailed error information.
404    pub fn mode(&self) -> Result<crate::types::Mode, TypesParseError> {
405        Ok(self
406            .get_field("Mode")
407            .map(|s| s.parse())
408            .transpose()?
409            .unwrap_or_default())
410    }
411
412    /// Expand template if present
413    fn expand_template(
414        &self,
415    ) -> Result<crate::templates::ExpandedTemplate, crate::templates::TemplateError> {
416        use crate::templates::{expand_template, parse_github_url, Template, TemplateError};
417
418        // Check if there's a Template field
419        let template_str =
420            self.get_field("Template")
421                .ok_or_else(|| TemplateError::MissingField {
422                    template: "any".to_string(),
423                    field: "Template".to_string(),
424                })?;
425
426        let release_only = self
427            .get_field("Release-Only")
428            .map(|v| v.to_lowercase() == "yes")
429            .unwrap_or(false);
430
431        let version_type = self.get_field("Version-Type");
432
433        // Build the appropriate Template enum variant
434        let template = match template_str.to_lowercase().as_str() {
435            "github" => {
436                // GitHub requires either Dist or Owner+Project
437                let (owner, repository) = if let (Some(o), Some(p)) =
438                    (self.get_field("Owner"), self.get_field("Project"))
439                {
440                    (o, p)
441                } else if let Some(dist) = self.get_field("Dist") {
442                    parse_github_url(&dist)?
443                } else {
444                    return Err(TemplateError::MissingField {
445                        template: "GitHub".to_string(),
446                        field: "Dist or Owner+Project".to_string(),
447                    });
448                };
449
450                Template::GitHub {
451                    owner,
452                    repository,
453                    release_only,
454                    version_type,
455                }
456            }
457            "gitlab" => {
458                let dist = self
459                    .get_field("Dist")
460                    .ok_or_else(|| TemplateError::MissingField {
461                        template: "GitLab".to_string(),
462                        field: "Dist".to_string(),
463                    })?;
464
465                Template::GitLab {
466                    dist,
467                    release_only,
468                    version_type,
469                }
470            }
471            "pypi" => {
472                let package =
473                    self.get_field("Dist")
474                        .ok_or_else(|| TemplateError::MissingField {
475                            template: "PyPI".to_string(),
476                            field: "Dist".to_string(),
477                        })?;
478
479                Template::PyPI {
480                    package,
481                    version_type,
482                }
483            }
484            "npmregistry" => {
485                let package =
486                    self.get_field("Dist")
487                        .ok_or_else(|| TemplateError::MissingField {
488                            template: "Npmregistry".to_string(),
489                            field: "Dist".to_string(),
490                        })?;
491
492                Template::Npmregistry {
493                    package,
494                    version_type,
495                }
496            }
497            "metacpan" => {
498                let dist = self
499                    .get_field("Dist")
500                    .ok_or_else(|| TemplateError::MissingField {
501                        template: "Metacpan".to_string(),
502                        field: "Dist".to_string(),
503                    })?;
504
505                Template::Metacpan { dist, version_type }
506            }
507            "cran" => {
508                let package =
509                    self.get_field("Package")
510                        .ok_or_else(|| TemplateError::MissingField {
511                            template: "CRAN".to_string(),
512                            field: "Package".to_string(),
513                        })?;
514
515                Template::Cran {
516                    package,
517                    version_type,
518                }
519            }
520            "bioconductor" => {
521                let package =
522                    self.get_field("Package")
523                        .ok_or_else(|| TemplateError::MissingField {
524                            template: "Bioconductor".to_string(),
525                            field: "Package".to_string(),
526                        })?;
527
528                Template::Bioconductor {
529                    package,
530                    version_type,
531                }
532            }
533            _ => return Err(TemplateError::UnknownTemplate(template_str)),
534        };
535
536        Ok(expand_template(template))
537    }
538
539    /// Try to detect if this entry matches a template pattern and convert it to use that template.
540    ///
541    /// This analyzes the Source, Matching-Pattern, Searchmode, and Mode fields to determine
542    /// if they match a known template pattern. If a match is found, the entry is converted
543    /// to use the template syntax instead.
544    ///
545    /// # Returns
546    ///
547    /// Returns `Some(template)` if a template was detected and applied, `None` if no
548    /// template matches the current entry configuration.
549    ///
550    /// # Example
551    ///
552    /// ```
553    /// # #[cfg(feature = "deb822")]
554    /// # {
555    /// use debian_watch::deb822::WatchFile;
556    ///
557    /// let mut wf = WatchFile::new();
558    /// let mut entry = wf.add_entry(
559    ///     "https://github.com/torvalds/linux/tags",
560    ///     r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"
561    /// );
562    /// entry.set_option_str("Searchmode", "html");
563    ///
564    /// // Convert to template
565    /// if let Some(template) = entry.try_convert_to_template() {
566    ///     println!("Converted to {:?}", template);
567    /// }
568    /// # }
569    /// ```
570    pub fn try_convert_to_template(&mut self) -> Option<crate::templates::Template> {
571        use crate::templates::detect_template;
572
573        // Get current field values
574        let source = self.source().ok().flatten();
575        let matching_pattern = self.matching_pattern().ok().flatten();
576        let searchmode = self.get_field("Searchmode");
577        let mode = self.get_field("Mode");
578
579        // Try to detect template
580        let template = detect_template(
581            source.as_deref(),
582            matching_pattern.as_deref(),
583            searchmode.as_deref(),
584            mode.as_deref(),
585        )?;
586
587        // Apply the template - remove old fields and add template fields
588        self.paragraph.remove("Source");
589        self.paragraph.remove("Matching-Pattern");
590        self.paragraph.remove("Searchmode");
591        self.paragraph.remove("Mode");
592
593        // Set template fields based on the detected template
594        match &template {
595            crate::templates::Template::GitHub {
596                owner,
597                repository,
598                release_only,
599                version_type,
600            } => {
601                self.paragraph.set("Template", "GitHub");
602                self.paragraph.set("Owner", owner);
603                self.paragraph.set("Project", repository);
604                if *release_only {
605                    self.paragraph.set("Release-Only", "yes");
606                }
607                if let Some(vt) = version_type {
608                    self.paragraph.set("Version-Type", vt);
609                }
610            }
611            crate::templates::Template::GitLab {
612                dist,
613                release_only: _,
614                version_type,
615            } => {
616                self.paragraph.set("Template", "GitLab");
617                self.paragraph.set("Dist", dist);
618                if let Some(vt) = version_type {
619                    self.paragraph.set("Version-Type", vt);
620                }
621            }
622            crate::templates::Template::PyPI {
623                package,
624                version_type,
625            } => {
626                self.paragraph.set("Template", "PyPI");
627                self.paragraph.set("Dist", package);
628                if let Some(vt) = version_type {
629                    self.paragraph.set("Version-Type", vt);
630                }
631            }
632            crate::templates::Template::Npmregistry {
633                package,
634                version_type,
635            } => {
636                self.paragraph.set("Template", "Npmregistry");
637                self.paragraph.set("Dist", package);
638                if let Some(vt) = version_type {
639                    self.paragraph.set("Version-Type", vt);
640                }
641            }
642            crate::templates::Template::Metacpan { dist, version_type } => {
643                self.paragraph.set("Template", "Metacpan");
644                self.paragraph.set("Dist", dist);
645                if let Some(vt) = version_type {
646                    self.paragraph.set("Version-Type", vt);
647                }
648            }
649            crate::templates::Template::Cran {
650                package,
651                version_type,
652            } => {
653                self.paragraph.set("Template", "CRAN");
654                self.paragraph.set("Package", &package);
655                if let Some(vt) = version_type {
656                    self.paragraph.set("Version-Type", vt);
657                }
658            }
659            crate::templates::Template::Bioconductor {
660                package,
661                version_type,
662            } => {
663                self.paragraph.set("Template", "Bioconductor");
664                self.paragraph.set("Package", &package);
665                if let Some(vt) = version_type {
666                    self.paragraph.set("Version-Type", vt);
667                }
668            }
669        }
670
671        Some(template)
672    }
673}
674
675/// Normalize a field key according to RFC822 rules:
676/// - Convert to lowercase
677/// - Hyphens and underscores are treated as equivalent
678fn normalize_key(key: &str) -> String {
679    key.to_lowercase().replace(['-', '_'], "")
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn test_as_deb822() {
688        let input = r#"Version: 5
689
690Source: https://github.com/owner/repo/tags
691Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
692"#;
693
694        let wf: WatchFile = input.parse().unwrap();
695        let deb822 = wf.as_deb822();
696
697        // Should have 2 paragraphs: version header + entry
698        assert_eq!(deb822.paragraphs().count(), 2);
699    }
700
701    #[test]
702    fn test_create_v5_watchfile() {
703        let wf = WatchFile::new();
704        assert_eq!(wf.version(), 5);
705
706        let output = wf.to_string();
707        assert!(output.contains("Version"));
708        assert!(output.contains("5"));
709    }
710
711    #[test]
712    fn test_parse_v5_basic() {
713        let input = r#"Version: 5
714
715Source: https://github.com/owner/repo/tags
716Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
717"#;
718
719        let wf: WatchFile = input.parse().unwrap();
720        assert_eq!(wf.version(), 5);
721
722        let entries: Vec<_> = wf.entries().collect();
723        assert_eq!(entries.len(), 1);
724
725        let entry = &entries[0];
726        assert_eq!(
727            entry.source().unwrap().as_deref(),
728            Some("https://github.com/owner/repo/tags")
729        );
730        assert_eq!(
731            entry.matching_pattern().unwrap(),
732            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
733        );
734    }
735
736    #[test]
737    fn test_parse_v5_multiple_entries() {
738        let input = r#"Version: 5
739
740Source: https://github.com/owner/repo1/tags
741Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
742
743Source: https://github.com/owner/repo2/tags
744Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
745"#;
746
747        let wf: WatchFile = input.parse().unwrap();
748        let entries: Vec<_> = wf.entries().collect();
749        assert_eq!(entries.len(), 2);
750
751        assert_eq!(
752            entries[0].source().unwrap().as_deref(),
753            Some("https://github.com/owner/repo1/tags")
754        );
755        assert_eq!(
756            entries[1].source().unwrap().as_deref(),
757            Some("https://github.com/owner/repo2/tags")
758        );
759    }
760
761    #[test]
762    fn test_v5_case_insensitive_fields() {
763        let input = r#"Version: 5
764
765source: https://example.com/files
766matching-pattern: .*\.tar\.gz
767"#;
768
769        let wf: WatchFile = input.parse().unwrap();
770        let entries: Vec<_> = wf.entries().collect();
771        assert_eq!(entries.len(), 1);
772
773        let entry = &entries[0];
774        assert_eq!(
775            entry.source().unwrap().as_deref(),
776            Some("https://example.com/files")
777        );
778        assert_eq!(
779            entry.matching_pattern().unwrap().as_deref(),
780            Some(".*\\.tar\\.gz")
781        );
782    }
783
784    #[test]
785    fn test_v5_with_compression_option() {
786        let input = r#"Version: 5
787
788Source: https://example.com/files
789Matching-Pattern: .*\.tar\.gz
790Compression: xz
791"#;
792
793        let wf: WatchFile = input.parse().unwrap();
794        let entries: Vec<_> = wf.entries().collect();
795        assert_eq!(entries.len(), 1);
796
797        let entry = &entries[0];
798        let compression = entry.get_option("compression");
799        assert!(compression.is_some());
800    }
801
802    #[test]
803    fn test_v5_with_component() {
804        let input = r#"Version: 5
805
806Source: https://example.com/files
807Matching-Pattern: .*\.tar\.gz
808Component: foo
809"#;
810
811        let wf: WatchFile = input.parse().unwrap();
812        let entries: Vec<_> = wf.entries().collect();
813        assert_eq!(entries.len(), 1);
814
815        let entry = &entries[0];
816        assert_eq!(entry.component(), Some("foo".to_string()));
817    }
818
819    #[test]
820    fn test_v5_rejects_wrong_version() {
821        let input = r#"Version: 4
822
823Source: https://example.com/files
824Matching-Pattern: .*\.tar\.gz
825"#;
826
827        let result: Result<WatchFile, _> = input.parse();
828        assert!(result.is_err());
829    }
830
831    #[test]
832    fn test_v5_roundtrip() {
833        let input = r#"Version: 5
834
835Source: https://example.com/files
836Matching-Pattern: .*\.tar\.gz
837"#;
838
839        let wf: WatchFile = input.parse().unwrap();
840        let output = wf.to_string();
841
842        // The output should be parseable again
843        let wf2: WatchFile = output.parse().unwrap();
844        assert_eq!(wf2.version(), 5);
845
846        let entries: Vec<_> = wf2.entries().collect();
847        assert_eq!(entries.len(), 1);
848    }
849
850    #[test]
851    fn test_normalize_key() {
852        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
853        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
854        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
855        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
856    }
857
858    #[test]
859    fn test_defaults_paragraph() {
860        let input = r#"Version: 5
861
862Compression: xz
863User-Agent: Custom/1.0
864
865Source: https://example.com/repo1
866Matching-Pattern: .*\.tar\.gz
867
868Source: https://example.com/repo2
869Matching-Pattern: .*\.tar\.gz
870Compression: gz
871"#;
872
873        let wf: WatchFile = input.parse().unwrap();
874
875        // Check that defaults paragraph is detected
876        let defaults = wf.defaults();
877        assert!(defaults.is_some());
878        let defaults = defaults.unwrap();
879        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
880        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
881
882        // Check that entries inherit from defaults
883        let entries: Vec<_> = wf.entries().collect();
884        assert_eq!(entries.len(), 2);
885
886        // First entry should inherit Compression and User-Agent from defaults
887        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
888        assert_eq!(
889            entries[0].get_option("User-Agent"),
890            Some("Custom/1.0".to_string())
891        );
892
893        // Second entry overrides Compression but inherits User-Agent
894        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
895        assert_eq!(
896            entries[1].get_option("User-Agent"),
897            Some("Custom/1.0".to_string())
898        );
899    }
900
901    #[test]
902    fn test_no_defaults_paragraph() {
903        let input = r#"Version: 5
904
905Source: https://example.com/repo1
906Matching-Pattern: .*\.tar\.gz
907"#;
908
909        let wf: WatchFile = input.parse().unwrap();
910
911        // Check that there's no defaults paragraph (first paragraph has Source)
912        assert!(wf.defaults().is_none());
913
914        let entries: Vec<_> = wf.entries().collect();
915        assert_eq!(entries.len(), 1);
916    }
917
918    #[test]
919    fn test_set_source() {
920        let mut wf = WatchFile::new();
921        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
922
923        assert_eq!(
924            entry.source().unwrap(),
925            Some("https://example.com/repo1".to_string())
926        );
927
928        entry.set_source("https://example.com/repo2");
929        assert_eq!(
930            entry.source().unwrap(),
931            Some("https://example.com/repo2".to_string())
932        );
933    }
934
935    #[test]
936    fn test_set_matching_pattern() {
937        let mut wf = WatchFile::new();
938        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
939
940        assert_eq!(
941            entry.matching_pattern().unwrap(),
942            Some(".*\\.tar\\.gz".to_string())
943        );
944
945        entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
946        assert_eq!(
947            entry.matching_pattern().unwrap(),
948            Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
949        );
950    }
951
952    #[test]
953    fn test_entry_line() {
954        let input = r#"Version: 5
955
956Source: https://example.com/repo1
957Matching-Pattern: .*\.tar\.gz
958
959Source: https://example.com/repo2
960Matching-Pattern: .*\.tar\.xz
961"#;
962
963        let wf: WatchFile = input.parse().unwrap();
964        let entries: Vec<_> = wf.entries().collect();
965
966        // First entry starts at line 2 (0-indexed)
967        assert_eq!(entries[0].line(), 2);
968        // Second entry starts at line 5 (0-indexed)
969        assert_eq!(entries[1].line(), 5);
970    }
971
972    #[test]
973    fn test_defaults_with_case_variations() {
974        let input = r#"Version: 5
975
976compression: xz
977user-agent: Custom/1.0
978
979Source: https://example.com/repo1
980Matching-Pattern: .*\.tar\.gz
981"#;
982
983        let wf: WatchFile = input.parse().unwrap();
984
985        // Check that defaults work with different case
986        let entries: Vec<_> = wf.entries().collect();
987        assert_eq!(entries.len(), 1);
988
989        // Should find defaults even with different case
990        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
991        assert_eq!(
992            entries[0].get_option("User-Agent"),
993            Some("Custom/1.0".to_string())
994        );
995    }
996
997    #[test]
998    fn test_v5_with_uversionmangle() {
999        let input = r#"Version: 5
1000
1001Source: https://pypi.org/project/foo/
1002Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
1003Uversionmangle: s/\.0+$//
1004"#;
1005
1006        let wf: WatchFile = input.parse().unwrap();
1007        let entries: Vec<_> = wf.entries().collect();
1008        assert_eq!(entries.len(), 1);
1009
1010        let entry = &entries[0];
1011        assert_eq!(
1012            entry.get_option("Uversionmangle"),
1013            Some("s/\\.0+$//".to_string())
1014        );
1015    }
1016
1017    #[test]
1018    fn test_v5_with_filenamemangle() {
1019        let input = r#"Version: 5
1020
1021Source: https://example.com/files
1022Matching-Pattern: .*\.tar\.gz
1023Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
1024"#;
1025
1026        let wf: WatchFile = input.parse().unwrap();
1027        let entries: Vec<_> = wf.entries().collect();
1028        assert_eq!(entries.len(), 1);
1029
1030        let entry = &entries[0];
1031        assert_eq!(
1032            entry.get_option("Filenamemangle"),
1033            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
1034        );
1035    }
1036
1037    #[test]
1038    fn test_v5_with_searchmode() {
1039        let input = r#"Version: 5
1040
1041Source: https://example.com/files
1042Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
1043Searchmode: plain
1044"#;
1045
1046        let wf: WatchFile = input.parse().unwrap();
1047        let entries: Vec<_> = wf.entries().collect();
1048        assert_eq!(entries.len(), 1);
1049
1050        let entry = &entries[0];
1051        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
1052    }
1053
1054    #[test]
1055    fn test_v5_with_version_policy() {
1056        let input = r#"Version: 5
1057
1058Source: https://example.com/files
1059Matching-Pattern: .*\.tar\.gz
1060Version-Policy: debian
1061"#;
1062
1063        let wf: WatchFile = input.parse().unwrap();
1064        let entries: Vec<_> = wf.entries().collect();
1065        assert_eq!(entries.len(), 1);
1066
1067        let entry = &entries[0];
1068        let policy = entry.version_policy();
1069        assert!(policy.is_ok());
1070        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
1071    }
1072
1073    #[test]
1074    fn test_v5_multiple_mangles() {
1075        let input = r#"Version: 5
1076
1077Source: https://example.com/files
1078Matching-Pattern: .*\.tar\.gz
1079Uversionmangle: s/^v//;s/\.0+$//
1080Dversionmangle: s/\+dfsg\d*$//
1081Filenamemangle: s/.*/foo-$1.tar.gz/
1082"#;
1083
1084        let wf: WatchFile = input.parse().unwrap();
1085        let entries: Vec<_> = wf.entries().collect();
1086        assert_eq!(entries.len(), 1);
1087
1088        let entry = &entries[0];
1089        assert_eq!(
1090            entry.get_option("Uversionmangle"),
1091            Some("s/^v//;s/\\.0+$//".to_string())
1092        );
1093        assert_eq!(
1094            entry.get_option("Dversionmangle"),
1095            Some("s/\\+dfsg\\d*$//".to_string())
1096        );
1097        assert_eq!(
1098            entry.get_option("Filenamemangle"),
1099            Some("s/.*/foo-$1.tar.gz/".to_string())
1100        );
1101    }
1102
1103    #[test]
1104    fn test_v5_with_pgpmode() {
1105        let input = r#"Version: 5
1106
1107Source: https://example.com/files
1108Matching-Pattern: .*\.tar\.gz
1109Pgpmode: auto
1110"#;
1111
1112        let wf: WatchFile = input.parse().unwrap();
1113        let entries: Vec<_> = wf.entries().collect();
1114        assert_eq!(entries.len(), 1);
1115
1116        let entry = &entries[0];
1117        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
1118    }
1119
1120    #[test]
1121    fn test_v5_with_comments() {
1122        let input = r#"Version: 5
1123
1124# This is a comment about the entry
1125Source: https://example.com/files
1126Matching-Pattern: .*\.tar\.gz
1127"#;
1128
1129        let wf: WatchFile = input.parse().unwrap();
1130        let entries: Vec<_> = wf.entries().collect();
1131        assert_eq!(entries.len(), 1);
1132
1133        // Verify roundtrip preserves comments
1134        let output = wf.to_string();
1135        assert!(output.contains("# This is a comment about the entry"));
1136    }
1137
1138    #[test]
1139    fn test_v5_empty_after_version() {
1140        let input = "Version: 5\n";
1141
1142        let wf: WatchFile = input.parse().unwrap();
1143        assert_eq!(wf.version(), 5);
1144
1145        let entries: Vec<_> = wf.entries().collect();
1146        assert_eq!(entries.len(), 0);
1147    }
1148
1149    #[test]
1150    fn test_v5_trait_url() {
1151        let input = r#"Version: 5
1152
1153Source: https://example.com/files/@PACKAGE@
1154Matching-Pattern: .*\.tar\.gz
1155"#;
1156
1157        let wf: WatchFile = input.parse().unwrap();
1158        let entries: Vec<_> = wf.entries().collect();
1159        assert_eq!(entries.len(), 1);
1160
1161        let entry = &entries[0];
1162        // Test url() method
1163        assert_eq!(
1164            entry.source().unwrap().as_deref(),
1165            Some("https://example.com/files/@PACKAGE@")
1166        );
1167    }
1168
1169    #[test]
1170    fn test_github_template() {
1171        let input = r#"Version: 5
1172
1173Template: GitHub
1174Owner: torvalds
1175Project: linux
1176"#;
1177
1178        let wf: WatchFile = input.parse().unwrap();
1179        let entries: Vec<_> = wf.entries().collect();
1180        assert_eq!(entries.len(), 1);
1181
1182        let entry = &entries[0];
1183        assert_eq!(
1184            entry.source().unwrap(),
1185            Some("https://github.com/torvalds/linux/tags".to_string())
1186        );
1187        assert_eq!(
1188            entry.matching_pattern().unwrap(),
1189            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1190        );
1191    }
1192
1193    #[test]
1194    fn test_github_template_with_dist() {
1195        let input = r#"Version: 5
1196
1197Template: GitHub
1198Dist: https://github.com/guimard/llng-docker
1199"#;
1200
1201        let wf: WatchFile = input.parse().unwrap();
1202        let entries: Vec<_> = wf.entries().collect();
1203        assert_eq!(entries.len(), 1);
1204
1205        let entry = &entries[0];
1206        assert_eq!(
1207            entry.source().unwrap(),
1208            Some("https://github.com/guimard/llng-docker/tags".to_string())
1209        );
1210    }
1211
1212    #[test]
1213    fn test_pypi_template() {
1214        let input = r#"Version: 5
1215
1216Template: PyPI
1217Dist: bitbox02
1218"#;
1219
1220        let wf: WatchFile = input.parse().unwrap();
1221        let entries: Vec<_> = wf.entries().collect();
1222        assert_eq!(entries.len(), 1);
1223
1224        let entry = &entries[0];
1225        assert_eq!(
1226            entry.source().unwrap(),
1227            Some("https://pypi.debian.net/bitbox02/".to_string())
1228        );
1229        assert_eq!(
1230            entry.matching_pattern().unwrap(),
1231            Some(
1232                r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"
1233                    .to_string()
1234            )
1235        );
1236    }
1237
1238    #[test]
1239    fn test_gitlab_template() {
1240        let input = r#"Version: 5
1241
1242Template: GitLab
1243Dist: https://salsa.debian.org/debian/devscripts
1244"#;
1245
1246        let wf: WatchFile = input.parse().unwrap();
1247        let entries: Vec<_> = wf.entries().collect();
1248        assert_eq!(entries.len(), 1);
1249
1250        let entry = &entries[0];
1251        assert_eq!(
1252            entry.source().unwrap(),
1253            Some("https://salsa.debian.org/debian/devscripts".to_string())
1254        );
1255        assert_eq!(
1256            entry.matching_pattern().unwrap(),
1257            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1258        );
1259    }
1260
1261    #[test]
1262    fn test_template_with_explicit_source() {
1263        // Explicit Source should override template expansion
1264        let input = r#"Version: 5
1265
1266Template: GitHub
1267Owner: test
1268Project: project
1269Source: https://custom.example.com/
1270"#;
1271
1272        let wf: WatchFile = input.parse().unwrap();
1273        let entries: Vec<_> = wf.entries().collect();
1274        assert_eq!(entries.len(), 1);
1275
1276        let entry = &entries[0];
1277        assert_eq!(
1278            entry.source().unwrap(),
1279            Some("https://custom.example.com/".to_string())
1280        );
1281    }
1282
1283    #[test]
1284    fn test_convert_to_template_github() {
1285        let mut wf = WatchFile::new();
1286        let mut entry = wf.add_entry(
1287            "https://github.com/torvalds/linux/tags",
1288            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1289        );
1290        entry.set_option_str("Searchmode", "html");
1291
1292        // Convert to template
1293        let template = entry.try_convert_to_template();
1294        assert_eq!(
1295            template,
1296            Some(crate::templates::Template::GitHub {
1297                owner: "torvalds".to_string(),
1298                repository: "linux".to_string(),
1299                release_only: false,
1300                version_type: None,
1301            })
1302        );
1303
1304        // Verify the entry now uses template syntax
1305        assert_eq!(entry.get_field("Template"), Some("GitHub".to_string()));
1306        assert_eq!(entry.get_field("Owner"), Some("torvalds".to_string()));
1307        assert_eq!(entry.get_field("Project"), Some("linux".to_string()));
1308        assert_eq!(entry.get_field("Source"), None);
1309        assert_eq!(entry.get_field("Matching-Pattern"), None);
1310    }
1311
1312    #[test]
1313    fn test_convert_to_template_pypi() {
1314        let mut wf = WatchFile::new();
1315        let mut entry = wf.add_entry(
1316            "https://pypi.debian.net/bitbox02/",
1317            r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz",
1318        );
1319        entry.set_option_str("Searchmode", "plain");
1320
1321        // Convert to template
1322        let template = entry.try_convert_to_template();
1323        assert_eq!(
1324            template,
1325            Some(crate::templates::Template::PyPI {
1326                package: "bitbox02".to_string(),
1327                version_type: None,
1328            })
1329        );
1330
1331        // Verify the entry now uses template syntax
1332        assert_eq!(entry.get_field("Template"), Some("PyPI".to_string()));
1333        assert_eq!(entry.get_field("Dist"), Some("bitbox02".to_string()));
1334    }
1335
1336    #[test]
1337    fn test_convert_to_template_no_match() {
1338        let mut wf = WatchFile::new();
1339        let mut entry = wf.add_entry(
1340            "https://example.com/downloads/",
1341            r".*/v?(\d+\.\d+)\.tar\.gz",
1342        );
1343
1344        // Try to convert - should return None
1345        let template = entry.try_convert_to_template();
1346        assert_eq!(template, None);
1347
1348        // Entry should remain unchanged
1349        assert_eq!(
1350            entry.source().unwrap(),
1351            Some("https://example.com/downloads/".to_string())
1352        );
1353    }
1354
1355    #[test]
1356    fn test_convert_to_template_roundtrip() {
1357        let mut wf = WatchFile::new();
1358        let mut entry = wf.add_entry(
1359            "https://github.com/test/project/releases",
1360            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1361        );
1362        entry.set_option_str("Searchmode", "html");
1363
1364        // Convert to template
1365        entry.try_convert_to_template().unwrap();
1366
1367        // Now the entry should be able to expand back to the same values
1368        let source = entry.source().unwrap();
1369        let matching_pattern = entry.matching_pattern().unwrap();
1370
1371        assert_eq!(
1372            source,
1373            Some("https://github.com/test/project/releases".to_string())
1374        );
1375        assert_eq!(
1376            matching_pattern,
1377            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1378        );
1379    }
1380}