1use deb822_lossless::{Deb822, Paragraph};
2use std::str::FromStr;
3
4use crate::types::ParseError;
5
6#[derive(Debug)]
8pub struct WatchFileV5(Deb822);
9
10pub struct EntryV5 {
12 paragraph: Paragraph,
13 defaults: Option<Paragraph>,
14}
15
16impl WatchFileV5 {
17 pub fn new() -> Self {
19 let content = "Version: 5\n";
21 WatchFileV5::from_str(content).expect("Failed to create empty watch file")
22 }
23
24 pub fn version(&self) -> u32 {
26 5
27 }
28
29 pub fn defaults(&self) -> Option<Paragraph> {
32 let paragraphs: Vec<_> = self.0.paragraphs().collect();
33
34 if paragraphs.len() > 1 {
35 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 pub fn entries(&self) -> impl Iterator<Item = EntryV5> + '_ {
47 let paragraphs: Vec<_> = self.0.paragraphs().collect();
48 let defaults = self.defaults();
49
50 let start_index = if paragraphs.len() > 1 {
54 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
56 2 } else {
58 1 }
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 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 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 pub(crate) fn get_field(&self, key: &str) -> Option<String> {
125 if let Some(value) = self.paragraph.get(key) {
127 return Some(value);
128 }
129
130 let normalized_key = normalize_key(key);
133
134 for (k, v) in self.paragraph.items() {
136 if normalize_key(&k) == normalized_key {
137 return Some(v);
138 }
139 }
140
141 if let Some(ref defaults) = self.defaults {
143 if let Some(value) = defaults.get(key) {
145 return Some(value);
146 }
147
148 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 pub fn source(&self) -> Option<String> {
161 self.get_field("Source")
162 }
163
164 pub fn matching_pattern_v5(&self) -> Option<String> {
166 self.get_field("Matching-Pattern")
167 }
168
169 pub fn paragraph(&self) -> &Paragraph {
171 &self.paragraph
172 }
173}
174
175fn 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 assert_eq!(wf.version(), 5);
314 let entries: Vec<_> = wf.entries().collect();
315 assert_eq!(entries.len(), 1);
316
317 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 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 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 let entries: Vec<_> = wf.entries().collect();
376 assert_eq!(entries.len(), 2);
377
378 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 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 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 let entries: Vec<_> = wf.entries().collect();
425 assert_eq!(entries.len(), 1);
426
427 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}