Skip to main content

debian_watch/
parse.rs

1#![cfg(any(feature = "linebased", feature = "deb822"))]
2//! Format detection and parsing for watch files
3//!
4//! This module is only available when at least one of the `linebased` or `deb822` features is enabled.
5
6/// Error type for parsing watch files
7#[derive(Debug)]
8pub enum ParseError {
9    /// Error parsing line-based format (v1-4)
10    #[cfg(feature = "linebased")]
11    LineBased(crate::linebased::ParseError),
12    /// Error parsing deb822 format (v5)
13    #[cfg(feature = "deb822")]
14    Deb822(crate::deb822::ParseError),
15    /// Could not detect version
16    UnknownVersion,
17    /// Feature not enabled
18    FeatureNotEnabled(String),
19}
20
21impl std::fmt::Display for ParseError {
22    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
23        match self {
24            #[cfg(feature = "linebased")]
25            ParseError::LineBased(e) => write!(f, "{}", e),
26            #[cfg(feature = "deb822")]
27            ParseError::Deb822(e) => write!(f, "{}", e),
28            ParseError::UnknownVersion => write!(f, "Could not detect watch file version"),
29            ParseError::FeatureNotEnabled(msg) => write!(f, "{}", msg),
30        }
31    }
32}
33
34impl std::error::Error for ParseError {}
35
36/// Detected watch file format
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum WatchFileVersion {
39    /// Line-based format (versions 1-4)
40    LineBased(u32),
41    /// Deb822 format (version 5)
42    Deb822,
43}
44
45/// Detect the version/format of a watch file from its content
46///
47/// This function examines the content to determine if it's a line-based
48/// format (v1-4) or deb822 format (v5).
49///
50/// After detecting the version, you can either:
51/// - Use the `parse()` function to automatically parse and return a `ParsedWatchFile`
52/// - Parse directly: `content.parse::<debian_watch::linebased::WatchFile>()`
53///
54/// # Examples
55///
56/// ```
57/// use debian_watch::parse::{detect_version, WatchFileVersion};
58///
59/// let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
60/// assert_eq!(detect_version(v4_content), Some(WatchFileVersion::LineBased(4)));
61///
62/// let v5_content = "Version: 5\n\nSource: https://example.com/";
63/// assert_eq!(detect_version(v5_content), Some(WatchFileVersion::Deb822));
64/// ```
65pub fn detect_version(content: &str) -> Option<WatchFileVersion> {
66    let trimmed = content.trim_start();
67
68    // Check if it starts with RFC822-style "Version: 5"
69    if trimmed.starts_with("Version:") || trimmed.starts_with("version:") {
70        // Try to extract the version number
71        if let Some(first_line) = trimmed.lines().next() {
72            if let Some(colon_pos) = first_line.find(':') {
73                let version_str = first_line[colon_pos + 1..].trim();
74                if version_str == "5" {
75                    return Some(WatchFileVersion::Deb822);
76                }
77            }
78        }
79    }
80
81    // Otherwise, it's line-based format
82    // Try to detect the version from "version=N" line
83    for line in trimmed.lines() {
84        let line = line.trim();
85
86        // Skip comments and blank lines
87        if line.starts_with('#') || line.is_empty() {
88            continue;
89        }
90
91        // Check for version=N
92        if line.starts_with("version=") || line.starts_with("version =") {
93            let version_part = if line.starts_with("version=") {
94                &line[8..]
95            } else {
96                &line[9..]
97            };
98
99            if let Ok(version) = version_part.trim().parse::<u32>() {
100                return Some(WatchFileVersion::LineBased(version));
101            }
102        }
103
104        // If we hit a non-comment, non-version line, assume default version
105        break;
106    }
107
108    // Default to version 1 for line-based format
109    Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION))
110}
111
112/// Parsed watch file that can be either line-based or deb822 format
113#[derive(Debug)]
114pub enum ParsedWatchFile {
115    /// Line-based watch file (v1-4)
116    #[cfg(feature = "linebased")]
117    LineBased(crate::linebased::WatchFile),
118    /// Deb822 watch file (v5)
119    #[cfg(feature = "deb822")]
120    Deb822(crate::deb822::WatchFile),
121}
122
123/// Parsed watch entry that can be either line-based or deb822 format
124#[derive(Debug)]
125pub enum ParsedEntry {
126    /// Line-based entry (v1-4)
127    #[cfg(feature = "linebased")]
128    LineBased(crate::linebased::Entry),
129    /// Deb822 entry (v5)
130    #[cfg(feature = "deb822")]
131    Deb822(crate::deb822::Entry),
132}
133
134impl ParsedWatchFile {
135    /// Create a new empty watch file with the specified version.
136    ///
137    /// - For version 5, creates a deb822-format watch file (requires `deb822` feature)
138    /// - For versions 1-4, creates a line-based watch file (requires `linebased` feature)
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// # #[cfg(feature = "deb822")]
144    /// # {
145    /// use debian_watch::parse::ParsedWatchFile;
146    ///
147    /// let wf = ParsedWatchFile::new(5).unwrap();
148    /// assert_eq!(wf.version(), 5);
149    /// # }
150    /// ```
151    pub fn new(version: u32) -> Result<Self, ParseError> {
152        match version {
153            #[cfg(feature = "deb822")]
154            5 => Ok(ParsedWatchFile::Deb822(crate::deb822::WatchFile::new())),
155            #[cfg(not(feature = "deb822"))]
156            5 => Err(ParseError::FeatureNotEnabled(
157                "deb822 feature required for v5 format".to_string(),
158            )),
159            #[cfg(feature = "linebased")]
160            v @ 1..=4 => Ok(ParsedWatchFile::LineBased(
161                crate::linebased::WatchFile::new(Some(v)),
162            )),
163            #[cfg(not(feature = "linebased"))]
164            v @ 1..=4 => Err(ParseError::FeatureNotEnabled(format!(
165                "linebased feature required for v{} format",
166                v
167            ))),
168            v => Err(ParseError::FeatureNotEnabled(format!(
169                "unsupported watch file version: {}",
170                v
171            ))),
172        }
173    }
174
175    /// Get the version of the watch file
176    pub fn version(&self) -> u32 {
177        match self {
178            #[cfg(feature = "linebased")]
179            ParsedWatchFile::LineBased(wf) => wf.version(),
180            #[cfg(feature = "deb822")]
181            ParsedWatchFile::Deb822(wf) => wf.version(),
182        }
183    }
184
185    /// Get an iterator over entries as ParsedEntry enum
186    pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
187        // We need to collect because we can't return different iterator types from match arms
188        let entries: Vec<_> = match self {
189            #[cfg(feature = "linebased")]
190            ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(),
191            #[cfg(feature = "deb822")]
192            ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(),
193        };
194        entries.into_iter()
195    }
196
197    /// Add a new entry to the watch file and return it.
198    ///
199    /// For v5 (deb822) watch files, this adds a new paragraph with Source and Matching-Pattern fields.
200    /// For v1-4 (line-based) watch files, this adds a new entry line.
201    ///
202    /// Returns a `ParsedEntry` that can be used to query or modify the entry.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// # #[cfg(feature = "deb822")]
208    /// # {
209    /// use debian_watch::parse::ParsedWatchFile;
210    /// use debian_watch::WatchOption;
211    ///
212    /// let mut wf = ParsedWatchFile::new(5).unwrap();
213    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
214    /// entry.set_option(WatchOption::Component("upstream".to_string()));
215    /// # }
216    /// ```
217    pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> ParsedEntry {
218        match self {
219            #[cfg(feature = "linebased")]
220            ParsedWatchFile::LineBased(wf) => {
221                let entry = crate::linebased::EntryBuilder::new(source)
222                    .matching_pattern(matching_pattern)
223                    .build();
224                let added_entry = wf.add_entry(entry);
225                ParsedEntry::LineBased(added_entry)
226            }
227            #[cfg(feature = "deb822")]
228            ParsedWatchFile::Deb822(wf) => {
229                let added_entry = wf.add_entry(source, matching_pattern);
230                ParsedEntry::Deb822(added_entry)
231            }
232        }
233    }
234}
235
236impl ParsedEntry {
237    /// Get the URL/Source of the entry
238    pub fn url(&self) -> String {
239        match self {
240            #[cfg(feature = "linebased")]
241            ParsedEntry::LineBased(e) => e.url(),
242            #[cfg(feature = "deb822")]
243            ParsedEntry::Deb822(e) => e.source().unwrap_or(None).unwrap_or_default(),
244        }
245    }
246
247    /// Get the matching pattern
248    pub fn matching_pattern(&self) -> Option<String> {
249        match self {
250            #[cfg(feature = "linebased")]
251            ParsedEntry::LineBased(e) => e.matching_pattern(),
252            #[cfg(feature = "deb822")]
253            ParsedEntry::Deb822(e) => e.matching_pattern().unwrap_or(None),
254        }
255    }
256
257    /// Get a generic option/field value by key (case-insensitive)
258    ///
259    /// This handles the difference between line-based format (lowercase keys)
260    /// and deb822 format (capitalized keys). It tries the key as-is first,
261    /// then tries with the first letter capitalized.
262    pub fn get_option(&self, key: &str) -> Option<String> {
263        match self {
264            #[cfg(feature = "linebased")]
265            ParsedEntry::LineBased(e) => e.get_option(key),
266            #[cfg(feature = "deb822")]
267            ParsedEntry::Deb822(e) => {
268                // Try exact match first, then try capitalized
269                e.get_field(key).or_else(|| {
270                    let mut chars = key.chars();
271                    if let Some(first) = chars.next() {
272                        let capitalized = first.to_uppercase().chain(chars).collect::<String>();
273                        e.get_field(&capitalized)
274                    } else {
275                        None
276                    }
277                })
278            }
279        }
280    }
281
282    /// Check if an option/field is set (case-insensitive)
283    pub fn has_option(&self, key: &str) -> bool {
284        self.get_option(key).is_some()
285    }
286
287    /// Get the script
288    pub fn script(&self) -> Option<String> {
289        self.get_option("script")
290    }
291
292    /// Get the component name (empty for main paragraph)
293    pub fn component(&self) -> Option<String> {
294        self.get_option("component")
295    }
296
297    /// Format the URL with package and component substitution
298    pub fn format_url(
299        &self,
300        package: impl FnOnce() -> String,
301        component: impl FnOnce() -> String,
302    ) -> Result<url::Url, url::ParseError> {
303        crate::subst::subst(&self.url(), package, component).parse()
304    }
305
306    /// Get the user agent
307    pub fn user_agent(&self) -> Option<String> {
308        self.get_option("user-agent")
309    }
310
311    /// Get the pagemangle option
312    pub fn pagemangle(&self) -> Option<String> {
313        self.get_option("pagemangle")
314    }
315
316    /// Get the uversionmangle option
317    pub fn uversionmangle(&self) -> Option<String> {
318        self.get_option("uversionmangle")
319    }
320
321    /// Get the downloadurlmangle option
322    pub fn downloadurlmangle(&self) -> Option<String> {
323        self.get_option("downloadurlmangle")
324    }
325
326    /// Get the pgpsigurlmangle option
327    pub fn pgpsigurlmangle(&self) -> Option<String> {
328        self.get_option("pgpsigurlmangle")
329    }
330
331    /// Get the filenamemangle option
332    pub fn filenamemangle(&self) -> Option<String> {
333        self.get_option("filenamemangle")
334    }
335
336    /// Get the oversionmangle option
337    pub fn oversionmangle(&self) -> Option<String> {
338        self.get_option("oversionmangle")
339    }
340
341    /// Get the searchmode, with default fallback
342    pub fn searchmode(&self) -> crate::types::SearchMode {
343        self.get_option("searchmode")
344            .and_then(|s| s.parse().ok())
345            .unwrap_or_default()
346    }
347
348    /// Set an option/field value using a WatchOption enum.
349    ///
350    /// For v5 (deb822) entries, this sets a field in the paragraph.
351    /// For v1-4 (line-based) entries, this sets an option in the opts= list.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// # #[cfg(feature = "linebased")]
357    /// # {
358    /// use debian_watch::parse::ParsedWatchFile;
359    /// use debian_watch::{WatchOption, Compression};
360    ///
361    /// let mut wf = ParsedWatchFile::new(4).unwrap();
362    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
363    /// entry.set_option(WatchOption::Component("upstream".to_string()));
364    /// entry.set_option(WatchOption::Compression(Compression::Xz));
365    /// assert_eq!(entry.get_option("component"), Some("upstream".to_string()));
366    /// assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
367    /// # }
368    /// ```
369    pub fn set_option(&mut self, option: crate::types::WatchOption) {
370        match self {
371            #[cfg(feature = "linebased")]
372            ParsedEntry::LineBased(e) => {
373                e.set_option(option);
374            }
375            #[cfg(feature = "deb822")]
376            ParsedEntry::Deb822(e) => {
377                e.set_option(option);
378            }
379        }
380    }
381
382    /// Set the URL/Source of the entry
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// # #[cfg(feature = "linebased")]
388    /// # {
389    /// use debian_watch::parse::ParsedWatchFile;
390    ///
391    /// let mut wf = ParsedWatchFile::new(4).unwrap();
392    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
393    /// entry.set_url("https://github.com/foo/bar/releases");
394    /// assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
395    /// # }
396    /// ```
397    pub fn set_url(&mut self, url: &str) {
398        match self {
399            #[cfg(feature = "linebased")]
400            ParsedEntry::LineBased(e) => e.set_url(url),
401            #[cfg(feature = "deb822")]
402            ParsedEntry::Deb822(e) => e.set_source(url),
403        }
404    }
405
406    /// Set the matching pattern of the entry
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// # #[cfg(feature = "linebased")]
412    /// # {
413    /// use debian_watch::parse::ParsedWatchFile;
414    ///
415    /// let mut wf = ParsedWatchFile::new(4).unwrap();
416    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
417    /// entry.set_matching_pattern(".*/release-([\\d.]+)\\.tar\\.gz");
418    /// assert_eq!(entry.matching_pattern(), Some(".*/release-([\\d.]+)\\.tar\\.gz".to_string()));
419    /// # }
420    /// ```
421    pub fn set_matching_pattern(&mut self, pattern: &str) {
422        match self {
423            #[cfg(feature = "linebased")]
424            ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
425            #[cfg(feature = "deb822")]
426            ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
427        }
428    }
429
430    /// Get the line number (0-indexed) where this entry starts
431    ///
432    /// For line-based formats (v1-4), this returns the actual line number in the file.
433    /// For deb822 format (v5), this returns the line where the paragraph starts.
434    ///
435    /// # Examples
436    ///
437    /// ```
438    /// # #[cfg(feature = "linebased")]
439    /// # {
440    /// use debian_watch::parse::parse;
441    ///
442    /// let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
443    /// let wf = parse(content).unwrap();
444    /// let entries: Vec<_> = wf.entries().collect();
445    /// assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
446    /// assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
447    /// # }
448    /// ```
449    pub fn line(&self) -> usize {
450        match self {
451            #[cfg(feature = "linebased")]
452            ParsedEntry::LineBased(e) => e.line(),
453            #[cfg(feature = "deb822")]
454            ParsedEntry::Deb822(e) => e.line(),
455        }
456    }
457
458    /// Remove/delete an option from the entry
459    ///
460    /// For v5 (deb822) entries, this removes a field from the paragraph.
461    /// For v1-4 (line-based) entries, this removes an option from the opts= list.
462    /// If this is the last option in a line-based entry, the entire opts= declaration is removed.
463    ///
464    /// # Examples
465    ///
466    /// ```
467    /// # #[cfg(feature = "linebased")]
468    /// # {
469    /// use debian_watch::parse::ParsedWatchFile;
470    /// use debian_watch::WatchOption;
471    ///
472    /// let mut wf = ParsedWatchFile::new(4).unwrap();
473    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
474    /// entry.set_option(WatchOption::Compression(debian_watch::Compression::Xz));
475    /// assert!(entry.has_option("compression"));
476    /// entry.remove_option(WatchOption::Compression(debian_watch::Compression::Xz));
477    /// assert!(!entry.has_option("compression"));
478    /// # }
479    /// ```
480    pub fn remove_option(&mut self, option: crate::types::WatchOption) {
481        match self {
482            #[cfg(feature = "linebased")]
483            ParsedEntry::LineBased(e) => e.del_opt(option),
484            #[cfg(feature = "deb822")]
485            ParsedEntry::Deb822(e) => e.delete_option(option),
486        }
487    }
488
489    /// Retrieve the mode of the watch file entry.
490    ///
491    /// Returns the mode with default fallback to `Mode::LWP` if not specified.
492    /// Returns an error if the mode value is invalid.
493    ///
494    /// # Examples
495    ///
496    /// ```
497    /// # #[cfg(feature = "linebased")]
498    /// # {
499    /// use debian_watch::parse::ParsedWatchFile;
500    /// use debian_watch::{WatchOption, Mode};
501    ///
502    /// let mut wf = ParsedWatchFile::new(4).unwrap();
503    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
504    ///
505    /// // Default mode is LWP
506    /// assert_eq!(entry.mode().unwrap(), Mode::LWP);
507    ///
508    /// // Set git mode
509    /// entry.set_option(WatchOption::Mode(Mode::Git));
510    /// assert_eq!(entry.mode().unwrap(), Mode::Git);
511    /// # }
512    /// ```
513    pub fn mode(&self) -> Result<crate::types::Mode, crate::types::ParseError> {
514        match self {
515            #[cfg(feature = "linebased")]
516            ParsedEntry::LineBased(e) => e.try_mode(),
517            #[cfg(feature = "deb822")]
518            ParsedEntry::Deb822(e) => e.mode(),
519        }
520    }
521}
522
523impl std::fmt::Display for ParsedWatchFile {
524    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
525        match self {
526            #[cfg(feature = "linebased")]
527            ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
528            #[cfg(feature = "deb822")]
529            ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
530        }
531    }
532}
533
534/// Parse a watch file with automatic format detection
535///
536/// This function detects whether the input is line-based (v1-4) or
537/// deb822 format (v5) and parses it accordingly, returning a unified
538/// ParsedWatchFile enum.
539///
540/// # Examples
541///
542/// ```
543/// # #[cfg(feature = "linebased")]
544/// # {
545/// use debian_watch::parse::parse;
546///
547/// let content = "version=4\nhttps://example.com/ .*.tar.gz";
548/// let parsed = parse(content).unwrap();
549/// assert_eq!(parsed.version(), 4);
550/// # }
551/// ```
552pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
553    let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
554
555    match version {
556        #[cfg(feature = "linebased")]
557        WatchFileVersion::LineBased(_v) => {
558            let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
559            Ok(ParsedWatchFile::LineBased(wf))
560        }
561        #[cfg(not(feature = "linebased"))]
562        WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
563            "linebased feature required for v1-4 formats".to_string(),
564        )),
565        #[cfg(feature = "deb822")]
566        WatchFileVersion::Deb822 => {
567            let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
568            Ok(ParsedWatchFile::Deb822(wf))
569        }
570        #[cfg(not(feature = "deb822"))]
571        WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
572            "deb822 feature required for v5 format".to_string(),
573        )),
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn test_detect_version_v1_default() {
583        let content = "https://example.com/ .*.tar.gz";
584        assert_eq!(
585            detect_version(content),
586            Some(WatchFileVersion::LineBased(1))
587        );
588    }
589
590    #[test]
591    fn test_detect_version_v4() {
592        let content = "version=4\nhttps://example.com/ .*.tar.gz";
593        assert_eq!(
594            detect_version(content),
595            Some(WatchFileVersion::LineBased(4))
596        );
597    }
598
599    #[test]
600    fn test_detect_version_v4_with_spaces() {
601        let content = "version = 4\nhttps://example.com/ .*.tar.gz";
602        assert_eq!(
603            detect_version(content),
604            Some(WatchFileVersion::LineBased(4))
605        );
606    }
607
608    #[test]
609    fn test_detect_version_v5() {
610        let content = "Version: 5\n\nSource: https://example.com/";
611        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
612    }
613
614    #[test]
615    fn test_detect_version_v5_lowercase() {
616        let content = "version: 5\n\nSource: https://example.com/";
617        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
618    }
619
620    #[test]
621    fn test_detect_version_with_leading_comments() {
622        let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
623        assert_eq!(
624            detect_version(content),
625            Some(WatchFileVersion::LineBased(4))
626        );
627    }
628
629    #[test]
630    fn test_detect_version_with_leading_whitespace() {
631        let content = "  \n  version=3\nhttps://example.com/ .*.tar.gz";
632        assert_eq!(
633            detect_version(content),
634            Some(WatchFileVersion::LineBased(3))
635        );
636    }
637
638    #[test]
639    fn test_detect_version_v2() {
640        let content = "version=2\nhttps://example.com/ .*.tar.gz";
641        assert_eq!(
642            detect_version(content),
643            Some(WatchFileVersion::LineBased(2))
644        );
645    }
646
647    #[cfg(feature = "linebased")]
648    #[test]
649    fn test_parse_linebased() {
650        let content = "version=4\nhttps://example.com/ .*.tar.gz";
651        let parsed = parse(content).unwrap();
652        assert_eq!(parsed.version(), 4);
653    }
654
655    #[cfg(feature = "deb822")]
656    #[test]
657    fn test_parse_deb822() {
658        let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
659        let parsed = parse(content).unwrap();
660        assert_eq!(parsed.version(), 5);
661    }
662
663    #[cfg(all(feature = "linebased", feature = "deb822"))]
664    #[test]
665    fn test_parse_both_formats() {
666        // Test v4
667        let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
668        let v4_parsed = parse(v4_content).unwrap();
669        assert_eq!(v4_parsed.version(), 4);
670
671        // Test v5
672        let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
673        let v5_parsed = parse(v5_content).unwrap();
674        assert_eq!(v5_parsed.version(), 5);
675    }
676
677    #[cfg(feature = "linebased")]
678    #[test]
679    fn test_parse_roundtrip() {
680        let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
681        let parsed = parse(content).unwrap();
682        let output = parsed.to_string();
683
684        // Parse again
685        let reparsed = parse(&output).unwrap();
686        assert_eq!(reparsed.version(), 4);
687    }
688
689    #[cfg(feature = "deb822")]
690    #[test]
691    fn test_parsed_watch_file_new_v5() {
692        let wf = ParsedWatchFile::new(5).unwrap();
693        assert_eq!(wf.version(), 5);
694        assert_eq!(wf.entries().count(), 0);
695    }
696
697    #[cfg(feature = "linebased")]
698    #[test]
699    fn test_parsed_watch_file_new_v4() {
700        let wf = ParsedWatchFile::new(4).unwrap();
701        assert_eq!(wf.version(), 4);
702        assert_eq!(wf.entries().count(), 0);
703    }
704
705    #[cfg(feature = "deb822")]
706    #[test]
707    fn test_parsed_watch_file_add_entry_v5() {
708        let mut wf = ParsedWatchFile::new(5).unwrap();
709        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
710
711        assert_eq!(wf.entries().count(), 1);
712        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
713        assert_eq!(
714            entry.matching_pattern(),
715            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
716        );
717
718        // Test setting options with enum
719        entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
720        entry.set_option(crate::types::WatchOption::Compression(
721            crate::types::Compression::Xz,
722        ));
723
724        assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
725        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
726    }
727
728    #[cfg(feature = "linebased")]
729    #[test]
730    fn test_parsed_watch_file_add_entry_v4() {
731        let mut wf = ParsedWatchFile::new(4).unwrap();
732        let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
733
734        assert_eq!(wf.entries().count(), 1);
735        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
736        assert_eq!(
737            entry.matching_pattern(),
738            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
739        );
740    }
741
742    #[cfg(feature = "deb822")]
743    #[test]
744    fn test_parsed_watch_file_roundtrip_with_add_entry() {
745        let mut wf = ParsedWatchFile::new(5).unwrap();
746        let mut entry = wf.add_entry(
747            "https://github.com/owner/repo/tags",
748            r".*/v?([\d.]+)\.tar\.gz",
749        );
750        entry.set_option(crate::types::WatchOption::Compression(
751            crate::types::Compression::Xz,
752        ));
753
754        let output = wf.to_string();
755
756        // Parse again
757        let reparsed = parse(&output).unwrap();
758        assert_eq!(reparsed.version(), 5);
759
760        let entries: Vec<_> = reparsed.entries().collect();
761        assert_eq!(entries.len(), 1);
762        assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
763        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
764    }
765
766    #[cfg(feature = "linebased")]
767    #[test]
768    fn test_parsed_entry_set_url_v4() {
769        let mut wf = ParsedWatchFile::new(4).unwrap();
770        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
771
772        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
773
774        entry.set_url("https://github.com/foo/bar/releases");
775        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
776    }
777
778    #[cfg(feature = "deb822")]
779    #[test]
780    fn test_parsed_entry_set_url_v5() {
781        let mut wf = ParsedWatchFile::new(5).unwrap();
782        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
783
784        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
785
786        entry.set_url("https://github.com/foo/bar/releases");
787        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
788    }
789
790    #[cfg(feature = "linebased")]
791    #[test]
792    fn test_parsed_entry_set_matching_pattern_v4() {
793        let mut wf = ParsedWatchFile::new(4).unwrap();
794        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
795
796        assert_eq!(
797            entry.matching_pattern(),
798            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
799        );
800
801        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
802        assert_eq!(
803            entry.matching_pattern(),
804            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
805        );
806    }
807
808    #[cfg(feature = "deb822")]
809    #[test]
810    fn test_parsed_entry_set_matching_pattern_v5() {
811        let mut wf = ParsedWatchFile::new(5).unwrap();
812        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
813
814        assert_eq!(
815            entry.matching_pattern(),
816            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
817        );
818
819        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
820        assert_eq!(
821            entry.matching_pattern(),
822            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
823        );
824    }
825
826    #[cfg(feature = "linebased")]
827    #[test]
828    fn test_parsed_entry_line_v4() {
829        let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
830        let wf = parse(content).unwrap();
831        let entries: Vec<_> = wf.entries().collect();
832
833        assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
834        assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
835    }
836
837    #[cfg(feature = "deb822")]
838    #[test]
839    fn test_parsed_entry_line_v5() {
840        let content = r#"Version: 5
841
842Source: https://example.com/repo1
843Matching-Pattern: .*\.tar\.gz
844
845Source: https://example.com/repo2
846Matching-Pattern: .*\.tar\.xz
847"#;
848        let wf = parse(content).unwrap();
849        let entries: Vec<_> = wf.entries().collect();
850
851        assert_eq!(entries[0].line(), 2); // Third line (0-indexed)
852        assert_eq!(entries[1].line(), 5); // Sixth line (0-indexed)
853    }
854}
855
856/// Thread-safe parse result for watch files, suitable for use in Salsa databases.
857///
858/// This wrapper provides a thread-safe interface around the parsed watch file,
859/// storing either a line-based parse tree or the raw text for deb822 format.
860/// The underlying lossless parse trees (based on rowan's GreenNode) are thread-safe.
861#[derive(Clone, PartialEq, Eq)]
862pub struct Parse {
863    inner: ParseInner,
864}
865
866#[derive(Clone, PartialEq, Eq)]
867enum ParseInner {
868    #[cfg(feature = "linebased")]
869    LineBased(crate::linebased::Parse<crate::linebased::WatchFile>),
870    #[cfg(feature = "deb822")]
871    Deb822(String), // Store raw text for deb822 to avoid SyntaxNode
872}
873
874impl Parse {
875    /// Parse a watch file with automatic format detection
876    pub fn parse(text: &str) -> Self {
877        let version = detect_version(text);
878
879        let inner = match version {
880            #[cfg(feature = "linebased")]
881            Some(WatchFileVersion::LineBased(_)) => {
882                ParseInner::LineBased(crate::linebased::parse_watch_file(text))
883            }
884            #[cfg(feature = "deb822")]
885            Some(WatchFileVersion::Deb822) => {
886                ParseInner::Deb822(text.to_string())
887            }
888            #[cfg(not(feature = "linebased"))]
889            Some(WatchFileVersion::LineBased(_)) => {
890                // Fallback to storing text if linebased feature is not enabled
891                #[cfg(feature = "deb822")]
892                { ParseInner::Deb822(text.to_string()) }
893                #[cfg(not(feature = "deb822"))]
894                { panic!("No watch file parsing features enabled") }
895            }
896            #[cfg(not(feature = "deb822"))]
897            Some(WatchFileVersion::Deb822) => {
898                // Fallback to linebased if deb822 feature is not enabled
899                #[cfg(feature = "linebased")]
900                { ParseInner::LineBased(crate::linebased::parse_watch_file(text)) }
901                #[cfg(not(feature = "linebased"))]
902                { panic!("No watch file parsing features enabled") }
903            }
904            None => {
905                // Default to linebased v1 if we can't detect
906                #[cfg(feature = "linebased")]
907                { ParseInner::LineBased(crate::linebased::parse_watch_file(text)) }
908                #[cfg(not(feature = "linebased"))]
909                #[cfg(feature = "deb822")]
910                { ParseInner::Deb822(text.to_string()) }
911                #[cfg(not(any(feature = "linebased", feature = "deb822")))]
912                { panic!("No watch file parsing features enabled") }
913            }
914        };
915
916        Parse { inner }
917    }
918
919    /// Get the parsed watch file
920    pub fn to_watch_file(&self) -> ParsedWatchFile {
921        match &self.inner {
922            #[cfg(feature = "linebased")]
923            ParseInner::LineBased(parse) => {
924                ParsedWatchFile::LineBased(parse.tree())
925            }
926            #[cfg(feature = "deb822")]
927            ParseInner::Deb822(text) => {
928                let wf: crate::deb822::WatchFile = text.parse().unwrap();
929                ParsedWatchFile::Deb822(wf)
930            }
931        }
932    }
933
934    /// Get the version of the watch file
935    pub fn version(&self) -> u32 {
936        match &self.inner {
937            #[cfg(feature = "linebased")]
938            ParseInner::LineBased(parse) => {
939                parse.tree().version()
940            }
941            #[cfg(feature = "deb822")]
942            ParseInner::Deb822(_) => 5,
943        }
944    }
945}
946
947// Implement Send + Sync since the underlying types are thread-safe
948// LineBased parse uses GreenNode (thread-safe)
949// Deb822 variant stores String (thread-safe)
950unsafe impl Send for Parse {}
951unsafe impl Sync for Parse {}