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
303/// Normalize a field key according to RFC822 rules:
304/// - Convert to lowercase
305/// - Hyphens and underscores are treated as equivalent
306fn normalize_key(key: &str) -> String {
307    key.to_lowercase().replace(['-', '_'], "")
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_create_v5_watchfile() {
316        let wf = WatchFile::new();
317        assert_eq!(wf.version(), 5);
318
319        let output = wf.to_string();
320        assert!(output.contains("Version"));
321        assert!(output.contains("5"));
322    }
323
324    #[test]
325    fn test_parse_v5_basic() {
326        let input = r#"Version: 5
327
328Source: https://github.com/owner/repo/tags
329Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
330"#;
331
332        let wf: WatchFile = input.parse().unwrap();
333        assert_eq!(wf.version(), 5);
334
335        let entries: Vec<_> = wf.entries().collect();
336        assert_eq!(entries.len(), 1);
337
338        let entry = &entries[0];
339        assert_eq!(
340            entry.source().as_deref(),
341            Some("https://github.com/owner/repo/tags")
342        );
343        assert_eq!(
344            entry.matching_pattern(),
345            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
346        );
347    }
348
349    #[test]
350    fn test_parse_v5_multiple_entries() {
351        let input = r#"Version: 5
352
353Source: https://github.com/owner/repo1/tags
354Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
355
356Source: https://github.com/owner/repo2/tags
357Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
358"#;
359
360        let wf: WatchFile = input.parse().unwrap();
361        let entries: Vec<_> = wf.entries().collect();
362        assert_eq!(entries.len(), 2);
363
364        assert_eq!(
365            entries[0].source().as_deref(),
366            Some("https://github.com/owner/repo1/tags")
367        );
368        assert_eq!(
369            entries[1].source().as_deref(),
370            Some("https://github.com/owner/repo2/tags")
371        );
372    }
373
374    #[test]
375    fn test_v5_case_insensitive_fields() {
376        let input = r#"Version: 5
377
378source: https://example.com/files
379matching-pattern: .*\.tar\.gz
380"#;
381
382        let wf: WatchFile = input.parse().unwrap();
383        let entries: Vec<_> = wf.entries().collect();
384        assert_eq!(entries.len(), 1);
385
386        let entry = &entries[0];
387        assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
388        assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
389    }
390
391    #[test]
392    fn test_v5_with_compression_option() {
393        let input = r#"Version: 5
394
395Source: https://example.com/files
396Matching-Pattern: .*\.tar\.gz
397Compression: xz
398"#;
399
400        let wf: WatchFile = input.parse().unwrap();
401        let entries: Vec<_> = wf.entries().collect();
402        assert_eq!(entries.len(), 1);
403
404        let entry = &entries[0];
405        let compression = entry.get_option("compression");
406        assert!(compression.is_some());
407    }
408
409    #[test]
410    fn test_v5_with_component() {
411        let input = r#"Version: 5
412
413Source: https://example.com/files
414Matching-Pattern: .*\.tar\.gz
415Component: foo
416"#;
417
418        let wf: WatchFile = input.parse().unwrap();
419        let entries: Vec<_> = wf.entries().collect();
420        assert_eq!(entries.len(), 1);
421
422        let entry = &entries[0];
423        assert_eq!(entry.component(), Some("foo".to_string()));
424    }
425
426    #[test]
427    fn test_v5_rejects_wrong_version() {
428        let input = r#"Version: 4
429
430Source: https://example.com/files
431Matching-Pattern: .*\.tar\.gz
432"#;
433
434        let result: Result<WatchFile, _> = input.parse();
435        assert!(result.is_err());
436    }
437
438    #[test]
439    fn test_v5_roundtrip() {
440        let input = r#"Version: 5
441
442Source: https://example.com/files
443Matching-Pattern: .*\.tar\.gz
444"#;
445
446        let wf: WatchFile = input.parse().unwrap();
447        let output = wf.to_string();
448
449        // The output should be parseable again
450        let wf2: WatchFile = output.parse().unwrap();
451        assert_eq!(wf2.version(), 5);
452
453        let entries: Vec<_> = wf2.entries().collect();
454        assert_eq!(entries.len(), 1);
455    }
456
457    #[test]
458    fn test_normalize_key() {
459        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
460        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
461        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
462        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
463    }
464
465    #[test]
466    fn test_defaults_paragraph() {
467        let input = r#"Version: 5
468
469Compression: xz
470User-Agent: Custom/1.0
471
472Source: https://example.com/repo1
473Matching-Pattern: .*\.tar\.gz
474
475Source: https://example.com/repo2
476Matching-Pattern: .*\.tar\.gz
477Compression: gz
478"#;
479
480        let wf: WatchFile = input.parse().unwrap();
481
482        // Check that defaults paragraph is detected
483        let defaults = wf.defaults();
484        assert!(defaults.is_some());
485        let defaults = defaults.unwrap();
486        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
487        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
488
489        // Check that entries inherit from defaults
490        let entries: Vec<_> = wf.entries().collect();
491        assert_eq!(entries.len(), 2);
492
493        // First entry should inherit Compression and User-Agent from defaults
494        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
495        assert_eq!(
496            entries[0].get_option("User-Agent"),
497            Some("Custom/1.0".to_string())
498        );
499
500        // Second entry overrides Compression but inherits User-Agent
501        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
502        assert_eq!(
503            entries[1].get_option("User-Agent"),
504            Some("Custom/1.0".to_string())
505        );
506    }
507
508    #[test]
509    fn test_no_defaults_paragraph() {
510        let input = r#"Version: 5
511
512Source: https://example.com/repo1
513Matching-Pattern: .*\.tar\.gz
514"#;
515
516        let wf: WatchFile = input.parse().unwrap();
517
518        // Check that there's no defaults paragraph (first paragraph has Source)
519        assert!(wf.defaults().is_none());
520
521        let entries: Vec<_> = wf.entries().collect();
522        assert_eq!(entries.len(), 1);
523    }
524
525    #[test]
526    fn test_defaults_with_case_variations() {
527        let input = r#"Version: 5
528
529compression: xz
530user-agent: Custom/1.0
531
532Source: https://example.com/repo1
533Matching-Pattern: .*\.tar\.gz
534"#;
535
536        let wf: WatchFile = input.parse().unwrap();
537
538        // Check that defaults work with different case
539        let entries: Vec<_> = wf.entries().collect();
540        assert_eq!(entries.len(), 1);
541
542        // Should find defaults even with different case
543        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
544        assert_eq!(
545            entries[0].get_option("User-Agent"),
546            Some("Custom/1.0".to_string())
547        );
548    }
549
550    #[test]
551    fn test_v5_with_uversionmangle() {
552        let input = r#"Version: 5
553
554Source: https://pypi.org/project/foo/
555Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
556Uversionmangle: s/\.0+$//
557"#;
558
559        let wf: WatchFile = input.parse().unwrap();
560        let entries: Vec<_> = wf.entries().collect();
561        assert_eq!(entries.len(), 1);
562
563        let entry = &entries[0];
564        assert_eq!(
565            entry.get_option("Uversionmangle"),
566            Some("s/\\.0+$//".to_string())
567        );
568    }
569
570    #[test]
571    fn test_v5_with_filenamemangle() {
572        let input = r#"Version: 5
573
574Source: https://example.com/files
575Matching-Pattern: .*\.tar\.gz
576Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
577"#;
578
579        let wf: WatchFile = input.parse().unwrap();
580        let entries: Vec<_> = wf.entries().collect();
581        assert_eq!(entries.len(), 1);
582
583        let entry = &entries[0];
584        assert_eq!(
585            entry.get_option("Filenamemangle"),
586            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
587        );
588    }
589
590    #[test]
591    fn test_v5_with_searchmode() {
592        let input = r#"Version: 5
593
594Source: https://example.com/files
595Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
596Searchmode: plain
597"#;
598
599        let wf: WatchFile = input.parse().unwrap();
600        let entries: Vec<_> = wf.entries().collect();
601        assert_eq!(entries.len(), 1);
602
603        let entry = &entries[0];
604        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
605    }
606
607    #[test]
608    fn test_v5_with_version_policy() {
609        let input = r#"Version: 5
610
611Source: https://example.com/files
612Matching-Pattern: .*\.tar\.gz
613Version-Policy: debian
614"#;
615
616        let wf: WatchFile = input.parse().unwrap();
617        let entries: Vec<_> = wf.entries().collect();
618        assert_eq!(entries.len(), 1);
619
620        let entry = &entries[0];
621        let policy = entry.version_policy();
622        assert!(policy.is_ok());
623        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
624    }
625
626    #[test]
627    fn test_v5_multiple_mangles() {
628        let input = r#"Version: 5
629
630Source: https://example.com/files
631Matching-Pattern: .*\.tar\.gz
632Uversionmangle: s/^v//;s/\.0+$//
633Dversionmangle: s/\+dfsg\d*$//
634Filenamemangle: s/.*/foo-$1.tar.gz/
635"#;
636
637        let wf: WatchFile = input.parse().unwrap();
638        let entries: Vec<_> = wf.entries().collect();
639        assert_eq!(entries.len(), 1);
640
641        let entry = &entries[0];
642        assert_eq!(
643            entry.get_option("Uversionmangle"),
644            Some("s/^v//;s/\\.0+$//".to_string())
645        );
646        assert_eq!(
647            entry.get_option("Dversionmangle"),
648            Some("s/\\+dfsg\\d*$//".to_string())
649        );
650        assert_eq!(
651            entry.get_option("Filenamemangle"),
652            Some("s/.*/foo-$1.tar.gz/".to_string())
653        );
654    }
655
656    #[test]
657    fn test_v5_with_pgpmode() {
658        let input = r#"Version: 5
659
660Source: https://example.com/files
661Matching-Pattern: .*\.tar\.gz
662Pgpmode: auto
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_option("Pgpmode"), Some("auto".to_string()));
671    }
672
673    #[test]
674    fn test_v5_with_comments() {
675        let input = r#"Version: 5
676
677# This is a comment about the entry
678Source: https://example.com/files
679Matching-Pattern: .*\.tar\.gz
680"#;
681
682        let wf: WatchFile = input.parse().unwrap();
683        let entries: Vec<_> = wf.entries().collect();
684        assert_eq!(entries.len(), 1);
685
686        // Verify roundtrip preserves comments
687        let output = wf.to_string();
688        assert!(output.contains("# This is a comment about the entry"));
689    }
690
691    #[test]
692    fn test_v5_empty_after_version() {
693        let input = "Version: 5\n";
694
695        let wf: WatchFile = input.parse().unwrap();
696        assert_eq!(wf.version(), 5);
697
698        let entries: Vec<_> = wf.entries().collect();
699        assert_eq!(entries.len(), 0);
700    }
701
702    #[test]
703    fn test_v5_trait_url() {
704        let input = r#"Version: 5
705
706Source: https://example.com/files/@PACKAGE@
707Matching-Pattern: .*\.tar\.gz
708"#;
709
710        let wf: WatchFile = input.parse().unwrap();
711        let entries: Vec<_> = wf.entries().collect();
712        assert_eq!(entries.len(), 1);
713
714        let entry = &entries[0];
715        // Test url() method
716        assert_eq!(
717            entry.source().as_deref(),
718            Some("https://example.com/files/@PACKAGE@")
719        );
720    }
721}