Skip to main content

debian_watch/
parse.rs

1//! Format detection and parsing for watch files
2
3/// Error type for parsing watch files
4#[derive(Debug)]
5pub enum ParseError {
6    /// Error parsing line-based format (v1-4)
7    #[cfg(feature = "linebased")]
8    LineBased(crate::linebased::ParseError),
9    /// Error parsing deb822 format (v5)
10    #[cfg(feature = "deb822")]
11    Deb822(crate::deb822::ParseError),
12    /// Could not detect version
13    UnknownVersion,
14    /// Feature not enabled
15    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/// Detected watch file format
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WatchFileVersion {
36    /// Line-based format (versions 1-4)
37    LineBased(u32),
38    /// Deb822 format (version 5)
39    Deb822,
40}
41
42/// Detect the version/format of a watch file from its content
43///
44/// This function examines the content to determine if it's a line-based
45/// format (v1-4) or deb822 format (v5).
46///
47/// After detecting the version, you can either:
48/// - Use the `parse()` function to automatically parse and return a `ParsedWatchFile`
49/// - Parse directly: `content.parse::<debian_watch::linebased::WatchFile>()`
50///
51/// # Examples
52///
53/// ```
54/// use debian_watch::parse::{detect_version, WatchFileVersion};
55///
56/// let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
57/// assert_eq!(detect_version(v4_content), Some(WatchFileVersion::LineBased(4)));
58///
59/// let v5_content = "Version: 5\n\nSource: https://example.com/";
60/// assert_eq!(detect_version(v5_content), Some(WatchFileVersion::Deb822));
61/// ```
62pub fn detect_version(content: &str) -> Option<WatchFileVersion> {
63    let trimmed = content.trim_start();
64
65    // Check if it starts with RFC822-style "Version: 5"
66    if trimmed.starts_with("Version:") || trimmed.starts_with("version:") {
67        // Try to extract the version number
68        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    // Otherwise, it's line-based format
79    // Try to detect the version from "version=N" line
80    for line in trimmed.lines() {
81        let line = line.trim();
82
83        // Skip comments and blank lines
84        if line.starts_with('#') || line.is_empty() {
85            continue;
86        }
87
88        // Check for version=N
89        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        // If we hit a non-comment, non-version line, assume default version
102        break;
103    }
104
105    // Default to version 1 for line-based format
106    Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION))
107}
108
109/// Parsed watch file that can be either line-based or deb822 format
110#[derive(Debug)]
111pub enum ParsedWatchFile {
112    /// Line-based watch file (v1-4)
113    #[cfg(feature = "linebased")]
114    LineBased(crate::linebased::WatchFile),
115    /// Deb822 watch file (v5)
116    #[cfg(feature = "deb822")]
117    Deb822(crate::deb822::WatchFile),
118}
119
120/// Parsed watch entry that can be either line-based or deb822 format
121#[derive(Debug)]
122pub enum ParsedEntry {
123    /// Line-based entry (v1-4)
124    #[cfg(feature = "linebased")]
125    LineBased(crate::linebased::Entry),
126    /// Deb822 entry (v5)
127    #[cfg(feature = "deb822")]
128    Deb822(crate::deb822::Entry),
129}
130
131impl ParsedWatchFile {
132    /// Create a new empty watch file with the specified version.
133    ///
134    /// - For version 5, creates a deb822-format watch file (requires `deb822` feature)
135    /// - For versions 1-4, creates a line-based watch file (requires `linebased` feature)
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// # #[cfg(feature = "deb822")]
141    /// # {
142    /// use debian_watch::parse::ParsedWatchFile;
143    ///
144    /// let wf = ParsedWatchFile::new(5).unwrap();
145    /// assert_eq!(wf.version(), 5);
146    /// # }
147    /// ```
148    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    /// Get the version of the watch file
173    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    /// Get an iterator over entries as ParsedEntry enum
183    pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
184        // We need to collect because we can't return different iterator types from match arms
185        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    /// Add a new entry to the watch file and return it.
195    ///
196    /// For v5 (deb822) watch files, this adds a new paragraph with Source and Matching-Pattern fields.
197    /// For v1-4 (line-based) watch files, this adds a new entry line.
198    ///
199    /// Returns a `ParsedEntry` that can be used to query or modify the entry.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// # #[cfg(feature = "deb822")]
205    /// # {
206    /// use debian_watch::parse::ParsedWatchFile;
207    /// use debian_watch::WatchOption;
208    ///
209    /// let mut wf = ParsedWatchFile::new(5).unwrap();
210    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
211    /// entry.set_option(WatchOption::Component("upstream".to_string()));
212    /// # }
213    /// ```
214    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    /// Get the URL/Source of the entry
235    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    /// Get the matching pattern
245    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    /// Get a generic option/field value by key (case-insensitive)
255    ///
256    /// This handles the difference between line-based format (lowercase keys)
257    /// and deb822 format (capitalized keys). It tries the key as-is first,
258    /// then tries with the first letter capitalized.
259    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                // Try exact match first, then try capitalized
266                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    /// Check if an option/field is set (case-insensitive)
280    pub fn has_option(&self, key: &str) -> bool {
281        self.get_option(key).is_some()
282    }
283
284    /// Get the script
285    pub fn script(&self) -> Option<String> {
286        self.get_option("script")
287    }
288
289    /// Format the URL with package substitution
290    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    /// Get the user agent
298    pub fn user_agent(&self) -> Option<String> {
299        self.get_option("user-agent")
300    }
301
302    /// Get the pagemangle option
303    pub fn pagemangle(&self) -> Option<String> {
304        self.get_option("pagemangle")
305    }
306
307    /// Get the uversionmangle option
308    pub fn uversionmangle(&self) -> Option<String> {
309        self.get_option("uversionmangle")
310    }
311
312    /// Get the downloadurlmangle option
313    pub fn downloadurlmangle(&self) -> Option<String> {
314        self.get_option("downloadurlmangle")
315    }
316
317    /// Get the pgpsigurlmangle option
318    pub fn pgpsigurlmangle(&self) -> Option<String> {
319        self.get_option("pgpsigurlmangle")
320    }
321
322    /// Get the filenamemangle option
323    pub fn filenamemangle(&self) -> Option<String> {
324        self.get_option("filenamemangle")
325    }
326
327    /// Get the oversionmangle option
328    pub fn oversionmangle(&self) -> Option<String> {
329        self.get_option("oversionmangle")
330    }
331
332    /// Get the searchmode, with default fallback
333    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    /// Set an option/field value using a WatchOption enum.
340    ///
341    /// For v5 (deb822) entries, this sets a field in the paragraph.
342    /// For v1-4 (line-based) entries, this sets an option in the opts= list.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// # #[cfg(feature = "linebased")]
348    /// # {
349    /// use debian_watch::parse::ParsedWatchFile;
350    /// use debian_watch::{WatchOption, Compression};
351    ///
352    /// let mut wf = ParsedWatchFile::new(4).unwrap();
353    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
354    /// entry.set_option(WatchOption::Component("upstream".to_string()));
355    /// entry.set_option(WatchOption::Compression(Compression::Xz));
356    /// assert_eq!(entry.get_option("component"), Some("upstream".to_string()));
357    /// assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
358    /// # }
359    /// ```
360    pub fn set_option(&mut self, option: crate::types::WatchOption) {
361        match self {
362            #[cfg(feature = "linebased")]
363            ParsedEntry::LineBased(e) => {
364                e.set_option(option);
365            }
366            #[cfg(feature = "deb822")]
367            ParsedEntry::Deb822(e) => {
368                e.set_option(option);
369            }
370        }
371    }
372
373    /// Set the URL/Source of the entry
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// # #[cfg(feature = "linebased")]
379    /// # {
380    /// use debian_watch::parse::ParsedWatchFile;
381    ///
382    /// let mut wf = ParsedWatchFile::new(4).unwrap();
383    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
384    /// entry.set_url("https://github.com/foo/bar/releases");
385    /// assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
386    /// # }
387    /// ```
388    pub fn set_url(&mut self, url: &str) {
389        match self {
390            #[cfg(feature = "linebased")]
391            ParsedEntry::LineBased(e) => e.set_url(url),
392            #[cfg(feature = "deb822")]
393            ParsedEntry::Deb822(e) => e.set_source(url),
394        }
395    }
396
397    /// Set the matching pattern of the entry
398    ///
399    /// # Examples
400    ///
401    /// ```
402    /// # #[cfg(feature = "linebased")]
403    /// # {
404    /// use debian_watch::parse::ParsedWatchFile;
405    ///
406    /// let mut wf = ParsedWatchFile::new(4).unwrap();
407    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
408    /// entry.set_matching_pattern(".*/release-([\\d.]+)\\.tar\\.gz");
409    /// assert_eq!(entry.matching_pattern(), Some(".*/release-([\\d.]+)\\.tar\\.gz".to_string()));
410    /// # }
411    /// ```
412    pub fn set_matching_pattern(&mut self, pattern: &str) {
413        match self {
414            #[cfg(feature = "linebased")]
415            ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
416            #[cfg(feature = "deb822")]
417            ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
418        }
419    }
420
421    /// Get the line number (0-indexed) where this entry starts
422    ///
423    /// For line-based formats (v1-4), this returns the actual line number in the file.
424    /// For deb822 format (v5), this returns the line where the paragraph starts.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// # #[cfg(feature = "linebased")]
430    /// # {
431    /// use debian_watch::parse::parse;
432    ///
433    /// let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
434    /// let wf = parse(content).unwrap();
435    /// let entries: Vec<_> = wf.entries().collect();
436    /// assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
437    /// assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
438    /// # }
439    /// ```
440    pub fn line(&self) -> usize {
441        match self {
442            #[cfg(feature = "linebased")]
443            ParsedEntry::LineBased(e) => e.line(),
444            #[cfg(feature = "deb822")]
445            ParsedEntry::Deb822(e) => e.line(),
446        }
447    }
448
449    /// Remove/delete an option from the entry
450    ///
451    /// For v5 (deb822) entries, this removes a field from the paragraph.
452    /// For v1-4 (line-based) entries, this removes an option from the opts= list.
453    /// If this is the last option in a line-based entry, the entire opts= declaration is removed.
454    ///
455    /// # Examples
456    ///
457    /// ```
458    /// # #[cfg(feature = "linebased")]
459    /// # {
460    /// use debian_watch::parse::ParsedWatchFile;
461    /// use debian_watch::WatchOption;
462    ///
463    /// let mut wf = ParsedWatchFile::new(4).unwrap();
464    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
465    /// entry.set_option(WatchOption::Compression(debian_watch::Compression::Xz));
466    /// assert!(entry.has_option("compression"));
467    /// entry.remove_option(WatchOption::Compression(debian_watch::Compression::Xz));
468    /// assert!(!entry.has_option("compression"));
469    /// # }
470    /// ```
471    pub fn remove_option(&mut self, option: crate::types::WatchOption) {
472        match self {
473            #[cfg(feature = "linebased")]
474            ParsedEntry::LineBased(e) => e.del_opt(option),
475            #[cfg(feature = "deb822")]
476            ParsedEntry::Deb822(e) => e.delete_option(option),
477        }
478    }
479}
480
481impl std::fmt::Display for ParsedWatchFile {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        match self {
484            #[cfg(feature = "linebased")]
485            ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
486            #[cfg(feature = "deb822")]
487            ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
488        }
489    }
490}
491
492/// Parse a watch file with automatic format detection
493///
494/// This function detects whether the input is line-based (v1-4) or
495/// deb822 format (v5) and parses it accordingly, returning a unified
496/// ParsedWatchFile enum.
497///
498/// # Examples
499///
500/// ```
501/// # #[cfg(feature = "linebased")]
502/// # {
503/// use debian_watch::parse::parse;
504///
505/// let content = "version=4\nhttps://example.com/ .*.tar.gz";
506/// let parsed = parse(content).unwrap();
507/// assert_eq!(parsed.version(), 4);
508/// # }
509/// ```
510pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
511    let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
512
513    match version {
514        #[cfg(feature = "linebased")]
515        WatchFileVersion::LineBased(_v) => {
516            let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
517            Ok(ParsedWatchFile::LineBased(wf))
518        }
519        #[cfg(not(feature = "linebased"))]
520        WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
521            "linebased feature required for v1-4 formats".to_string(),
522        )),
523        #[cfg(feature = "deb822")]
524        WatchFileVersion::Deb822 => {
525            let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
526            Ok(ParsedWatchFile::Deb822(wf))
527        }
528        #[cfg(not(feature = "deb822"))]
529        WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
530            "deb822 feature required for v5 format".to_string(),
531        )),
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn test_detect_version_v1_default() {
541        let content = "https://example.com/ .*.tar.gz";
542        assert_eq!(
543            detect_version(content),
544            Some(WatchFileVersion::LineBased(1))
545        );
546    }
547
548    #[test]
549    fn test_detect_version_v4() {
550        let content = "version=4\nhttps://example.com/ .*.tar.gz";
551        assert_eq!(
552            detect_version(content),
553            Some(WatchFileVersion::LineBased(4))
554        );
555    }
556
557    #[test]
558    fn test_detect_version_v4_with_spaces() {
559        let content = "version = 4\nhttps://example.com/ .*.tar.gz";
560        assert_eq!(
561            detect_version(content),
562            Some(WatchFileVersion::LineBased(4))
563        );
564    }
565
566    #[test]
567    fn test_detect_version_v5() {
568        let content = "Version: 5\n\nSource: https://example.com/";
569        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
570    }
571
572    #[test]
573    fn test_detect_version_v5_lowercase() {
574        let content = "version: 5\n\nSource: https://example.com/";
575        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
576    }
577
578    #[test]
579    fn test_detect_version_with_leading_comments() {
580        let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
581        assert_eq!(
582            detect_version(content),
583            Some(WatchFileVersion::LineBased(4))
584        );
585    }
586
587    #[test]
588    fn test_detect_version_with_leading_whitespace() {
589        let content = "  \n  version=3\nhttps://example.com/ .*.tar.gz";
590        assert_eq!(
591            detect_version(content),
592            Some(WatchFileVersion::LineBased(3))
593        );
594    }
595
596    #[test]
597    fn test_detect_version_v2() {
598        let content = "version=2\nhttps://example.com/ .*.tar.gz";
599        assert_eq!(
600            detect_version(content),
601            Some(WatchFileVersion::LineBased(2))
602        );
603    }
604
605    #[cfg(feature = "linebased")]
606    #[test]
607    fn test_parse_linebased() {
608        let content = "version=4\nhttps://example.com/ .*.tar.gz";
609        let parsed = parse(content).unwrap();
610        assert_eq!(parsed.version(), 4);
611    }
612
613    #[cfg(feature = "deb822")]
614    #[test]
615    fn test_parse_deb822() {
616        let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
617        let parsed = parse(content).unwrap();
618        assert_eq!(parsed.version(), 5);
619    }
620
621    #[cfg(all(feature = "linebased", feature = "deb822"))]
622    #[test]
623    fn test_parse_both_formats() {
624        // Test v4
625        let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
626        let v4_parsed = parse(v4_content).unwrap();
627        assert_eq!(v4_parsed.version(), 4);
628
629        // Test v5
630        let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
631        let v5_parsed = parse(v5_content).unwrap();
632        assert_eq!(v5_parsed.version(), 5);
633    }
634
635    #[cfg(feature = "linebased")]
636    #[test]
637    fn test_parse_roundtrip() {
638        let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
639        let parsed = parse(content).unwrap();
640        let output = parsed.to_string();
641
642        // Parse again
643        let reparsed = parse(&output).unwrap();
644        assert_eq!(reparsed.version(), 4);
645    }
646
647    #[cfg(feature = "deb822")]
648    #[test]
649    fn test_parsed_watch_file_new_v5() {
650        let wf = ParsedWatchFile::new(5).unwrap();
651        assert_eq!(wf.version(), 5);
652        assert_eq!(wf.entries().count(), 0);
653    }
654
655    #[cfg(feature = "linebased")]
656    #[test]
657    fn test_parsed_watch_file_new_v4() {
658        let wf = ParsedWatchFile::new(4).unwrap();
659        assert_eq!(wf.version(), 4);
660        assert_eq!(wf.entries().count(), 0);
661    }
662
663    #[cfg(feature = "deb822")]
664    #[test]
665    fn test_parsed_watch_file_add_entry_v5() {
666        let mut wf = ParsedWatchFile::new(5).unwrap();
667        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
668
669        assert_eq!(wf.entries().count(), 1);
670        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
671        assert_eq!(
672            entry.matching_pattern(),
673            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
674        );
675
676        // Test setting options with enum
677        entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
678        entry.set_option(crate::types::WatchOption::Compression(
679            crate::types::Compression::Xz,
680        ));
681
682        assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
683        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
684    }
685
686    #[cfg(feature = "linebased")]
687    #[test]
688    fn test_parsed_watch_file_add_entry_v4() {
689        let mut wf = ParsedWatchFile::new(4).unwrap();
690        let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
691
692        assert_eq!(wf.entries().count(), 1);
693        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
694        assert_eq!(
695            entry.matching_pattern(),
696            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
697        );
698    }
699
700    #[cfg(feature = "deb822")]
701    #[test]
702    fn test_parsed_watch_file_roundtrip_with_add_entry() {
703        let mut wf = ParsedWatchFile::new(5).unwrap();
704        let mut entry = wf.add_entry(
705            "https://github.com/owner/repo/tags",
706            r".*/v?([\d.]+)\.tar\.gz",
707        );
708        entry.set_option(crate::types::WatchOption::Compression(
709            crate::types::Compression::Xz,
710        ));
711
712        let output = wf.to_string();
713
714        // Parse again
715        let reparsed = parse(&output).unwrap();
716        assert_eq!(reparsed.version(), 5);
717
718        let entries: Vec<_> = reparsed.entries().collect();
719        assert_eq!(entries.len(), 1);
720        assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
721        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
722    }
723
724    #[cfg(feature = "linebased")]
725    #[test]
726    fn test_parsed_entry_set_url_v4() {
727        let mut wf = ParsedWatchFile::new(4).unwrap();
728        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
729
730        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
731
732        entry.set_url("https://github.com/foo/bar/releases");
733        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
734    }
735
736    #[cfg(feature = "deb822")]
737    #[test]
738    fn test_parsed_entry_set_url_v5() {
739        let mut wf = ParsedWatchFile::new(5).unwrap();
740        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
741
742        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
743
744        entry.set_url("https://github.com/foo/bar/releases");
745        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
746    }
747
748    #[cfg(feature = "linebased")]
749    #[test]
750    fn test_parsed_entry_set_matching_pattern_v4() {
751        let mut wf = ParsedWatchFile::new(4).unwrap();
752        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
753
754        assert_eq!(
755            entry.matching_pattern(),
756            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
757        );
758
759        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
760        assert_eq!(
761            entry.matching_pattern(),
762            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
763        );
764    }
765
766    #[cfg(feature = "deb822")]
767    #[test]
768    fn test_parsed_entry_set_matching_pattern_v5() {
769        let mut wf = ParsedWatchFile::new(5).unwrap();
770        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
771
772        assert_eq!(
773            entry.matching_pattern(),
774            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
775        );
776
777        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
778        assert_eq!(
779            entry.matching_pattern(),
780            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
781        );
782    }
783
784    #[cfg(feature = "linebased")]
785    #[test]
786    fn test_parsed_entry_line_v4() {
787        let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
788        let wf = parse(content).unwrap();
789        let entries: Vec<_> = wf.entries().collect();
790
791        assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
792        assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
793    }
794
795    #[cfg(feature = "deb822")]
796    #[test]
797    fn test_parsed_entry_line_v5() {
798        let content = r#"Version: 5
799
800Source: https://example.com/repo1
801Matching-Pattern: .*\.tar\.gz
802
803Source: https://example.com/repo2
804Matching-Pattern: .*\.tar\.xz
805"#;
806        let wf = parse(content).unwrap();
807        let entries: Vec<_> = wf.entries().collect();
808
809        assert_eq!(entries[0].line(), 2); // Third line (0-indexed)
810        assert_eq!(entries[1].line(), 5); // Sixth line (0-indexed)
811    }
812}