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