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
373impl std::fmt::Display for ParsedWatchFile {
374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 match self {
376 #[cfg(feature = "linebased")]
377 ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
378 #[cfg(feature = "deb822")]
379 ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
380 }
381 }
382}
383
384pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
403 let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
404
405 match version {
406 #[cfg(feature = "linebased")]
407 WatchFileVersion::LineBased(_v) => {
408 let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
409 Ok(ParsedWatchFile::LineBased(wf))
410 }
411 #[cfg(not(feature = "linebased"))]
412 WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
413 "linebased feature required for v1-4 formats".to_string(),
414 )),
415 #[cfg(feature = "deb822")]
416 WatchFileVersion::Deb822 => {
417 let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
418 Ok(ParsedWatchFile::Deb822(wf))
419 }
420 #[cfg(not(feature = "deb822"))]
421 WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
422 "deb822 feature required for v5 format".to_string(),
423 )),
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_detect_version_v1_default() {
433 let content = "https://example.com/ .*.tar.gz";
434 assert_eq!(
435 detect_version(content),
436 Some(WatchFileVersion::LineBased(1))
437 );
438 }
439
440 #[test]
441 fn test_detect_version_v4() {
442 let content = "version=4\nhttps://example.com/ .*.tar.gz";
443 assert_eq!(
444 detect_version(content),
445 Some(WatchFileVersion::LineBased(4))
446 );
447 }
448
449 #[test]
450 fn test_detect_version_v4_with_spaces() {
451 let content = "version = 4\nhttps://example.com/ .*.tar.gz";
452 assert_eq!(
453 detect_version(content),
454 Some(WatchFileVersion::LineBased(4))
455 );
456 }
457
458 #[test]
459 fn test_detect_version_v5() {
460 let content = "Version: 5\n\nSource: https://example.com/";
461 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
462 }
463
464 #[test]
465 fn test_detect_version_v5_lowercase() {
466 let content = "version: 5\n\nSource: https://example.com/";
467 assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
468 }
469
470 #[test]
471 fn test_detect_version_with_leading_comments() {
472 let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
473 assert_eq!(
474 detect_version(content),
475 Some(WatchFileVersion::LineBased(4))
476 );
477 }
478
479 #[test]
480 fn test_detect_version_with_leading_whitespace() {
481 let content = " \n version=3\nhttps://example.com/ .*.tar.gz";
482 assert_eq!(
483 detect_version(content),
484 Some(WatchFileVersion::LineBased(3))
485 );
486 }
487
488 #[test]
489 fn test_detect_version_v2() {
490 let content = "version=2\nhttps://example.com/ .*.tar.gz";
491 assert_eq!(
492 detect_version(content),
493 Some(WatchFileVersion::LineBased(2))
494 );
495 }
496
497 #[cfg(feature = "linebased")]
498 #[test]
499 fn test_parse_linebased() {
500 let content = "version=4\nhttps://example.com/ .*.tar.gz";
501 let parsed = parse(content).unwrap();
502 assert_eq!(parsed.version(), 4);
503 }
504
505 #[cfg(feature = "deb822")]
506 #[test]
507 fn test_parse_deb822() {
508 let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
509 let parsed = parse(content).unwrap();
510 assert_eq!(parsed.version(), 5);
511 }
512
513 #[cfg(all(feature = "linebased", feature = "deb822"))]
514 #[test]
515 fn test_parse_both_formats() {
516 let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
518 let v4_parsed = parse(v4_content).unwrap();
519 assert_eq!(v4_parsed.version(), 4);
520
521 let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
523 let v5_parsed = parse(v5_content).unwrap();
524 assert_eq!(v5_parsed.version(), 5);
525 }
526
527 #[cfg(feature = "linebased")]
528 #[test]
529 fn test_parse_roundtrip() {
530 let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
531 let parsed = parse(content).unwrap();
532 let output = parsed.to_string();
533
534 let reparsed = parse(&output).unwrap();
536 assert_eq!(reparsed.version(), 4);
537 }
538
539 #[cfg(feature = "deb822")]
540 #[test]
541 fn test_parsed_watch_file_new_v5() {
542 let wf = ParsedWatchFile::new(5).unwrap();
543 assert_eq!(wf.version(), 5);
544 assert_eq!(wf.entries().count(), 0);
545 }
546
547 #[cfg(feature = "linebased")]
548 #[test]
549 fn test_parsed_watch_file_new_v4() {
550 let wf = ParsedWatchFile::new(4).unwrap();
551 assert_eq!(wf.version(), 4);
552 assert_eq!(wf.entries().count(), 0);
553 }
554
555 #[cfg(feature = "deb822")]
556 #[test]
557 fn test_parsed_watch_file_add_entry_v5() {
558 let mut wf = ParsedWatchFile::new(5).unwrap();
559 let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
560
561 assert_eq!(wf.entries().count(), 1);
562 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
563 assert_eq!(
564 entry.matching_pattern(),
565 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
566 );
567
568 entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
570 entry.set_option(crate::types::WatchOption::Compression(
571 crate::types::Compression::Xz,
572 ));
573
574 assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
575 assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
576 }
577
578 #[cfg(feature = "linebased")]
579 #[test]
580 fn test_parsed_watch_file_add_entry_v4() {
581 let mut wf = ParsedWatchFile::new(4).unwrap();
582 let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
583
584 assert_eq!(wf.entries().count(), 1);
585 assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
586 assert_eq!(
587 entry.matching_pattern(),
588 Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
589 );
590 }
591
592 #[cfg(feature = "deb822")]
593 #[test]
594 fn test_parsed_watch_file_roundtrip_with_add_entry() {
595 let mut wf = ParsedWatchFile::new(5).unwrap();
596 let mut entry = wf.add_entry(
597 "https://github.com/owner/repo/tags",
598 r".*/v?([\d.]+)\.tar\.gz",
599 );
600 entry.set_option(crate::types::WatchOption::Compression(
601 crate::types::Compression::Xz,
602 ));
603
604 let output = wf.to_string();
605
606 let reparsed = parse(&output).unwrap();
608 assert_eq!(reparsed.version(), 5);
609
610 let entries: Vec<_> = reparsed.entries().collect();
611 assert_eq!(entries.len(), 1);
612 assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
613 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
614 }
615}