1use crate::types::ParseError as TypesParseError;
3use crate::VersionPolicy;
4use deb822_lossless::{Deb822, Paragraph};
5use std::str::FromStr;
6
7#[derive(Debug)]
8pub struct ParseError(String);
10
11impl std::error::Error for ParseError {}
12
13impl std::fmt::Display for ParseError {
14 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
15 write!(f, "ParseError: {}", self.0)
16 }
17}
18
19#[derive(Debug)]
21pub struct WatchFile(Deb822);
22
23#[derive(Debug)]
25pub struct Entry {
26 paragraph: Paragraph,
27 defaults: Option<Paragraph>,
28}
29
30impl WatchFile {
31 pub fn new() -> Self {
33 let content = "Version: 5\n";
35 WatchFile::from_str(content).expect("Failed to create empty watch file")
36 }
37
38 pub fn version(&self) -> u32 {
40 5
41 }
42
43 pub fn defaults(&self) -> Option<Paragraph> {
46 let paragraphs: Vec<_> = self.0.paragraphs().collect();
47
48 if paragraphs.len() > 1 {
49 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
51 return Some(paragraphs[1].clone());
52 }
53 }
54
55 None
56 }
57
58 pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
61 let paragraphs: Vec<_> = self.0.paragraphs().collect();
62 let defaults = self.defaults();
63
64 let start_index = if paragraphs.len() > 1 {
68 if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
70 2 } else {
72 1 }
74 } else {
75 1
76 };
77
78 paragraphs
79 .into_iter()
80 .skip(start_index)
81 .map(move |p| Entry {
82 paragraph: p,
83 defaults: defaults.clone(),
84 })
85 }
86
87 pub fn inner(&self) -> &Deb822 {
89 &self.0
90 }
91
92 pub fn inner_mut(&mut self) -> &mut Deb822 {
94 &mut self.0
95 }
96
97 pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry {
114 let mut para = self.0.add_paragraph();
115 para.set("Source", source);
116 para.set("Matching-Pattern", matching_pattern);
117
118 let defaults = self.defaults();
121
122 Entry {
123 paragraph: para.clone(),
124 defaults,
125 }
126 }
127}
128
129impl Default for WatchFile {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135impl FromStr for WatchFile {
136 type Err = ParseError;
137
138 fn from_str(s: &str) -> Result<Self, Self::Err> {
139 match Deb822::from_str(s) {
140 Ok(deb822) => {
141 let version = deb822
143 .paragraphs()
144 .next()
145 .and_then(|p| p.get("Version"))
146 .unwrap_or_else(|| "1".to_string());
147
148 if version != "5" {
149 return Err(ParseError(format!("Expected version 5, got {}", version)));
150 }
151
152 Ok(WatchFile(deb822))
153 }
154 Err(e) => Err(ParseError(e.to_string())),
155 }
156 }
157}
158
159impl std::fmt::Display for WatchFile {
160 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
161 write!(f, "{}", self.0)
162 }
163}
164
165impl Entry {
166 pub(crate) fn get_field(&self, key: &str) -> Option<String> {
169 if let Some(value) = self.paragraph.get(key) {
171 return Some(value);
172 }
173
174 let normalized_key = normalize_key(key);
177
178 for (k, v) in self.paragraph.items() {
180 if normalize_key(&k) == normalized_key {
181 return Some(v);
182 }
183 }
184
185 if let Some(ref defaults) = self.defaults {
187 if let Some(value) = defaults.get(key) {
189 return Some(value);
190 }
191
192 for (k, v) in defaults.items() {
194 if normalize_key(&k) == normalized_key {
195 return Some(v);
196 }
197 }
198 }
199
200 None
201 }
202
203 pub fn source(&self) -> Option<String> {
205 self.get_field("Source")
206 }
207
208 pub fn matching_pattern(&self) -> Option<String> {
210 self.get_field("Matching-Pattern")
211 }
212
213 pub fn as_deb822(&self) -> &Paragraph {
215 &self.paragraph
216 }
217
218 pub fn component(&self) -> Option<String> {
220 self.get_field("Component")
221 }
222
223 pub fn get_option(&self, key: &str) -> Option<String> {
225 match key {
226 "Source" => None, "Matching-Pattern" => None, "Component" => None, "Version" => None, key => self.get_field(key),
231 }
232 }
233
234 pub fn set_option(&mut self, option: crate::types::WatchOption) {
236 use crate::types::WatchOption;
237
238 let (key, value) = match option {
239 WatchOption::Component(v) => ("Component", Some(v)),
240 WatchOption::Compression(v) => ("Compression", Some(v.to_string())),
241 WatchOption::UserAgent(v) => ("User-Agent", Some(v)),
242 WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)),
243 WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)),
244 WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)),
245 WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)),
246 WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)),
247 WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)),
248 WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)),
249 WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)),
250 WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())),
251 WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())),
252 WatchOption::Mode(v) => ("Mode", Some(v.to_string())),
253 WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())),
254 WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())),
255 WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())),
256 WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())),
257 WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())),
258 WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)),
259 WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)),
260 WatchOption::Script(v) => ("Script", Some(v)),
261 WatchOption::Decompress => ("Decompress", None),
262 WatchOption::Bare => ("Bare", None),
263 WatchOption::Repack => ("Repack", None),
264 };
265
266 if let Some(v) = value {
267 self.paragraph.set(key, &v);
268 } else {
269 self.paragraph.set(key, "");
271 }
272 }
273
274 pub fn set_option_str(&mut self, key: &str, value: &str) {
276 self.paragraph.set(key, value);
277 }
278
279 pub fn delete_option(&mut self, key: &str) {
281 self.paragraph.remove(key);
282 }
283
284 pub fn url(&self) -> String {
286 self.source().unwrap_or_default()
287 }
288
289 pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
291 match self.get_field("Version-Policy") {
292 Some(policy) => Ok(Some(policy.parse()?)),
293 None => Ok(None),
294 }
295 }
296
297 pub fn script(&self) -> Option<String> {
299 self.get_field("Script")
300 }
301
302 pub fn set_source(&mut self, url: &str) {
304 self.paragraph.set("Source", url);
305 }
306
307 pub fn set_matching_pattern(&mut self, pattern: &str) {
309 self.paragraph.set("Matching-Pattern", pattern);
310 }
311
312 pub fn line(&self) -> usize {
314 self.paragraph.line()
315 }
316}
317
318fn normalize_key(key: &str) -> String {
322 key.to_lowercase().replace(['-', '_'], "")
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn test_create_v5_watchfile() {
331 let wf = WatchFile::new();
332 assert_eq!(wf.version(), 5);
333
334 let output = wf.to_string();
335 assert!(output.contains("Version"));
336 assert!(output.contains("5"));
337 }
338
339 #[test]
340 fn test_parse_v5_basic() {
341 let input = r#"Version: 5
342
343Source: https://github.com/owner/repo/tags
344Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
345"#;
346
347 let wf: WatchFile = input.parse().unwrap();
348 assert_eq!(wf.version(), 5);
349
350 let entries: Vec<_> = wf.entries().collect();
351 assert_eq!(entries.len(), 1);
352
353 let entry = &entries[0];
354 assert_eq!(
355 entry.source().as_deref(),
356 Some("https://github.com/owner/repo/tags")
357 );
358 assert_eq!(
359 entry.matching_pattern(),
360 Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
361 );
362 }
363
364 #[test]
365 fn test_parse_v5_multiple_entries() {
366 let input = r#"Version: 5
367
368Source: https://github.com/owner/repo1/tags
369Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
370
371Source: https://github.com/owner/repo2/tags
372Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
373"#;
374
375 let wf: WatchFile = input.parse().unwrap();
376 let entries: Vec<_> = wf.entries().collect();
377 assert_eq!(entries.len(), 2);
378
379 assert_eq!(
380 entries[0].source().as_deref(),
381 Some("https://github.com/owner/repo1/tags")
382 );
383 assert_eq!(
384 entries[1].source().as_deref(),
385 Some("https://github.com/owner/repo2/tags")
386 );
387 }
388
389 #[test]
390 fn test_v5_case_insensitive_fields() {
391 let input = r#"Version: 5
392
393source: https://example.com/files
394matching-pattern: .*\.tar\.gz
395"#;
396
397 let wf: WatchFile = input.parse().unwrap();
398 let entries: Vec<_> = wf.entries().collect();
399 assert_eq!(entries.len(), 1);
400
401 let entry = &entries[0];
402 assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
403 assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
404 }
405
406 #[test]
407 fn test_v5_with_compression_option() {
408 let input = r#"Version: 5
409
410Source: https://example.com/files
411Matching-Pattern: .*\.tar\.gz
412Compression: xz
413"#;
414
415 let wf: WatchFile = input.parse().unwrap();
416 let entries: Vec<_> = wf.entries().collect();
417 assert_eq!(entries.len(), 1);
418
419 let entry = &entries[0];
420 let compression = entry.get_option("compression");
421 assert!(compression.is_some());
422 }
423
424 #[test]
425 fn test_v5_with_component() {
426 let input = r#"Version: 5
427
428Source: https://example.com/files
429Matching-Pattern: .*\.tar\.gz
430Component: foo
431"#;
432
433 let wf: WatchFile = input.parse().unwrap();
434 let entries: Vec<_> = wf.entries().collect();
435 assert_eq!(entries.len(), 1);
436
437 let entry = &entries[0];
438 assert_eq!(entry.component(), Some("foo".to_string()));
439 }
440
441 #[test]
442 fn test_v5_rejects_wrong_version() {
443 let input = r#"Version: 4
444
445Source: https://example.com/files
446Matching-Pattern: .*\.tar\.gz
447"#;
448
449 let result: Result<WatchFile, _> = input.parse();
450 assert!(result.is_err());
451 }
452
453 #[test]
454 fn test_v5_roundtrip() {
455 let input = r#"Version: 5
456
457Source: https://example.com/files
458Matching-Pattern: .*\.tar\.gz
459"#;
460
461 let wf: WatchFile = input.parse().unwrap();
462 let output = wf.to_string();
463
464 let wf2: WatchFile = output.parse().unwrap();
466 assert_eq!(wf2.version(), 5);
467
468 let entries: Vec<_> = wf2.entries().collect();
469 assert_eq!(entries.len(), 1);
470 }
471
472 #[test]
473 fn test_normalize_key() {
474 assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
475 assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
476 assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
477 assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
478 }
479
480 #[test]
481 fn test_defaults_paragraph() {
482 let input = r#"Version: 5
483
484Compression: xz
485User-Agent: Custom/1.0
486
487Source: https://example.com/repo1
488Matching-Pattern: .*\.tar\.gz
489
490Source: https://example.com/repo2
491Matching-Pattern: .*\.tar\.gz
492Compression: gz
493"#;
494
495 let wf: WatchFile = input.parse().unwrap();
496
497 let defaults = wf.defaults();
499 assert!(defaults.is_some());
500 let defaults = defaults.unwrap();
501 assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
502 assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
503
504 let entries: Vec<_> = wf.entries().collect();
506 assert_eq!(entries.len(), 2);
507
508 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
510 assert_eq!(
511 entries[0].get_option("User-Agent"),
512 Some("Custom/1.0".to_string())
513 );
514
515 assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
517 assert_eq!(
518 entries[1].get_option("User-Agent"),
519 Some("Custom/1.0".to_string())
520 );
521 }
522
523 #[test]
524 fn test_no_defaults_paragraph() {
525 let input = r#"Version: 5
526
527Source: https://example.com/repo1
528Matching-Pattern: .*\.tar\.gz
529"#;
530
531 let wf: WatchFile = input.parse().unwrap();
532
533 assert!(wf.defaults().is_none());
535
536 let entries: Vec<_> = wf.entries().collect();
537 assert_eq!(entries.len(), 1);
538 }
539
540 #[test]
541 fn test_set_source() {
542 let mut wf = WatchFile::new();
543 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
544
545 assert_eq!(
546 entry.source(),
547 Some("https://example.com/repo1".to_string())
548 );
549
550 entry.set_source("https://example.com/repo2");
551 assert_eq!(
552 entry.source(),
553 Some("https://example.com/repo2".to_string())
554 );
555 }
556
557 #[test]
558 fn test_set_matching_pattern() {
559 let mut wf = WatchFile::new();
560 let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
561
562 assert_eq!(entry.matching_pattern(), Some(".*\\.tar\\.gz".to_string()));
563
564 entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
565 assert_eq!(
566 entry.matching_pattern(),
567 Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
568 );
569 }
570
571 #[test]
572 fn test_entry_line() {
573 let input = r#"Version: 5
574
575Source: https://example.com/repo1
576Matching-Pattern: .*\.tar\.gz
577
578Source: https://example.com/repo2
579Matching-Pattern: .*\.tar\.xz
580"#;
581
582 let wf: WatchFile = input.parse().unwrap();
583 let entries: Vec<_> = wf.entries().collect();
584
585 assert_eq!(entries[0].line(), 2);
587 assert_eq!(entries[1].line(), 5);
589 }
590
591 #[test]
592 fn test_defaults_with_case_variations() {
593 let input = r#"Version: 5
594
595compression: xz
596user-agent: Custom/1.0
597
598Source: https://example.com/repo1
599Matching-Pattern: .*\.tar\.gz
600"#;
601
602 let wf: WatchFile = input.parse().unwrap();
603
604 let entries: Vec<_> = wf.entries().collect();
606 assert_eq!(entries.len(), 1);
607
608 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
610 assert_eq!(
611 entries[0].get_option("User-Agent"),
612 Some("Custom/1.0".to_string())
613 );
614 }
615
616 #[test]
617 fn test_v5_with_uversionmangle() {
618 let input = r#"Version: 5
619
620Source: https://pypi.org/project/foo/
621Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
622Uversionmangle: s/\.0+$//
623"#;
624
625 let wf: WatchFile = input.parse().unwrap();
626 let entries: Vec<_> = wf.entries().collect();
627 assert_eq!(entries.len(), 1);
628
629 let entry = &entries[0];
630 assert_eq!(
631 entry.get_option("Uversionmangle"),
632 Some("s/\\.0+$//".to_string())
633 );
634 }
635
636 #[test]
637 fn test_v5_with_filenamemangle() {
638 let input = r#"Version: 5
639
640Source: https://example.com/files
641Matching-Pattern: .*\.tar\.gz
642Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
643"#;
644
645 let wf: WatchFile = input.parse().unwrap();
646 let entries: Vec<_> = wf.entries().collect();
647 assert_eq!(entries.len(), 1);
648
649 let entry = &entries[0];
650 assert_eq!(
651 entry.get_option("Filenamemangle"),
652 Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
653 );
654 }
655
656 #[test]
657 fn test_v5_with_searchmode() {
658 let input = r#"Version: 5
659
660Source: https://example.com/files
661Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
662Searchmode: plain
663"#;
664
665 let wf: WatchFile = input.parse().unwrap();
666 let entries: Vec<_> = wf.entries().collect();
667 assert_eq!(entries.len(), 1);
668
669 let entry = &entries[0];
670 assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
671 }
672
673 #[test]
674 fn test_v5_with_version_policy() {
675 let input = r#"Version: 5
676
677Source: https://example.com/files
678Matching-Pattern: .*\.tar\.gz
679Version-Policy: debian
680"#;
681
682 let wf: WatchFile = input.parse().unwrap();
683 let entries: Vec<_> = wf.entries().collect();
684 assert_eq!(entries.len(), 1);
685
686 let entry = &entries[0];
687 let policy = entry.version_policy();
688 assert!(policy.is_ok());
689 assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
690 }
691
692 #[test]
693 fn test_v5_multiple_mangles() {
694 let input = r#"Version: 5
695
696Source: https://example.com/files
697Matching-Pattern: .*\.tar\.gz
698Uversionmangle: s/^v//;s/\.0+$//
699Dversionmangle: s/\+dfsg\d*$//
700Filenamemangle: s/.*/foo-$1.tar.gz/
701"#;
702
703 let wf: WatchFile = input.parse().unwrap();
704 let entries: Vec<_> = wf.entries().collect();
705 assert_eq!(entries.len(), 1);
706
707 let entry = &entries[0];
708 assert_eq!(
709 entry.get_option("Uversionmangle"),
710 Some("s/^v//;s/\\.0+$//".to_string())
711 );
712 assert_eq!(
713 entry.get_option("Dversionmangle"),
714 Some("s/\\+dfsg\\d*$//".to_string())
715 );
716 assert_eq!(
717 entry.get_option("Filenamemangle"),
718 Some("s/.*/foo-$1.tar.gz/".to_string())
719 );
720 }
721
722 #[test]
723 fn test_v5_with_pgpmode() {
724 let input = r#"Version: 5
725
726Source: https://example.com/files
727Matching-Pattern: .*\.tar\.gz
728Pgpmode: auto
729"#;
730
731 let wf: WatchFile = input.parse().unwrap();
732 let entries: Vec<_> = wf.entries().collect();
733 assert_eq!(entries.len(), 1);
734
735 let entry = &entries[0];
736 assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
737 }
738
739 #[test]
740 fn test_v5_with_comments() {
741 let input = r#"Version: 5
742
743# This is a comment about the entry
744Source: https://example.com/files
745Matching-Pattern: .*\.tar\.gz
746"#;
747
748 let wf: WatchFile = input.parse().unwrap();
749 let entries: Vec<_> = wf.entries().collect();
750 assert_eq!(entries.len(), 1);
751
752 let output = wf.to_string();
754 assert!(output.contains("# This is a comment about the entry"));
755 }
756
757 #[test]
758 fn test_v5_empty_after_version() {
759 let input = "Version: 5\n";
760
761 let wf: WatchFile = input.parse().unwrap();
762 assert_eq!(wf.version(), 5);
763
764 let entries: Vec<_> = wf.entries().collect();
765 assert_eq!(entries.len(), 0);
766 }
767
768 #[test]
769 fn test_v5_trait_url() {
770 let input = r#"Version: 5
771
772Source: https://example.com/files/@PACKAGE@
773Matching-Pattern: .*\.tar\.gz
774"#;
775
776 let wf: WatchFile = input.parse().unwrap();
777 let entries: Vec<_> = wf.entries().collect();
778 assert_eq!(entries.len(), 1);
779
780 let entry = &entries[0];
781 assert_eq!(
783 entry.source().as_deref(),
784 Some("https://example.com/files/@PACKAGE@")
785 );
786 }
787}