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            _ => return Err(TemplateError::UnknownTemplate(template_str)),
508        };
509
510        Ok(expand_template(template))
511    }
512
513    /// Try to detect if this entry matches a template pattern and convert it to use that template.
514    ///
515    /// This analyzes the Source, Matching-Pattern, Searchmode, and Mode fields to determine
516    /// if they match a known template pattern. If a match is found, the entry is converted
517    /// to use the template syntax instead.
518    ///
519    /// # Returns
520    ///
521    /// Returns `Some(template)` if a template was detected and applied, `None` if no
522    /// template matches the current entry configuration.
523    ///
524    /// # Example
525    ///
526    /// ```
527    /// # #[cfg(feature = "deb822")]
528    /// # {
529    /// use debian_watch::deb822::WatchFile;
530    ///
531    /// let mut wf = WatchFile::new();
532    /// let mut entry = wf.add_entry(
533    ///     "https://github.com/torvalds/linux/tags",
534    ///     r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"
535    /// );
536    /// entry.set_option_str("Searchmode", "html");
537    ///
538    /// // Convert to template
539    /// if let Some(template) = entry.try_convert_to_template() {
540    ///     println!("Converted to {:?}", template);
541    /// }
542    /// # }
543    /// ```
544    pub fn try_convert_to_template(&mut self) -> Option<crate::templates::Template> {
545        use crate::templates::detect_template;
546
547        // Get current field values
548        let source = self.source().ok().flatten();
549        let matching_pattern = self.matching_pattern().ok().flatten();
550        let searchmode = self.get_field("Searchmode");
551        let mode = self.get_field("Mode");
552
553        // Try to detect template
554        let template = detect_template(
555            source.as_deref(),
556            matching_pattern.as_deref(),
557            searchmode.as_deref(),
558            mode.as_deref(),
559        )?;
560
561        // Apply the template - remove old fields and add template fields
562        self.paragraph.remove("Source");
563        self.paragraph.remove("Matching-Pattern");
564        self.paragraph.remove("Searchmode");
565        self.paragraph.remove("Mode");
566
567        // Set template fields based on the detected template
568        match &template {
569            crate::templates::Template::GitHub {
570                owner,
571                repository,
572                release_only,
573                version_type,
574            } => {
575                self.paragraph.set("Template", "GitHub");
576                self.paragraph.set("Owner", owner);
577                self.paragraph.set("Project", repository);
578                if *release_only {
579                    self.paragraph.set("Release-Only", "yes");
580                }
581                if let Some(vt) = version_type {
582                    self.paragraph.set("Version-Type", vt);
583                }
584            }
585            crate::templates::Template::GitLab {
586                dist,
587                release_only: _,
588                version_type,
589            } => {
590                self.paragraph.set("Template", "GitLab");
591                self.paragraph.set("Dist", dist);
592                if let Some(vt) = version_type {
593                    self.paragraph.set("Version-Type", vt);
594                }
595            }
596            crate::templates::Template::PyPI {
597                package,
598                version_type,
599            } => {
600                self.paragraph.set("Template", "PyPI");
601                self.paragraph.set("Dist", package);
602                if let Some(vt) = version_type {
603                    self.paragraph.set("Version-Type", vt);
604                }
605            }
606            crate::templates::Template::Npmregistry {
607                package,
608                version_type,
609            } => {
610                self.paragraph.set("Template", "Npmregistry");
611                self.paragraph.set("Dist", package);
612                if let Some(vt) = version_type {
613                    self.paragraph.set("Version-Type", vt);
614                }
615            }
616            crate::templates::Template::Metacpan { dist, version_type } => {
617                self.paragraph.set("Template", "Metacpan");
618                self.paragraph.set("Dist", dist);
619                if let Some(vt) = version_type {
620                    self.paragraph.set("Version-Type", vt);
621                }
622            }
623        }
624
625        Some(template)
626    }
627}
628
629/// Normalize a field key according to RFC822 rules:
630/// - Convert to lowercase
631/// - Hyphens and underscores are treated as equivalent
632fn normalize_key(key: &str) -> String {
633    key.to_lowercase().replace(['-', '_'], "")
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn test_as_deb822() {
642        let input = r#"Version: 5
643
644Source: https://github.com/owner/repo/tags
645Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
646"#;
647
648        let wf: WatchFile = input.parse().unwrap();
649        let deb822 = wf.as_deb822();
650
651        // Should have 2 paragraphs: version header + entry
652        assert_eq!(deb822.paragraphs().count(), 2);
653    }
654
655    #[test]
656    fn test_create_v5_watchfile() {
657        let wf = WatchFile::new();
658        assert_eq!(wf.version(), 5);
659
660        let output = wf.to_string();
661        assert!(output.contains("Version"));
662        assert!(output.contains("5"));
663    }
664
665    #[test]
666    fn test_parse_v5_basic() {
667        let input = r#"Version: 5
668
669Source: https://github.com/owner/repo/tags
670Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
671"#;
672
673        let wf: WatchFile = input.parse().unwrap();
674        assert_eq!(wf.version(), 5);
675
676        let entries: Vec<_> = wf.entries().collect();
677        assert_eq!(entries.len(), 1);
678
679        let entry = &entries[0];
680        assert_eq!(
681            entry.source().unwrap().as_deref(),
682            Some("https://github.com/owner/repo/tags")
683        );
684        assert_eq!(
685            entry.matching_pattern().unwrap(),
686            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
687        );
688    }
689
690    #[test]
691    fn test_parse_v5_multiple_entries() {
692        let input = r#"Version: 5
693
694Source: https://github.com/owner/repo1/tags
695Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
696
697Source: https://github.com/owner/repo2/tags
698Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
699"#;
700
701        let wf: WatchFile = input.parse().unwrap();
702        let entries: Vec<_> = wf.entries().collect();
703        assert_eq!(entries.len(), 2);
704
705        assert_eq!(
706            entries[0].source().unwrap().as_deref(),
707            Some("https://github.com/owner/repo1/tags")
708        );
709        assert_eq!(
710            entries[1].source().unwrap().as_deref(),
711            Some("https://github.com/owner/repo2/tags")
712        );
713    }
714
715    #[test]
716    fn test_v5_case_insensitive_fields() {
717        let input = r#"Version: 5
718
719source: https://example.com/files
720matching-pattern: .*\.tar\.gz
721"#;
722
723        let wf: WatchFile = input.parse().unwrap();
724        let entries: Vec<_> = wf.entries().collect();
725        assert_eq!(entries.len(), 1);
726
727        let entry = &entries[0];
728        assert_eq!(
729            entry.source().unwrap().as_deref(),
730            Some("https://example.com/files")
731        );
732        assert_eq!(
733            entry.matching_pattern().unwrap().as_deref(),
734            Some(".*\\.tar\\.gz")
735        );
736    }
737
738    #[test]
739    fn test_v5_with_compression_option() {
740        let input = r#"Version: 5
741
742Source: https://example.com/files
743Matching-Pattern: .*\.tar\.gz
744Compression: xz
745"#;
746
747        let wf: WatchFile = input.parse().unwrap();
748        let entries: Vec<_> = wf.entries().collect();
749        assert_eq!(entries.len(), 1);
750
751        let entry = &entries[0];
752        let compression = entry.get_option("compression");
753        assert!(compression.is_some());
754    }
755
756    #[test]
757    fn test_v5_with_component() {
758        let input = r#"Version: 5
759
760Source: https://example.com/files
761Matching-Pattern: .*\.tar\.gz
762Component: foo
763"#;
764
765        let wf: WatchFile = input.parse().unwrap();
766        let entries: Vec<_> = wf.entries().collect();
767        assert_eq!(entries.len(), 1);
768
769        let entry = &entries[0];
770        assert_eq!(entry.component(), Some("foo".to_string()));
771    }
772
773    #[test]
774    fn test_v5_rejects_wrong_version() {
775        let input = r#"Version: 4
776
777Source: https://example.com/files
778Matching-Pattern: .*\.tar\.gz
779"#;
780
781        let result: Result<WatchFile, _> = input.parse();
782        assert!(result.is_err());
783    }
784
785    #[test]
786    fn test_v5_roundtrip() {
787        let input = r#"Version: 5
788
789Source: https://example.com/files
790Matching-Pattern: .*\.tar\.gz
791"#;
792
793        let wf: WatchFile = input.parse().unwrap();
794        let output = wf.to_string();
795
796        // The output should be parseable again
797        let wf2: WatchFile = output.parse().unwrap();
798        assert_eq!(wf2.version(), 5);
799
800        let entries: Vec<_> = wf2.entries().collect();
801        assert_eq!(entries.len(), 1);
802    }
803
804    #[test]
805    fn test_normalize_key() {
806        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
807        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
808        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
809        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
810    }
811
812    #[test]
813    fn test_defaults_paragraph() {
814        let input = r#"Version: 5
815
816Compression: xz
817User-Agent: Custom/1.0
818
819Source: https://example.com/repo1
820Matching-Pattern: .*\.tar\.gz
821
822Source: https://example.com/repo2
823Matching-Pattern: .*\.tar\.gz
824Compression: gz
825"#;
826
827        let wf: WatchFile = input.parse().unwrap();
828
829        // Check that defaults paragraph is detected
830        let defaults = wf.defaults();
831        assert!(defaults.is_some());
832        let defaults = defaults.unwrap();
833        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
834        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
835
836        // Check that entries inherit from defaults
837        let entries: Vec<_> = wf.entries().collect();
838        assert_eq!(entries.len(), 2);
839
840        // First entry should inherit Compression and User-Agent from defaults
841        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
842        assert_eq!(
843            entries[0].get_option("User-Agent"),
844            Some("Custom/1.0".to_string())
845        );
846
847        // Second entry overrides Compression but inherits User-Agent
848        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
849        assert_eq!(
850            entries[1].get_option("User-Agent"),
851            Some("Custom/1.0".to_string())
852        );
853    }
854
855    #[test]
856    fn test_no_defaults_paragraph() {
857        let input = r#"Version: 5
858
859Source: https://example.com/repo1
860Matching-Pattern: .*\.tar\.gz
861"#;
862
863        let wf: WatchFile = input.parse().unwrap();
864
865        // Check that there's no defaults paragraph (first paragraph has Source)
866        assert!(wf.defaults().is_none());
867
868        let entries: Vec<_> = wf.entries().collect();
869        assert_eq!(entries.len(), 1);
870    }
871
872    #[test]
873    fn test_set_source() {
874        let mut wf = WatchFile::new();
875        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
876
877        assert_eq!(
878            entry.source().unwrap(),
879            Some("https://example.com/repo1".to_string())
880        );
881
882        entry.set_source("https://example.com/repo2");
883        assert_eq!(
884            entry.source().unwrap(),
885            Some("https://example.com/repo2".to_string())
886        );
887    }
888
889    #[test]
890    fn test_set_matching_pattern() {
891        let mut wf = WatchFile::new();
892        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
893
894        assert_eq!(
895            entry.matching_pattern().unwrap(),
896            Some(".*\\.tar\\.gz".to_string())
897        );
898
899        entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
900        assert_eq!(
901            entry.matching_pattern().unwrap(),
902            Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
903        );
904    }
905
906    #[test]
907    fn test_entry_line() {
908        let input = r#"Version: 5
909
910Source: https://example.com/repo1
911Matching-Pattern: .*\.tar\.gz
912
913Source: https://example.com/repo2
914Matching-Pattern: .*\.tar\.xz
915"#;
916
917        let wf: WatchFile = input.parse().unwrap();
918        let entries: Vec<_> = wf.entries().collect();
919
920        // First entry starts at line 2 (0-indexed)
921        assert_eq!(entries[0].line(), 2);
922        // Second entry starts at line 5 (0-indexed)
923        assert_eq!(entries[1].line(), 5);
924    }
925
926    #[test]
927    fn test_defaults_with_case_variations() {
928        let input = r#"Version: 5
929
930compression: xz
931user-agent: Custom/1.0
932
933Source: https://example.com/repo1
934Matching-Pattern: .*\.tar\.gz
935"#;
936
937        let wf: WatchFile = input.parse().unwrap();
938
939        // Check that defaults work with different case
940        let entries: Vec<_> = wf.entries().collect();
941        assert_eq!(entries.len(), 1);
942
943        // Should find defaults even with different case
944        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
945        assert_eq!(
946            entries[0].get_option("User-Agent"),
947            Some("Custom/1.0".to_string())
948        );
949    }
950
951    #[test]
952    fn test_v5_with_uversionmangle() {
953        let input = r#"Version: 5
954
955Source: https://pypi.org/project/foo/
956Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
957Uversionmangle: s/\.0+$//
958"#;
959
960        let wf: WatchFile = input.parse().unwrap();
961        let entries: Vec<_> = wf.entries().collect();
962        assert_eq!(entries.len(), 1);
963
964        let entry = &entries[0];
965        assert_eq!(
966            entry.get_option("Uversionmangle"),
967            Some("s/\\.0+$//".to_string())
968        );
969    }
970
971    #[test]
972    fn test_v5_with_filenamemangle() {
973        let input = r#"Version: 5
974
975Source: https://example.com/files
976Matching-Pattern: .*\.tar\.gz
977Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
978"#;
979
980        let wf: WatchFile = input.parse().unwrap();
981        let entries: Vec<_> = wf.entries().collect();
982        assert_eq!(entries.len(), 1);
983
984        let entry = &entries[0];
985        assert_eq!(
986            entry.get_option("Filenamemangle"),
987            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
988        );
989    }
990
991    #[test]
992    fn test_v5_with_searchmode() {
993        let input = r#"Version: 5
994
995Source: https://example.com/files
996Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
997Searchmode: plain
998"#;
999
1000        let wf: WatchFile = input.parse().unwrap();
1001        let entries: Vec<_> = wf.entries().collect();
1002        assert_eq!(entries.len(), 1);
1003
1004        let entry = &entries[0];
1005        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
1006    }
1007
1008    #[test]
1009    fn test_v5_with_version_policy() {
1010        let input = r#"Version: 5
1011
1012Source: https://example.com/files
1013Matching-Pattern: .*\.tar\.gz
1014Version-Policy: debian
1015"#;
1016
1017        let wf: WatchFile = input.parse().unwrap();
1018        let entries: Vec<_> = wf.entries().collect();
1019        assert_eq!(entries.len(), 1);
1020
1021        let entry = &entries[0];
1022        let policy = entry.version_policy();
1023        assert!(policy.is_ok());
1024        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
1025    }
1026
1027    #[test]
1028    fn test_v5_multiple_mangles() {
1029        let input = r#"Version: 5
1030
1031Source: https://example.com/files
1032Matching-Pattern: .*\.tar\.gz
1033Uversionmangle: s/^v//;s/\.0+$//
1034Dversionmangle: s/\+dfsg\d*$//
1035Filenamemangle: s/.*/foo-$1.tar.gz/
1036"#;
1037
1038        let wf: WatchFile = input.parse().unwrap();
1039        let entries: Vec<_> = wf.entries().collect();
1040        assert_eq!(entries.len(), 1);
1041
1042        let entry = &entries[0];
1043        assert_eq!(
1044            entry.get_option("Uversionmangle"),
1045            Some("s/^v//;s/\\.0+$//".to_string())
1046        );
1047        assert_eq!(
1048            entry.get_option("Dversionmangle"),
1049            Some("s/\\+dfsg\\d*$//".to_string())
1050        );
1051        assert_eq!(
1052            entry.get_option("Filenamemangle"),
1053            Some("s/.*/foo-$1.tar.gz/".to_string())
1054        );
1055    }
1056
1057    #[test]
1058    fn test_v5_with_pgpmode() {
1059        let input = r#"Version: 5
1060
1061Source: https://example.com/files
1062Matching-Pattern: .*\.tar\.gz
1063Pgpmode: auto
1064"#;
1065
1066        let wf: WatchFile = input.parse().unwrap();
1067        let entries: Vec<_> = wf.entries().collect();
1068        assert_eq!(entries.len(), 1);
1069
1070        let entry = &entries[0];
1071        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
1072    }
1073
1074    #[test]
1075    fn test_v5_with_comments() {
1076        let input = r#"Version: 5
1077
1078# This is a comment about the entry
1079Source: https://example.com/files
1080Matching-Pattern: .*\.tar\.gz
1081"#;
1082
1083        let wf: WatchFile = input.parse().unwrap();
1084        let entries: Vec<_> = wf.entries().collect();
1085        assert_eq!(entries.len(), 1);
1086
1087        // Verify roundtrip preserves comments
1088        let output = wf.to_string();
1089        assert!(output.contains("# This is a comment about the entry"));
1090    }
1091
1092    #[test]
1093    fn test_v5_empty_after_version() {
1094        let input = "Version: 5\n";
1095
1096        let wf: WatchFile = input.parse().unwrap();
1097        assert_eq!(wf.version(), 5);
1098
1099        let entries: Vec<_> = wf.entries().collect();
1100        assert_eq!(entries.len(), 0);
1101    }
1102
1103    #[test]
1104    fn test_v5_trait_url() {
1105        let input = r#"Version: 5
1106
1107Source: https://example.com/files/@PACKAGE@
1108Matching-Pattern: .*\.tar\.gz
1109"#;
1110
1111        let wf: WatchFile = input.parse().unwrap();
1112        let entries: Vec<_> = wf.entries().collect();
1113        assert_eq!(entries.len(), 1);
1114
1115        let entry = &entries[0];
1116        // Test url() method
1117        assert_eq!(
1118            entry.source().unwrap().as_deref(),
1119            Some("https://example.com/files/@PACKAGE@")
1120        );
1121    }
1122
1123    #[test]
1124    fn test_github_template() {
1125        let input = r#"Version: 5
1126
1127Template: GitHub
1128Owner: torvalds
1129Project: linux
1130"#;
1131
1132        let wf: WatchFile = input.parse().unwrap();
1133        let entries: Vec<_> = wf.entries().collect();
1134        assert_eq!(entries.len(), 1);
1135
1136        let entry = &entries[0];
1137        assert_eq!(
1138            entry.source().unwrap(),
1139            Some("https://github.com/torvalds/linux/tags".to_string())
1140        );
1141        assert_eq!(
1142            entry.matching_pattern().unwrap(),
1143            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1144        );
1145    }
1146
1147    #[test]
1148    fn test_github_template_with_dist() {
1149        let input = r#"Version: 5
1150
1151Template: GitHub
1152Dist: https://github.com/guimard/llng-docker
1153"#;
1154
1155        let wf: WatchFile = input.parse().unwrap();
1156        let entries: Vec<_> = wf.entries().collect();
1157        assert_eq!(entries.len(), 1);
1158
1159        let entry = &entries[0];
1160        assert_eq!(
1161            entry.source().unwrap(),
1162            Some("https://github.com/guimard/llng-docker/tags".to_string())
1163        );
1164    }
1165
1166    #[test]
1167    fn test_pypi_template() {
1168        let input = r#"Version: 5
1169
1170Template: PyPI
1171Dist: bitbox02
1172"#;
1173
1174        let wf: WatchFile = input.parse().unwrap();
1175        let entries: Vec<_> = wf.entries().collect();
1176        assert_eq!(entries.len(), 1);
1177
1178        let entry = &entries[0];
1179        assert_eq!(
1180            entry.source().unwrap(),
1181            Some("https://pypi.debian.net/bitbox02/".to_string())
1182        );
1183        assert_eq!(
1184            entry.matching_pattern().unwrap(),
1185            Some(
1186                r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"
1187                    .to_string()
1188            )
1189        );
1190    }
1191
1192    #[test]
1193    fn test_gitlab_template() {
1194        let input = r#"Version: 5
1195
1196Template: GitLab
1197Dist: https://salsa.debian.org/debian/devscripts
1198"#;
1199
1200        let wf: WatchFile = input.parse().unwrap();
1201        let entries: Vec<_> = wf.entries().collect();
1202        assert_eq!(entries.len(), 1);
1203
1204        let entry = &entries[0];
1205        assert_eq!(
1206            entry.source().unwrap(),
1207            Some("https://salsa.debian.org/debian/devscripts".to_string())
1208        );
1209        assert_eq!(
1210            entry.matching_pattern().unwrap(),
1211            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1212        );
1213    }
1214
1215    #[test]
1216    fn test_template_with_explicit_source() {
1217        // Explicit Source should override template expansion
1218        let input = r#"Version: 5
1219
1220Template: GitHub
1221Owner: test
1222Project: project
1223Source: https://custom.example.com/
1224"#;
1225
1226        let wf: WatchFile = input.parse().unwrap();
1227        let entries: Vec<_> = wf.entries().collect();
1228        assert_eq!(entries.len(), 1);
1229
1230        let entry = &entries[0];
1231        assert_eq!(
1232            entry.source().unwrap(),
1233            Some("https://custom.example.com/".to_string())
1234        );
1235    }
1236
1237    #[test]
1238    fn test_convert_to_template_github() {
1239        let mut wf = WatchFile::new();
1240        let mut entry = wf.add_entry(
1241            "https://github.com/torvalds/linux/tags",
1242            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1243        );
1244        entry.set_option_str("Searchmode", "html");
1245
1246        // Convert to template
1247        let template = entry.try_convert_to_template();
1248        assert_eq!(
1249            template,
1250            Some(crate::templates::Template::GitHub {
1251                owner: "torvalds".to_string(),
1252                repository: "linux".to_string(),
1253                release_only: false,
1254                version_type: None,
1255            })
1256        );
1257
1258        // Verify the entry now uses template syntax
1259        assert_eq!(entry.get_field("Template"), Some("GitHub".to_string()));
1260        assert_eq!(entry.get_field("Owner"), Some("torvalds".to_string()));
1261        assert_eq!(entry.get_field("Project"), Some("linux".to_string()));
1262        assert_eq!(entry.get_field("Source"), None);
1263        assert_eq!(entry.get_field("Matching-Pattern"), None);
1264    }
1265
1266    #[test]
1267    fn test_convert_to_template_pypi() {
1268        let mut wf = WatchFile::new();
1269        let mut entry = wf.add_entry(
1270            "https://pypi.debian.net/bitbox02/",
1271            r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz",
1272        );
1273        entry.set_option_str("Searchmode", "plain");
1274
1275        // Convert to template
1276        let template = entry.try_convert_to_template();
1277        assert_eq!(
1278            template,
1279            Some(crate::templates::Template::PyPI {
1280                package: "bitbox02".to_string(),
1281                version_type: None,
1282            })
1283        );
1284
1285        // Verify the entry now uses template syntax
1286        assert_eq!(entry.get_field("Template"), Some("PyPI".to_string()));
1287        assert_eq!(entry.get_field("Dist"), Some("bitbox02".to_string()));
1288    }
1289
1290    #[test]
1291    fn test_convert_to_template_no_match() {
1292        let mut wf = WatchFile::new();
1293        let mut entry = wf.add_entry(
1294            "https://example.com/downloads/",
1295            r".*/v?(\d+\.\d+)\.tar\.gz",
1296        );
1297
1298        // Try to convert - should return None
1299        let template = entry.try_convert_to_template();
1300        assert_eq!(template, None);
1301
1302        // Entry should remain unchanged
1303        assert_eq!(
1304            entry.source().unwrap(),
1305            Some("https://example.com/downloads/".to_string())
1306        );
1307    }
1308
1309    #[test]
1310    fn test_convert_to_template_roundtrip() {
1311        let mut wf = WatchFile::new();
1312        let mut entry = wf.add_entry(
1313            "https://github.com/test/project/releases",
1314            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1315        );
1316        entry.set_option_str("Searchmode", "html");
1317
1318        // Convert to template
1319        entry.try_convert_to_template().unwrap();
1320
1321        // Now the entry should be able to expand back to the same values
1322        let source = entry.source().unwrap();
1323        let matching_pattern = entry.matching_pattern().unwrap();
1324
1325        assert_eq!(
1326            source,
1327            Some("https://github.com/test/project/releases".to_string())
1328        );
1329        assert_eq!(
1330            matching_pattern,
1331            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1332        );
1333    }
1334}