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    /// Create a new empty format 5 watch file
65    pub fn new() -> Self {
66        // Create a minimal format 5 watch file from a string
67        let content = "Version: 5\n";
68        WatchFile::from_str(content).expect("Failed to create empty watch file")
69    }
70
71    /// Returns the version of the watch file (always 5 for this type)
72    pub fn version(&self) -> u32 {
73        5
74    }
75
76    /// Returns the defaults paragraph if it exists.
77    /// The defaults paragraph is the second paragraph (after Version) if it has no Source field.
78    pub fn defaults(&self) -> Option<Paragraph> {
79        let paragraphs: Vec<_> = self.0.paragraphs().collect();
80
81        if paragraphs.len() > 1 {
82            // Check if second paragraph looks like defaults (no Source field)
83            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
84                return Some(paragraphs[1].clone());
85            }
86        }
87
88        None
89    }
90
91    /// Returns an iterator over all entries in the watch file.
92    /// The first paragraph contains defaults, subsequent paragraphs are entries.
93    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
94        let paragraphs: Vec<_> = self.0.paragraphs().collect();
95        let defaults = self.defaults();
96
97        // Skip the first paragraph (version)
98        // The second paragraph (if it exists and has specific fields) contains defaults
99        // Otherwise all paragraphs are entries
100        let start_index = if paragraphs.len() > 1 {
101            // Check if second paragraph looks like defaults (no Source field)
102            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
103                2 // Skip version and defaults
104            } else {
105                1 // Skip only version
106            }
107        } else {
108            1
109        };
110
111        paragraphs
112            .into_iter()
113            .skip(start_index)
114            .map(move |p| Entry {
115                paragraph: p,
116                defaults: defaults.clone(),
117            })
118    }
119
120    /// Get the underlying Deb822 object
121    pub fn inner(&self) -> &Deb822 {
122        &self.0
123    }
124
125    /// Get a mutable reference to the underlying Deb822 object
126    pub fn inner_mut(&mut self) -> &mut Deb822 {
127        &mut self.0
128    }
129
130    /// Add a new entry to the watch file with the given source and matching pattern.
131    /// Returns the newly created Entry.
132    ///
133    /// # Example
134    ///
135    /// ```
136    /// # #[cfg(feature = "deb822")]
137    /// # {
138    /// use debian_watch::deb822::WatchFile;
139    /// use debian_watch::WatchOption;
140    ///
141    /// let mut wf = WatchFile::new();
142    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
143    /// entry.set_option(WatchOption::Component("upstream".to_string()));
144    /// # }
145    /// ```
146    pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry {
147        let mut para = self.0.add_paragraph();
148        para.set("Source", source);
149        para.set("Matching-Pattern", matching_pattern);
150
151        // Create an Entry from the paragraph we just added
152        // Get the defaults paragraph if it exists
153        let defaults = self.defaults();
154
155        Entry {
156            paragraph: para.clone(),
157            defaults,
158        }
159    }
160}
161
162impl Default for WatchFile {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl FromStr for WatchFile {
169    type Err = ParseError;
170
171    fn from_str(s: &str) -> Result<Self, Self::Err> {
172        match Deb822::from_str(s) {
173            Ok(deb822) => {
174                // Verify it's version 5
175                let version = deb822
176                    .paragraphs()
177                    .next()
178                    .and_then(|p| p.get("Version"))
179                    .unwrap_or_else(|| "1".to_string());
180
181                if version != "5" {
182                    return Err(ParseError(format!("Expected version 5, got {}", version)));
183                }
184
185                Ok(WatchFile(deb822))
186            }
187            Err(e) => Err(ParseError(e.to_string())),
188        }
189    }
190}
191
192impl std::fmt::Display for WatchFile {
193    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
194        write!(f, "{}", self.0)
195    }
196}
197
198impl Entry {
199    /// Get a field value from the entry, with fallback to defaults paragraph.
200    /// First checks the entry's own fields, then falls back to the defaults paragraph if present.
201    pub(crate) fn get_field(&self, key: &str) -> Option<String> {
202        // Try the key as-is first in the entry
203        if let Some(value) = self.paragraph.get(key) {
204            return Some(value);
205        }
206
207        // If not found, try with different case variations in the entry
208        // deb822-lossless is case-preserving, so we need to check all field names
209        let normalized_key = normalize_key(key);
210
211        // Iterate through all keys in the paragraph and check for normalized match
212        for (k, v) in self.paragraph.items() {
213            if normalize_key(&k) == normalized_key {
214                return Some(v);
215            }
216        }
217
218        // If not found in entry, check the defaults paragraph
219        if let Some(ref defaults) = self.defaults {
220            // Try the key as-is first in defaults
221            if let Some(value) = defaults.get(key) {
222                return Some(value);
223            }
224
225            // Try with case variations in defaults
226            for (k, v) in defaults.items() {
227                if normalize_key(&k) == normalized_key {
228                    return Some(v);
229                }
230            }
231        }
232
233        None
234    }
235
236    /// Returns the source URL
237    pub fn source(&self) -> Option<String> {
238        self.get_field("Source")
239    }
240
241    /// Returns the matching pattern
242    pub fn matching_pattern(&self) -> Option<String> {
243        self.get_field("Matching-Pattern")
244    }
245
246    /// Get the underlying paragraph
247    pub fn as_deb822(&self) -> &Paragraph {
248        &self.paragraph
249    }
250
251    /// Name of the component, if specified
252    pub fn component(&self) -> Option<String> {
253        self.get_field("Component")
254    }
255
256    /// Get the an option value from the entry, with fallback to defaults paragraph.
257    pub fn get_option(&self, key: &str) -> Option<String> {
258        match key {
259            "Source" => None,           // Source is not an option
260            "Matching-Pattern" => None, // Matching-Pattern is not an option
261            "Component" => None,        // Component is not an option
262            "Version" => None,          // Version is not an option
263            key => self.get_field(key),
264        }
265    }
266
267    /// Set an option value in the entry using a WatchOption enum
268    pub fn set_option(&mut self, option: crate::types::WatchOption) {
269        use crate::types::WatchOption;
270
271        let (key, value) = match option {
272            WatchOption::Component(v) => ("Component", Some(v)),
273            WatchOption::Compression(v) => ("Compression", Some(v.to_string())),
274            WatchOption::UserAgent(v) => ("User-Agent", Some(v)),
275            WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)),
276            WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)),
277            WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)),
278            WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)),
279            WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)),
280            WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)),
281            WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)),
282            WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)),
283            WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())),
284            WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())),
285            WatchOption::Mode(v) => ("Mode", Some(v.to_string())),
286            WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())),
287            WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())),
288            WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())),
289            WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())),
290            WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())),
291            WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)),
292            WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)),
293            WatchOption::Script(v) => ("Script", Some(v)),
294            WatchOption::Decompress => ("Decompress", None),
295            WatchOption::Bare => ("Bare", None),
296            WatchOption::Repack => ("Repack", None),
297        };
298
299        if let Some(v) = value {
300            self.paragraph.set(key, &v);
301        } else {
302            // For boolean flags, set the key with empty value
303            self.paragraph.set(key, "");
304        }
305    }
306
307    /// Set an option value in the entry using string key and value (for backward compatibility)
308    pub fn set_option_str(&mut self, key: &str, value: &str) {
309        self.paragraph.set(key, value);
310    }
311
312    /// Delete an option from the entry using a WatchOption enum
313    pub fn delete_option(&mut self, option: crate::types::WatchOption) {
314        let key = watch_option_to_key(&option);
315        self.paragraph.remove(key);
316    }
317
318    /// Delete an option from the entry using a string key (for backward compatibility)
319    pub fn delete_option_str(&mut self, key: &str) {
320        self.paragraph.remove(key);
321    }
322
323    /// Get the URL (same as source() but named url() for consistency)
324    pub fn url(&self) -> String {
325        self.source().unwrap_or_default()
326    }
327
328    /// Get the version policy
329    pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
330        match self.get_field("Version-Policy") {
331            Some(policy) => Ok(Some(policy.parse()?)),
332            None => Ok(None),
333        }
334    }
335
336    /// Get the script
337    pub fn script(&self) -> Option<String> {
338        self.get_field("Script")
339    }
340
341    /// Set the source URL
342    pub fn set_source(&mut self, url: &str) {
343        self.paragraph.set("Source", url);
344    }
345
346    /// Set the matching pattern
347    pub fn set_matching_pattern(&mut self, pattern: &str) {
348        self.paragraph.set("Matching-Pattern", pattern);
349    }
350
351    /// Get the line number (0-indexed) where this entry starts
352    pub fn line(&self) -> usize {
353        self.paragraph.line()
354    }
355
356    /// Retrieve the mode of the watch file entry with detailed error information.
357    pub fn mode(&self) -> Result<crate::types::Mode, TypesParseError> {
358        Ok(self
359            .get_field("Mode")
360            .map(|s| s.parse())
361            .transpose()?
362            .unwrap_or_default())
363    }
364}
365
366/// Normalize a field key according to RFC822 rules:
367/// - Convert to lowercase
368/// - Hyphens and underscores are treated as equivalent
369fn normalize_key(key: &str) -> String {
370    key.to_lowercase().replace(['-', '_'], "")
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_create_v5_watchfile() {
379        let wf = WatchFile::new();
380        assert_eq!(wf.version(), 5);
381
382        let output = wf.to_string();
383        assert!(output.contains("Version"));
384        assert!(output.contains("5"));
385    }
386
387    #[test]
388    fn test_parse_v5_basic() {
389        let input = r#"Version: 5
390
391Source: https://github.com/owner/repo/tags
392Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
393"#;
394
395        let wf: WatchFile = input.parse().unwrap();
396        assert_eq!(wf.version(), 5);
397
398        let entries: Vec<_> = wf.entries().collect();
399        assert_eq!(entries.len(), 1);
400
401        let entry = &entries[0];
402        assert_eq!(
403            entry.source().as_deref(),
404            Some("https://github.com/owner/repo/tags")
405        );
406        assert_eq!(
407            entry.matching_pattern(),
408            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
409        );
410    }
411
412    #[test]
413    fn test_parse_v5_multiple_entries() {
414        let input = r#"Version: 5
415
416Source: https://github.com/owner/repo1/tags
417Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
418
419Source: https://github.com/owner/repo2/tags
420Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
421"#;
422
423        let wf: WatchFile = input.parse().unwrap();
424        let entries: Vec<_> = wf.entries().collect();
425        assert_eq!(entries.len(), 2);
426
427        assert_eq!(
428            entries[0].source().as_deref(),
429            Some("https://github.com/owner/repo1/tags")
430        );
431        assert_eq!(
432            entries[1].source().as_deref(),
433            Some("https://github.com/owner/repo2/tags")
434        );
435    }
436
437    #[test]
438    fn test_v5_case_insensitive_fields() {
439        let input = r#"Version: 5
440
441source: https://example.com/files
442matching-pattern: .*\.tar\.gz
443"#;
444
445        let wf: WatchFile = input.parse().unwrap();
446        let entries: Vec<_> = wf.entries().collect();
447        assert_eq!(entries.len(), 1);
448
449        let entry = &entries[0];
450        assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
451        assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
452    }
453
454    #[test]
455    fn test_v5_with_compression_option() {
456        let input = r#"Version: 5
457
458Source: https://example.com/files
459Matching-Pattern: .*\.tar\.gz
460Compression: xz
461"#;
462
463        let wf: WatchFile = input.parse().unwrap();
464        let entries: Vec<_> = wf.entries().collect();
465        assert_eq!(entries.len(), 1);
466
467        let entry = &entries[0];
468        let compression = entry.get_option("compression");
469        assert!(compression.is_some());
470    }
471
472    #[test]
473    fn test_v5_with_component() {
474        let input = r#"Version: 5
475
476Source: https://example.com/files
477Matching-Pattern: .*\.tar\.gz
478Component: foo
479"#;
480
481        let wf: WatchFile = input.parse().unwrap();
482        let entries: Vec<_> = wf.entries().collect();
483        assert_eq!(entries.len(), 1);
484
485        let entry = &entries[0];
486        assert_eq!(entry.component(), Some("foo".to_string()));
487    }
488
489    #[test]
490    fn test_v5_rejects_wrong_version() {
491        let input = r#"Version: 4
492
493Source: https://example.com/files
494Matching-Pattern: .*\.tar\.gz
495"#;
496
497        let result: Result<WatchFile, _> = input.parse();
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn test_v5_roundtrip() {
503        let input = r#"Version: 5
504
505Source: https://example.com/files
506Matching-Pattern: .*\.tar\.gz
507"#;
508
509        let wf: WatchFile = input.parse().unwrap();
510        let output = wf.to_string();
511
512        // The output should be parseable again
513        let wf2: WatchFile = output.parse().unwrap();
514        assert_eq!(wf2.version(), 5);
515
516        let entries: Vec<_> = wf2.entries().collect();
517        assert_eq!(entries.len(), 1);
518    }
519
520    #[test]
521    fn test_normalize_key() {
522        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
523        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
524        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
525        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
526    }
527
528    #[test]
529    fn test_defaults_paragraph() {
530        let input = r#"Version: 5
531
532Compression: xz
533User-Agent: Custom/1.0
534
535Source: https://example.com/repo1
536Matching-Pattern: .*\.tar\.gz
537
538Source: https://example.com/repo2
539Matching-Pattern: .*\.tar\.gz
540Compression: gz
541"#;
542
543        let wf: WatchFile = input.parse().unwrap();
544
545        // Check that defaults paragraph is detected
546        let defaults = wf.defaults();
547        assert!(defaults.is_some());
548        let defaults = defaults.unwrap();
549        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
550        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
551
552        // Check that entries inherit from defaults
553        let entries: Vec<_> = wf.entries().collect();
554        assert_eq!(entries.len(), 2);
555
556        // First entry should inherit Compression and User-Agent from defaults
557        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
558        assert_eq!(
559            entries[0].get_option("User-Agent"),
560            Some("Custom/1.0".to_string())
561        );
562
563        // Second entry overrides Compression but inherits User-Agent
564        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
565        assert_eq!(
566            entries[1].get_option("User-Agent"),
567            Some("Custom/1.0".to_string())
568        );
569    }
570
571    #[test]
572    fn test_no_defaults_paragraph() {
573        let input = r#"Version: 5
574
575Source: https://example.com/repo1
576Matching-Pattern: .*\.tar\.gz
577"#;
578
579        let wf: WatchFile = input.parse().unwrap();
580
581        // Check that there's no defaults paragraph (first paragraph has Source)
582        assert!(wf.defaults().is_none());
583
584        let entries: Vec<_> = wf.entries().collect();
585        assert_eq!(entries.len(), 1);
586    }
587
588    #[test]
589    fn test_set_source() {
590        let mut wf = WatchFile::new();
591        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
592
593        assert_eq!(
594            entry.source(),
595            Some("https://example.com/repo1".to_string())
596        );
597
598        entry.set_source("https://example.com/repo2");
599        assert_eq!(
600            entry.source(),
601            Some("https://example.com/repo2".to_string())
602        );
603    }
604
605    #[test]
606    fn test_set_matching_pattern() {
607        let mut wf = WatchFile::new();
608        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
609
610        assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
611
612        entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
613        assert_eq!(
614            entry.matching_pattern(),
615            Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
616        );
617    }
618
619    #[test]
620    fn test_entry_line() {
621        let input = r#"Version: 5
622
623Source: https://example.com/repo1
624Matching-Pattern: .*\.tar\.gz
625
626Source: https://example.com/repo2
627Matching-Pattern: .*\.tar\.xz
628"#;
629
630        let wf: WatchFile = input.parse().unwrap();
631        let entries: Vec<_> = wf.entries().collect();
632
633        // First entry starts at line 2 (0-indexed)
634        assert_eq!(entries[0].line(), 2);
635        // Second entry starts at line 5 (0-indexed)
636        assert_eq!(entries[1].line(), 5);
637    }
638
639    #[test]
640    fn test_defaults_with_case_variations() {
641        let input = r#"Version: 5
642
643compression: xz
644user-agent: Custom/1.0
645
646Source: https://example.com/repo1
647Matching-Pattern: .*\.tar\.gz
648"#;
649
650        let wf: WatchFile = input.parse().unwrap();
651
652        // Check that defaults work with different case
653        let entries: Vec<_> = wf.entries().collect();
654        assert_eq!(entries.len(), 1);
655
656        // Should find defaults even with different case
657        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
658        assert_eq!(
659            entries[0].get_option("User-Agent"),
660            Some("Custom/1.0".to_string())
661        );
662    }
663
664    #[test]
665    fn test_v5_with_uversionmangle() {
666        let input = r#"Version: 5
667
668Source: https://pypi.org/project/foo/
669Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
670Uversionmangle: s/\.0+$//
671"#;
672
673        let wf: WatchFile = input.parse().unwrap();
674        let entries: Vec<_> = wf.entries().collect();
675        assert_eq!(entries.len(), 1);
676
677        let entry = &entries[0];
678        assert_eq!(
679            entry.get_option("Uversionmangle"),
680            Some("s/\\.0+$//".to_string())
681        );
682    }
683
684    #[test]
685    fn test_v5_with_filenamemangle() {
686        let input = r#"Version: 5
687
688Source: https://example.com/files
689Matching-Pattern: .*\.tar\.gz
690Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
691"#;
692
693        let wf: WatchFile = input.parse().unwrap();
694        let entries: Vec<_> = wf.entries().collect();
695        assert_eq!(entries.len(), 1);
696
697        let entry = &entries[0];
698        assert_eq!(
699            entry.get_option("Filenamemangle"),
700            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
701        );
702    }
703
704    #[test]
705    fn test_v5_with_searchmode() {
706        let input = r#"Version: 5
707
708Source: https://example.com/files
709Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
710Searchmode: plain
711"#;
712
713        let wf: WatchFile = input.parse().unwrap();
714        let entries: Vec<_> = wf.entries().collect();
715        assert_eq!(entries.len(), 1);
716
717        let entry = &entries[0];
718        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
719    }
720
721    #[test]
722    fn test_v5_with_version_policy() {
723        let input = r#"Version: 5
724
725Source: https://example.com/files
726Matching-Pattern: .*\.tar\.gz
727Version-Policy: debian
728"#;
729
730        let wf: WatchFile = input.parse().unwrap();
731        let entries: Vec<_> = wf.entries().collect();
732        assert_eq!(entries.len(), 1);
733
734        let entry = &entries[0];
735        let policy = entry.version_policy();
736        assert!(policy.is_ok());
737        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
738    }
739
740    #[test]
741    fn test_v5_multiple_mangles() {
742        let input = r#"Version: 5
743
744Source: https://example.com/files
745Matching-Pattern: .*\.tar\.gz
746Uversionmangle: s/^v//;s/\.0+$//
747Dversionmangle: s/\+dfsg\d*$//
748Filenamemangle: s/.*/foo-$1.tar.gz/
749"#;
750
751        let wf: WatchFile = input.parse().unwrap();
752        let entries: Vec<_> = wf.entries().collect();
753        assert_eq!(entries.len(), 1);
754
755        let entry = &entries[0];
756        assert_eq!(
757            entry.get_option("Uversionmangle"),
758            Some("s/^v//;s/\\.0+$//".to_string())
759        );
760        assert_eq!(
761            entry.get_option("Dversionmangle"),
762            Some("s/\\+dfsg\\d*$//".to_string())
763        );
764        assert_eq!(
765            entry.get_option("Filenamemangle"),
766            Some("s/.*/foo-$1.tar.gz/".to_string())
767        );
768    }
769
770    #[test]
771    fn test_v5_with_pgpmode() {
772        let input = r#"Version: 5
773
774Source: https://example.com/files
775Matching-Pattern: .*\.tar\.gz
776Pgpmode: auto
777"#;
778
779        let wf: WatchFile = input.parse().unwrap();
780        let entries: Vec<_> = wf.entries().collect();
781        assert_eq!(entries.len(), 1);
782
783        let entry = &entries[0];
784        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
785    }
786
787    #[test]
788    fn test_v5_with_comments() {
789        let input = r#"Version: 5
790
791# This is a comment about the entry
792Source: https://example.com/files
793Matching-Pattern: .*\.tar\.gz
794"#;
795
796        let wf: WatchFile = input.parse().unwrap();
797        let entries: Vec<_> = wf.entries().collect();
798        assert_eq!(entries.len(), 1);
799
800        // Verify roundtrip preserves comments
801        let output = wf.to_string();
802        assert!(output.contains("# This is a comment about the entry"));
803    }
804
805    #[test]
806    fn test_v5_empty_after_version() {
807        let input = "Version: 5\n";
808
809        let wf: WatchFile = input.parse().unwrap();
810        assert_eq!(wf.version(), 5);
811
812        let entries: Vec<_> = wf.entries().collect();
813        assert_eq!(entries.len(), 0);
814    }
815
816    #[test]
817    fn test_v5_trait_url() {
818        let input = r#"Version: 5
819
820Source: https://example.com/files/@PACKAGE@
821Matching-Pattern: .*\.tar\.gz
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        // Test url() method
830        assert_eq!(
831            entry.source().as_deref(),
832            Some("https://example.com/files/@PACKAGE@")
833        );
834    }
835}