1use crate::types::ParseError as TypesParseError;
3use crate::VersionPolicy;
4use deb822_lossless::{Deb822, Paragraph};
5use std::str::FromStr;
6
7fn watch_option_to_key(option: &crate::types::WatchOption) -> &'static str {
9 use crate::types::WatchOption;
10
11 match option {
12 WatchOption::Component(_) => "Component",
13 WatchOption::Compression(_) => "Compression",
14 WatchOption::UserAgent(_) => "User-Agent",
15 WatchOption::Pagemangle(_) => "Pagemangle",
16 WatchOption::Uversionmangle(_) => "Uversionmangle",
17 WatchOption::Dversionmangle(_) => "Dversionmangle",
18 WatchOption::Dirversionmangle(_) => "Dirversionmangle",
19 WatchOption::Oversionmangle(_) => "Oversionmangle",
20 WatchOption::Downloadurlmangle(_) => "Downloadurlmangle",
21 WatchOption::Pgpsigurlmangle(_) => "Pgpsigurlmangle",
22 WatchOption::Filenamemangle(_) => "Filenamemangle",
23 WatchOption::VersionPolicy(_) => "Version-Policy",
24 WatchOption::Searchmode(_) => "Searchmode",
25 WatchOption::Mode(_) => "Mode",
26 WatchOption::Pgpmode(_) => "Pgpmode",
27 WatchOption::Gitexport(_) => "Gitexport",
28 WatchOption::Gitmode(_) => "Gitmode",
29 WatchOption::Pretty(_) => "Pretty",
30 WatchOption::Ctype(_) => "Ctype",
31 WatchOption::Repacksuffix(_) => "Repacksuffix",
32 WatchOption::Unzipopt(_) => "Unzipopt",
33 WatchOption::Script(_) => "Script",
34 WatchOption::Decompress => "Decompress",
35 WatchOption::Bare => "Bare",
36 WatchOption::Repack => "Repack",
37 }
38}
39
40#[derive(Debug)]
41pub struct ParseError(String);
43
44impl std::error::Error for ParseError {}
45
46impl std::fmt::Display for ParseError {
47 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
48 write!(f, "ParseError: {}", self.0)
49 }
50}
51
52#[derive(Debug)]
54pub struct WatchFile(Deb822);
55
56#[derive(Debug)]
58pub struct Entry {
59 paragraph: Paragraph,
60 defaults: Option<Paragraph>,
61}
62
63impl WatchFile {
64 pub fn new() -> Self {
66 let content = "Version: 5\n";
68 WatchFile::from_str(content).expect("Failed to create empty watch file")
69 }
70
71 pub fn version(&self) -> u32 {
73 5
74 }
75
76 pub fn defaults(&self) -> Option<Paragraph> {
79 let paragraphs: Vec<_> = self.0.paragraphs().collect();
80
81 if paragraphs.len() > 1 {
82 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
84 return Some(paragraphs[1].clone());
85 }
86 }
87
88 None
89 }
90
91 pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
94 let paragraphs: Vec<_> = self.0.paragraphs().collect();
95 let defaults = self.defaults();
96
97 let start_index = if paragraphs.len() > 1 {
101 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
103 2 } else {
105 1 }
107 } else {
108 1
109 };
110
111 paragraphs
112 .into_iter()
113 .skip(start_index)
114 .map(move |p| Entry {
115 paragraph: p,
116 defaults: defaults.clone(),
117 })
118 }
119
120 pub fn inner(&self) -> &Deb822 {
122 &self.0
123 }
124
125 pub fn inner_mut(&mut self) -> &mut Deb822 {
127 &mut self.0
128 }
129
130 pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry {
147 let mut para = self.0.add_paragraph();
148 para.set("Source", source);
149 para.set("Matching-Pattern", matching_pattern);
150
151 let defaults = self.defaults();
154
155 Entry {
156 paragraph: para.clone(),
157 defaults,
158 }
159 }
160}
161
162impl Default for WatchFile {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl FromStr for WatchFile {
169 type Err = ParseError;
170
171 fn from_str(s: &str) -> Result<Self, Self::Err> {
172 match Deb822::from_str(s) {
173 Ok(deb822) => {
174 let version = deb822
176 .paragraphs()
177 .next()
178 .and_then(|p| p.get("Version"))
179 .unwrap_or_else(|| "1".to_string());
180
181 if version != "5" {
182 return Err(ParseError(format!("Expected version 5, got {}", version)));
183 }
184
185 Ok(WatchFile(deb822))
186 }
187 Err(e) => Err(ParseError(e.to_string())),
188 }
189 }
190}
191
192impl std::fmt::Display for WatchFile {
193 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
194 write!(f, "{}", self.0)
195 }
196}
197
198impl Entry {
199 pub(crate) fn get_field(&self, key: &str) -> Option<String> {
202 if let Some(value) = self.paragraph.get(key) {
204 return Some(value);
205 }
206
207 let normalized_key = normalize_key(key);
210
211 for (k, v) in self.paragraph.items() {
213 if normalize_key(&k) == normalized_key {
214 return Some(v);
215 }
216 }
217
218 if let Some(ref defaults) = self.defaults {
220 if let Some(value) = defaults.get(key) {
222 return Some(value);
223 }
224
225 for (k, v) in defaults.items() {
227 if normalize_key(&k) == normalized_key {
228 return Some(v);
229 }
230 }
231 }
232
233 None
234 }
235
236 pub fn source(&self) -> Option<String> {
238 self.get_field("Source")
239 }
240
241 pub fn matching_pattern(&self) -> Option<String> {
243 self.get_field("Matching-Pattern")
244 }
245
246 pub fn as_deb822(&self) -> &Paragraph {
248 &self.paragraph
249 }
250
251 pub fn component(&self) -> Option<String> {
253 self.get_field("Component")
254 }
255
256 pub fn get_option(&self, key: &str) -> Option<String> {
258 match key {
259 "Source" => None, "Matching-Pattern" => None, "Component" => None, "Version" => None, key => self.get_field(key),
264 }
265 }
266
267 pub fn set_option(&mut self, option: crate::types::WatchOption) {
269 use crate::types::WatchOption;
270
271 let (key, value) = match option {
272 WatchOption::Component(v) => ("Component", Some(v)),
273 WatchOption::Compression(v) => ("Compression", Some(v.to_string())),
274 WatchOption::UserAgent(v) => ("User-Agent", Some(v)),
275 WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)),
276 WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)),
277 WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)),
278 WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)),
279 WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)),
280 WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)),
281 WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)),
282 WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)),
283 WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())),
284 WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())),
285 WatchOption::Mode(v) => ("Mode", Some(v.to_string())),
286 WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())),
287 WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())),
288 WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())),
289 WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())),
290 WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())),
291 WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)),
292 WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)),
293 WatchOption::Script(v) => ("Script", Some(v)),
294 WatchOption::Decompress => ("Decompress", None),
295 WatchOption::Bare => ("Bare", None),
296 WatchOption::Repack => ("Repack", None),
297 };
298
299 if let Some(v) = value {
300 self.paragraph.set(key, &v);
301 } else {
302 self.paragraph.set(key, "");
304 }
305 }
306
307 pub fn set_option_str(&mut self, key: &str, value: &str) {
309 self.paragraph.set(key, value);
310 }
311
312 pub fn delete_option(&mut self, option: crate::types::WatchOption) {
314 let key = watch_option_to_key(&option);
315 self.paragraph.remove(key);
316 }
317
318 pub fn delete_option_str(&mut self, key: &str) {
320 self.paragraph.remove(key);
321 }
322
323 pub fn url(&self) -> String {
325 self.source().unwrap_or_default()
326 }
327
328 pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
330 match self.get_field("Version-Policy") {
331 Some(policy) => Ok(Some(policy.parse()?)),
332 None => Ok(None),
333 }
334 }
335
336 pub fn script(&self) -> Option<String> {
338 self.get_field("Script")
339 }
340
341 pub fn set_source(&mut self, url: &str) {
343 self.paragraph.set("Source", url);
344 }
345
346 pub fn set_matching_pattern(&mut self, pattern: &str) {
348 self.paragraph.set("Matching-Pattern", pattern);
349 }
350
351 pub fn line(&self) -> usize {
353 self.paragraph.line()
354 }
355
356 pub fn mode(&self) -> Result<crate::types::Mode, TypesParseError> {
358 Ok(self
359 .get_field("Mode")
360 .map(|s| s.parse())
361 .transpose()?
362 .unwrap_or_default())
363 }
364}
365
366fn normalize_key(key: &str) -> String {
370 key.to_lowercase().replace(['-', '_'], "")
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_create_v5_watchfile() {
379 let wf = WatchFile::new();
380 assert_eq!(wf.version(), 5);
381
382 let output = wf.to_string();
383 assert!(output.contains("Version"));
384 assert!(output.contains("5"));
385 }
386
387 #[test]
388 fn test_parse_v5_basic() {
389 let input = r#"Version: 5
390
391Source: https://github.com/owner/repo/tags
392Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
393"#;
394
395 let wf: WatchFile = input.parse().unwrap();
396 assert_eq!(wf.version(), 5);
397
398 let entries: Vec<_> = wf.entries().collect();
399 assert_eq!(entries.len(), 1);
400
401 let entry = &entries[0];
402 assert_eq!(
403 entry.source().as_deref(),
404 Some("https://github.com/owner/repo/tags")
405 );
406 assert_eq!(
407 entry.matching_pattern(),
408 Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
409 );
410 }
411
412 #[test]
413 fn test_parse_v5_multiple_entries() {
414 let input = r#"Version: 5
415
416Source: https://github.com/owner/repo1/tags
417Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
418
419Source: https://github.com/owner/repo2/tags
420Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
421"#;
422
423 let wf: WatchFile = input.parse().unwrap();
424 let entries: Vec<_> = wf.entries().collect();
425 assert_eq!(entries.len(), 2);
426
427 assert_eq!(
428 entries[0].source().as_deref(),
429 Some("https://github.com/owner/repo1/tags")
430 );
431 assert_eq!(
432 entries[1].source().as_deref(),
433 Some("https://github.com/owner/repo2/tags")
434 );
435 }
436
437 #[test]
438 fn test_v5_case_insensitive_fields() {
439 let input = r#"Version: 5
440
441source: https://example.com/files
442matching-pattern: .*\.tar\.gz
443"#;
444
445 let wf: WatchFile = input.parse().unwrap();
446 let entries: Vec<_> = wf.entries().collect();
447 assert_eq!(entries.len(), 1);
448
449 let entry = &entries[0];
450 assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
451 assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
452 }
453
454 #[test]
455 fn test_v5_with_compression_option() {
456 let input = r#"Version: 5
457
458Source: https://example.com/files
459Matching-Pattern: .*\.tar\.gz
460Compression: xz
461"#;
462
463 let wf: WatchFile = input.parse().unwrap();
464 let entries: Vec<_> = wf.entries().collect();
465 assert_eq!(entries.len(), 1);
466
467 let entry = &entries[0];
468 let compression = entry.get_option("compression");
469 assert!(compression.is_some());
470 }
471
472 #[test]
473 fn test_v5_with_component() {
474 let input = r#"Version: 5
475
476Source: https://example.com/files
477Matching-Pattern: .*\.tar\.gz
478Component: foo
479"#;
480
481 let wf: WatchFile = input.parse().unwrap();
482 let entries: Vec<_> = wf.entries().collect();
483 assert_eq!(entries.len(), 1);
484
485 let entry = &entries[0];
486 assert_eq!(entry.component(), Some("foo".to_string()));
487 }
488
489 #[test]
490 fn test_v5_rejects_wrong_version() {
491 let input = r#"Version: 4
492
493Source: https://example.com/files
494Matching-Pattern: .*\.tar\.gz
495"#;
496
497 let result: Result<WatchFile, _> = input.parse();
498 assert!(result.is_err());
499 }
500
501 #[test]
502 fn test_v5_roundtrip() {
503 let input = r#"Version: 5
504
505Source: https://example.com/files
506Matching-Pattern: .*\.tar\.gz
507"#;
508
509 let wf: WatchFile = input.parse().unwrap();
510 let output = wf.to_string();
511
512 let wf2: WatchFile = output.parse().unwrap();
514 assert_eq!(wf2.version(), 5);
515
516 let entries: Vec<_> = wf2.entries().collect();
517 assert_eq!(entries.len(), 1);
518 }
519
520 #[test]
521 fn test_normalize_key() {
522 assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
523 assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
524 assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
525 assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
526 }
527
528 #[test]
529 fn test_defaults_paragraph() {
530 let input = r#"Version: 5
531
532Compression: xz
533User-Agent: Custom/1.0
534
535Source: https://example.com/repo1
536Matching-Pattern: .*\.tar\.gz
537
538Source: https://example.com/repo2
539Matching-Pattern: .*\.tar\.gz
540Compression: gz
541"#;
542
543 let wf: WatchFile = input.parse().unwrap();
544
545 let defaults = wf.defaults();
547 assert!(defaults.is_some());
548 let defaults = defaults.unwrap();
549 assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
550 assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
551
552 let entries: Vec<_> = wf.entries().collect();
554 assert_eq!(entries.len(), 2);
555
556 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
558 assert_eq!(
559 entries[0].get_option("User-Agent"),
560 Some("Custom/1.0".to_string())
561 );
562
563 assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
565 assert_eq!(
566 entries[1].get_option("User-Agent"),
567 Some("Custom/1.0".to_string())
568 );
569 }
570
571 #[test]
572 fn test_no_defaults_paragraph() {
573 let input = r#"Version: 5
574
575Source: https://example.com/repo1
576Matching-Pattern: .*\.tar\.gz
577"#;
578
579 let wf: WatchFile = input.parse().unwrap();
580
581 assert!(wf.defaults().is_none());
583
584 let entries: Vec<_> = wf.entries().collect();
585 assert_eq!(entries.len(), 1);
586 }
587
588 #[test]
589 fn test_set_source() {
590 let mut wf = WatchFile::new();
591 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
592
593 assert_eq!(
594 entry.source(),
595 Some("https://example.com/repo1".to_string())
596 );
597
598 entry.set_source("https://example.com/repo2");
599 assert_eq!(
600 entry.source(),
601 Some("https://example.com/repo2".to_string())
602 );
603 }
604
605 #[test]
606 fn test_set_matching_pattern() {
607 let mut wf = WatchFile::new();
608 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
609
610 assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
611
612 entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
613 assert_eq!(
614 entry.matching_pattern(),
615 Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
616 );
617 }
618
619 #[test]
620 fn test_entry_line() {
621 let input = r#"Version: 5
622
623Source: https://example.com/repo1
624Matching-Pattern: .*\.tar\.gz
625
626Source: https://example.com/repo2
627Matching-Pattern: .*\.tar\.xz
628"#;
629
630 let wf: WatchFile = input.parse().unwrap();
631 let entries: Vec<_> = wf.entries().collect();
632
633 assert_eq!(entries[0].line(), 2);
635 assert_eq!(entries[1].line(), 5);
637 }
638
639 #[test]
640 fn test_defaults_with_case_variations() {
641 let input = r#"Version: 5
642
643compression: xz
644user-agent: Custom/1.0
645
646Source: https://example.com/repo1
647Matching-Pattern: .*\.tar\.gz
648"#;
649
650 let wf: WatchFile = input.parse().unwrap();
651
652 let entries: Vec<_> = wf.entries().collect();
654 assert_eq!(entries.len(), 1);
655
656 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
658 assert_eq!(
659 entries[0].get_option("User-Agent"),
660 Some("Custom/1.0".to_string())
661 );
662 }
663
664 #[test]
665 fn test_v5_with_uversionmangle() {
666 let input = r#"Version: 5
667
668Source: https://pypi.org/project/foo/
669Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
670Uversionmangle: s/\.0+$//
671"#;
672
673 let wf: WatchFile = input.parse().unwrap();
674 let entries: Vec<_> = wf.entries().collect();
675 assert_eq!(entries.len(), 1);
676
677 let entry = &entries[0];
678 assert_eq!(
679 entry.get_option("Uversionmangle"),
680 Some("s/\\.0+$//".to_string())
681 );
682 }
683
684 #[test]
685 fn test_v5_with_filenamemangle() {
686 let input = r#"Version: 5
687
688Source: https://example.com/files
689Matching-Pattern: .*\.tar\.gz
690Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
691"#;
692
693 let wf: WatchFile = input.parse().unwrap();
694 let entries: Vec<_> = wf.entries().collect();
695 assert_eq!(entries.len(), 1);
696
697 let entry = &entries[0];
698 assert_eq!(
699 entry.get_option("Filenamemangle"),
700 Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
701 );
702 }
703
704 #[test]
705 fn test_v5_with_searchmode() {
706 let input = r#"Version: 5
707
708Source: https://example.com/files
709Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
710Searchmode: plain
711"#;
712
713 let wf: WatchFile = input.parse().unwrap();
714 let entries: Vec<_> = wf.entries().collect();
715 assert_eq!(entries.len(), 1);
716
717 let entry = &entries[0];
718 assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
719 }
720
721 #[test]
722 fn test_v5_with_version_policy() {
723 let input = r#"Version: 5
724
725Source: https://example.com/files
726Matching-Pattern: .*\.tar\.gz
727Version-Policy: debian
728"#;
729
730 let wf: WatchFile = input.parse().unwrap();
731 let entries: Vec<_> = wf.entries().collect();
732 assert_eq!(entries.len(), 1);
733
734 let entry = &entries[0];
735 let policy = entry.version_policy();
736 assert!(policy.is_ok());
737 assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
738 }
739
740 #[test]
741 fn test_v5_multiple_mangles() {
742 let input = r#"Version: 5
743
744Source: https://example.com/files
745Matching-Pattern: .*\.tar\.gz
746Uversionmangle: s/^v//;s/\.0+$//
747Dversionmangle: s/\+dfsg\d*$//
748Filenamemangle: s/.*/foo-$1.tar.gz/
749"#;
750
751 let wf: WatchFile = input.parse().unwrap();
752 let entries: Vec<_> = wf.entries().collect();
753 assert_eq!(entries.len(), 1);
754
755 let entry = &entries[0];
756 assert_eq!(
757 entry.get_option("Uversionmangle"),
758 Some("s/^v//;s/\\.0+$//".to_string())
759 );
760 assert_eq!(
761 entry.get_option("Dversionmangle"),
762 Some("s/\\+dfsg\\d*$//".to_string())
763 );
764 assert_eq!(
765 entry.get_option("Filenamemangle"),
766 Some("s/.*/foo-$1.tar.gz/".to_string())
767 );
768 }
769
770 #[test]
771 fn test_v5_with_pgpmode() {
772 let input = r#"Version: 5
773
774Source: https://example.com/files
775Matching-Pattern: .*\.tar\.gz
776Pgpmode: auto
777"#;
778
779 let wf: WatchFile = input.parse().unwrap();
780 let entries: Vec<_> = wf.entries().collect();
781 assert_eq!(entries.len(), 1);
782
783 let entry = &entries[0];
784 assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
785 }
786
787 #[test]
788 fn test_v5_with_comments() {
789 let input = r#"Version: 5
790
791# This is a comment about the entry
792Source: https://example.com/files
793Matching-Pattern: .*\.tar\.gz
794"#;
795
796 let wf: WatchFile = input.parse().unwrap();
797 let entries: Vec<_> = wf.entries().collect();
798 assert_eq!(entries.len(), 1);
799
800 let output = wf.to_string();
802 assert!(output.contains("# This is a comment about the entry"));
803 }
804
805 #[test]
806 fn test_v5_empty_after_version() {
807 let input = "Version: 5\n";
808
809 let wf: WatchFile = input.parse().unwrap();
810 assert_eq!(wf.version(), 5);
811
812 let entries: Vec<_> = wf.entries().collect();
813 assert_eq!(entries.len(), 0);
814 }
815
816 #[test]
817 fn test_v5_trait_url() {
818 let input = r#"Version: 5
819
820Source: https://example.com/files/@PACKAGE@
821Matching-Pattern: .*\.tar\.gz
822"#;
823
824 let wf: WatchFile = input.parse().unwrap();
825 let entries: Vec<_> = wf.entries().collect();
826 assert_eq!(entries.len(), 1);
827
828 let entry = &entries[0];
829 assert_eq!(
831 entry.source().as_deref(),
832 Some("https://example.com/files/@PACKAGE@")
833 );
834 }
835}