debian_watch/
parse_v5.rs

1use deb822_lossless::{Deb822, Paragraph};
2use std::str::FromStr;
3
4use crate::types::ParseError;
5
6/// A watch file in format 5 (RFC822/deb822 style)
7#[derive(Debug)]
8pub struct WatchFileV5(Deb822);
9
10/// An entry in a format 5 watch file
11pub struct EntryV5 {
12    paragraph: Paragraph,
13    defaults: Option<Paragraph>,
14}
15
16impl WatchFileV5 {
17    /// Create a new empty format 5 watch file
18    pub fn new() -> Self {
19        // Create a minimal format 5 watch file from a string
20        let content = "Version: 5\n";
21        WatchFileV5::from_str(content).expect("Failed to create empty watch file")
22    }
23
24    /// Returns the version of the watch file (always 5 for this type)
25    pub fn version(&self) -> u32 {
26        5
27    }
28
29    /// Returns the defaults paragraph if it exists.
30    /// The defaults paragraph is the second paragraph (after Version) if it has no Source field.
31    pub fn defaults(&self) -> Option<Paragraph> {
32        let paragraphs: Vec<_> = self.0.paragraphs().collect();
33
34        if paragraphs.len() > 1 {
35            // Check if second paragraph looks like defaults (no Source field)
36            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
37                return Some(paragraphs[1].clone());
38            }
39        }
40
41        None
42    }
43
44    /// Returns an iterator over all entries in the watch file.
45    /// The first paragraph contains defaults, subsequent paragraphs are entries.
46    pub fn entries(&self) -> impl Iterator<Item = EntryV5> + '_ {
47        let paragraphs: Vec<_> = self.0.paragraphs().collect();
48        let defaults = self.defaults();
49
50        // Skip the first paragraph (version)
51        // The second paragraph (if it exists and has specific fields) contains defaults
52        // Otherwise all paragraphs are entries
53        let start_index = if paragraphs.len() > 1 {
54            // Check if second paragraph looks like defaults (no Source field)
55            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
56                2 // Skip version and defaults
57            } else {
58                1 // Skip only version
59            }
60        } else {
61            1
62        };
63
64        paragraphs
65            .into_iter()
66            .skip(start_index)
67            .map(move |p| EntryV5 {
68                paragraph: p,
69                defaults: defaults.clone(),
70            })
71    }
72
73    /// Get the underlying Deb822 object
74    pub fn inner(&self) -> &Deb822 {
75        &self.0
76    }
77}
78
79impl Default for WatchFileV5 {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl FromStr for WatchFileV5 {
86    type Err = ParseError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        match Deb822::from_str(s) {
90            Ok(deb822) => {
91                // Verify it's version 5
92                let version = deb822
93                    .paragraphs()
94                    .next()
95                    .and_then(|p| p.get("Version"))
96                    .unwrap_or_else(|| "1".to_string());
97
98                if version != "5" {
99                    return Err(ParseError {
100                        type_name: "WatchFileV5",
101                        value: format!("Expected version 5, got {}", version),
102                    });
103                }
104
105                Ok(WatchFileV5(deb822))
106            }
107            Err(e) => Err(ParseError {
108                type_name: "WatchFileV5",
109                value: e.to_string(),
110            }),
111        }
112    }
113}
114
115impl std::fmt::Display for WatchFileV5 {
116    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
117        write!(f, "{}", self.0)
118    }
119}
120
121impl EntryV5 {
122    /// Get a field value from the entry, with fallback to defaults paragraph.
123    /// First checks the entry's own fields, then falls back to the defaults paragraph if present.
124    pub(crate) fn get_field(&self, key: &str) -> Option<String> {
125        // Try the key as-is first in the entry
126        if let Some(value) = self.paragraph.get(key) {
127            return Some(value);
128        }
129
130        // If not found, try with different case variations in the entry
131        // deb822-lossless is case-preserving, so we need to check all field names
132        let normalized_key = normalize_key(key);
133
134        // Iterate through all keys in the paragraph and check for normalized match
135        for (k, v) in self.paragraph.items() {
136            if normalize_key(&k) == normalized_key {
137                return Some(v);
138            }
139        }
140
141        // If not found in entry, check the defaults paragraph
142        if let Some(ref defaults) = self.defaults {
143            // Try the key as-is first in defaults
144            if let Some(value) = defaults.get(key) {
145                return Some(value);
146            }
147
148            // Try with case variations in defaults
149            for (k, v) in defaults.items() {
150                if normalize_key(&k) == normalized_key {
151                    return Some(v);
152                }
153            }
154        }
155
156        None
157    }
158
159    /// Returns the source URL
160    pub fn source(&self) -> Option<String> {
161        self.get_field("Source")
162    }
163
164    /// Returns the matching pattern
165    pub fn matching_pattern_v5(&self) -> Option<String> {
166        self.get_field("Matching-Pattern")
167    }
168
169    /// Get the underlying paragraph
170    pub fn paragraph(&self) -> &Paragraph {
171        &self.paragraph
172    }
173}
174
175/// Normalize a field key according to RFC822 rules:
176/// - Convert to lowercase
177/// - Hyphens and underscores are treated as equivalent
178fn normalize_key(key: &str) -> String {
179    key.to_lowercase().replace(['-', '_'], "")
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::traits::WatchEntry;
186
187    #[test]
188    fn test_create_v5_watchfile() {
189        let wf = WatchFileV5::new();
190        assert_eq!(wf.version(), 5);
191
192        let output = wf.to_string();
193        assert!(output.contains("Version"));
194        assert!(output.contains("5"));
195    }
196
197    #[test]
198    fn test_parse_v5_basic() {
199        let input = r#"Version: 5
200
201Source: https://github.com/owner/repo/tags
202Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
203"#;
204
205        let wf: WatchFileV5 = input.parse().unwrap();
206        assert_eq!(wf.version(), 5);
207
208        let entries: Vec<_> = wf.entries().collect();
209        assert_eq!(entries.len(), 1);
210
211        let entry = &entries[0];
212        assert_eq!(entry.url(), "https://github.com/owner/repo/tags");
213        assert_eq!(
214            entry.matching_pattern(),
215            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
216        );
217    }
218
219    #[test]
220    fn test_parse_v5_multiple_entries() {
221        let input = r#"Version: 5
222
223Source: https://github.com/owner/repo1/tags
224Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
225
226Source: https://github.com/owner/repo2/tags
227Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
228"#;
229
230        let wf: WatchFileV5 = input.parse().unwrap();
231        let entries: Vec<_> = wf.entries().collect();
232        assert_eq!(entries.len(), 2);
233
234        assert_eq!(entries[0].url(), "https://github.com/owner/repo1/tags");
235        assert_eq!(entries[1].url(), "https://github.com/owner/repo2/tags");
236    }
237
238    #[test]
239    fn test_v5_case_insensitive_fields() {
240        let input = r#"Version: 5
241
242source: https://example.com/files
243matching-pattern: .*\.tar\.gz
244"#;
245
246        let wf: WatchFileV5 = input.parse().unwrap();
247        let entries: Vec<_> = wf.entries().collect();
248        assert_eq!(entries.len(), 1);
249
250        let entry = &entries[0];
251        assert_eq!(entry.url(), "https://example.com/files");
252        assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
253    }
254
255    #[test]
256    fn test_v5_with_compression_option() {
257        let input = r#"Version: 5
258
259Source: https://example.com/files
260Matching-Pattern: .*\.tar\.gz
261Compression: xz
262"#;
263
264        let wf: WatchFileV5 = input.parse().unwrap();
265        let entries: Vec<_> = wf.entries().collect();
266        assert_eq!(entries.len(), 1);
267
268        let entry = &entries[0];
269        let compression = entry.compression().unwrap();
270        assert!(compression.is_some());
271    }
272
273    #[test]
274    fn test_v5_with_component() {
275        let input = r#"Version: 5
276
277Source: https://example.com/files
278Matching-Pattern: .*\.tar\.gz
279Component: foo
280"#;
281
282        let wf: WatchFileV5 = input.parse().unwrap();
283        let entries: Vec<_> = wf.entries().collect();
284        assert_eq!(entries.len(), 1);
285
286        let entry = &entries[0];
287        assert_eq!(entry.component(), Some("foo".to_string()));
288    }
289
290    #[test]
291    fn test_v5_rejects_wrong_version() {
292        let input = r#"Version: 4
293
294Source: https://example.com/files
295Matching-Pattern: .*\.tar\.gz
296"#;
297
298        let result: Result<WatchFileV5, _> = input.parse();
299        assert!(result.is_err());
300    }
301
302    #[test]
303    fn test_v5_trait_implementation() {
304        let input = r#"Version: 5
305
306Source: https://example.com/files
307Matching-Pattern: .*\.tar\.gz
308"#;
309
310        let wf: WatchFileV5 = input.parse().unwrap();
311
312        // Test WatchFileFormat trait
313        assert_eq!(wf.version(), 5);
314        let entries: Vec<_> = wf.entries().collect();
315        assert_eq!(entries.len(), 1);
316
317        // Test WatchEntry trait
318        let entry = &entries[0];
319        assert_eq!(entry.url(), "https://example.com/files");
320        assert!(entry.matching_pattern().is_some());
321    }
322
323    #[test]
324    fn test_v5_roundtrip() {
325        let input = r#"Version: 5
326
327Source: https://example.com/files
328Matching-Pattern: .*\.tar\.gz
329"#;
330
331        let wf: WatchFileV5 = input.parse().unwrap();
332        let output = wf.to_string();
333
334        // The output should be parseable again
335        let wf2: WatchFileV5 = output.parse().unwrap();
336        assert_eq!(wf2.version(), 5);
337
338        let entries: Vec<_> = wf2.entries().collect();
339        assert_eq!(entries.len(), 1);
340    }
341
342    #[test]
343    fn test_normalize_key() {
344        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
345        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
346        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
347        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
348    }
349
350    #[test]
351    fn test_defaults_paragraph() {
352        let input = r#"Version: 5
353
354Compression: xz
355User-Agent: Custom/1.0
356
357Source: https://example.com/repo1
358Matching-Pattern: .*\.tar\.gz
359
360Source: https://example.com/repo2
361Matching-Pattern: .*\.tar\.gz
362Compression: gz
363"#;
364
365        let wf: WatchFileV5 = input.parse().unwrap();
366
367        // Check that defaults paragraph is detected
368        let defaults = wf.defaults();
369        assert!(defaults.is_some());
370        let defaults = defaults.unwrap();
371        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
372        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
373
374        // Check that entries inherit from defaults
375        let entries: Vec<_> = wf.entries().collect();
376        assert_eq!(entries.len(), 2);
377
378        // First entry should inherit Compression and User-Agent from defaults
379        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
380        assert_eq!(
381            entries[0].get_option("User-Agent"),
382            Some("Custom/1.0".to_string())
383        );
384
385        // Second entry overrides Compression but inherits User-Agent
386        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
387        assert_eq!(
388            entries[1].get_option("User-Agent"),
389            Some("Custom/1.0".to_string())
390        );
391    }
392
393    #[test]
394    fn test_no_defaults_paragraph() {
395        let input = r#"Version: 5
396
397Source: https://example.com/repo1
398Matching-Pattern: .*\.tar\.gz
399"#;
400
401        let wf: WatchFileV5 = input.parse().unwrap();
402
403        // Check that there's no defaults paragraph (first paragraph has Source)
404        assert!(wf.defaults().is_none());
405
406        let entries: Vec<_> = wf.entries().collect();
407        assert_eq!(entries.len(), 1);
408    }
409
410    #[test]
411    fn test_defaults_with_case_variations() {
412        let input = r#"Version: 5
413
414compression: xz
415user-agent: Custom/1.0
416
417Source: https://example.com/repo1
418Matching-Pattern: .*\.tar\.gz
419"#;
420
421        let wf: WatchFileV5 = input.parse().unwrap();
422
423        // Check that defaults work with different case
424        let entries: Vec<_> = wf.entries().collect();
425        assert_eq!(entries.len(), 1);
426
427        // Should find defaults even with different case
428        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
429        assert_eq!(
430            entries[0].get_option("User-Agent"),
431            Some("Custom/1.0".to_string())
432        );
433    }
434}