1use crate::types::ParseError as TypesParseError;
3use crate::VersionPolicy;
4use deb822_lossless::{Deb822, Paragraph};
5use std::str::FromStr;
6
7#[derive(Debug)]
8pub 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#[derive(Debug)]
21pub struct WatchFile(Deb822);
22
23#[derive(Debug)]
25pub struct Entry {
26 paragraph: Paragraph,
27 defaults: Option<Paragraph>,
28}
29
30impl WatchFile {
31 pub fn new() -> Self {
33 let content = "Version: 5\n";
35 WatchFile::from_str(content).expect("Failed to create empty watch file")
36 }
37
38 pub fn version(&self) -> u32 {
40 5
41 }
42
43 pub fn defaults(&self) -> Option<Paragraph> {
46 let paragraphs: Vec<_> = self.0.paragraphs().collect();
47
48 if paragraphs.len() > 1 {
49 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 pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
61 let paragraphs: Vec<_> = self.0.paragraphs().collect();
62 let defaults = self.defaults();
63
64 let start_index = if paragraphs.len() > 1 {
68 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
70 2 } else {
72 1 }
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 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 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 pub(crate) fn get_field(&self, key: &str) -> Option<String> {
133 if let Some(value) = self.paragraph.get(key) {
135 return Some(value);
136 }
137
138 let normalized_key = normalize_key(key);
141
142 for (k, v) in self.paragraph.items() {
144 if normalize_key(&k) == normalized_key {
145 return Some(v);
146 }
147 }
148
149 if let Some(ref defaults) = self.defaults {
151 if let Some(value) = defaults.get(key) {
153 return Some(value);
154 }
155
156 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 pub fn source(&self) -> Option<String> {
169 self.get_field("Source")
170 }
171
172 pub fn matching_pattern(&self) -> Option<String> {
174 self.get_field("Matching-Pattern")
175 }
176
177 pub fn as_deb822(&self) -> &Paragraph {
179 &self.paragraph
180 }
181
182 pub fn component(&self) -> Option<String> {
184 self.get_field("Component")
185 }
186
187 pub fn get_option(&self, key: &str) -> Option<String> {
189 match key {
190 "Source" => None, "Matching-Pattern" => None, "Component" => None, "Version" => None, key => self.get_field(key),
195 }
196 }
197
198 pub fn set_option(&mut self, key: &str, value: &str) {
200 self.paragraph.insert(key, value);
201 }
202
203 pub fn delete_option(&mut self, key: &str) {
205 self.paragraph.remove(key);
206 }
207
208 pub fn url(&self) -> String {
210 self.source().unwrap_or_default()
211 }
212
213 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 pub fn script(&self) -> Option<String> {
223 self.get_field("Script")
224 }
225}
226
227fn 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 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 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 let entries: Vec<_> = wf.entries().collect();
415 assert_eq!(entries.len(), 2);
416
417 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 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 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 let entries: Vec<_> = wf.entries().collect();
464 assert_eq!(entries.len(), 1);
465
466 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 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 assert_eq!(
641 entry.source().as_deref(),
642 Some("https://example.com/files/@PACKAGE@")
643 );
644 }
645}