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
357fn normalize_key(key: &str) -> String {
361 key.to_lowercase().replace(['-', '_'], "")
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_create_v5_watchfile() {
370 let wf = WatchFile::new();
371 assert_eq!(wf.version(), 5);
372
373 let output = wf.to_string();
374 assert!(output.contains("Version"));
375 assert!(output.contains("5"));
376 }
377
378 #[test]
379 fn test_parse_v5_basic() {
380 let input = r#"Version: 5
381
382Source: https://github.com/owner/repo/tags
383Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
384"#;
385
386 let wf: WatchFile = input.parse().unwrap();
387 assert_eq!(wf.version(), 5);
388
389 let entries: Vec<_> = wf.entries().collect();
390 assert_eq!(entries.len(), 1);
391
392 let entry = &entries[0];
393 assert_eq!(
394 entry.source().as_deref(),
395 Some("https://github.com/owner/repo/tags")
396 );
397 assert_eq!(
398 entry.matching_pattern(),
399 Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
400 );
401 }
402
403 #[test]
404 fn test_parse_v5_multiple_entries() {
405 let input = r#"Version: 5
406
407Source: https://github.com/owner/repo1/tags
408Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
409
410Source: https://github.com/owner/repo2/tags
411Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
412"#;
413
414 let wf: WatchFile = input.parse().unwrap();
415 let entries: Vec<_> = wf.entries().collect();
416 assert_eq!(entries.len(), 2);
417
418 assert_eq!(
419 entries[0].source().as_deref(),
420 Some("https://github.com/owner/repo1/tags")
421 );
422 assert_eq!(
423 entries[1].source().as_deref(),
424 Some("https://github.com/owner/repo2/tags")
425 );
426 }
427
428 #[test]
429 fn test_v5_case_insensitive_fields() {
430 let input = r#"Version: 5
431
432source: https://example.com/files
433matching-pattern: .*\.tar\.gz
434"#;
435
436 let wf: WatchFile = input.parse().unwrap();
437 let entries: Vec<_> = wf.entries().collect();
438 assert_eq!(entries.len(), 1);
439
440 let entry = &entries[0];
441 assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
442 assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
443 }
444
445 #[test]
446 fn test_v5_with_compression_option() {
447 let input = r#"Version: 5
448
449Source: https://example.com/files
450Matching-Pattern: .*\.tar\.gz
451Compression: xz
452"#;
453
454 let wf: WatchFile = input.parse().unwrap();
455 let entries: Vec<_> = wf.entries().collect();
456 assert_eq!(entries.len(), 1);
457
458 let entry = &entries[0];
459 let compression = entry.get_option("compression");
460 assert!(compression.is_some());
461 }
462
463 #[test]
464 fn test_v5_with_component() {
465 let input = r#"Version: 5
466
467Source: https://example.com/files
468Matching-Pattern: .*\.tar\.gz
469Component: foo
470"#;
471
472 let wf: WatchFile = input.parse().unwrap();
473 let entries: Vec<_> = wf.entries().collect();
474 assert_eq!(entries.len(), 1);
475
476 let entry = &entries[0];
477 assert_eq!(entry.component(), Some("foo".to_string()));
478 }
479
480 #[test]
481 fn test_v5_rejects_wrong_version() {
482 let input = r#"Version: 4
483
484Source: https://example.com/files
485Matching-Pattern: .*\.tar\.gz
486"#;
487
488 let result: Result<WatchFile, _> = input.parse();
489 assert!(result.is_err());
490 }
491
492 #[test]
493 fn test_v5_roundtrip() {
494 let input = r#"Version: 5
495
496Source: https://example.com/files
497Matching-Pattern: .*\.tar\.gz
498"#;
499
500 let wf: WatchFile = input.parse().unwrap();
501 let output = wf.to_string();
502
503 let wf2: WatchFile = output.parse().unwrap();
505 assert_eq!(wf2.version(), 5);
506
507 let entries: Vec<_> = wf2.entries().collect();
508 assert_eq!(entries.len(), 1);
509 }
510
511 #[test]
512 fn test_normalize_key() {
513 assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
514 assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
515 assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
516 assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
517 }
518
519 #[test]
520 fn test_defaults_paragraph() {
521 let input = r#"Version: 5
522
523Compression: xz
524User-Agent: Custom/1.0
525
526Source: https://example.com/repo1
527Matching-Pattern: .*\.tar\.gz
528
529Source: https://example.com/repo2
530Matching-Pattern: .*\.tar\.gz
531Compression: gz
532"#;
533
534 let wf: WatchFile = input.parse().unwrap();
535
536 let defaults = wf.defaults();
538 assert!(defaults.is_some());
539 let defaults = defaults.unwrap();
540 assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
541 assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
542
543 let entries: Vec<_> = wf.entries().collect();
545 assert_eq!(entries.len(), 2);
546
547 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
549 assert_eq!(
550 entries[0].get_option("User-Agent"),
551 Some("Custom/1.0".to_string())
552 );
553
554 assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
556 assert_eq!(
557 entries[1].get_option("User-Agent"),
558 Some("Custom/1.0".to_string())
559 );
560 }
561
562 #[test]
563 fn test_no_defaults_paragraph() {
564 let input = r#"Version: 5
565
566Source: https://example.com/repo1
567Matching-Pattern: .*\.tar\.gz
568"#;
569
570 let wf: WatchFile = input.parse().unwrap();
571
572 assert!(wf.defaults().is_none());
574
575 let entries: Vec<_> = wf.entries().collect();
576 assert_eq!(entries.len(), 1);
577 }
578
579 #[test]
580 fn test_set_source() {
581 let mut wf = WatchFile::new();
582 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
583
584 assert_eq!(
585 entry.source(),
586 Some("https://example.com/repo1".to_string())
587 );
588
589 entry.set_source("https://example.com/repo2");
590 assert_eq!(
591 entry.source(),
592 Some("https://example.com/repo2".to_string())
593 );
594 }
595
596 #[test]
597 fn test_set_matching_pattern() {
598 let mut wf = WatchFile::new();
599 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
600
601 assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
602
603 entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
604 assert_eq!(
605 entry.matching_pattern(),
606 Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
607 );
608 }
609
610 #[test]
611 fn test_entry_line() {
612 let input = r#"Version: 5
613
614Source: https://example.com/repo1
615Matching-Pattern: .*\.tar\.gz
616
617Source: https://example.com/repo2
618Matching-Pattern: .*\.tar\.xz
619"#;
620
621 let wf: WatchFile = input.parse().unwrap();
622 let entries: Vec<_> = wf.entries().collect();
623
624 assert_eq!(entries[0].line(), 2);
626 assert_eq!(entries[1].line(), 5);
628 }
629
630 #[test]
631 fn test_defaults_with_case_variations() {
632 let input = r#"Version: 5
633
634compression: xz
635user-agent: Custom/1.0
636
637Source: https://example.com/repo1
638Matching-Pattern: .*\.tar\.gz
639"#;
640
641 let wf: WatchFile = input.parse().unwrap();
642
643 let entries: Vec<_> = wf.entries().collect();
645 assert_eq!(entries.len(), 1);
646
647 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
649 assert_eq!(
650 entries[0].get_option("User-Agent"),
651 Some("Custom/1.0".to_string())
652 );
653 }
654
655 #[test]
656 fn test_v5_with_uversionmangle() {
657 let input = r#"Version: 5
658
659Source: https://pypi.org/project/foo/
660Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
661Uversionmangle: s/\.0+$//
662"#;
663
664 let wf: WatchFile = input.parse().unwrap();
665 let entries: Vec<_> = wf.entries().collect();
666 assert_eq!(entries.len(), 1);
667
668 let entry = &entries[0];
669 assert_eq!(
670 entry.get_option("Uversionmangle"),
671 Some("s/\\.0+$//".to_string())
672 );
673 }
674
675 #[test]
676 fn test_v5_with_filenamemangle() {
677 let input = r#"Version: 5
678
679Source: https://example.com/files
680Matching-Pattern: .*\.tar\.gz
681Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
682"#;
683
684 let wf: WatchFile = input.parse().unwrap();
685 let entries: Vec<_> = wf.entries().collect();
686 assert_eq!(entries.len(), 1);
687
688 let entry = &entries[0];
689 assert_eq!(
690 entry.get_option("Filenamemangle"),
691 Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
692 );
693 }
694
695 #[test]
696 fn test_v5_with_searchmode() {
697 let input = r#"Version: 5
698
699Source: https://example.com/files
700Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
701Searchmode: plain
702"#;
703
704 let wf: WatchFile = input.parse().unwrap();
705 let entries: Vec<_> = wf.entries().collect();
706 assert_eq!(entries.len(), 1);
707
708 let entry = &entries[0];
709 assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
710 }
711
712 #[test]
713 fn test_v5_with_version_policy() {
714 let input = r#"Version: 5
715
716Source: https://example.com/files
717Matching-Pattern: .*\.tar\.gz
718Version-Policy: debian
719"#;
720
721 let wf: WatchFile = input.parse().unwrap();
722 let entries: Vec<_> = wf.entries().collect();
723 assert_eq!(entries.len(), 1);
724
725 let entry = &entries[0];
726 let policy = entry.version_policy();
727 assert!(policy.is_ok());
728 assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
729 }
730
731 #[test]
732 fn test_v5_multiple_mangles() {
733 let input = r#"Version: 5
734
735Source: https://example.com/files
736Matching-Pattern: .*\.tar\.gz
737Uversionmangle: s/^v//;s/\.0+$//
738Dversionmangle: s/\+dfsg\d*$//
739Filenamemangle: s/.*/foo-$1.tar.gz/
740"#;
741
742 let wf: WatchFile = input.parse().unwrap();
743 let entries: Vec<_> = wf.entries().collect();
744 assert_eq!(entries.len(), 1);
745
746 let entry = &entries[0];
747 assert_eq!(
748 entry.get_option("Uversionmangle"),
749 Some("s/^v//;s/\\.0+$//".to_string())
750 );
751 assert_eq!(
752 entry.get_option("Dversionmangle"),
753 Some("s/\\+dfsg\\d*$//".to_string())
754 );
755 assert_eq!(
756 entry.get_option("Filenamemangle"),
757 Some("s/.*/foo-$1.tar.gz/".to_string())
758 );
759 }
760
761 #[test]
762 fn test_v5_with_pgpmode() {
763 let input = r#"Version: 5
764
765Source: https://example.com/files
766Matching-Pattern: .*\.tar\.gz
767Pgpmode: auto
768"#;
769
770 let wf: WatchFile = input.parse().unwrap();
771 let entries: Vec<_> = wf.entries().collect();
772 assert_eq!(entries.len(), 1);
773
774 let entry = &entries[0];
775 assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
776 }
777
778 #[test]
779 fn test_v5_with_comments() {
780 let input = r#"Version: 5
781
782# This is a comment about the entry
783Source: https://example.com/files
784Matching-Pattern: .*\.tar\.gz
785"#;
786
787 let wf: WatchFile = input.parse().unwrap();
788 let entries: Vec<_> = wf.entries().collect();
789 assert_eq!(entries.len(), 1);
790
791 let output = wf.to_string();
793 assert!(output.contains("# This is a comment about the entry"));
794 }
795
796 #[test]
797 fn test_v5_empty_after_version() {
798 let input = "Version: 5\n";
799
800 let wf: WatchFile = input.parse().unwrap();
801 assert_eq!(wf.version(), 5);
802
803 let entries: Vec<_> = wf.entries().collect();
804 assert_eq!(entries.len(), 0);
805 }
806
807 #[test]
808 fn test_v5_trait_url() {
809 let input = r#"Version: 5
810
811Source: https://example.com/files/@PACKAGE@
812Matching-Pattern: .*\.tar\.gz
813"#;
814
815 let wf: WatchFile = input.parse().unwrap();
816 let entries: Vec<_> = wf.entries().collect();
817 assert_eq!(entries.len(), 1);
818
819 let entry = &entries[0];
820 assert_eq!(
822 entry.source().as_deref(),
823 Some("https://example.com/files/@PACKAGE@")
824 );
825 }
826}