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