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
303fn normalize_key(key: &str) -> String {
307 key.to_lowercase().replace(['-', '_'], "")
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_create_v5_watchfile() {
316 let wf = WatchFile::new();
317 assert_eq!(wf.version(), 5);
318
319 let output = wf.to_string();
320 assert!(output.contains("Version"));
321 assert!(output.contains("5"));
322 }
323
324 #[test]
325 fn test_parse_v5_basic() {
326 let input = r#"Version: 5
327
328Source: https://github.com/owner/repo/tags
329Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
330"#;
331
332 let wf: WatchFile = input.parse().unwrap();
333 assert_eq!(wf.version(), 5);
334
335 let entries: Vec<_> = wf.entries().collect();
336 assert_eq!(entries.len(), 1);
337
338 let entry = &entries[0];
339 assert_eq!(
340 entry.source().as_deref(),
341 Some("https://github.com/owner/repo/tags")
342 );
343 assert_eq!(
344 entry.matching_pattern(),
345 Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
346 );
347 }
348
349 #[test]
350 fn test_parse_v5_multiple_entries() {
351 let input = r#"Version: 5
352
353Source: https://github.com/owner/repo1/tags
354Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
355
356Source: https://github.com/owner/repo2/tags
357Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
358"#;
359
360 let wf: WatchFile = input.parse().unwrap();
361 let entries: Vec<_> = wf.entries().collect();
362 assert_eq!(entries.len(), 2);
363
364 assert_eq!(
365 entries[0].source().as_deref(),
366 Some("https://github.com/owner/repo1/tags")
367 );
368 assert_eq!(
369 entries[1].source().as_deref(),
370 Some("https://github.com/owner/repo2/tags")
371 );
372 }
373
374 #[test]
375 fn test_v5_case_insensitive_fields() {
376 let input = r#"Version: 5
377
378source: https://example.com/files
379matching-pattern: .*\.tar\.gz
380"#;
381
382 let wf: WatchFile = input.parse().unwrap();
383 let entries: Vec<_> = wf.entries().collect();
384 assert_eq!(entries.len(), 1);
385
386 let entry = &entries[0];
387 assert_eq!(entry.source().as_deref(), Some("https://example.com/files"));
388 assert_eq!(entry.matching_pattern().as_deref(), Some(".*\\.tar\\.gz"));
389 }
390
391 #[test]
392 fn test_v5_with_compression_option() {
393 let input = r#"Version: 5
394
395Source: https://example.com/files
396Matching-Pattern: .*\.tar\.gz
397Compression: xz
398"#;
399
400 let wf: WatchFile = input.parse().unwrap();
401 let entries: Vec<_> = wf.entries().collect();
402 assert_eq!(entries.len(), 1);
403
404 let entry = &entries[0];
405 let compression = entry.get_option("compression");
406 assert!(compression.is_some());
407 }
408
409 #[test]
410 fn test_v5_with_component() {
411 let input = r#"Version: 5
412
413Source: https://example.com/files
414Matching-Pattern: .*\.tar\.gz
415Component: foo
416"#;
417
418 let wf: WatchFile = input.parse().unwrap();
419 let entries: Vec<_> = wf.entries().collect();
420 assert_eq!(entries.len(), 1);
421
422 let entry = &entries[0];
423 assert_eq!(entry.component(), Some("foo".to_string()));
424 }
425
426 #[test]
427 fn test_v5_rejects_wrong_version() {
428 let input = r#"Version: 4
429
430Source: https://example.com/files
431Matching-Pattern: .*\.tar\.gz
432"#;
433
434 let result: Result<WatchFile, _> = input.parse();
435 assert!(result.is_err());
436 }
437
438 #[test]
439 fn test_v5_roundtrip() {
440 let input = r#"Version: 5
441
442Source: https://example.com/files
443Matching-Pattern: .*\.tar\.gz
444"#;
445
446 let wf: WatchFile = input.parse().unwrap();
447 let output = wf.to_string();
448
449 let wf2: WatchFile = output.parse().unwrap();
451 assert_eq!(wf2.version(), 5);
452
453 let entries: Vec<_> = wf2.entries().collect();
454 assert_eq!(entries.len(), 1);
455 }
456
457 #[test]
458 fn test_normalize_key() {
459 assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
460 assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
461 assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
462 assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
463 }
464
465 #[test]
466 fn test_defaults_paragraph() {
467 let input = r#"Version: 5
468
469Compression: xz
470User-Agent: Custom/1.0
471
472Source: https://example.com/repo1
473Matching-Pattern: .*\.tar\.gz
474
475Source: https://example.com/repo2
476Matching-Pattern: .*\.tar\.gz
477Compression: gz
478"#;
479
480 let wf: WatchFile = input.parse().unwrap();
481
482 let defaults = wf.defaults();
484 assert!(defaults.is_some());
485 let defaults = defaults.unwrap();
486 assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
487 assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
488
489 let entries: Vec<_> = wf.entries().collect();
491 assert_eq!(entries.len(), 2);
492
493 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
495 assert_eq!(
496 entries[0].get_option("User-Agent"),
497 Some("Custom/1.0".to_string())
498 );
499
500 assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
502 assert_eq!(
503 entries[1].get_option("User-Agent"),
504 Some("Custom/1.0".to_string())
505 );
506 }
507
508 #[test]
509 fn test_no_defaults_paragraph() {
510 let input = r#"Version: 5
511
512Source: https://example.com/repo1
513Matching-Pattern: .*\.tar\.gz
514"#;
515
516 let wf: WatchFile = input.parse().unwrap();
517
518 assert!(wf.defaults().is_none());
520
521 let entries: Vec<_> = wf.entries().collect();
522 assert_eq!(entries.len(), 1);
523 }
524
525 #[test]
526 fn test_defaults_with_case_variations() {
527 let input = r#"Version: 5
528
529compression: xz
530user-agent: Custom/1.0
531
532Source: https://example.com/repo1
533Matching-Pattern: .*\.tar\.gz
534"#;
535
536 let wf: WatchFile = input.parse().unwrap();
537
538 let entries: Vec<_> = wf.entries().collect();
540 assert_eq!(entries.len(), 1);
541
542 assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
544 assert_eq!(
545 entries[0].get_option("User-Agent"),
546 Some("Custom/1.0".to_string())
547 );
548 }
549
550 #[test]
551 fn test_v5_with_uversionmangle() {
552 let input = r#"Version: 5
553
554Source: https://pypi.org/project/foo/
555Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
556Uversionmangle: s/\.0+$//
557"#;
558
559 let wf: WatchFile = input.parse().unwrap();
560 let entries: Vec<_> = wf.entries().collect();
561 assert_eq!(entries.len(), 1);
562
563 let entry = &entries[0];
564 assert_eq!(
565 entry.get_option("Uversionmangle"),
566 Some("s/\\.0+$//".to_string())
567 );
568 }
569
570 #[test]
571 fn test_v5_with_filenamemangle() {
572 let input = r#"Version: 5
573
574Source: https://example.com/files
575Matching-Pattern: .*\.tar\.gz
576Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
577"#;
578
579 let wf: WatchFile = input.parse().unwrap();
580 let entries: Vec<_> = wf.entries().collect();
581 assert_eq!(entries.len(), 1);
582
583 let entry = &entries[0];
584 assert_eq!(
585 entry.get_option("Filenamemangle"),
586 Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
587 );
588 }
589
590 #[test]
591 fn test_v5_with_searchmode() {
592 let input = r#"Version: 5
593
594Source: https://example.com/files
595Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
596Searchmode: plain
597"#;
598
599 let wf: WatchFile = input.parse().unwrap();
600 let entries: Vec<_> = wf.entries().collect();
601 assert_eq!(entries.len(), 1);
602
603 let entry = &entries[0];
604 assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
605 }
606
607 #[test]
608 fn test_v5_with_version_policy() {
609 let input = r#"Version: 5
610
611Source: https://example.com/files
612Matching-Pattern: .*\.tar\.gz
613Version-Policy: debian
614"#;
615
616 let wf: WatchFile = input.parse().unwrap();
617 let entries: Vec<_> = wf.entries().collect();
618 assert_eq!(entries.len(), 1);
619
620 let entry = &entries[0];
621 let policy = entry.version_policy();
622 assert!(policy.is_ok());
623 assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
624 }
625
626 #[test]
627 fn test_v5_multiple_mangles() {
628 let input = r#"Version: 5
629
630Source: https://example.com/files
631Matching-Pattern: .*\.tar\.gz
632Uversionmangle: s/^v//;s/\.0+$//
633Dversionmangle: s/\+dfsg\d*$//
634Filenamemangle: s/.*/foo-$1.tar.gz/
635"#;
636
637 let wf: WatchFile = input.parse().unwrap();
638 let entries: Vec<_> = wf.entries().collect();
639 assert_eq!(entries.len(), 1);
640
641 let entry = &entries[0];
642 assert_eq!(
643 entry.get_option("Uversionmangle"),
644 Some("s/^v//;s/\\.0+$//".to_string())
645 );
646 assert_eq!(
647 entry.get_option("Dversionmangle"),
648 Some("s/\\+dfsg\\d*$//".to_string())
649 );
650 assert_eq!(
651 entry.get_option("Filenamemangle"),
652 Some("s/.*/foo-$1.tar.gz/".to_string())
653 );
654 }
655
656 #[test]
657 fn test_v5_with_pgpmode() {
658 let input = r#"Version: 5
659
660Source: https://example.com/files
661Matching-Pattern: .*\.tar\.gz
662Pgpmode: auto
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_option("Pgpmode"), Some("auto".to_string()));
671 }
672
673 #[test]
674 fn test_v5_with_comments() {
675 let input = r#"Version: 5
676
677# This is a comment about the entry
678Source: https://example.com/files
679Matching-Pattern: .*\.tar\.gz
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 output = wf.to_string();
688 assert!(output.contains("# This is a comment about the entry"));
689 }
690
691 #[test]
692 fn test_v5_empty_after_version() {
693 let input = "Version: 5\n";
694
695 let wf: WatchFile = input.parse().unwrap();
696 assert_eq!(wf.version(), 5);
697
698 let entries: Vec<_> = wf.entries().collect();
699 assert_eq!(entries.len(), 0);
700 }
701
702 #[test]
703 fn test_v5_trait_url() {
704 let input = r#"Version: 5
705
706Source: https://example.com/files/@PACKAGE@
707Matching-Pattern: .*\.tar\.gz
708"#;
709
710 let wf: WatchFile = input.parse().unwrap();
711 let entries: Vec<_> = wf.entries().collect();
712 assert_eq!(entries.len(), 1);
713
714 let entry = &entries[0];
715 assert_eq!(
717 entry.source().as_deref(),
718 Some("https://example.com/files/@PACKAGE@")
719 );
720 }
721}