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