Skip to main content

debian_watch/
deb822.rs

1//! Watch file implementation for format 5 (RFC822/deb822 style)
2use crate::types::ParseError as TypesParseError;
3use crate::VersionPolicy;
4use deb822_lossless::{Deb822, Paragraph};
5use std::str::FromStr;
6
7/// Get the deb822 field name for a WatchOption variant
8fn watch_option_to_key(option: &crate::types::WatchOption) -> &'static str {
9    use crate::types::WatchOption;
10
11    match option {
12        WatchOption::Component(_) => "Component",
13        WatchOption::Compression(_) => "Compression",
14        WatchOption::UserAgent(_) => "User-Agent",
15        WatchOption::Pagemangle(_) => "Pagemangle",
16        WatchOption::Uversionmangle(_) => "Uversionmangle",
17        WatchOption::Dversionmangle(_) => "Dversionmangle",
18        WatchOption::Dirversionmangle(_) => "Dirversionmangle",
19        WatchOption::Oversionmangle(_) => "Oversionmangle",
20        WatchOption::Downloadurlmangle(_) => "Downloadurlmangle",
21        WatchOption::Pgpsigurlmangle(_) => "Pgpsigurlmangle",
22        WatchOption::Filenamemangle(_) => "Filenamemangle",
23        WatchOption::VersionPolicy(_) => "Version-Policy",
24        WatchOption::Searchmode(_) => "Searchmode",
25        WatchOption::Mode(_) => "Mode",
26        WatchOption::Pgpmode(_) => "Pgpmode",
27        WatchOption::Gitexport(_) => "Gitexport",
28        WatchOption::Gitmode(_) => "Gitmode",
29        WatchOption::Pretty(_) => "Pretty",
30        WatchOption::Ctype(_) => "Ctype",
31        WatchOption::Repacksuffix(_) => "Repacksuffix",
32        WatchOption::Unzipopt(_) => "Unzipopt",
33        WatchOption::Script(_) => "Script",
34        WatchOption::Decompress => "Decompress",
35        WatchOption::Bare => "Bare",
36        WatchOption::Repack => "Repack",
37    }
38}
39
40#[derive(Debug)]
41/// Parse error for watch file parsing
42pub struct ParseError(String);
43
44impl std::error::Error for ParseError {}
45
46impl std::fmt::Display for ParseError {
47    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
48        write!(f, "ParseError: {}", self.0)
49    }
50}
51
52/// A watch file in format 5 (RFC822/deb822 style)
53#[derive(Debug)]
54pub struct WatchFile(Deb822);
55
56/// An entry in a format 5 watch file
57#[derive(Debug)]
58pub struct Entry {
59    paragraph: Paragraph,
60    defaults: Option<Paragraph>,
61}
62
63impl WatchFile {
64    /// Create a new empty format 5 watch file
65    pub fn new() -> Self {
66        // Create a minimal format 5 watch file from a string
67        let content = "Version: 5\n";
68        WatchFile::from_str(content).expect("Failed to create empty watch file")
69    }
70
71    /// Returns the version of the watch file (always 5 for this type)
72    pub fn version(&self) -> u32 {
73        5
74    }
75
76    /// Returns the defaults paragraph if it exists.
77    /// The defaults paragraph is the second paragraph (after Version) if it has no Source field.
78    pub fn defaults(&self) -> Option<Paragraph> {
79        let paragraphs: Vec<_> = self.0.paragraphs().collect();
80
81        if paragraphs.len() > 1 {
82            // Check if second paragraph looks like defaults (no Source field)
83            if !paragraphs[1].contains_key("Source") && !paragraphs[1].contains_key("source") {
84                return Some(paragraphs[1].clone());
85            }
86        }
87
88        None
89    }
90
91    /// Returns an iterator over all entries in the watch file.
92    /// The first paragraph contains defaults, subsequent paragraphs are entries.
93    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
94        let paragraphs: Vec<_> = self.0.paragraphs().collect();
95        let defaults = self.defaults();
96
97        // Skip the first paragraph (version)
98        // The second paragraph (if it exists and has specific fields) contains defaults
99        // Otherwise all paragraphs are entries
100        let start_index = if paragraphs.len() > 1 {
101            // Check if second paragraph looks like defaults (no Source or Template field)
102            let has_source =
103                paragraphs[1].contains_key("Source") || paragraphs[1].contains_key("source");
104            let has_template =
105                paragraphs[1].contains_key("Template") || paragraphs[1].contains_key("template");
106
107            if !has_source && !has_template {
108                2 // Skip version and defaults
109            } else {
110                1 // Skip only version
111            }
112        } else {
113            1
114        };
115
116        paragraphs
117            .into_iter()
118            .skip(start_index)
119            .map(move |p| Entry {
120                paragraph: p,
121                defaults: defaults.clone(),
122            })
123    }
124
125    /// Get the underlying Deb822 object
126    pub fn inner(&self) -> &Deb822 {
127        &self.0
128    }
129
130    /// Get a mutable reference to the underlying Deb822 object
131    pub fn inner_mut(&mut self) -> &mut Deb822 {
132        &mut self.0
133    }
134
135    /// Add a new entry to the watch file with the given source and matching pattern.
136    /// Returns the newly created Entry.
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// # #[cfg(feature = "deb822")]
142    /// # {
143    /// use debian_watch::deb822::WatchFile;
144    /// use debian_watch::WatchOption;
145    ///
146    /// let mut wf = WatchFile::new();
147    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
148    /// entry.set_option(WatchOption::Component("upstream".to_string()));
149    /// # }
150    /// ```
151    pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> Entry {
152        let mut para = self.0.add_paragraph();
153        para.set("Source", source);
154        para.set("Matching-Pattern", matching_pattern);
155
156        // Create an Entry from the paragraph we just added
157        // Get the defaults paragraph if it exists
158        let defaults = self.defaults();
159
160        Entry {
161            paragraph: para.clone(),
162            defaults,
163        }
164    }
165}
166
167impl Default for WatchFile {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173impl FromStr for WatchFile {
174    type Err = ParseError;
175
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        match Deb822::from_str(s) {
178            Ok(deb822) => {
179                // Verify it's version 5
180                let version = deb822
181                    .paragraphs()
182                    .next()
183                    .and_then(|p| p.get("Version"))
184                    .unwrap_or_else(|| "1".to_string());
185
186                if version != "5" {
187                    return Err(ParseError(format!("Expected version 5, got {}", version)));
188                }
189
190                Ok(WatchFile(deb822))
191            }
192            Err(e) => Err(ParseError(e.to_string())),
193        }
194    }
195}
196
197impl std::fmt::Display for WatchFile {
198    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
199        write!(f, "{}", self.0)
200    }
201}
202
203impl Entry {
204    /// Get a field value from the entry, with fallback to defaults paragraph.
205    /// First checks the entry's own fields, then falls back to the defaults paragraph if present.
206    pub(crate) fn get_field(&self, key: &str) -> Option<String> {
207        // Try the key as-is first in the entry
208        if let Some(value) = self.paragraph.get(key) {
209            return Some(value);
210        }
211
212        // If not found, try with different case variations in the entry
213        // deb822-lossless is case-preserving, so we need to check all field names
214        let normalized_key = normalize_key(key);
215
216        // Iterate through all keys in the paragraph and check for normalized match
217        for (k, v) in self.paragraph.items() {
218            if normalize_key(&k) == normalized_key {
219                return Some(v);
220            }
221        }
222
223        // If not found in entry, check the defaults paragraph
224        if let Some(ref defaults) = self.defaults {
225            // Try the key as-is first in defaults
226            if let Some(value) = defaults.get(key) {
227                return Some(value);
228            }
229
230            // Try with case variations in defaults
231            for (k, v) in defaults.items() {
232                if normalize_key(&k) == normalized_key {
233                    return Some(v);
234                }
235            }
236        }
237
238        None
239    }
240
241    /// Returns the source URL, expanding templates if present
242    ///
243    /// Returns `Ok(None)` if no Source field is set and no template is present.
244    /// Returns `Err` if template expansion fails.
245    pub fn source(&self) -> Result<Option<String>, crate::templates::TemplateError> {
246        // First check if explicitly set
247        if let Some(source) = self.get_field("Source") {
248            return Ok(Some(source));
249        }
250
251        // If not set, check if there's a template to expand
252        if self.get_field("Template").is_none() {
253            return Ok(None);
254        }
255
256        // Template exists, expand it (propagate any errors)
257        self.expand_template().map(|t| t.source)
258    }
259
260    /// Returns the matching pattern, expanding templates if present
261    ///
262    /// Returns `Ok(None)` if no Matching-Pattern field is set and no template is present.
263    /// Returns `Err` if template expansion fails.
264    pub fn matching_pattern(&self) -> Result<Option<String>, crate::templates::TemplateError> {
265        // First check if explicitly set
266        if let Some(pattern) = self.get_field("Matching-Pattern") {
267            return Ok(Some(pattern));
268        }
269
270        // If not set, check if there's a template to expand
271        if self.get_field("Template").is_none() {
272            return Ok(None);
273        }
274
275        // Template exists, expand it (propagate any errors)
276        self.expand_template().map(|t| t.matching_pattern)
277    }
278
279    /// Get the underlying paragraph
280    pub fn as_deb822(&self) -> &Paragraph {
281        &self.paragraph
282    }
283
284    /// Name of the component, if specified
285    pub fn component(&self) -> Option<String> {
286        self.get_field("Component")
287    }
288
289    /// Get the an option value from the entry, with fallback to defaults paragraph.
290    pub fn get_option(&self, key: &str) -> Option<String> {
291        match key {
292            "Source" => None,           // Source is not an option
293            "Matching-Pattern" => None, // Matching-Pattern is not an option
294            "Component" => None,        // Component is not an option
295            "Version" => None,          // Version is not an option
296            key => self.get_field(key),
297        }
298    }
299
300    /// Set an option value in the entry using a WatchOption enum
301    pub fn set_option(&mut self, option: crate::types::WatchOption) {
302        use crate::types::WatchOption;
303
304        let (key, value) = match option {
305            WatchOption::Component(v) => ("Component", Some(v)),
306            WatchOption::Compression(v) => ("Compression", Some(v.to_string())),
307            WatchOption::UserAgent(v) => ("User-Agent", Some(v)),
308            WatchOption::Pagemangle(v) => ("Pagemangle", Some(v)),
309            WatchOption::Uversionmangle(v) => ("Uversionmangle", Some(v)),
310            WatchOption::Dversionmangle(v) => ("Dversionmangle", Some(v)),
311            WatchOption::Dirversionmangle(v) => ("Dirversionmangle", Some(v)),
312            WatchOption::Oversionmangle(v) => ("Oversionmangle", Some(v)),
313            WatchOption::Downloadurlmangle(v) => ("Downloadurlmangle", Some(v)),
314            WatchOption::Pgpsigurlmangle(v) => ("Pgpsigurlmangle", Some(v)),
315            WatchOption::Filenamemangle(v) => ("Filenamemangle", Some(v)),
316            WatchOption::VersionPolicy(v) => ("Version-Policy", Some(v.to_string())),
317            WatchOption::Searchmode(v) => ("Searchmode", Some(v.to_string())),
318            WatchOption::Mode(v) => ("Mode", Some(v.to_string())),
319            WatchOption::Pgpmode(v) => ("Pgpmode", Some(v.to_string())),
320            WatchOption::Gitexport(v) => ("Gitexport", Some(v.to_string())),
321            WatchOption::Gitmode(v) => ("Gitmode", Some(v.to_string())),
322            WatchOption::Pretty(v) => ("Pretty", Some(v.to_string())),
323            WatchOption::Ctype(v) => ("Ctype", Some(v.to_string())),
324            WatchOption::Repacksuffix(v) => ("Repacksuffix", Some(v)),
325            WatchOption::Unzipopt(v) => ("Unzipopt", Some(v)),
326            WatchOption::Script(v) => ("Script", Some(v)),
327            WatchOption::Decompress => ("Decompress", None),
328            WatchOption::Bare => ("Bare", None),
329            WatchOption::Repack => ("Repack", None),
330        };
331
332        if let Some(v) = value {
333            self.paragraph.set(key, &v);
334        } else {
335            // For boolean flags, set the key with empty value
336            self.paragraph.set(key, "");
337        }
338    }
339
340    /// Set an option value in the entry using string key and value (for backward compatibility)
341    pub fn set_option_str(&mut self, key: &str, value: &str) {
342        self.paragraph.set(key, value);
343    }
344
345    /// Delete an option from the entry using a WatchOption enum
346    pub fn delete_option(&mut self, option: crate::types::WatchOption) {
347        let key = watch_option_to_key(&option);
348        self.paragraph.remove(key);
349    }
350
351    /// Delete an option from the entry using a string key (for backward compatibility)
352    pub fn delete_option_str(&mut self, key: &str) {
353        self.paragraph.remove(key);
354    }
355
356    /// Get the URL (same as source() but named url() for consistency)
357    pub fn url(&self) -> String {
358        self.source().unwrap_or(None).unwrap_or_default()
359    }
360
361    /// Get the version policy
362    pub fn version_policy(&self) -> Result<Option<VersionPolicy>, TypesParseError> {
363        match self.get_field("Version-Policy") {
364            Some(policy) => Ok(Some(policy.parse()?)),
365            None => Ok(None),
366        }
367    }
368
369    /// Get the script
370    pub fn script(&self) -> Option<String> {
371        self.get_field("Script")
372    }
373
374    /// Set the source URL
375    pub fn set_source(&mut self, url: &str) {
376        self.paragraph.set("Source", url);
377    }
378
379    /// Set the matching pattern
380    pub fn set_matching_pattern(&mut self, pattern: &str) {
381        self.paragraph.set("Matching-Pattern", pattern);
382    }
383
384    /// Get the line number (0-indexed) where this entry starts
385    pub fn line(&self) -> usize {
386        self.paragraph.line()
387    }
388
389    /// Retrieve the mode of the watch file entry with detailed error information.
390    pub fn mode(&self) -> Result<crate::types::Mode, TypesParseError> {
391        Ok(self
392            .get_field("Mode")
393            .map(|s| s.parse())
394            .transpose()?
395            .unwrap_or_default())
396    }
397
398    /// Expand template if present
399    fn expand_template(
400        &self,
401    ) -> Result<crate::templates::ExpandedTemplate, crate::templates::TemplateError> {
402        use crate::templates::{expand_template, parse_github_url, Template, TemplateError};
403
404        // Check if there's a Template field
405        let template_str =
406            self.get_field("Template")
407                .ok_or_else(|| TemplateError::MissingField {
408                    template: "any".to_string(),
409                    field: "Template".to_string(),
410                })?;
411
412        let release_only = self
413            .get_field("Release-Only")
414            .map(|v| v.to_lowercase() == "yes")
415            .unwrap_or(false);
416
417        let version_type = self.get_field("Version-Type");
418
419        // Build the appropriate Template enum variant
420        let template = match template_str.to_lowercase().as_str() {
421            "github" => {
422                // GitHub requires either Dist or Owner+Project
423                let (owner, repository) = if let (Some(o), Some(p)) =
424                    (self.get_field("Owner"), self.get_field("Project"))
425                {
426                    (o, p)
427                } else if let Some(dist) = self.get_field("Dist") {
428                    parse_github_url(&dist)?
429                } else {
430                    return Err(TemplateError::MissingField {
431                        template: "GitHub".to_string(),
432                        field: "Dist or Owner+Project".to_string(),
433                    });
434                };
435
436                Template::GitHub {
437                    owner,
438                    repository,
439                    release_only,
440                    version_type,
441                }
442            }
443            "gitlab" => {
444                let dist = self
445                    .get_field("Dist")
446                    .ok_or_else(|| TemplateError::MissingField {
447                        template: "GitLab".to_string(),
448                        field: "Dist".to_string(),
449                    })?;
450
451                Template::GitLab {
452                    dist,
453                    release_only,
454                    version_type,
455                }
456            }
457            "pypi" => {
458                let package =
459                    self.get_field("Dist")
460                        .ok_or_else(|| TemplateError::MissingField {
461                            template: "PyPI".to_string(),
462                            field: "Dist".to_string(),
463                        })?;
464
465                Template::PyPI {
466                    package,
467                    version_type,
468                }
469            }
470            "npmregistry" => {
471                let package =
472                    self.get_field("Dist")
473                        .ok_or_else(|| TemplateError::MissingField {
474                            template: "Npmregistry".to_string(),
475                            field: "Dist".to_string(),
476                        })?;
477
478                Template::Npmregistry {
479                    package,
480                    version_type,
481                }
482            }
483            "metacpan" => {
484                let dist = self
485                    .get_field("Dist")
486                    .ok_or_else(|| TemplateError::MissingField {
487                        template: "Metacpan".to_string(),
488                        field: "Dist".to_string(),
489                    })?;
490
491                Template::Metacpan { dist, version_type }
492            }
493            _ => return Err(TemplateError::UnknownTemplate(template_str)),
494        };
495
496        Ok(expand_template(template))
497    }
498
499    /// Try to detect if this entry matches a template pattern and convert it to use that template.
500    ///
501    /// This analyzes the Source, Matching-Pattern, Searchmode, and Mode fields to determine
502    /// if they match a known template pattern. If a match is found, the entry is converted
503    /// to use the template syntax instead.
504    ///
505    /// # Returns
506    ///
507    /// Returns `Some(template)` if a template was detected and applied, `None` if no
508    /// template matches the current entry configuration.
509    ///
510    /// # Example
511    ///
512    /// ```
513    /// # #[cfg(feature = "deb822")]
514    /// # {
515    /// use debian_watch::deb822::WatchFile;
516    ///
517    /// let mut wf = WatchFile::new();
518    /// let mut entry = wf.add_entry(
519    ///     "https://github.com/torvalds/linux/tags",
520    ///     r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@"
521    /// );
522    /// entry.set_option_str("Searchmode", "html");
523    ///
524    /// // Convert to template
525    /// if let Some(template) = entry.try_convert_to_template() {
526    ///     println!("Converted to {:?}", template);
527    /// }
528    /// # }
529    /// ```
530    pub fn try_convert_to_template(&mut self) -> Option<crate::templates::Template> {
531        use crate::templates::detect_template;
532
533        // Get current field values
534        let source = self.source().ok().flatten();
535        let matching_pattern = self.matching_pattern().ok().flatten();
536        let searchmode = self.get_field("Searchmode");
537        let mode = self.get_field("Mode");
538
539        // Try to detect template
540        let template = detect_template(
541            source.as_deref(),
542            matching_pattern.as_deref(),
543            searchmode.as_deref(),
544            mode.as_deref(),
545        )?;
546
547        // Apply the template - remove old fields and add template fields
548        self.paragraph.remove("Source");
549        self.paragraph.remove("Matching-Pattern");
550        self.paragraph.remove("Searchmode");
551        self.paragraph.remove("Mode");
552
553        // Set template fields based on the detected template
554        match &template {
555            crate::templates::Template::GitHub {
556                owner,
557                repository,
558                release_only,
559                version_type,
560            } => {
561                self.paragraph.set("Template", "GitHub");
562                self.paragraph.set("Owner", owner);
563                self.paragraph.set("Project", repository);
564                if *release_only {
565                    self.paragraph.set("Release-Only", "yes");
566                }
567                if let Some(vt) = version_type {
568                    self.paragraph.set("Version-Type", vt);
569                }
570            }
571            crate::templates::Template::GitLab {
572                dist,
573                release_only: _,
574                version_type,
575            } => {
576                self.paragraph.set("Template", "GitLab");
577                self.paragraph.set("Dist", dist);
578                if let Some(vt) = version_type {
579                    self.paragraph.set("Version-Type", vt);
580                }
581            }
582            crate::templates::Template::PyPI {
583                package,
584                version_type,
585            } => {
586                self.paragraph.set("Template", "PyPI");
587                self.paragraph.set("Dist", package);
588                if let Some(vt) = version_type {
589                    self.paragraph.set("Version-Type", vt);
590                }
591            }
592            crate::templates::Template::Npmregistry {
593                package,
594                version_type,
595            } => {
596                self.paragraph.set("Template", "Npmregistry");
597                self.paragraph.set("Dist", package);
598                if let Some(vt) = version_type {
599                    self.paragraph.set("Version-Type", vt);
600                }
601            }
602            crate::templates::Template::Metacpan { dist, version_type } => {
603                self.paragraph.set("Template", "Metacpan");
604                self.paragraph.set("Dist", dist);
605                if let Some(vt) = version_type {
606                    self.paragraph.set("Version-Type", vt);
607                }
608            }
609        }
610
611        Some(template)
612    }
613}
614
615/// Normalize a field key according to RFC822 rules:
616/// - Convert to lowercase
617/// - Hyphens and underscores are treated as equivalent
618fn normalize_key(key: &str) -> String {
619    key.to_lowercase().replace(['-', '_'], "")
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn test_create_v5_watchfile() {
628        let wf = WatchFile::new();
629        assert_eq!(wf.version(), 5);
630
631        let output = wf.to_string();
632        assert!(output.contains("Version"));
633        assert!(output.contains("5"));
634    }
635
636    #[test]
637    fn test_parse_v5_basic() {
638        let input = r#"Version: 5
639
640Source: https://github.com/owner/repo/tags
641Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
642"#;
643
644        let wf: WatchFile = input.parse().unwrap();
645        assert_eq!(wf.version(), 5);
646
647        let entries: Vec<_> = wf.entries().collect();
648        assert_eq!(entries.len(), 1);
649
650        let entry = &entries[0];
651        assert_eq!(
652            entry.source().unwrap().as_deref(),
653            Some("https://github.com/owner/repo/tags")
654        );
655        assert_eq!(
656            entry.matching_pattern().unwrap(),
657            Some(".*/v?(\\d\\S+)\\.tar\\.gz".to_string())
658        );
659    }
660
661    #[test]
662    fn test_parse_v5_multiple_entries() {
663        let input = r#"Version: 5
664
665Source: https://github.com/owner/repo1/tags
666Matching-Pattern: .*/v?(\d\S+)\.tar\.gz
667
668Source: https://github.com/owner/repo2/tags
669Matching-Pattern: .*/release-(\d\S+)\.tar\.gz
670"#;
671
672        let wf: WatchFile = input.parse().unwrap();
673        let entries: Vec<_> = wf.entries().collect();
674        assert_eq!(entries.len(), 2);
675
676        assert_eq!(
677            entries[0].source().unwrap().as_deref(),
678            Some("https://github.com/owner/repo1/tags")
679        );
680        assert_eq!(
681            entries[1].source().unwrap().as_deref(),
682            Some("https://github.com/owner/repo2/tags")
683        );
684    }
685
686    #[test]
687    fn test_v5_case_insensitive_fields() {
688        let input = r#"Version: 5
689
690source: https://example.com/files
691matching-pattern: .*\.tar\.gz
692"#;
693
694        let wf: WatchFile = input.parse().unwrap();
695        let entries: Vec<_> = wf.entries().collect();
696        assert_eq!(entries.len(), 1);
697
698        let entry = &entries[0];
699        assert_eq!(
700            entry.source().unwrap().as_deref(),
701            Some("https://example.com/files")
702        );
703        assert_eq!(
704            entry.matching_pattern().unwrap().as_deref(),
705            Some(".*\\.tar\\.gz")
706        );
707    }
708
709    #[test]
710    fn test_v5_with_compression_option() {
711        let input = r#"Version: 5
712
713Source: https://example.com/files
714Matching-Pattern: .*\.tar\.gz
715Compression: xz
716"#;
717
718        let wf: WatchFile = input.parse().unwrap();
719        let entries: Vec<_> = wf.entries().collect();
720        assert_eq!(entries.len(), 1);
721
722        let entry = &entries[0];
723        let compression = entry.get_option("compression");
724        assert!(compression.is_some());
725    }
726
727    #[test]
728    fn test_v5_with_component() {
729        let input = r#"Version: 5
730
731Source: https://example.com/files
732Matching-Pattern: .*\.tar\.gz
733Component: foo
734"#;
735
736        let wf: WatchFile = input.parse().unwrap();
737        let entries: Vec<_> = wf.entries().collect();
738        assert_eq!(entries.len(), 1);
739
740        let entry = &entries[0];
741        assert_eq!(entry.component(), Some("foo".to_string()));
742    }
743
744    #[test]
745    fn test_v5_rejects_wrong_version() {
746        let input = r#"Version: 4
747
748Source: https://example.com/files
749Matching-Pattern: .*\.tar\.gz
750"#;
751
752        let result: Result<WatchFile, _> = input.parse();
753        assert!(result.is_err());
754    }
755
756    #[test]
757    fn test_v5_roundtrip() {
758        let input = r#"Version: 5
759
760Source: https://example.com/files
761Matching-Pattern: .*\.tar\.gz
762"#;
763
764        let wf: WatchFile = input.parse().unwrap();
765        let output = wf.to_string();
766
767        // The output should be parseable again
768        let wf2: WatchFile = output.parse().unwrap();
769        assert_eq!(wf2.version(), 5);
770
771        let entries: Vec<_> = wf2.entries().collect();
772        assert_eq!(entries.len(), 1);
773    }
774
775    #[test]
776    fn test_normalize_key() {
777        assert_eq!(normalize_key("Matching-Pattern"), "matchingpattern");
778        assert_eq!(normalize_key("matching_pattern"), "matchingpattern");
779        assert_eq!(normalize_key("MatchingPattern"), "matchingpattern");
780        assert_eq!(normalize_key("MATCHING-PATTERN"), "matchingpattern");
781    }
782
783    #[test]
784    fn test_defaults_paragraph() {
785        let input = r#"Version: 5
786
787Compression: xz
788User-Agent: Custom/1.0
789
790Source: https://example.com/repo1
791Matching-Pattern: .*\.tar\.gz
792
793Source: https://example.com/repo2
794Matching-Pattern: .*\.tar\.gz
795Compression: gz
796"#;
797
798        let wf: WatchFile = input.parse().unwrap();
799
800        // Check that defaults paragraph is detected
801        let defaults = wf.defaults();
802        assert!(defaults.is_some());
803        let defaults = defaults.unwrap();
804        assert_eq!(defaults.get("Compression"), Some("xz".to_string()));
805        assert_eq!(defaults.get("User-Agent"), Some("Custom/1.0".to_string()));
806
807        // Check that entries inherit from defaults
808        let entries: Vec<_> = wf.entries().collect();
809        assert_eq!(entries.len(), 2);
810
811        // First entry should inherit Compression and User-Agent from defaults
812        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
813        assert_eq!(
814            entries[0].get_option("User-Agent"),
815            Some("Custom/1.0".to_string())
816        );
817
818        // Second entry overrides Compression but inherits User-Agent
819        assert_eq!(entries[1].get_option("Compression"), Some("gz".to_string()));
820        assert_eq!(
821            entries[1].get_option("User-Agent"),
822            Some("Custom/1.0".to_string())
823        );
824    }
825
826    #[test]
827    fn test_no_defaults_paragraph() {
828        let input = r#"Version: 5
829
830Source: https://example.com/repo1
831Matching-Pattern: .*\.tar\.gz
832"#;
833
834        let wf: WatchFile = input.parse().unwrap();
835
836        // Check that there's no defaults paragraph (first paragraph has Source)
837        assert!(wf.defaults().is_none());
838
839        let entries: Vec<_> = wf.entries().collect();
840        assert_eq!(entries.len(), 1);
841    }
842
843    #[test]
844    fn test_set_source() {
845        let mut wf = WatchFile::new();
846        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
847
848        assert_eq!(
849            entry.source().unwrap(),
850            Some("https://example.com/repo1".to_string())
851        );
852
853        entry.set_source("https://example.com/repo2");
854        assert_eq!(
855            entry.source().unwrap(),
856            Some("https://example.com/repo2".to_string())
857        );
858    }
859
860    #[test]
861    fn test_set_matching_pattern() {
862        let mut wf = WatchFile::new();
863        let mut entry = wf.add_entry("https://example.com/repo1", ".*\\.tar\\.gz");
864
865        assert_eq!(
866            entry.matching_pattern().unwrap(),
867            Some(".*\\.tar\\.gz".to_string())
868        );
869
870        entry.set_matching_pattern(".*/v?([\\d.]+)\\.tar\\.gz");
871        assert_eq!(
872            entry.matching_pattern().unwrap(),
873            Some(".*/v?([\\d.]+)\\.tar\\.gz".to_string())
874        );
875    }
876
877    #[test]
878    fn test_entry_line() {
879        let input = r#"Version: 5
880
881Source: https://example.com/repo1
882Matching-Pattern: .*\.tar\.gz
883
884Source: https://example.com/repo2
885Matching-Pattern: .*\.tar\.xz
886"#;
887
888        let wf: WatchFile = input.parse().unwrap();
889        let entries: Vec<_> = wf.entries().collect();
890
891        // First entry starts at line 2 (0-indexed)
892        assert_eq!(entries[0].line(), 2);
893        // Second entry starts at line 5 (0-indexed)
894        assert_eq!(entries[1].line(), 5);
895    }
896
897    #[test]
898    fn test_defaults_with_case_variations() {
899        let input = r#"Version: 5
900
901compression: xz
902user-agent: Custom/1.0
903
904Source: https://example.com/repo1
905Matching-Pattern: .*\.tar\.gz
906"#;
907
908        let wf: WatchFile = input.parse().unwrap();
909
910        // Check that defaults work with different case
911        let entries: Vec<_> = wf.entries().collect();
912        assert_eq!(entries.len(), 1);
913
914        // Should find defaults even with different case
915        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
916        assert_eq!(
917            entries[0].get_option("User-Agent"),
918            Some("Custom/1.0".to_string())
919        );
920    }
921
922    #[test]
923    fn test_v5_with_uversionmangle() {
924        let input = r#"Version: 5
925
926Source: https://pypi.org/project/foo/
927Matching-Pattern: foo-(\d+\.\d+)\.tar\.gz
928Uversionmangle: s/\.0+$//
929"#;
930
931        let wf: WatchFile = input.parse().unwrap();
932        let entries: Vec<_> = wf.entries().collect();
933        assert_eq!(entries.len(), 1);
934
935        let entry = &entries[0];
936        assert_eq!(
937            entry.get_option("Uversionmangle"),
938            Some("s/\\.0+$//".to_string())
939        );
940    }
941
942    #[test]
943    fn test_v5_with_filenamemangle() {
944        let input = r#"Version: 5
945
946Source: https://example.com/files
947Matching-Pattern: .*\.tar\.gz
948Filenamemangle: s/.*\///;s/@PACKAGE@-(.*)\.tar\.gz/foo_$1.orig.tar.gz/
949"#;
950
951        let wf: WatchFile = input.parse().unwrap();
952        let entries: Vec<_> = wf.entries().collect();
953        assert_eq!(entries.len(), 1);
954
955        let entry = &entries[0];
956        assert_eq!(
957            entry.get_option("Filenamemangle"),
958            Some("s/.*\\///;s/@PACKAGE@-(.*)\\.tar\\.gz/foo_$1.orig.tar.gz/".to_string())
959        );
960    }
961
962    #[test]
963    fn test_v5_with_searchmode() {
964        let input = r#"Version: 5
965
966Source: https://example.com/files
967Matching-Pattern: foo-(\d[\d.]*)\.tar\.gz
968Searchmode: plain
969"#;
970
971        let wf: WatchFile = input.parse().unwrap();
972        let entries: Vec<_> = wf.entries().collect();
973        assert_eq!(entries.len(), 1);
974
975        let entry = &entries[0];
976        assert_eq!(entry.get_field("Searchmode").as_deref(), Some("plain"));
977    }
978
979    #[test]
980    fn test_v5_with_version_policy() {
981        let input = r#"Version: 5
982
983Source: https://example.com/files
984Matching-Pattern: .*\.tar\.gz
985Version-Policy: debian
986"#;
987
988        let wf: WatchFile = input.parse().unwrap();
989        let entries: Vec<_> = wf.entries().collect();
990        assert_eq!(entries.len(), 1);
991
992        let entry = &entries[0];
993        let policy = entry.version_policy();
994        assert!(policy.is_ok());
995        assert_eq!(format!("{:?}", policy.unwrap().unwrap()), "Debian");
996    }
997
998    #[test]
999    fn test_v5_multiple_mangles() {
1000        let input = r#"Version: 5
1001
1002Source: https://example.com/files
1003Matching-Pattern: .*\.tar\.gz
1004Uversionmangle: s/^v//;s/\.0+$//
1005Dversionmangle: s/\+dfsg\d*$//
1006Filenamemangle: s/.*/foo-$1.tar.gz/
1007"#;
1008
1009        let wf: WatchFile = input.parse().unwrap();
1010        let entries: Vec<_> = wf.entries().collect();
1011        assert_eq!(entries.len(), 1);
1012
1013        let entry = &entries[0];
1014        assert_eq!(
1015            entry.get_option("Uversionmangle"),
1016            Some("s/^v//;s/\\.0+$//".to_string())
1017        );
1018        assert_eq!(
1019            entry.get_option("Dversionmangle"),
1020            Some("s/\\+dfsg\\d*$//".to_string())
1021        );
1022        assert_eq!(
1023            entry.get_option("Filenamemangle"),
1024            Some("s/.*/foo-$1.tar.gz/".to_string())
1025        );
1026    }
1027
1028    #[test]
1029    fn test_v5_with_pgpmode() {
1030        let input = r#"Version: 5
1031
1032Source: https://example.com/files
1033Matching-Pattern: .*\.tar\.gz
1034Pgpmode: auto
1035"#;
1036
1037        let wf: WatchFile = input.parse().unwrap();
1038        let entries: Vec<_> = wf.entries().collect();
1039        assert_eq!(entries.len(), 1);
1040
1041        let entry = &entries[0];
1042        assert_eq!(entry.get_option("Pgpmode"), Some("auto".to_string()));
1043    }
1044
1045    #[test]
1046    fn test_v5_with_comments() {
1047        let input = r#"Version: 5
1048
1049# This is a comment about the entry
1050Source: https://example.com/files
1051Matching-Pattern: .*\.tar\.gz
1052"#;
1053
1054        let wf: WatchFile = input.parse().unwrap();
1055        let entries: Vec<_> = wf.entries().collect();
1056        assert_eq!(entries.len(), 1);
1057
1058        // Verify roundtrip preserves comments
1059        let output = wf.to_string();
1060        assert!(output.contains("# This is a comment about the entry"));
1061    }
1062
1063    #[test]
1064    fn test_v5_empty_after_version() {
1065        let input = "Version: 5\n";
1066
1067        let wf: WatchFile = input.parse().unwrap();
1068        assert_eq!(wf.version(), 5);
1069
1070        let entries: Vec<_> = wf.entries().collect();
1071        assert_eq!(entries.len(), 0);
1072    }
1073
1074    #[test]
1075    fn test_v5_trait_url() {
1076        let input = r#"Version: 5
1077
1078Source: https://example.com/files/@PACKAGE@
1079Matching-Pattern: .*\.tar\.gz
1080"#;
1081
1082        let wf: WatchFile = input.parse().unwrap();
1083        let entries: Vec<_> = wf.entries().collect();
1084        assert_eq!(entries.len(), 1);
1085
1086        let entry = &entries[0];
1087        // Test url() method
1088        assert_eq!(
1089            entry.source().unwrap().as_deref(),
1090            Some("https://example.com/files/@PACKAGE@")
1091        );
1092    }
1093
1094    #[test]
1095    fn test_github_template() {
1096        let input = r#"Version: 5
1097
1098Template: GitHub
1099Owner: torvalds
1100Project: linux
1101"#;
1102
1103        let wf: WatchFile = input.parse().unwrap();
1104        let entries: Vec<_> = wf.entries().collect();
1105        assert_eq!(entries.len(), 1);
1106
1107        let entry = &entries[0];
1108        assert_eq!(
1109            entry.source().unwrap(),
1110            Some("https://github.com/torvalds/linux/tags".to_string())
1111        );
1112        assert_eq!(
1113            entry.matching_pattern().unwrap(),
1114            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1115        );
1116    }
1117
1118    #[test]
1119    fn test_github_template_with_dist() {
1120        let input = r#"Version: 5
1121
1122Template: GitHub
1123Dist: https://github.com/guimard/llng-docker
1124"#;
1125
1126        let wf: WatchFile = input.parse().unwrap();
1127        let entries: Vec<_> = wf.entries().collect();
1128        assert_eq!(entries.len(), 1);
1129
1130        let entry = &entries[0];
1131        assert_eq!(
1132            entry.source().unwrap(),
1133            Some("https://github.com/guimard/llng-docker/tags".to_string())
1134        );
1135    }
1136
1137    #[test]
1138    fn test_pypi_template() {
1139        let input = r#"Version: 5
1140
1141Template: PyPI
1142Dist: bitbox02
1143"#;
1144
1145        let wf: WatchFile = input.parse().unwrap();
1146        let entries: Vec<_> = wf.entries().collect();
1147        assert_eq!(entries.len(), 1);
1148
1149        let entry = &entries[0];
1150        assert_eq!(
1151            entry.source().unwrap(),
1152            Some("https://pypi.debian.net/bitbox02/".to_string())
1153        );
1154        assert_eq!(
1155            entry.matching_pattern().unwrap(),
1156            Some(
1157                r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz"
1158                    .to_string()
1159            )
1160        );
1161    }
1162
1163    #[test]
1164    fn test_gitlab_template() {
1165        let input = r#"Version: 5
1166
1167Template: GitLab
1168Dist: https://salsa.debian.org/debian/devscripts
1169"#;
1170
1171        let wf: WatchFile = input.parse().unwrap();
1172        let entries: Vec<_> = wf.entries().collect();
1173        assert_eq!(entries.len(), 1);
1174
1175        let entry = &entries[0];
1176        assert_eq!(
1177            entry.source().unwrap(),
1178            Some("https://salsa.debian.org/debian/devscripts".to_string())
1179        );
1180        assert_eq!(
1181            entry.matching_pattern().unwrap(),
1182            Some(r".*/v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1183        );
1184    }
1185
1186    #[test]
1187    fn test_template_with_explicit_source() {
1188        // Explicit Source should override template expansion
1189        let input = r#"Version: 5
1190
1191Template: GitHub
1192Owner: test
1193Project: project
1194Source: https://custom.example.com/
1195"#;
1196
1197        let wf: WatchFile = input.parse().unwrap();
1198        let entries: Vec<_> = wf.entries().collect();
1199        assert_eq!(entries.len(), 1);
1200
1201        let entry = &entries[0];
1202        assert_eq!(
1203            entry.source().unwrap(),
1204            Some("https://custom.example.com/".to_string())
1205        );
1206    }
1207
1208    #[test]
1209    fn test_convert_to_template_github() {
1210        let mut wf = WatchFile::new();
1211        let mut entry = wf.add_entry(
1212            "https://github.com/torvalds/linux/tags",
1213            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1214        );
1215        entry.set_option_str("Searchmode", "html");
1216
1217        // Convert to template
1218        let template = entry.try_convert_to_template();
1219        assert_eq!(
1220            template,
1221            Some(crate::templates::Template::GitHub {
1222                owner: "torvalds".to_string(),
1223                repository: "linux".to_string(),
1224                release_only: false,
1225                version_type: None,
1226            })
1227        );
1228
1229        // Verify the entry now uses template syntax
1230        assert_eq!(entry.get_field("Template"), Some("GitHub".to_string()));
1231        assert_eq!(entry.get_field("Owner"), Some("torvalds".to_string()));
1232        assert_eq!(entry.get_field("Project"), Some("linux".to_string()));
1233        assert_eq!(entry.get_field("Source"), None);
1234        assert_eq!(entry.get_field("Matching-Pattern"), None);
1235    }
1236
1237    #[test]
1238    fn test_convert_to_template_pypi() {
1239        let mut wf = WatchFile::new();
1240        let mut entry = wf.add_entry(
1241            "https://pypi.debian.net/bitbox02/",
1242            r"https://pypi\.debian\.net/bitbox02/[^/]+\.tar\.gz#/.*-@ANY_VERSION@\.tar\.gz",
1243        );
1244        entry.set_option_str("Searchmode", "plain");
1245
1246        // Convert to template
1247        let template = entry.try_convert_to_template();
1248        assert_eq!(
1249            template,
1250            Some(crate::templates::Template::PyPI {
1251                package: "bitbox02".to_string(),
1252                version_type: None,
1253            })
1254        );
1255
1256        // Verify the entry now uses template syntax
1257        assert_eq!(entry.get_field("Template"), Some("PyPI".to_string()));
1258        assert_eq!(entry.get_field("Dist"), Some("bitbox02".to_string()));
1259    }
1260
1261    #[test]
1262    fn test_convert_to_template_no_match() {
1263        let mut wf = WatchFile::new();
1264        let mut entry = wf.add_entry(
1265            "https://example.com/downloads/",
1266            r".*/v?(\d+\.\d+)\.tar\.gz",
1267        );
1268
1269        // Try to convert - should return None
1270        let template = entry.try_convert_to_template();
1271        assert_eq!(template, None);
1272
1273        // Entry should remain unchanged
1274        assert_eq!(
1275            entry.source().unwrap(),
1276            Some("https://example.com/downloads/".to_string())
1277        );
1278    }
1279
1280    #[test]
1281    fn test_convert_to_template_roundtrip() {
1282        let mut wf = WatchFile::new();
1283        let mut entry = wf.add_entry(
1284            "https://github.com/test/project/releases",
1285            r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@",
1286        );
1287        entry.set_option_str("Searchmode", "html");
1288
1289        // Convert to template
1290        entry.try_convert_to_template().unwrap();
1291
1292        // Now the entry should be able to expand back to the same values
1293        let source = entry.source().unwrap();
1294        let matching_pattern = entry.matching_pattern().unwrap();
1295
1296        assert_eq!(
1297            source,
1298            Some("https://github.com/test/project/releases".to_string())
1299        );
1300        assert_eq!(
1301            matching_pattern,
1302            Some(r".*/(?:refs/tags/)?v?@ANY_VERSION@@ARCHIVE_EXT@".to_string())
1303        );
1304    }
1305}