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
357/// Normalize a field key according to RFC822 rules:
358/// - Convert to lowercase
359/// - Hyphens and underscores are treated as equivalent
360fn normalize_key(key: &str) -> String {
361    key.to_lowercase().replace(['-', '_'], "")
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_create_v5_watchfile() {
370        let wf = WatchFile::new();
371        assert_eq!(wf.version(), 5);
372
373        let output = wf.to_string();
374        assert!(output.contains("Version"));
375        assert!(output.contains("5"));
376    }
377
378    #[test]
379    fn test_parse_v5_basic() {
380        let input = r#"Version: 5
381
382Source: https://github.com/owner/repo/tags
383Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
384"#;
385
386        let wf: WatchFile = input.parse().unwrap();
387        assert_eq!(wf.version(), 5);
388
389        let entries: Vec<_> = wf.entries().collect();
390        assert_eq!(entries.len(), 1);
391
392        let entry = &entries[0];
393        assert_eq!(
394            entry.source().as_deref(),
395            Some("https://github.com/owner/repo/tags")
396        );
397        assert_eq!(
398            entry.matching_pattern(),
399            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
400        );
401    }
402
403    #[test]
404    fn test_parse_v5_multiple_entries() {
405        let input = r#"Version: 5
406
407Source: https://github.com/owner/repo1/tags
408Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
409
410Source: https://github.com/owner/repo2/tags
411Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
412"#;
413
414        let wf: WatchFile = input.parse().unwrap();
415        let entries: Vec<_> = wf.entries().collect();
416        assert_eq!(entries.len(), 2);
417
418        assert_eq!(
419            entries[0].source().as_deref(),
420            Some("https://github.com/owner/repo1/tags")
421        );
422        assert_eq!(
423            entries[1].source().as_deref(),
424            Some("https://github.com/owner/repo2/tags")
425        );
426    }
427
428    #[test]
429    fn test_v5_case_insensitive_fields() {
430        let input = r#"Version: 5
431
432source: https://example.com/files
433matching-pattern: .*\.tar\.gz
434"#;
435
436        let wf: WatchFile = input.parse().unwrap();
437        let entries: Vec<_> = wf.entries().collect();
438        assert_eq!(entries.len(), 1);
439
440        let entry = &entries[0];
441        assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
442        assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
443    }
444
445    #[test]
446    fn test_v5_with_compression_option() {
447        let input = r#"Version: 5
448
449Source: https://example.com/files
450Matching-Pattern: .*\.tar\.gz
451Compression: xz
452"#;
453
454        let wf: WatchFile = input.parse().unwrap();
455        let entries: Vec<_> = wf.entries().collect();
456        assert_eq!(entries.len(), 1);
457
458        let entry = &entries[0];
459        let compression = entry.get_option("compression");
460        assert!(compression.is_some());
461    }
462
463    #[test]
464    fn test_v5_with_component() {
465        let input = r#"Version: 5
466
467Source: https://example.com/files
468Matching-Pattern: .*\.tar\.gz
469Component: foo
470"#;
471
472        let wf: WatchFile = input.parse().unwrap();
473        let entries: Vec<_> = wf.entries().collect();
474        assert_eq!(entries.len(), 1);
475
476        let entry = &entries[0];
477        assert_eq!(entry.component(), Some("foo".to_string()));
478    }
479
480    #[test]
481    fn test_v5_rejects_wrong_version() {
482        let input = r#"Version: 4
483
484Source: https://example.com/files
485Matching-Pattern: .*\.tar\.gz
486"#;
487
488        let result: Result<WatchFile, _> = input.parse();
489        assert!(result.is_err());
490    }
491
492    #[test]
493    fn test_v5_roundtrip() {
494        let input = r#"Version: 5
495
496Source: https://example.com/files
497Matching-Pattern: .*\.tar\.gz
498"#;
499
500        let wf: WatchFile = input.parse().unwrap();
501        let output = wf.to_string();
502
503        // The output should be parseable again
504        let wf2: WatchFile = output.parse().unwrap();
505        assert_eq!(wf2.version(), 5);
506
507        let entries: Vec<_> = wf2.entries().collect();
508        assert_eq!(entries.len(), 1);
509    }
510
511    #[test]
512    fn test_normalize_key() {
513        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
514        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
515        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
516        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
517    }
518
519    #[test]
520    fn test_defaults_paragraph() {
521        let input = r#"Version: 5
522
523Compression: xz
524User-Agent: Custom/1.0
525
526Source: https://example.com/repo1
527Matching-Pattern: .*\.tar\.gz
528
529Source: https://example.com/repo2
530Matching-Pattern: .*\.tar\.gz
531Compression: gz
532"#;
533
534        let wf: WatchFile = input.parse().unwrap();
535
536        // Check that defaults paragraph is detected
537        let defaults = wf.defaults();
538        assert!(defaults.is_some());
539        let defaults = defaults.unwrap();
540        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
541        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
542
543        // Check that entries inherit from defaults
544        let entries: Vec<_> = wf.entries().collect();
545        assert_eq!(entries.len(), 2);
546
547        // First entry should inherit Compression and User-Agent from defaults
548        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
549        assert_eq!(
550            entries[0].get_option("User-Agent"),
551            Some("Custom/1.0".to_string())
552        );
553
554        // Second entry overrides Compression but inherits User-Agent
555        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
556        assert_eq!(
557            entries[1].get_option("User-Agent"),
558            Some("Custom/1.0".to_string())
559        );
560    }
561
562    #[test]
563    fn test_no_defaults_paragraph() {
564        let input = r#"Version: 5
565
566Source: https://example.com/repo1
567Matching-Pattern: .*\.tar\.gz
568"#;
569
570        let wf: WatchFile = input.parse().unwrap();
571
572        // Check that there's no defaults paragraph (first paragraph has Source)
573        assert!(wf.defaults().is_none());
574
575        let entries: Vec<_> = wf.entries().collect();
576        assert_eq!(entries.len(), 1);
577    }
578
579    #[test]
580    fn test_set_source() {
581        let mut wf = WatchFile::new();
582        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
583
584        assert_eq!(
585            entry.source(),
586            Some("https://example.com/repo1".to_string())
587        );
588
589        entry.set_source("https://example.com/repo2");
590        assert_eq!(
591            entry.source(),
592            Some("https://example.com/repo2".to_string())
593        );
594    }
595
596    #[test]
597    fn test_set_matching_pattern() {
598        let mut wf = WatchFile::new();
599        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
600
601        assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
602
603        entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
604        assert_eq!(
605            entry.matching_pattern(),
606            Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
607        );
608    }
609
610    #[test]
611    fn test_entry_line() {
612        let input = r#"Version: 5
613
614Source: https://example.com/repo1
615Matching-Pattern: .*\.tar\.gz
616
617Source: https://example.com/repo2
618Matching-Pattern: .*\.tar\.xz
619"#;
620
621        let wf: WatchFile = input.parse().unwrap();
622        let entries: Vec<_> = wf.entries().collect();
623
624        // First entry starts at line 2 (0-indexed)
625        assert_eq!(entries[0].line(), 2);
626        // Second entry starts at line 5 (0-indexed)
627        assert_eq!(entries[1].line(), 5);
628    }
629
630    #[test]
631    fn test_defaults_with_case_variations() {
632        let input = r#"Version: 5
633
634compression: xz
635user-agent: Custom/1.0
636
637Source: https://example.com/repo1
638Matching-Pattern: .*\.tar\.gz
639"#;
640
641        let wf: WatchFile = input.parse().unwrap();
642
643        // Check that defaults work with different case
644        let entries: Vec<_> = wf.entries().collect();
645        assert_eq!(entries.len(), 1);
646
647        // Should find defaults even with different case
648        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
649        assert_eq!(
650            entries[0].get_option("User-Agent"),
651            Some("Custom/1.0".to_string())
652        );
653    }
654
655    #[test]
656    fn test_v5_with_uversionmangle() {
657        let input = r#"Version: 5
658
659Source: https://pypi.org/project/foo/
660Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
661Uversionmangle: s/\.0+$//
662"#;
663
664        let wf: WatchFile = input.parse().unwrap();
665        let entries: Vec<_> = wf.entries().collect();
666        assert_eq!(entries.len(), 1);
667
668        let entry = &entries[0];
669        assert_eq!(
670            entry.get_option("Uversionmangle"),
671            Some("s/\\.0+$//".to_string())
672        );
673    }
674
675    #[test]
676    fn test_v5_with_filenamemangle() {
677        let input = r#"Version: 5
678
679Source: https://example.com/files
680Matching-Pattern: .*\.tar\.gz
681Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
682"#;
683
684        let wf: WatchFile = input.parse().unwrap();
685        let entries: Vec<_> = wf.entries().collect();
686        assert_eq!(entries.len(), 1);
687
688        let entry = &entries[0];
689        assert_eq!(
690            entry.get_option("Filenamemangle"),
691            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
692        );
693    }
694
695    #[test]
696    fn test_v5_with_searchmode() {
697        let input = r#"Version: 5
698
699Source: https://example.com/files
700Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
701Searchmode: plain
702"#;
703
704        let wf: WatchFile = input.parse().unwrap();
705        let entries: Vec<_> = wf.entries().collect();
706        assert_eq!(entries.len(), 1);
707
708        let entry = &entries[0];
709        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
710    }
711
712    #[test]
713    fn test_v5_with_version_policy() {
714        let input = r#"Version: 5
715
716Source: https://example.com/files
717Matching-Pattern: .*\.tar\.gz
718Version-Policy: debian
719"#;
720
721        let wf: WatchFile = input.parse().unwrap();
722        let entries: Vec<_> = wf.entries().collect();
723        assert_eq!(entries.len(), 1);
724
725        let entry = &entries[0];
726        let policy = entry.version_policy();
727        assert!(policy.is_ok());
728        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
729    }
730
731    #[test]
732    fn test_v5_multiple_mangles() {
733        let input = r#"Version: 5
734
735Source: https://example.com/files
736Matching-Pattern: .*\.tar\.gz
737Uversionmangle: s/^v//;s/\.0+$//
738Dversionmangle: s/\+dfsg\d*$//
739Filenamemangle: s/.*/foo-$1.tar.gz/
740"#;
741
742        let wf: WatchFile = input.parse().unwrap();
743        let entries: Vec<_> = wf.entries().collect();
744        assert_eq!(entries.len(), 1);
745
746        let entry = &entries[0];
747        assert_eq!(
748            entry.get_option("Uversionmangle"),
749            Some("s/^v//;s/\\.0+$//".to_string())
750        );
751        assert_eq!(
752            entry.get_option("Dversionmangle"),
753            Some("s/\\+dfsg\\d*$//".to_string())
754        );
755        assert_eq!(
756            entry.get_option("Filenamemangle"),
757            Some("s/.*/foo-$1.tar.gz/".to_string())
758        );
759    }
760
761    #[test]
762    fn test_v5_with_pgpmode() {
763        let input = r#"Version: 5
764
765Source: https://example.com/files
766Matching-Pattern: .*\.tar\.gz
767Pgpmode: auto
768"#;
769
770        let wf: WatchFile = input.parse().unwrap();
771        let entries: Vec<_> = wf.entries().collect();
772        assert_eq!(entries.len(), 1);
773
774        let entry = &entries[0];
775        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
776    }
777
778    #[test]
779    fn test_v5_with_comments() {
780        let input = r#"Version: 5
781
782# This is a comment about the entry
783Source: https://example.com/files
784Matching-Pattern: .*\.tar\.gz
785"#;
786
787        let wf: WatchFile = input.parse().unwrap();
788        let entries: Vec<_> = wf.entries().collect();
789        assert_eq!(entries.len(), 1);
790
791        // Verify roundtrip preserves comments
792        let output = wf.to_string();
793        assert!(output.contains("# This is a comment about the entry"));
794    }
795
796    #[test]
797    fn test_v5_empty_after_version() {
798        let input = "Version: 5\n";
799
800        let wf: WatchFile = input.parse().unwrap();
801        assert_eq!(wf.version(), 5);
802
803        let entries: Vec<_> = wf.entries().collect();
804        assert_eq!(entries.len(), 0);
805    }
806
807    #[test]
808    fn test_v5_trait_url() {
809        let input = r#"Version: 5
810
811Source: https://example.com/files/@PACKAGE@
812Matching-Pattern: .*\.tar\.gz
813"#;
814
815        let wf: WatchFile = input.parse().unwrap();
816        let entries: Vec<_> = wf.entries().collect();
817        assert_eq!(entries.len(), 1);
818
819        let entry = &entries[0];
820        // Test url() method
821        assert_eq!(
822            entry.source().as_deref(),
823            Some("https://example.com/files/@PACKAGE@")
824        );
825    }
826}