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
93impl Default for WatchFile {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl FromStr for WatchFile {
100    type Err = ParseError;
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        match Deb822::from_str(s) {
104            Ok(deb822) => {
105                // Verify it's version 5
106                let version = deb822
107                    .paragraphs()
108                    .next()
109                    .and_then(|p| p.get("Version"))
110                    .unwrap_or_else(|| "1".to_string());
111
112                if version != "5" {
113                    return Err(ParseError(format!("Expected version 5, got {}", version)));
114                }
115
116                Ok(WatchFile(deb822))
117            }
118            Err(e) => Err(ParseError(e.to_string())),
119        }
120    }
121}
122
123impl std::fmt::Display for WatchFile {
124    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
125        write!(f, "{}", self.0)
126    }
127}
128
129impl Entry {
130    /// Get a field value from the entry, with fallback to defaults paragraph.
131    /// First checks the entry's own fields, then falls back to the defaults paragraph if present.
132    pub(crate) fn get_field(&self, key: &str) -> Option<String> {
133        // Try the key as-is first in the entry
134        if let Some(value) = self.paragraph.get(key) {
135            return Some(value);
136        }
137
138        // If not found, try with different case variations in the entry
139        // deb822-lossless is case-preserving, so we need to check all field names
140        let normalized_key = normalize_key(key);
141
142        // Iterate through all keys in the paragraph and check for normalized match
143        for (k, v) in self.paragraph.items() {
144            if normalize_key(&k) == normalized_key {
145                return Some(v);
146            }
147        }
148
149        // If not found in entry, check the defaults paragraph
150        if let Some(ref defaults) = self.defaults {
151            // Try the key as-is first in defaults
152            if let Some(value) = defaults.get(key) {
153                return Some(value);
154            }
155
156            // Try with case variations in defaults
157            for (k, v) in defaults.items() {
158                if normalize_key(&k) == normalized_key {
159                    return Some(v);
160                }
161            }
162        }
163
164        None
165    }
166
167    /// Returns the source URL
168    pub fn source(&self) -> Option<String> {
169        self.get_field("Source")
170    }
171
172    /// Returns the matching pattern
173    pub fn matching_pattern(&self) -> Option<String> {
174        self.get_field("Matching-Pattern")
175    }
176
177    /// Get the underlying paragraph
178    pub fn as_deb822(&self) -> &Paragraph {
179        &self.paragraph
180    }
181
182    /// Name of the component, if specified
183    pub fn component(&self) -> Option<String> {
184        self.get_field("Component")
185    }
186
187    /// Get the an option value from the entry, with fallback to defaults paragraph.
188    pub fn get_option(&self, key: &str) -> Option<String> {
189        match key {
190            "Source" => None,           // Source is not an option
191            "Matching-Pattern" => None, // Matching-Pattern is not an option
192            "Component" => None,        // Component is not an option
193            "Version" => None,          // Version is not an option
194            key => self.get_field(key),
195        }
196    }
197
198    /// Set an option value in the entry
199    pub fn set_option(&mut self, key: &str, value: &str) {
200        self.paragraph.insert(key, value);
201    }
202
203    /// Delete an option from the entry
204    pub fn delete_option(&mut self, key: &str) {
205        self.paragraph.remove(key);
206    }
207
208    /// Get the URL (same as source() but named url() for consistency)
209    pub fn url(&self) -> String {
210        self.source().unwrap_or_default()
211    }
212
213    /// Get the version policy
214    pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
215        match self.get_field("Version-Policy") {
216            Some(policy) => Ok(Some(policy.parse()?)),
217            None => Ok(None),
218        }
219    }
220
221    /// Get the script
222    pub fn script(&self) -> Option<String> {
223        self.get_field("Script")
224    }
225}
226
227/// Normalize a field key according to RFC822 rules:
228/// - Convert to lowercase
229/// - Hyphens and underscores are treated as equivalent
230fn normalize_key(key: &str) -> String {
231    key.to_lowercase().replace(['-', '_'], "")
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_create_v5_watchfile() {
240        let wf = WatchFile::new();
241        assert_eq!(wf.version(), 5);
242
243        let output = wf.to_string();
244        assert!(output.contains("Version"));
245        assert!(output.contains("5"));
246    }
247
248    #[test]
249    fn test_parse_v5_basic() {
250        let input = r#"Version: 5
251
252Source: https://github.com/owner/repo/tags
253Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
254"#;
255
256        let wf: WatchFile = input.parse().unwrap();
257        assert_eq!(wf.version(), 5);
258
259        let entries: Vec<_> = wf.entries().collect();
260        assert_eq!(entries.len(), 1);
261
262        let entry = &entries[0];
263        assert_eq!(
264            entry.source().as_deref(),
265            Some("https://github.com/owner/repo/tags")
266        );
267        assert_eq!(
268            entry.matching_pattern(),
269            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
270        );
271    }
272
273    #[test]
274    fn test_parse_v5_multiple_entries() {
275        let input = r#"Version: 5
276
277Source: https://github.com/owner/repo1/tags
278Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
279
280Source: https://github.com/owner/repo2/tags
281Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
282"#;
283
284        let wf: WatchFile = input.parse().unwrap();
285        let entries: Vec<_> = wf.entries().collect();
286        assert_eq!(entries.len(), 2);
287
288        assert_eq!(
289            entries[0].source().as_deref(),
290            Some("https://github.com/owner/repo1/tags")
291        );
292        assert_eq!(
293            entries[1].source().as_deref(),
294            Some("https://github.com/owner/repo2/tags")
295        );
296    }
297
298    #[test]
299    fn test_v5_case_insensitive_fields() {
300        let input = r#"Version: 5
301
302source: https://example.com/files
303matching-pattern: .*\.tar\.gz
304"#;
305
306        let wf: WatchFile = input.parse().unwrap();
307        let entries: Vec<_> = wf.entries().collect();
308        assert_eq!(entries.len(), 1);
309
310        let entry = &entries[0];
311        assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
312        assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
313    }
314
315    #[test]
316    fn test_v5_with_compression_option() {
317        let input = r#"Version: 5
318
319Source: https://example.com/files
320Matching-Pattern: .*\.tar\.gz
321Compression: xz
322"#;
323
324        let wf: WatchFile = input.parse().unwrap();
325        let entries: Vec<_> = wf.entries().collect();
326        assert_eq!(entries.len(), 1);
327
328        let entry = &entries[0];
329        let compression = entry.get_option("compression");
330        assert!(compression.is_some());
331    }
332
333    #[test]
334    fn test_v5_with_component() {
335        let input = r#"Version: 5
336
337Source: https://example.com/files
338Matching-Pattern: .*\.tar\.gz
339Component: foo
340"#;
341
342        let wf: WatchFile = input.parse().unwrap();
343        let entries: Vec<_> = wf.entries().collect();
344        assert_eq!(entries.len(), 1);
345
346        let entry = &entries[0];
347        assert_eq!(entry.component(), Some("foo".to_string()));
348    }
349
350    #[test]
351    fn test_v5_rejects_wrong_version() {
352        let input = r#"Version: 4
353
354Source: https://example.com/files
355Matching-Pattern: .*\.tar\.gz
356"#;
357
358        let result: Result<WatchFile, _> = input.parse();
359        assert!(result.is_err());
360    }
361
362    #[test]
363    fn test_v5_roundtrip() {
364        let input = r#"Version: 5
365
366Source: https://example.com/files
367Matching-Pattern: .*\.tar\.gz
368"#;
369
370        let wf: WatchFile = input.parse().unwrap();
371        let output = wf.to_string();
372
373        // The output should be parseable again
374        let wf2: WatchFile = output.parse().unwrap();
375        assert_eq!(wf2.version(), 5);
376
377        let entries: Vec<_> = wf2.entries().collect();
378        assert_eq!(entries.len(), 1);
379    }
380
381    #[test]
382    fn test_normalize_key() {
383        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
384        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
385        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
386        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
387    }
388
389    #[test]
390    fn test_defaults_paragraph() {
391        let input = r#"Version: 5
392
393Compression: xz
394User-Agent: Custom/1.0
395
396Source: https://example.com/repo1
397Matching-Pattern: .*\.tar\.gz
398
399Source: https://example.com/repo2
400Matching-Pattern: .*\.tar\.gz
401Compression: gz
402"#;
403
404        let wf: WatchFile = input.parse().unwrap();
405
406        // Check that defaults paragraph is detected
407        let defaults = wf.defaults();
408        assert!(defaults.is_some());
409        let defaults = defaults.unwrap();
410        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
411        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
412
413        // Check that entries inherit from defaults
414        let entries: Vec<_> = wf.entries().collect();
415        assert_eq!(entries.len(), 2);
416
417        // First entry should inherit Compression and User-Agent from defaults
418        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
419        assert_eq!(
420            entries[0].get_option("User-Agent"),
421            Some("Custom/1.0".to_string())
422        );
423
424        // Second entry overrides Compression but inherits User-Agent
425        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
426        assert_eq!(
427            entries[1].get_option("User-Agent"),
428            Some("Custom/1.0".to_string())
429        );
430    }
431
432    #[test]
433    fn test_no_defaults_paragraph() {
434        let input = r#"Version: 5
435
436Source: https://example.com/repo1
437Matching-Pattern: .*\.tar\.gz
438"#;
439
440        let wf: WatchFile = input.parse().unwrap();
441
442        // Check that there's no defaults paragraph (first paragraph has Source)
443        assert!(wf.defaults().is_none());
444
445        let entries: Vec<_> = wf.entries().collect();
446        assert_eq!(entries.len(), 1);
447    }
448
449    #[test]
450    fn test_defaults_with_case_variations() {
451        let input = r#"Version: 5
452
453compression: xz
454user-agent: Custom/1.0
455
456Source: https://example.com/repo1
457Matching-Pattern: .*\.tar\.gz
458"#;
459
460        let wf: WatchFile = input.parse().unwrap();
461
462        // Check that defaults work with different case
463        let entries: Vec<_> = wf.entries().collect();
464        assert_eq!(entries.len(), 1);
465
466        // Should find defaults even with different case
467        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
468        assert_eq!(
469            entries[0].get_option("User-Agent"),
470            Some("Custom/1.0".to_string())
471        );
472    }
473
474    #[test]
475    fn test_v5_with_uversionmangle() {
476        let input = r#"Version: 5
477
478Source: https://pypi.org/project/foo/
479Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
480Uversionmangle: s/\.0+$//
481"#;
482
483        let wf: WatchFile = input.parse().unwrap();
484        let entries: Vec<_> = wf.entries().collect();
485        assert_eq!(entries.len(), 1);
486
487        let entry = &entries[0];
488        assert_eq!(
489            entry.get_option("Uversionmangle"),
490            Some("s/\\.0+$//".to_string())
491        );
492    }
493
494    #[test]
495    fn test_v5_with_filenamemangle() {
496        let input = r#"Version: 5
497
498Source: https://example.com/files
499Matching-Pattern: .*\.tar\.gz
500Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
501"#;
502
503        let wf: WatchFile = input.parse().unwrap();
504        let entries: Vec<_> = wf.entries().collect();
505        assert_eq!(entries.len(), 1);
506
507        let entry = &entries[0];
508        assert_eq!(
509            entry.get_option("Filenamemangle"),
510            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
511        );
512    }
513
514    #[test]
515    fn test_v5_with_searchmode() {
516        let input = r#"Version: 5
517
518Source: https://example.com/files
519Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
520Searchmode: plain
521"#;
522
523        let wf: WatchFile = input.parse().unwrap();
524        let entries: Vec<_> = wf.entries().collect();
525        assert_eq!(entries.len(), 1);
526
527        let entry = &entries[0];
528        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
529    }
530
531    #[test]
532    fn test_v5_with_version_policy() {
533        let input = r#"Version: 5
534
535Source: https://example.com/files
536Matching-Pattern: .*\.tar\.gz
537Version-Policy: debian
538"#;
539
540        let wf: WatchFile = input.parse().unwrap();
541        let entries: Vec<_> = wf.entries().collect();
542        assert_eq!(entries.len(), 1);
543
544        let entry = &entries[0];
545        let policy = entry.version_policy();
546        assert!(policy.is_ok());
547        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
548    }
549
550    #[test]
551    fn test_v5_multiple_mangles() {
552        let input = r#"Version: 5
553
554Source: https://example.com/files
555Matching-Pattern: .*\.tar\.gz
556Uversionmangle: s/^v//;s/\.0+$//
557Dversionmangle: s/\+dfsg\d*$//
558Filenamemangle: s/.*/foo-$1.tar.gz/
559"#;
560
561        let wf: WatchFile = input.parse().unwrap();
562        let entries: Vec<_> = wf.entries().collect();
563        assert_eq!(entries.len(), 1);
564
565        let entry = &entries[0];
566        assert_eq!(
567            entry.get_option("Uversionmangle"),
568            Some("s/^v//;s/\\.0+$//".to_string())
569        );
570        assert_eq!(
571            entry.get_option("Dversionmangle"),
572            Some("s/\\+dfsg\\d*$//".to_string())
573        );
574        assert_eq!(
575            entry.get_option("Filenamemangle"),
576            Some("s/.*/foo-$1.tar.gz/".to_string())
577        );
578    }
579
580    #[test]
581    fn test_v5_with_pgpmode() {
582        let input = r#"Version: 5
583
584Source: https://example.com/files
585Matching-Pattern: .*\.tar\.gz
586Pgpmode: auto
587"#;
588
589        let wf: WatchFile = input.parse().unwrap();
590        let entries: Vec<_> = wf.entries().collect();
591        assert_eq!(entries.len(), 1);
592
593        let entry = &entries[0];
594        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
595    }
596
597    #[test]
598    fn test_v5_with_comments() {
599        let input = r#"Version: 5
600
601# This is a comment about the entry
602Source: https://example.com/files
603Matching-Pattern: .*\.tar\.gz
604"#;
605
606        let wf: WatchFile = input.parse().unwrap();
607        let entries: Vec<_> = wf.entries().collect();
608        assert_eq!(entries.len(), 1);
609
610        // Verify roundtrip preserves comments
611        let output = wf.to_string();
612        assert!(output.contains("# This is a comment about the entry"));
613    }
614
615    #[test]
616    fn test_v5_empty_after_version() {
617        let input = "Version: 5\n";
618
619        let wf: WatchFile = input.parse().unwrap();
620        assert_eq!(wf.version(), 5);
621
622        let entries: Vec<_> = wf.entries().collect();
623        assert_eq!(entries.len(), 0);
624    }
625
626    #[test]
627    fn test_v5_trait_url() {
628        let input = r#"Version: 5
629
630Source: https://example.com/files/@PACKAGE@
631Matching-Pattern: .*\.tar\.gz
632"#;
633
634        let wf: WatchFile = input.parse().unwrap();
635        let entries: Vec<_> = wf.entries().collect();
636        assert_eq!(entries.len(), 1);
637
638        let entry = &entries[0];
639        // Test url() method
640        assert_eq!(
641            entry.source().as_deref(),
642            Some("https://example.com/files/@PACKAGE@")
643        );
644    }
645}