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 (only supported for deb822 format).
340    ///
341    /// For v5 (deb822) entries, this sets a field in the paragraph.
342    /// For v1-4 (line-based) entries, this is not supported as entries are immutable.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// # #[cfg(feature = "deb822")]
348    /// # {
349    /// use debian_watch::parse::ParsedWatchFile;
350    /// use debian_watch::{WatchOption, Compression};
351    ///
352    /// let mut wf = ParsedWatchFile::new(5).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    /// # }
357    /// ```
358    pub fn set_option(&mut self, option: crate::types::WatchOption) {
359        match self {
360            #[cfg(feature = "linebased")]
361            ParsedEntry::LineBased(_) => {
362                // Line-based entries are immutable, cannot set options after creation
363                // Options must be set during entry construction using EntryBuilder
364            }
365            #[cfg(feature = "deb822")]
366            ParsedEntry::Deb822(e) => {
367                e.set_option(option);
368            }
369        }
370    }
371
372    /// Set the URL/Source of the entry
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// # #[cfg(feature = "linebased")]
378    /// # {
379    /// use debian_watch::parse::ParsedWatchFile;
380    ///
381    /// let mut wf = ParsedWatchFile::new(4).unwrap();
382    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
383    /// entry.set_url("https://github.com/foo/bar/releases");
384    /// assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
385    /// # }
386    /// ```
387    pub fn set_url(&mut self, url: &str) {
388        match self {
389            #[cfg(feature = "linebased")]
390            ParsedEntry::LineBased(e) => e.set_url(url),
391            #[cfg(feature = "deb822")]
392            ParsedEntry::Deb822(e) => e.set_source(url),
393        }
394    }
395
396    /// Set the matching pattern of the entry
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// # #[cfg(feature = "linebased")]
402    /// # {
403    /// use debian_watch::parse::ParsedWatchFile;
404    ///
405    /// let mut wf = ParsedWatchFile::new(4).unwrap();
406    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
407    /// entry.set_matching_pattern(".*/release-([\\d.]+)\\.tar\\.gz");
408    /// assert_eq!(entry.matching_pattern(), Some(".*/release-([\\d.]+)\\.tar\\.gz".to_string()));
409    /// # }
410    /// ```
411    pub fn set_matching_pattern(&mut self, pattern: &str) {
412        match self {
413            #[cfg(feature = "linebased")]
414            ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
415            #[cfg(feature = "deb822")]
416            ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
417        }
418    }
419
420    /// Get the line number (0-indexed) where this entry starts
421    ///
422    /// For line-based formats (v1-4), this returns the actual line number in the file.
423    /// For deb822 format (v5), this returns the line where the paragraph starts.
424    ///
425    /// # Examples
426    ///
427    /// ```
428    /// # #[cfg(feature = "linebased")]
429    /// # {
430    /// use debian_watch::parse::parse;
431    ///
432    /// let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
433    /// let wf = parse(content).unwrap();
434    /// let entries: Vec<_> = wf.entries().collect();
435    /// assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
436    /// assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
437    /// # }
438    /// ```
439    pub fn line(&self) -> usize {
440        match self {
441            #[cfg(feature = "linebased")]
442            ParsedEntry::LineBased(e) => e.line(),
443            #[cfg(feature = "deb822")]
444            ParsedEntry::Deb822(e) => e.line(),
445        }
446    }
447}
448
449impl std::fmt::Display for ParsedWatchFile {
450    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451        match self {
452            #[cfg(feature = "linebased")]
453            ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
454            #[cfg(feature = "deb822")]
455            ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
456        }
457    }
458}
459
460/// Parse a watch file with automatic format detection
461///
462/// This function detects whether the input is line-based (v1-4) or
463/// deb822 format (v5) and parses it accordingly, returning a unified
464/// ParsedWatchFile enum.
465///
466/// # Examples
467///
468/// ```
469/// # #[cfg(feature = "linebased")]
470/// # {
471/// use debian_watch::parse::parse;
472///
473/// let content = "version=4\nhttps://example.com/ .*.tar.gz";
474/// let parsed = parse(content).unwrap();
475/// assert_eq!(parsed.version(), 4);
476/// # }
477/// ```
478pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
479    let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
480
481    match version {
482        #[cfg(feature = "linebased")]
483        WatchFileVersion::LineBased(_v) => {
484            let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
485            Ok(ParsedWatchFile::LineBased(wf))
486        }
487        #[cfg(not(feature = "linebased"))]
488        WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
489            "linebased feature required for v1-4 formats".to_string(),
490        )),
491        #[cfg(feature = "deb822")]
492        WatchFileVersion::Deb822 => {
493            let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
494            Ok(ParsedWatchFile::Deb822(wf))
495        }
496        #[cfg(not(feature = "deb822"))]
497        WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
498            "deb822 feature required for v5 format".to_string(),
499        )),
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn test_detect_version_v1_default() {
509        let content = "https://example.com/ .*.tar.gz";
510        assert_eq!(
511            detect_version(content),
512            Some(WatchFileVersion::LineBased(1))
513        );
514    }
515
516    #[test]
517    fn test_detect_version_v4() {
518        let content = "version=4\nhttps://example.com/ .*.tar.gz";
519        assert_eq!(
520            detect_version(content),
521            Some(WatchFileVersion::LineBased(4))
522        );
523    }
524
525    #[test]
526    fn test_detect_version_v4_with_spaces() {
527        let content = "version = 4\nhttps://example.com/ .*.tar.gz";
528        assert_eq!(
529            detect_version(content),
530            Some(WatchFileVersion::LineBased(4))
531        );
532    }
533
534    #[test]
535    fn test_detect_version_v5() {
536        let content = "Version: 5\n\nSource: https://example.com/";
537        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
538    }
539
540    #[test]
541    fn test_detect_version_v5_lowercase() {
542        let content = "version: 5\n\nSource: https://example.com/";
543        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
544    }
545
546    #[test]
547    fn test_detect_version_with_leading_comments() {
548        let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
549        assert_eq!(
550            detect_version(content),
551            Some(WatchFileVersion::LineBased(4))
552        );
553    }
554
555    #[test]
556    fn test_detect_version_with_leading_whitespace() {
557        let content = "  \n  version=3\nhttps://example.com/ .*.tar.gz";
558        assert_eq!(
559            detect_version(content),
560            Some(WatchFileVersion::LineBased(3))
561        );
562    }
563
564    #[test]
565    fn test_detect_version_v2() {
566        let content = "version=2\nhttps://example.com/ .*.tar.gz";
567        assert_eq!(
568            detect_version(content),
569            Some(WatchFileVersion::LineBased(2))
570        );
571    }
572
573    #[cfg(feature = "linebased")]
574    #[test]
575    fn test_parse_linebased() {
576        let content = "version=4\nhttps://example.com/ .*.tar.gz";
577        let parsed = parse(content).unwrap();
578        assert_eq!(parsed.version(), 4);
579    }
580
581    #[cfg(feature = "deb822")]
582    #[test]
583    fn test_parse_deb822() {
584        let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
585        let parsed = parse(content).unwrap();
586        assert_eq!(parsed.version(), 5);
587    }
588
589    #[cfg(all(feature = "linebased", feature = "deb822"))]
590    #[test]
591    fn test_parse_both_formats() {
592        // Test v4
593        let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
594        let v4_parsed = parse(v4_content).unwrap();
595        assert_eq!(v4_parsed.version(), 4);
596
597        // Test v5
598        let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
599        let v5_parsed = parse(v5_content).unwrap();
600        assert_eq!(v5_parsed.version(), 5);
601    }
602
603    #[cfg(feature = "linebased")]
604    #[test]
605    fn test_parse_roundtrip() {
606        let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
607        let parsed = parse(content).unwrap();
608        let output = parsed.to_string();
609
610        // Parse again
611        let reparsed = parse(&output).unwrap();
612        assert_eq!(reparsed.version(), 4);
613    }
614
615    #[cfg(feature = "deb822")]
616    #[test]
617    fn test_parsed_watch_file_new_v5() {
618        let wf = ParsedWatchFile::new(5).unwrap();
619        assert_eq!(wf.version(), 5);
620        assert_eq!(wf.entries().count(), 0);
621    }
622
623    #[cfg(feature = "linebased")]
624    #[test]
625    fn test_parsed_watch_file_new_v4() {
626        let wf = ParsedWatchFile::new(4).unwrap();
627        assert_eq!(wf.version(), 4);
628        assert_eq!(wf.entries().count(), 0);
629    }
630
631    #[cfg(feature = "deb822")]
632    #[test]
633    fn test_parsed_watch_file_add_entry_v5() {
634        let mut wf = ParsedWatchFile::new(5).unwrap();
635        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
636
637        assert_eq!(wf.entries().count(), 1);
638        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
639        assert_eq!(
640            entry.matching_pattern(),
641            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
642        );
643
644        // Test setting options with enum
645        entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
646        entry.set_option(crate::types::WatchOption::Compression(
647            crate::types::Compression::Xz,
648        ));
649
650        assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
651        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
652    }
653
654    #[cfg(feature = "linebased")]
655    #[test]
656    fn test_parsed_watch_file_add_entry_v4() {
657        let mut wf = ParsedWatchFile::new(4).unwrap();
658        let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
659
660        assert_eq!(wf.entries().count(), 1);
661        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
662        assert_eq!(
663            entry.matching_pattern(),
664            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
665        );
666    }
667
668    #[cfg(feature = "deb822")]
669    #[test]
670    fn test_parsed_watch_file_roundtrip_with_add_entry() {
671        let mut wf = ParsedWatchFile::new(5).unwrap();
672        let mut entry = wf.add_entry(
673            "https://github.com/owner/repo/tags",
674            r".*/v?([\d.]+)\.tar\.gz",
675        );
676        entry.set_option(crate::types::WatchOption::Compression(
677            crate::types::Compression::Xz,
678        ));
679
680        let output = wf.to_string();
681
682        // Parse again
683        let reparsed = parse(&output).unwrap();
684        assert_eq!(reparsed.version(), 5);
685
686        let entries: Vec<_> = reparsed.entries().collect();
687        assert_eq!(entries.len(), 1);
688        assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
689        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
690    }
691
692    #[cfg(feature = "linebased")]
693    #[test]
694    fn test_parsed_entry_set_url_v4() {
695        let mut wf = ParsedWatchFile::new(4).unwrap();
696        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
697
698        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
699
700        entry.set_url("https://github.com/foo/bar/releases");
701        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
702    }
703
704    #[cfg(feature = "deb822")]
705    #[test]
706    fn test_parsed_entry_set_url_v5() {
707        let mut wf = ParsedWatchFile::new(5).unwrap();
708        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
709
710        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
711
712        entry.set_url("https://github.com/foo/bar/releases");
713        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
714    }
715
716    #[cfg(feature = "linebased")]
717    #[test]
718    fn test_parsed_entry_set_matching_pattern_v4() {
719        let mut wf = ParsedWatchFile::new(4).unwrap();
720        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
721
722        assert_eq!(
723            entry.matching_pattern(),
724            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
725        );
726
727        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
728        assert_eq!(
729            entry.matching_pattern(),
730            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
731        );
732    }
733
734    #[cfg(feature = "deb822")]
735    #[test]
736    fn test_parsed_entry_set_matching_pattern_v5() {
737        let mut wf = ParsedWatchFile::new(5).unwrap();
738        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
739
740        assert_eq!(
741            entry.matching_pattern(),
742            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
743        );
744
745        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
746        assert_eq!(
747            entry.matching_pattern(),
748            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
749        );
750    }
751
752    #[cfg(feature = "linebased")]
753    #[test]
754    fn test_parsed_entry_line_v4() {
755        let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
756        let wf = parse(content).unwrap();
757        let entries: Vec<_> = wf.entries().collect();
758
759        assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
760        assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
761    }
762
763    #[cfg(feature = "deb822")]
764    #[test]
765    fn test_parsed_entry_line_v5() {
766        let content = r#"Version: 5
767
768Source: https://example.com/repo1
769Matching-Pattern: .*\.tar\.gz
770
771Source: https://example.com/repo2
772Matching-Pattern: .*\.tar\.xz
773"#;
774        let wf = parse(content).unwrap();
775        let entries: Vec<_> = wf.entries().collect();
776
777        assert_eq!(entries[0].line(), 2); // Third line (0-indexed)
778        assert_eq!(entries[1].line(), 5); // Sixth line (0-indexed)
779    }
780}