1#[derive(Debug)]
5pub enum ParseError {
6 #[cfg(feature = "linebased")]
8 LineBased(crate::linebased::ParseError),
9 #[cfg(feature = "deb822")]
11 Deb822(crate::deb822::ParseError),
12 UnknownVersion,
14 FeatureNotEnabled(String),
16}
17
18impl std::fmt::Display for ParseError {
19 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
20 match self {
21 #[cfg(feature = "linebased")]
22 ParseError::LineBased(e) => write!(f, "{}", e),
23 #[cfg(feature = "deb822")]
24 ParseError::Deb822(e) => write!(f, "{}", e),
25 ParseError::UnknownVersion => write!(f, "Could not detect watch file version"),
26 ParseError::FeatureNotEnabled(msg) => write!(f, "{}", msg),
27 }
28 }
29}
30
31impl std::error::Error for ParseError {}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WatchFileVersion {
36 LineBased(u32),
38 Deb822,
40}
41
42pub fn detect_version(content: &str) -> Option<WatchFileVersion> {
63 let trimmed = content.trim_start();
64
65 if trimmed.starts_with("Version:") || trimmed.starts_with("version:") {
67 if let Some(first_line) = trimmed.lines().next() {
69 if let Some(colon_pos) = first_line.find(':') {
70 let version_str = first_line[colon_pos + 1..].trim();
71 if version_str == "5" {
72 return Some(WatchFileVersion::Deb822);
73 }
74 }
75 }
76 }
77
78 for line in trimmed.lines() {
81 let line = line.trim();
82
83 if line.starts_with('#') || line.is_empty() {
85 continue;
86 }
87
88 if line.starts_with("version=") || line.starts_with("version =") {
90 let version_part = if line.starts_with("version=") {
91 &line[8..]
92 } else {
93 &line[9..]
94 };
95
96 if let Ok(version) = version_part.trim().parse::<u32>() {
97 return Some(WatchFileVersion::LineBased(version));
98 }
99 }
100
101 break;
103 }
104
105 Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION))
107}
108
109#[derive(Debug)]
111pub enum ParsedWatchFile {
112 #[cfg(feature = "linebased")]
114 LineBased(crate::linebased::WatchFile),
115 #[cfg(feature = "deb822")]
117 Deb822(crate::deb822::WatchFile),
118}
119
120#[derive(Debug)]
122pub enum ParsedEntry {
123 #[cfg(feature = "linebased")]
125 LineBased(crate::linebased::Entry),
126 #[cfg(feature = "deb822")]
128 Deb822(crate::deb822::Entry),
129}
130
131impl ParsedWatchFile {
132 pub fn new(version: u32) -> Result<Self, ParseError> {
149 match version {
150 #[cfg(feature = "deb822")]
151 5 => Ok(ParsedWatchFile::Deb822(crate::deb822::WatchFile::new())),
152 #[cfg(not(feature = "deb822"))]
153 5 => Err(ParseError::FeatureNotEnabled(
154 "deb822 feature required for v5 format".to_string(),
155 )),
156 #[cfg(feature = "linebased")]
157 v @ 1..=4 => Ok(ParsedWatchFile::LineBased(
158 crate::linebased::WatchFile::new(Some(v)),
159 )),
160 #[cfg(not(feature = "linebased"))]
161 v @ 1..=4 => Err(ParseError::FeatureNotEnabled(format!(
162 "linebased feature required for v{} format",
163 v
164 ))),
165 v => Err(ParseError::FeatureNotEnabled(format!(
166 "unsupported watch file version: {}",
167 v
168 ))),
169 }
170 }
171
172 pub fn version(&self) -> u32 {
174 match self {
175 #[cfg(feature = "linebased")]
176 ParsedWatchFile::LineBased(wf) => wf.version(),
177 #[cfg(feature = "deb822")]
178 ParsedWatchFile::Deb822(wf) => wf.version(),
179 }
180 }
181
182 pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
184 let entries: Vec<_> = match self {
186 #[cfg(feature = "linebased")]
187 ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(),
188 #[cfg(feature = "deb822")]
189 ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(),
190 };
191 entries.into_iter()
192 }
193
194 pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> ParsedEntry {
215 match self {
216 #[cfg(feature = "linebased")]
217 ParsedWatchFile::LineBased(wf) => {
218 let entry = crate::linebased::EntryBuilder::new(source)
219 .matching_pattern(matching_pattern)
220 .build();
221 let added_entry = wf.add_entry(entry);
222 ParsedEntry::LineBased(added_entry)
223 }
224 #[cfg(feature = "deb822")]
225 ParsedWatchFile::Deb822(wf) => {
226 let added_entry = wf.add_entry(source, matching_pattern);
227 ParsedEntry::Deb822(added_entry)
228 }
229 }
230 }
231}
232
233impl ParsedEntry {
234 pub fn url(&self) -> String {
236 match self {
237 #[cfg(feature = "linebased")]
238 ParsedEntry::LineBased(e) => e.url(),
239 #[cfg(feature = "deb822")]
240 ParsedEntry::Deb822(e) => e.source().unwrap_or_default(),
241 }
242 }
243
244 pub fn matching_pattern(&self) -> Option<String> {
246 match self {
247 #[cfg(feature = "linebased")]
248 ParsedEntry::LineBased(e) => e.matching_pattern(),
249 #[cfg(feature = "deb822")]
250 ParsedEntry::Deb822(e) => e.matching_pattern(),
251 }
252 }
253
254 pub fn get_option(&self, key: &str) -> Option<String> {
260 match self {
261 #[cfg(feature = "linebased")]
262 ParsedEntry::LineBased(e) => e.get_option(key),
263 #[cfg(feature = "deb822")]
264 ParsedEntry::Deb822(e) => {
265 e.get_field(key).or_else(|| {
267 let mut chars = key.chars();
268 if let Some(first) = chars.next() {
269 let capitalized = first.to_uppercase().chain(chars).collect::<String>();
270 e.get_field(&capitalized)
271 } else {
272 None
273 }
274 })
275 }
276 }
277 }
278
279 pub fn has_option(&self, key: &str) -> bool {
281 self.get_option(key).is_some()
282 }
283
284 pub fn script(&self) -> Option<String> {
286 self.get_option("script")
287 }
288
289 pub fn format_url(
291 &self,
292 package: impl FnOnce() -> String,
293 ) -> Result<url::Url, url::ParseError> {
294 crate::subst::subst(&self.url(), package).parse()
295 }
296
297 pub fn user_agent(&self) -> Option<String> {
299 self.get_option("user-agent")
300 }
301
302 pub fn pagemangle(&self) -> Option<String> {
304 self.get_option("pagemangle")
305 }
306
307 pub fn uversionmangle(&self) -> Option<String> {
309 self.get_option("uversionmangle")
310 }
311
312 pub fn downloadurlmangle(&self) -> Option<String> {
314 self.get_option("downloadurlmangle")
315 }
316
317 pub fn pgpsigurlmangle(&self) -> Option<String> {
319 self.get_option("pgpsigurlmangle")
320 }
321
322 pub fn filenamemangle(&self) -> Option<String> {
324 self.get_option("filenamemangle")
325 }
326
327 pub fn oversionmangle(&self) -> Option<String> {
329 self.get_option("oversionmangle")
330 }
331
332 pub fn searchmode(&self) -> crate::types::SearchMode {
334 self.get_option("searchmode")
335 .and_then(|s| s.parse().ok())
336 .unwrap_or_default()
337 }
338
339 pub fn set_option(&mut self, option: crate::types::WatchOption) {
361 match self {
362 #[cfg(feature = "linebased")]
363 ParsedEntry::LineBased(e) => {
364 e.set_option(option);
365 }
366 #[cfg(feature = "deb822")]
367 ParsedEntry::Deb822(e) => {
368 e.set_option(option);
369 }
370 }
371 }
372
373 pub fn set_url(&mut self, url: &str) {
389 match self {
390 #[cfg(feature = "linebased")]
391 ParsedEntry::LineBased(e) => e.set_url(url),
392 #[cfg(feature = "deb822")]
393 ParsedEntry::Deb822(e) => e.set_source(url),
394 }
395 }
396
397 pub fn set_matching_pattern(&mut self, pattern: &str) {
413 match self {
414 #[cfg(feature = "linebased")]
415 ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
416 #[cfg(feature = "deb822")]
417 ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
418 }
419 }
420
421 pub fn line(&self) -> usize {
441 match self {
442 #[cfg(feature = "linebased")]
443 ParsedEntry::LineBased(e) => e.line(),
444 #[cfg(feature = "deb822")]
445 ParsedEntry::Deb822(e) => e.line(),
446 }
447 }
448
449 pub fn remove_option(&mut self, option: crate::types::WatchOption) {
472 match self {
473 #[cfg(feature = "linebased")]
474 ParsedEntry::LineBased(e) => e.del_opt(option),
475 #[cfg(feature = "deb822")]
476 ParsedEntry::Deb822(e) => e.delete_option(option),
477 }
478 }
479}
480
481impl std::fmt::Display for ParsedWatchFile {
482 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 match self {
484 #[cfg(feature = "linebased")]
485 ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
486 #[cfg(feature = "deb822")]
487 ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
488 }
489 }
490}
491
492pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
511 let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
512
513 match version {
514 #[cfg(feature = "linebased")]
515 WatchFileVersion::LineBased(_v) => {
516 let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
517 Ok(ParsedWatchFile::LineBased(wf))
518 }
519 #[cfg(not(feature = "linebased"))]
520 WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
521 "linebased feature required for v1-4 formats".to_string(),
522 )),
523 #[cfg(feature = "deb822")]
524 WatchFileVersion::Deb822 => {
525 let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
526 Ok(ParsedWatchFile::Deb822(wf))
527 }
528 #[cfg(not(feature = "deb822"))]
529 WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
530 "deb822 feature required for v5 format".to_string(),
531 )),
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn test_detect_version_v1_default() {
541 let content = "https://example.com/ .*.tar.gz";
542 assert_eq!(
543 detect_version(content),
544 Some(WatchFileVersion::LineBased(1))
545 );
546 }
547
548 #[test]
549 fn test_detect_version_v4() {
550 let content = "version=4\nhttps://example.com/ .*.tar.gz";
551 assert_eq!(
552 detect_version(content),
553 Some(WatchFileVersion::LineBased(4))
554 );
555 }
556
557 #[test]
558 fn test_detect_version_v4_with_spaces() {
559 let content = "version = 4\nhttps://example.com/ .*.tar.gz";
560 assert_eq!(
561 detect_version(content),
562 Some(WatchFileVersion::LineBased(4))
563 );
564 }
565
566 #[test]
567 fn test_detect_version_v5() {
568 let content = "Version: 5\n\nSource: https://example.com/";
569 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
570 }
571
572 #[test]
573 fn test_detect_version_v5_lowercase() {
574 let content = "version: 5\n\nSource: https://example.com/";
575 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
576 }
577
578 #[test]
579 fn test_detect_version_with_leading_comments() {
580 let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
581 assert_eq!(
582 detect_version(content),
583 Some(WatchFileVersion::LineBased(4))
584 );
585 }
586
587 #[test]
588 fn test_detect_version_with_leading_whitespace() {
589 let content = " \n version=3\nhttps://example.com/ .*.tar.gz";
590 assert_eq!(
591 detect_version(content),
592 Some(WatchFileVersion::LineBased(3))
593 );
594 }
595
596 #[test]
597 fn test_detect_version_v2() {
598 let content = "version=2\nhttps://example.com/ .*.tar.gz";
599 assert_eq!(
600 detect_version(content),
601 Some(WatchFileVersion::LineBased(2))
602 );
603 }
604
605 #[cfg(feature = "linebased")]
606 #[test]
607 fn test_parse_linebased() {
608 let content = "version=4\nhttps://example.com/ .*.tar.gz";
609 let parsed = parse(content).unwrap();
610 assert_eq!(parsed.version(), 4);
611 }
612
613 #[cfg(feature = "deb822")]
614 #[test]
615 fn test_parse_deb822() {
616 let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
617 let parsed = parse(content).unwrap();
618 assert_eq!(parsed.version(), 5);
619 }
620
621 #[cfg(all(feature = "linebased", feature = "deb822"))]
622 #[test]
623 fn test_parse_both_formats() {
624 let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
626 let v4_parsed = parse(v4_content).unwrap();
627 assert_eq!(v4_parsed.version(), 4);
628
629 let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
631 let v5_parsed = parse(v5_content).unwrap();
632 assert_eq!(v5_parsed.version(), 5);
633 }
634
635 #[cfg(feature = "linebased")]
636 #[test]
637 fn test_parse_roundtrip() {
638 let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
639 let parsed = parse(content).unwrap();
640 let output = parsed.to_string();
641
642 let reparsed = parse(&output).unwrap();
644 assert_eq!(reparsed.version(), 4);
645 }
646
647 #[cfg(feature = "deb822")]
648 #[test]
649 fn test_parsed_watch_file_new_v5() {
650 let wf = ParsedWatchFile::new(5).unwrap();
651 assert_eq!(wf.version(), 5);
652 assert_eq!(wf.entries().count(), 0);
653 }
654
655 #[cfg(feature = "linebased")]
656 #[test]
657 fn test_parsed_watch_file_new_v4() {
658 let wf = ParsedWatchFile::new(4).unwrap();
659 assert_eq!(wf.version(), 4);
660 assert_eq!(wf.entries().count(), 0);
661 }
662
663 #[cfg(feature = "deb822")]
664 #[test]
665 fn test_parsed_watch_file_add_entry_v5() {
666 let mut wf = ParsedWatchFile::new(5).unwrap();
667 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
668
669 assert_eq!(wf.entries().count(), 1);
670 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
671 assert_eq!(
672 entry.matching_pattern(),
673 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
674 );
675
676 entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
678 entry.set_option(crate::types::WatchOption::Compression(
679 crate::types::Compression::Xz,
680 ));
681
682 assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
683 assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
684 }
685
686 #[cfg(feature = "linebased")]
687 #[test]
688 fn test_parsed_watch_file_add_entry_v4() {
689 let mut wf = ParsedWatchFile::new(4).unwrap();
690 let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
691
692 assert_eq!(wf.entries().count(), 1);
693 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
694 assert_eq!(
695 entry.matching_pattern(),
696 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
697 );
698 }
699
700 #[cfg(feature = "deb822")]
701 #[test]
702 fn test_parsed_watch_file_roundtrip_with_add_entry() {
703 let mut wf = ParsedWatchFile::new(5).unwrap();
704 let mut entry = wf.add_entry(
705 "https://github.com/owner/repo/tags",
706 r".*/v?([\d.]+)\.tar\.gz",
707 );
708 entry.set_option(crate::types::WatchOption::Compression(
709 crate::types::Compression::Xz,
710 ));
711
712 let output = wf.to_string();
713
714 let reparsed = parse(&output).unwrap();
716 assert_eq!(reparsed.version(), 5);
717
718 let entries: Vec<_> = reparsed.entries().collect();
719 assert_eq!(entries.len(), 1);
720 assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
721 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
722 }
723
724 #[cfg(feature = "linebased")]
725 #[test]
726 fn test_parsed_entry_set_url_v4() {
727 let mut wf = ParsedWatchFile::new(4).unwrap();
728 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
729
730 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
731
732 entry.set_url("https://github.com/foo/bar/releases");
733 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
734 }
735
736 #[cfg(feature = "deb822")]
737 #[test]
738 fn test_parsed_entry_set_url_v5() {
739 let mut wf = ParsedWatchFile::new(5).unwrap();
740 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
741
742 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
743
744 entry.set_url("https://github.com/foo/bar/releases");
745 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
746 }
747
748 #[cfg(feature = "linebased")]
749 #[test]
750 fn test_parsed_entry_set_matching_pattern_v4() {
751 let mut wf = ParsedWatchFile::new(4).unwrap();
752 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
753
754 assert_eq!(
755 entry.matching_pattern(),
756 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
757 );
758
759 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
760 assert_eq!(
761 entry.matching_pattern(),
762 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
763 );
764 }
765
766 #[cfg(feature = "deb822")]
767 #[test]
768 fn test_parsed_entry_set_matching_pattern_v5() {
769 let mut wf = ParsedWatchFile::new(5).unwrap();
770 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
771
772 assert_eq!(
773 entry.matching_pattern(),
774 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
775 );
776
777 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
778 assert_eq!(
779 entry.matching_pattern(),
780 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
781 );
782 }
783
784 #[cfg(feature = "linebased")]
785 #[test]
786 fn test_parsed_entry_line_v4() {
787 let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
788 let wf = parse(content).unwrap();
789 let entries: Vec<_> = wf.entries().collect();
790
791 assert_eq!(entries[0].line(), 1); assert_eq!(entries[1].line(), 2); }
794
795 #[cfg(feature = "deb822")]
796 #[test]
797 fn test_parsed_entry_line_v5() {
798 let content = r#"Version: 5
799
800Source: https://example.com/repo1
801Matching-Pattern: .*\.tar\.gz
802
803Source: https://example.com/repo2
804Matching-Pattern: .*\.tar\.xz
805"#;
806 let wf = parse(content).unwrap();
807 let entries: Vec<_> = wf.entries().collect();
808
809 assert_eq!(entries[0].line(), 2); assert_eq!(entries[1].line(), 5); }
812}