1#![cfg(any(feature = "linebased", feature = "deb822"))]
2#[derive(Debug)]
8pub enum ParseError {
9 #[cfg(feature = "linebased")]
11 LineBased(crate::linebased::ParseError),
12 #[cfg(feature = "deb822")]
14 Deb822(crate::deb822::ParseError),
15 UnknownVersion,
17 FeatureNotEnabled(String),
19}
20
21impl std::fmt::Display for ParseError {
22 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
23 match self {
24 #[cfg(feature = "linebased")]
25 ParseError::LineBased(e) => write!(f, "{}", e),
26 #[cfg(feature = "deb822")]
27 ParseError::Deb822(e) => write!(f, "{}", e),
28 ParseError::UnknownVersion => write!(f, "Could not detect watch file version"),
29 ParseError::FeatureNotEnabled(msg) => write!(f, "{}", msg),
30 }
31 }
32}
33
34impl std::error::Error for ParseError {}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum WatchFileVersion {
39 LineBased(u32),
41 Deb822,
43}
44
45pub fn detect_version(content: &str) -> Option<WatchFileVersion> {
66 let trimmed = content.trim_start();
67
68 if trimmed.starts_with("Version:") || trimmed.starts_with("version:") {
70 if let Some(first_line) = trimmed.lines().next() {
72 if let Some(colon_pos) = first_line.find(':') {
73 let version_str = first_line[colon_pos + 1..].trim();
74 if version_str == "5" {
75 return Some(WatchFileVersion::Deb822);
76 }
77 }
78 }
79 }
80
81 for line in trimmed.lines() {
84 let line = line.trim();
85
86 if line.starts_with('#') || line.is_empty() {
88 continue;
89 }
90
91 if line.starts_with("version=") || line.starts_with("version =") {
93 let version_part = if line.starts_with("version=") {
94 &line[8..]
95 } else {
96 &line[9..]
97 };
98
99 if let Ok(version) = version_part.trim().parse::<u32>() {
100 return Some(WatchFileVersion::LineBased(version));
101 }
102 }
103
104 break;
106 }
107
108 Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION))
110}
111
112#[derive(Debug)]
114pub enum ParsedWatchFile {
115 #[cfg(feature = "linebased")]
117 LineBased(crate::linebased::WatchFile),
118 #[cfg(feature = "deb822")]
120 Deb822(crate::deb822::WatchFile),
121}
122
123#[derive(Debug)]
125pub enum ParsedEntry {
126 #[cfg(feature = "linebased")]
128 LineBased(crate::linebased::Entry),
129 #[cfg(feature = "deb822")]
131 Deb822(crate::deb822::Entry),
132}
133
134impl ParsedWatchFile {
135 pub fn new(version: u32) -> Result<Self, ParseError> {
152 match version {
153 #[cfg(feature = "deb822")]
154 5 => Ok(ParsedWatchFile::Deb822(crate::deb822::WatchFile::new())),
155 #[cfg(not(feature = "deb822"))]
156 5 => Err(ParseError::FeatureNotEnabled(
157 "deb822 feature required for v5 format".to_string(),
158 )),
159 #[cfg(feature = "linebased")]
160 v @ 1..=4 => Ok(ParsedWatchFile::LineBased(
161 crate::linebased::WatchFile::new(Some(v)),
162 )),
163 #[cfg(not(feature = "linebased"))]
164 v @ 1..=4 => Err(ParseError::FeatureNotEnabled(format!(
165 "linebased feature required for v{} format",
166 v
167 ))),
168 v => Err(ParseError::FeatureNotEnabled(format!(
169 "unsupported watch file version: {}",
170 v
171 ))),
172 }
173 }
174
175 pub fn version(&self) -> u32 {
177 match self {
178 #[cfg(feature = "linebased")]
179 ParsedWatchFile::LineBased(wf) => wf.version(),
180 #[cfg(feature = "deb822")]
181 ParsedWatchFile::Deb822(wf) => wf.version(),
182 }
183 }
184
185 pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
187 let entries: Vec<_> = match self {
189 #[cfg(feature = "linebased")]
190 ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(),
191 #[cfg(feature = "deb822")]
192 ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(),
193 };
194 entries.into_iter()
195 }
196
197 pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> ParsedEntry {
218 match self {
219 #[cfg(feature = "linebased")]
220 ParsedWatchFile::LineBased(wf) => {
221 let entry = crate::linebased::EntryBuilder::new(source)
222 .matching_pattern(matching_pattern)
223 .build();
224 let added_entry = wf.add_entry(entry);
225 ParsedEntry::LineBased(added_entry)
226 }
227 #[cfg(feature = "deb822")]
228 ParsedWatchFile::Deb822(wf) => {
229 let added_entry = wf.add_entry(source, matching_pattern);
230 ParsedEntry::Deb822(added_entry)
231 }
232 }
233 }
234}
235
236impl ParsedEntry {
237 pub fn url(&self) -> String {
239 match self {
240 #[cfg(feature = "linebased")]
241 ParsedEntry::LineBased(e) => e.url(),
242 #[cfg(feature = "deb822")]
243 ParsedEntry::Deb822(e) => e.source().unwrap_or_default(),
244 }
245 }
246
247 pub fn matching_pattern(&self) -> Option<String> {
249 match self {
250 #[cfg(feature = "linebased")]
251 ParsedEntry::LineBased(e) => e.matching_pattern(),
252 #[cfg(feature = "deb822")]
253 ParsedEntry::Deb822(e) => e.matching_pattern(),
254 }
255 }
256
257 pub fn get_option(&self, key: &str) -> Option<String> {
263 match self {
264 #[cfg(feature = "linebased")]
265 ParsedEntry::LineBased(e) => e.get_option(key),
266 #[cfg(feature = "deb822")]
267 ParsedEntry::Deb822(e) => {
268 e.get_field(key).or_else(|| {
270 let mut chars = key.chars();
271 if let Some(first) = chars.next() {
272 let capitalized = first.to_uppercase().chain(chars).collect::<String>();
273 e.get_field(&capitalized)
274 } else {
275 None
276 }
277 })
278 }
279 }
280 }
281
282 pub fn has_option(&self, key: &str) -> bool {
284 self.get_option(key).is_some()
285 }
286
287 pub fn script(&self) -> Option<String> {
289 self.get_option("script")
290 }
291
292 pub fn format_url(
294 &self,
295 package: impl FnOnce() -> String,
296 ) -> Result<url::Url, url::ParseError> {
297 crate::subst::subst(&self.url(), package).parse()
298 }
299
300 pub fn user_agent(&self) -> Option<String> {
302 self.get_option("user-agent")
303 }
304
305 pub fn pagemangle(&self) -> Option<String> {
307 self.get_option("pagemangle")
308 }
309
310 pub fn uversionmangle(&self) -> Option<String> {
312 self.get_option("uversionmangle")
313 }
314
315 pub fn downloadurlmangle(&self) -> Option<String> {
317 self.get_option("downloadurlmangle")
318 }
319
320 pub fn pgpsigurlmangle(&self) -> Option<String> {
322 self.get_option("pgpsigurlmangle")
323 }
324
325 pub fn filenamemangle(&self) -> Option<String> {
327 self.get_option("filenamemangle")
328 }
329
330 pub fn oversionmangle(&self) -> Option<String> {
332 self.get_option("oversionmangle")
333 }
334
335 pub fn searchmode(&self) -> crate::types::SearchMode {
337 self.get_option("searchmode")
338 .and_then(|s| s.parse().ok())
339 .unwrap_or_default()
340 }
341
342 pub fn set_option(&mut self, option: crate::types::WatchOption) {
364 match self {
365 #[cfg(feature = "linebased")]
366 ParsedEntry::LineBased(e) => {
367 e.set_option(option);
368 }
369 #[cfg(feature = "deb822")]
370 ParsedEntry::Deb822(e) => {
371 e.set_option(option);
372 }
373 }
374 }
375
376 pub fn set_url(&mut self, url: &str) {
392 match self {
393 #[cfg(feature = "linebased")]
394 ParsedEntry::LineBased(e) => e.set_url(url),
395 #[cfg(feature = "deb822")]
396 ParsedEntry::Deb822(e) => e.set_source(url),
397 }
398 }
399
400 pub fn set_matching_pattern(&mut self, pattern: &str) {
416 match self {
417 #[cfg(feature = "linebased")]
418 ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
419 #[cfg(feature = "deb822")]
420 ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
421 }
422 }
423
424 pub fn line(&self) -> usize {
444 match self {
445 #[cfg(feature = "linebased")]
446 ParsedEntry::LineBased(e) => e.line(),
447 #[cfg(feature = "deb822")]
448 ParsedEntry::Deb822(e) => e.line(),
449 }
450 }
451
452 pub fn remove_option(&mut self, option: crate::types::WatchOption) {
475 match self {
476 #[cfg(feature = "linebased")]
477 ParsedEntry::LineBased(e) => e.del_opt(option),
478 #[cfg(feature = "deb822")]
479 ParsedEntry::Deb822(e) => e.delete_option(option),
480 }
481 }
482
483 pub fn mode(&self) -> Result<crate::types::Mode, crate::types::ParseError> {
508 match self {
509 #[cfg(feature = "linebased")]
510 ParsedEntry::LineBased(e) => e.try_mode(),
511 #[cfg(feature = "deb822")]
512 ParsedEntry::Deb822(e) => e.mode(),
513 }
514 }
515}
516
517impl std::fmt::Display for ParsedWatchFile {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 match self {
520 #[cfg(feature = "linebased")]
521 ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
522 #[cfg(feature = "deb822")]
523 ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
524 }
525 }
526}
527
528pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
547 let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
548
549 match version {
550 #[cfg(feature = "linebased")]
551 WatchFileVersion::LineBased(_v) => {
552 let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
553 Ok(ParsedWatchFile::LineBased(wf))
554 }
555 #[cfg(not(feature = "linebased"))]
556 WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
557 "linebased feature required for v1-4 formats".to_string(),
558 )),
559 #[cfg(feature = "deb822")]
560 WatchFileVersion::Deb822 => {
561 let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
562 Ok(ParsedWatchFile::Deb822(wf))
563 }
564 #[cfg(not(feature = "deb822"))]
565 WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
566 "deb822 feature required for v5 format".to_string(),
567 )),
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn test_detect_version_v1_default() {
577 let content = "https://example.com/ .*.tar.gz";
578 assert_eq!(
579 detect_version(content),
580 Some(WatchFileVersion::LineBased(1))
581 );
582 }
583
584 #[test]
585 fn test_detect_version_v4() {
586 let content = "version=4\nhttps://example.com/ .*.tar.gz";
587 assert_eq!(
588 detect_version(content),
589 Some(WatchFileVersion::LineBased(4))
590 );
591 }
592
593 #[test]
594 fn test_detect_version_v4_with_spaces() {
595 let content = "version = 4\nhttps://example.com/ .*.tar.gz";
596 assert_eq!(
597 detect_version(content),
598 Some(WatchFileVersion::LineBased(4))
599 );
600 }
601
602 #[test]
603 fn test_detect_version_v5() {
604 let content = "Version: 5\n\nSource: https://example.com/";
605 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
606 }
607
608 #[test]
609 fn test_detect_version_v5_lowercase() {
610 let content = "version: 5\n\nSource: https://example.com/";
611 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
612 }
613
614 #[test]
615 fn test_detect_version_with_leading_comments() {
616 let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
617 assert_eq!(
618 detect_version(content),
619 Some(WatchFileVersion::LineBased(4))
620 );
621 }
622
623 #[test]
624 fn test_detect_version_with_leading_whitespace() {
625 let content = " \n version=3\nhttps://example.com/ .*.tar.gz";
626 assert_eq!(
627 detect_version(content),
628 Some(WatchFileVersion::LineBased(3))
629 );
630 }
631
632 #[test]
633 fn test_detect_version_v2() {
634 let content = "version=2\nhttps://example.com/ .*.tar.gz";
635 assert_eq!(
636 detect_version(content),
637 Some(WatchFileVersion::LineBased(2))
638 );
639 }
640
641 #[cfg(feature = "linebased")]
642 #[test]
643 fn test_parse_linebased() {
644 let content = "version=4\nhttps://example.com/ .*.tar.gz";
645 let parsed = parse(content).unwrap();
646 assert_eq!(parsed.version(), 4);
647 }
648
649 #[cfg(feature = "deb822")]
650 #[test]
651 fn test_parse_deb822() {
652 let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
653 let parsed = parse(content).unwrap();
654 assert_eq!(parsed.version(), 5);
655 }
656
657 #[cfg(all(feature = "linebased", feature = "deb822"))]
658 #[test]
659 fn test_parse_both_formats() {
660 let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
662 let v4_parsed = parse(v4_content).unwrap();
663 assert_eq!(v4_parsed.version(), 4);
664
665 let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
667 let v5_parsed = parse(v5_content).unwrap();
668 assert_eq!(v5_parsed.version(), 5);
669 }
670
671 #[cfg(feature = "linebased")]
672 #[test]
673 fn test_parse_roundtrip() {
674 let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
675 let parsed = parse(content).unwrap();
676 let output = parsed.to_string();
677
678 let reparsed = parse(&output).unwrap();
680 assert_eq!(reparsed.version(), 4);
681 }
682
683 #[cfg(feature = "deb822")]
684 #[test]
685 fn test_parsed_watch_file_new_v5() {
686 let wf = ParsedWatchFile::new(5).unwrap();
687 assert_eq!(wf.version(), 5);
688 assert_eq!(wf.entries().count(), 0);
689 }
690
691 #[cfg(feature = "linebased")]
692 #[test]
693 fn test_parsed_watch_file_new_v4() {
694 let wf = ParsedWatchFile::new(4).unwrap();
695 assert_eq!(wf.version(), 4);
696 assert_eq!(wf.entries().count(), 0);
697 }
698
699 #[cfg(feature = "deb822")]
700 #[test]
701 fn test_parsed_watch_file_add_entry_v5() {
702 let mut wf = ParsedWatchFile::new(5).unwrap();
703 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
704
705 assert_eq!(wf.entries().count(), 1);
706 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
707 assert_eq!(
708 entry.matching_pattern(),
709 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
710 );
711
712 entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
714 entry.set_option(crate::types::WatchOption::Compression(
715 crate::types::Compression::Xz,
716 ));
717
718 assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
719 assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
720 }
721
722 #[cfg(feature = "linebased")]
723 #[test]
724 fn test_parsed_watch_file_add_entry_v4() {
725 let mut wf = ParsedWatchFile::new(4).unwrap();
726 let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
727
728 assert_eq!(wf.entries().count(), 1);
729 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
730 assert_eq!(
731 entry.matching_pattern(),
732 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
733 );
734 }
735
736 #[cfg(feature = "deb822")]
737 #[test]
738 fn test_parsed_watch_file_roundtrip_with_add_entry() {
739 let mut wf = ParsedWatchFile::new(5).unwrap();
740 let mut entry = wf.add_entry(
741 "https://github.com/owner/repo/tags",
742 r".*/v?([\d.]+)\.tar\.gz",
743 );
744 entry.set_option(crate::types::WatchOption::Compression(
745 crate::types::Compression::Xz,
746 ));
747
748 let output = wf.to_string();
749
750 let reparsed = parse(&output).unwrap();
752 assert_eq!(reparsed.version(), 5);
753
754 let entries: Vec<_> = reparsed.entries().collect();
755 assert_eq!(entries.len(), 1);
756 assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
757 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
758 }
759
760 #[cfg(feature = "linebased")]
761 #[test]
762 fn test_parsed_entry_set_url_v4() {
763 let mut wf = ParsedWatchFile::new(4).unwrap();
764 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
765
766 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
767
768 entry.set_url("https://github.com/foo/bar/releases");
769 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
770 }
771
772 #[cfg(feature = "deb822")]
773 #[test]
774 fn test_parsed_entry_set_url_v5() {
775 let mut wf = ParsedWatchFile::new(5).unwrap();
776 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
777
778 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
779
780 entry.set_url("https://github.com/foo/bar/releases");
781 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
782 }
783
784 #[cfg(feature = "linebased")]
785 #[test]
786 fn test_parsed_entry_set_matching_pattern_v4() {
787 let mut wf = ParsedWatchFile::new(4).unwrap();
788 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
789
790 assert_eq!(
791 entry.matching_pattern(),
792 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
793 );
794
795 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
796 assert_eq!(
797 entry.matching_pattern(),
798 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
799 );
800 }
801
802 #[cfg(feature = "deb822")]
803 #[test]
804 fn test_parsed_entry_set_matching_pattern_v5() {
805 let mut wf = ParsedWatchFile::new(5).unwrap();
806 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
807
808 assert_eq!(
809 entry.matching_pattern(),
810 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
811 );
812
813 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
814 assert_eq!(
815 entry.matching_pattern(),
816 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
817 );
818 }
819
820 #[cfg(feature = "linebased")]
821 #[test]
822 fn test_parsed_entry_line_v4() {
823 let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
824 let wf = parse(content).unwrap();
825 let entries: Vec<_> = wf.entries().collect();
826
827 assert_eq!(entries[0].line(), 1); assert_eq!(entries[1].line(), 2); }
830
831 #[cfg(feature = "deb822")]
832 #[test]
833 fn test_parsed_entry_line_v5() {
834 let content = r#"Version: 5
835
836Source: https://example.com/repo1
837Matching-Pattern: .*\.tar\.gz
838
839Source: https://example.com/repo2
840Matching-Pattern: .*\.tar\.xz
841"#;
842 let wf = parse(content).unwrap();
843 let entries: Vec<_> = wf.entries().collect();
844
845 assert_eq!(entries[0].line(), 2); assert_eq!(entries[1].line(), 5); }
848}