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) {
359 match self {
360 #[cfg(feature = "linebased")]
361 ParsedEntry::LineBased(_) => {
362 }
365 #[cfg(feature = "deb822")]
366 ParsedEntry::Deb822(e) => {
367 e.set_option(option);
368 }
369 }
370 }
371
372 pub fn set_url(&mut self, url: &str) {
388 match self {
389 #[cfg(feature = "linebased")]
390 ParsedEntry::LineBased(e) => e.set_url(url),
391 #[cfg(feature = "deb822")]
392 ParsedEntry::Deb822(e) => e.set_source(url),
393 }
394 }
395
396 pub fn set_matching_pattern(&mut self, pattern: &str) {
412 match self {
413 #[cfg(feature = "linebased")]
414 ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
415 #[cfg(feature = "deb822")]
416 ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
417 }
418 }
419
420 pub fn line(&self) -> usize {
440 match self {
441 #[cfg(feature = "linebased")]
442 ParsedEntry::LineBased(e) => e.line(),
443 #[cfg(feature = "deb822")]
444 ParsedEntry::Deb822(e) => e.line(),
445 }
446 }
447}
448
449impl std::fmt::Display for ParsedWatchFile {
450 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451 match self {
452 #[cfg(feature = "linebased")]
453 ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
454 #[cfg(feature = "deb822")]
455 ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
456 }
457 }
458}
459
460pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
479 let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
480
481 match version {
482 #[cfg(feature = "linebased")]
483 WatchFileVersion::LineBased(_v) => {
484 let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
485 Ok(ParsedWatchFile::LineBased(wf))
486 }
487 #[cfg(not(feature = "linebased"))]
488 WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
489 "linebased feature required for v1-4 formats".to_string(),
490 )),
491 #[cfg(feature = "deb822")]
492 WatchFileVersion::Deb822 => {
493 let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
494 Ok(ParsedWatchFile::Deb822(wf))
495 }
496 #[cfg(not(feature = "deb822"))]
497 WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
498 "deb822 feature required for v5 format".to_string(),
499 )),
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_detect_version_v1_default() {
509 let content = "https://example.com/ .*.tar.gz";
510 assert_eq!(
511 detect_version(content),
512 Some(WatchFileVersion::LineBased(1))
513 );
514 }
515
516 #[test]
517 fn test_detect_version_v4() {
518 let content = "version=4\nhttps://example.com/ .*.tar.gz";
519 assert_eq!(
520 detect_version(content),
521 Some(WatchFileVersion::LineBased(4))
522 );
523 }
524
525 #[test]
526 fn test_detect_version_v4_with_spaces() {
527 let content = "version = 4\nhttps://example.com/ .*.tar.gz";
528 assert_eq!(
529 detect_version(content),
530 Some(WatchFileVersion::LineBased(4))
531 );
532 }
533
534 #[test]
535 fn test_detect_version_v5() {
536 let content = "Version: 5\n\nSource: https://example.com/";
537 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
538 }
539
540 #[test]
541 fn test_detect_version_v5_lowercase() {
542 let content = "version: 5\n\nSource: https://example.com/";
543 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
544 }
545
546 #[test]
547 fn test_detect_version_with_leading_comments() {
548 let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
549 assert_eq!(
550 detect_version(content),
551 Some(WatchFileVersion::LineBased(4))
552 );
553 }
554
555 #[test]
556 fn test_detect_version_with_leading_whitespace() {
557 let content = " \n version=3\nhttps://example.com/ .*.tar.gz";
558 assert_eq!(
559 detect_version(content),
560 Some(WatchFileVersion::LineBased(3))
561 );
562 }
563
564 #[test]
565 fn test_detect_version_v2() {
566 let content = "version=2\nhttps://example.com/ .*.tar.gz";
567 assert_eq!(
568 detect_version(content),
569 Some(WatchFileVersion::LineBased(2))
570 );
571 }
572
573 #[cfg(feature = "linebased")]
574 #[test]
575 fn test_parse_linebased() {
576 let content = "version=4\nhttps://example.com/ .*.tar.gz";
577 let parsed = parse(content).unwrap();
578 assert_eq!(parsed.version(), 4);
579 }
580
581 #[cfg(feature = "deb822")]
582 #[test]
583 fn test_parse_deb822() {
584 let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
585 let parsed = parse(content).unwrap();
586 assert_eq!(parsed.version(), 5);
587 }
588
589 #[cfg(all(feature = "linebased", feature = "deb822"))]
590 #[test]
591 fn test_parse_both_formats() {
592 let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
594 let v4_parsed = parse(v4_content).unwrap();
595 assert_eq!(v4_parsed.version(), 4);
596
597 let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
599 let v5_parsed = parse(v5_content).unwrap();
600 assert_eq!(v5_parsed.version(), 5);
601 }
602
603 #[cfg(feature = "linebased")]
604 #[test]
605 fn test_parse_roundtrip() {
606 let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
607 let parsed = parse(content).unwrap();
608 let output = parsed.to_string();
609
610 let reparsed = parse(&output).unwrap();
612 assert_eq!(reparsed.version(), 4);
613 }
614
615 #[cfg(feature = "deb822")]
616 #[test]
617 fn test_parsed_watch_file_new_v5() {
618 let wf = ParsedWatchFile::new(5).unwrap();
619 assert_eq!(wf.version(), 5);
620 assert_eq!(wf.entries().count(), 0);
621 }
622
623 #[cfg(feature = "linebased")]
624 #[test]
625 fn test_parsed_watch_file_new_v4() {
626 let wf = ParsedWatchFile::new(4).unwrap();
627 assert_eq!(wf.version(), 4);
628 assert_eq!(wf.entries().count(), 0);
629 }
630
631 #[cfg(feature = "deb822")]
632 #[test]
633 fn test_parsed_watch_file_add_entry_v5() {
634 let mut wf = ParsedWatchFile::new(5).unwrap();
635 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
636
637 assert_eq!(wf.entries().count(), 1);
638 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
639 assert_eq!(
640 entry.matching_pattern(),
641 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
642 );
643
644 entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
646 entry.set_option(crate::types::WatchOption::Compression(
647 crate::types::Compression::Xz,
648 ));
649
650 assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
651 assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
652 }
653
654 #[cfg(feature = "linebased")]
655 #[test]
656 fn test_parsed_watch_file_add_entry_v4() {
657 let mut wf = ParsedWatchFile::new(4).unwrap();
658 let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
659
660 assert_eq!(wf.entries().count(), 1);
661 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
662 assert_eq!(
663 entry.matching_pattern(),
664 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
665 );
666 }
667
668 #[cfg(feature = "deb822")]
669 #[test]
670 fn test_parsed_watch_file_roundtrip_with_add_entry() {
671 let mut wf = ParsedWatchFile::new(5).unwrap();
672 let mut entry = wf.add_entry(
673 "https://github.com/owner/repo/tags",
674 r".*/v?([\d.]+)\.tar\.gz",
675 );
676 entry.set_option(crate::types::WatchOption::Compression(
677 crate::types::Compression::Xz,
678 ));
679
680 let output = wf.to_string();
681
682 let reparsed = parse(&output).unwrap();
684 assert_eq!(reparsed.version(), 5);
685
686 let entries: Vec<_> = reparsed.entries().collect();
687 assert_eq!(entries.len(), 1);
688 assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
689 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
690 }
691
692 #[cfg(feature = "linebased")]
693 #[test]
694 fn test_parsed_entry_set_url_v4() {
695 let mut wf = ParsedWatchFile::new(4).unwrap();
696 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
697
698 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
699
700 entry.set_url("https://github.com/foo/bar/releases");
701 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
702 }
703
704 #[cfg(feature = "deb822")]
705 #[test]
706 fn test_parsed_entry_set_url_v5() {
707 let mut wf = ParsedWatchFile::new(5).unwrap();
708 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
709
710 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
711
712 entry.set_url("https://github.com/foo/bar/releases");
713 assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
714 }
715
716 #[cfg(feature = "linebased")]
717 #[test]
718 fn test_parsed_entry_set_matching_pattern_v4() {
719 let mut wf = ParsedWatchFile::new(4).unwrap();
720 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
721
722 assert_eq!(
723 entry.matching_pattern(),
724 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
725 );
726
727 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
728 assert_eq!(
729 entry.matching_pattern(),
730 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
731 );
732 }
733
734 #[cfg(feature = "deb822")]
735 #[test]
736 fn test_parsed_entry_set_matching_pattern_v5() {
737 let mut wf = ParsedWatchFile::new(5).unwrap();
738 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
739
740 assert_eq!(
741 entry.matching_pattern(),
742 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
743 );
744
745 entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
746 assert_eq!(
747 entry.matching_pattern(),
748 Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
749 );
750 }
751
752 #[cfg(feature = "linebased")]
753 #[test]
754 fn test_parsed_entry_line_v4() {
755 let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
756 let wf = parse(content).unwrap();
757 let entries: Vec<_> = wf.entries().collect();
758
759 assert_eq!(entries[0].line(), 1); assert_eq!(entries[1].line(), 2); }
762
763 #[cfg(feature = "deb822")]
764 #[test]
765 fn test_parsed_entry_line_v5() {
766 let content = r#"Version: 5
767
768Source: https://example.com/repo1
769Matching-Pattern: .*\.tar\.gz
770
771Source: https://example.com/repo2
772Matching-Pattern: .*\.tar\.xz
773"#;
774 let wf = parse(content).unwrap();
775 let entries: Vec<_> = wf.entries().collect();
776
777 assert_eq!(entries[0].line(), 2); assert_eq!(entries[1].line(), 5); }
780}