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, Clone)]
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, Clone)]
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    /// Capture an independent snapshot of this watch file.
136    ///
137    /// The returned value shares the underlying immutable green-node data
138    /// with `self` at the time of the call, but lives in its own mutable
139    /// tree: subsequent mutations to `self` do not propagate to the snapshot.
140    /// Pair with [`Self::tree_eq`] to detect later mutations.
141    pub fn snapshot(&self) -> Self {
142        match self {
143            #[cfg(feature = "linebased")]
144            ParsedWatchFile::LineBased(wf) => ParsedWatchFile::LineBased(wf.snapshot()),
145            #[cfg(feature = "deb822")]
146            ParsedWatchFile::Deb822(wf) => ParsedWatchFile::Deb822(wf.snapshot()),
147        }
148    }
149
150    /// Returns true iff the syntax trees of `self` and `other` are
151    /// value-equal. An O(1) pointer-identity fast path makes this free for
152    /// trees that still share state with a recent [`Self::snapshot`].
153    /// Mismatched variants are never considered equal.
154    pub fn tree_eq(&self, other: &Self) -> bool {
155        match (self, other) {
156            #[cfg(feature = "linebased")]
157            (ParsedWatchFile::LineBased(a), ParsedWatchFile::LineBased(b)) => a.tree_eq(b),
158            #[cfg(feature = "deb822")]
159            (ParsedWatchFile::Deb822(a), ParsedWatchFile::Deb822(b)) => a.tree_eq(b),
160            #[allow(unreachable_patterns)]
161            _ => false,
162        }
163    }
164
165    /// Create a new empty watch file with the specified version.
166    ///
167    /// - For version 5, creates a deb822-format watch file (requires `deb822` feature)
168    /// - For versions 1-4, creates a line-based watch file (requires `linebased` feature)
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// # #[cfg(feature = "deb822")]
174    /// # {
175    /// use debian_watch::parse::ParsedWatchFile;
176    ///
177    /// let wf = ParsedWatchFile::new(5).unwrap();
178    /// assert_eq!(wf.version(), 5);
179    /// # }
180    /// ```
181    pub fn new(version: u32) -> Result<Self, ParseError> {
182        match version {
183            #[cfg(feature = "deb822")]
184            5 => Ok(ParsedWatchFile::Deb822(crate::deb822::WatchFile::new())),
185            #[cfg(not(feature = "deb822"))]
186            5 => Err(ParseError::FeatureNotEnabled(
187                "deb822 feature required for v5 format".to_string(),
188            )),
189            #[cfg(feature = "linebased")]
190            v @ 1..=4 => Ok(ParsedWatchFile::LineBased(
191                crate::linebased::WatchFile::new(Some(v)),
192            )),
193            #[cfg(not(feature = "linebased"))]
194            v @ 1..=4 => Err(ParseError::FeatureNotEnabled(format!(
195                "linebased feature required for v{} format",
196                v
197            ))),
198            v => Err(ParseError::FeatureNotEnabled(format!(
199                "unsupported watch file version: {}",
200                v
201            ))),
202        }
203    }
204
205    /// Get the version of the watch file
206    pub fn version(&self) -> u32 {
207        match self {
208            #[cfg(feature = "linebased")]
209            ParsedWatchFile::LineBased(wf) => wf.version(),
210            #[cfg(feature = "deb822")]
211            ParsedWatchFile::Deb822(wf) => wf.version(),
212        }
213    }
214
215    /// Get an iterator over entries as ParsedEntry enum
216    pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
217        // We need to collect because we can't return different iterator types from match arms
218        let entries: Vec<_> = match self {
219            #[cfg(feature = "linebased")]
220            ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(),
221            #[cfg(feature = "deb822")]
222            ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(),
223        };
224        entries.into_iter()
225    }
226
227    /// Add a new entry to the watch file and return it.
228    ///
229    /// For v5 (deb822) watch files, this adds a new paragraph with Source and Matching-Pattern fields.
230    /// For v1-4 (line-based) watch files, this adds a new entry line.
231    ///
232    /// Returns a `ParsedEntry` that can be used to query or modify the entry.
233    ///
234    /// # Examples
235    ///
236    /// ```
237    /// # #[cfg(feature = "deb822")]
238    /// # {
239    /// use debian_watch::parse::ParsedWatchFile;
240    /// use debian_watch::WatchOption;
241    ///
242    /// let mut wf = ParsedWatchFile::new(5).unwrap();
243    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
244    /// entry.set_option(WatchOption::Component("upstream".to_string()));
245    /// # }
246    /// ```
247    pub fn add_entry(&mut self, source: &str, matching_pattern: &str) -> ParsedEntry {
248        match self {
249            #[cfg(feature = "linebased")]
250            ParsedWatchFile::LineBased(wf) => {
251                let entry = crate::linebased::EntryBuilder::new(source)
252                    .matching_pattern(matching_pattern)
253                    .build();
254                let added_entry = wf.add_entry(entry);
255                ParsedEntry::LineBased(added_entry)
256            }
257            #[cfg(feature = "deb822")]
258            ParsedWatchFile::Deb822(wf) => {
259                let added_entry = wf.add_entry(source, matching_pattern);
260                ParsedEntry::Deb822(added_entry)
261            }
262        }
263    }
264
265    /// Byte range of the version declaration.
266    ///
267    /// In line-based files this is the `version=N` directive on the
268    /// first line; in deb822 files it's the `Version:` entry on the
269    /// header paragraph. Returns `None` when the file has no version
270    /// declaration (legal for v1 line-based files; unusual for v5).
271    pub fn version_range(&self) -> Option<rowan::TextRange> {
272        match self {
273            #[cfg(feature = "linebased")]
274            ParsedWatchFile::LineBased(wf) => wf.version_node().map(|v| v.text_range()),
275            #[cfg(feature = "deb822")]
276            ParsedWatchFile::Deb822(wf) => {
277                // The header paragraph in v5 carries `Version:`; it's
278                // the first paragraph in the deb822 document.
279                let first = wf.as_deb822().paragraphs().next()?;
280                first.get_entry("Version").map(|e| e.text_range())
281            }
282        }
283    }
284}
285
286impl ParsedEntry {
287    /// Get the URL/Source of the entry
288    pub fn url(&self) -> String {
289        match self {
290            #[cfg(feature = "linebased")]
291            ParsedEntry::LineBased(e) => e.url(),
292            #[cfg(feature = "deb822")]
293            ParsedEntry::Deb822(e) => e.source().unwrap_or(None).unwrap_or_default(),
294        }
295    }
296
297    /// Get the matching pattern
298    pub fn matching_pattern(&self) -> Option<String> {
299        match self {
300            #[cfg(feature = "linebased")]
301            ParsedEntry::LineBased(e) => e.matching_pattern(),
302            #[cfg(feature = "deb822")]
303            ParsedEntry::Deb822(e) => e.matching_pattern().unwrap_or(None),
304        }
305    }
306
307    /// Get a generic option/field value by key (case-insensitive)
308    ///
309    /// This handles the difference between line-based format (lowercase keys)
310    /// and deb822 format (capitalized keys). It tries the key as-is first,
311    /// then tries with the first letter capitalized.
312    pub fn get_option(&self, key: &str) -> Option<String> {
313        match self {
314            #[cfg(feature = "linebased")]
315            ParsedEntry::LineBased(e) => e.get_option(key),
316            #[cfg(feature = "deb822")]
317            ParsedEntry::Deb822(e) => {
318                // Try exact match first, then try capitalized
319                e.get_field(key).or_else(|| {
320                    let mut chars = key.chars();
321                    if let Some(first) = chars.next() {
322                        let capitalized = first.to_uppercase().chain(chars).collect::<String>();
323                        e.get_field(&capitalized)
324                    } else {
325                        None
326                    }
327                })
328            }
329        }
330    }
331
332    /// Check if an option/field is set (case-insensitive)
333    pub fn has_option(&self, key: &str) -> bool {
334        self.get_option(key).is_some()
335    }
336
337    /// Byte range of the source URL within the buffer.
338    ///
339    /// In line-based format this covers the URL token; in deb822 format
340    /// it covers the `Source:` (or `URL:`) entry as a whole — key,
341    /// separator, and value. Returns `None` when the entry has no
342    /// recognisable source.
343    pub fn url_range(&self) -> Option<rowan::TextRange> {
344        match self {
345            #[cfg(feature = "linebased")]
346            ParsedEntry::LineBased(e) => e.url_node().map(|n| n.text_range()),
347            #[cfg(feature = "deb822")]
348            ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Source", "URL"]),
349        }
350    }
351
352    /// Byte range of the matching-pattern within the buffer.
353    ///
354    /// Returns `None` when the entry has no matching pattern (either
355    /// not yet set, or the entry is a template).
356    pub fn matching_pattern_range(&self) -> Option<rowan::TextRange> {
357        match self {
358            #[cfg(feature = "linebased")]
359            ParsedEntry::LineBased(e) => e.matching_pattern_node().map(|n| n.text_range()),
360            #[cfg(feature = "deb822")]
361            ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Matching-Pattern"]),
362        }
363    }
364
365    /// Byte range of the named option's `key=value` pair (line-based)
366    /// or `Key: value` entry (deb822).
367    ///
368    /// `key` is matched case-insensitively, mirroring `get_option`.
369    /// Returns `None` if the option is unset.
370    pub fn option_range(&self, key: &str) -> Option<rowan::TextRange> {
371        match self {
372            #[cfg(feature = "linebased")]
373            ParsedEntry::LineBased(e) => {
374                let list = e.option_list()?;
375                let opt = list.find_option(key)?;
376                Some(opt.text_range())
377            }
378            #[cfg(feature = "deb822")]
379            ParsedEntry::Deb822(e) => {
380                // Try the key as-is, then capitalised — same shape as
381                // `get_option`, since deb822 uses `Component` /
382                // `Mode` / `Pgpsigurlmangle` while line-based uses
383                // lowercase.
384                if let Some(r) = deb822_field_range(e.as_deb822(), &[key]) {
385                    return Some(r);
386                }
387                let mut chars = key.chars();
388                if let Some(first) = chars.next() {
389                    let capitalized = first.to_uppercase().chain(chars).collect::<String>();
390                    deb822_field_range(e.as_deb822(), &[capitalized.as_str()])
391                } else {
392                    None
393                }
394            }
395        }
396    }
397
398    /// Byte range of the version-policy / `version=...` part of the
399    /// entry, in line-based files. Returns `None` when not set, or when
400    /// this is a deb822 entry (per-file `Version:` lives on the header
401    /// paragraph, not on individual entries — use
402    /// [`ParsedWatchFile::version_range`] for that).
403    pub fn version_policy_range(&self) -> Option<rowan::TextRange> {
404        match self {
405            #[cfg(feature = "linebased")]
406            ParsedEntry::LineBased(e) => e.version_node().map(|n| n.text_range()),
407            #[cfg(feature = "deb822")]
408            ParsedEntry::Deb822(_) => None,
409        }
410    }
411
412    /// Byte range of the `Template:` field in this entry, when the
413    /// entry uses one. Templates are a v5 (deb822) feature only;
414    /// line-based entries always return `None`.
415    pub fn template_range(&self) -> Option<rowan::TextRange> {
416        match self {
417            #[cfg(feature = "linebased")]
418            ParsedEntry::LineBased(_) => None,
419            #[cfg(feature = "deb822")]
420            ParsedEntry::Deb822(e) => deb822_field_range(e.as_deb822(), &["Template"]),
421        }
422    }
423
424    /// Template kind for this entry (e.g. `"GitHub"`, `"PyPI"`,
425    /// `"CRAN"`), if the entry uses one. Line-based entries always
426    /// return `None`.
427    pub fn template_kind(&self) -> Option<String> {
428        match self {
429            #[cfg(feature = "linebased")]
430            ParsedEntry::LineBased(_) => None,
431            #[cfg(feature = "deb822")]
432            ParsedEntry::Deb822(e) => e.as_deb822().get("Template"),
433        }
434    }
435
436    /// Get the script
437    pub fn script(&self) -> Option<String> {
438        match self {
439            #[cfg(feature = "linebased")]
440            ParsedEntry::LineBased(e) => e.script(),
441            #[cfg(feature = "deb822")]
442            ParsedEntry::Deb822(_) => self.get_option("script"),
443        }
444    }
445
446    /// Get the component name (empty for main paragraph)
447    pub fn component(&self) -> Option<String> {
448        self.get_option("component")
449    }
450
451    /// Format the URL with package and component substitution
452    pub fn format_url(
453        &self,
454        package: impl FnOnce() -> String,
455        component: impl FnOnce() -> String,
456    ) -> Result<url::Url, url::ParseError> {
457        crate::subst::subst(&self.url(), package, component).parse()
458    }
459
460    /// Get the user agent
461    pub fn user_agent(&self) -> Option<String> {
462        self.get_option("user-agent")
463    }
464
465    /// Get the pagemangle option
466    pub fn pagemangle(&self) -> Option<String> {
467        self.get_option("pagemangle")
468    }
469
470    /// Get the uversionmangle option
471    pub fn uversionmangle(&self) -> Option<String> {
472        self.get_option("uversionmangle")
473    }
474
475    /// Get the downloadurlmangle option
476    pub fn downloadurlmangle(&self) -> Option<String> {
477        self.get_option("downloadurlmangle")
478    }
479
480    /// Get the pgpsigurlmangle option
481    pub fn pgpsigurlmangle(&self) -> Option<String> {
482        self.get_option("pgpsigurlmangle")
483    }
484
485    /// Get the filenamemangle option
486    pub fn filenamemangle(&self) -> Option<String> {
487        self.get_option("filenamemangle")
488    }
489
490    /// Get the oversionmangle option
491    pub fn oversionmangle(&self) -> Option<String> {
492        self.get_option("oversionmangle")
493    }
494
495    /// Get the searchmode, with default fallback
496    pub fn searchmode(&self) -> crate::types::SearchMode {
497        self.get_option("searchmode")
498            .and_then(|s| s.parse().ok())
499            .unwrap_or_default()
500    }
501
502    /// Set an option/field value using a WatchOption enum.
503    ///
504    /// For v5 (deb822) entries, this sets a field in the paragraph.
505    /// For v1-4 (line-based) entries, this sets an option in the opts= list.
506    ///
507    /// # Examples
508    ///
509    /// ```
510    /// # #[cfg(feature = "linebased")]
511    /// # {
512    /// use debian_watch::parse::ParsedWatchFile;
513    /// use debian_watch::{WatchOption, Compression};
514    ///
515    /// let mut wf = ParsedWatchFile::new(4).unwrap();
516    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
517    /// entry.set_option(WatchOption::Component("upstream".to_string()));
518    /// entry.set_option(WatchOption::Compression(Compression::Xz));
519    /// assert_eq!(entry.get_option("component"), Some("upstream".to_string()));
520    /// assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
521    /// # }
522    /// ```
523    pub fn set_option(&mut self, option: crate::types::WatchOption) {
524        match self {
525            #[cfg(feature = "linebased")]
526            ParsedEntry::LineBased(e) => {
527                e.set_option(option);
528            }
529            #[cfg(feature = "deb822")]
530            ParsedEntry::Deb822(e) => {
531                e.set_option(option);
532            }
533        }
534    }
535
536    /// Set the URL/Source of the entry
537    ///
538    /// # Examples
539    ///
540    /// ```
541    /// # #[cfg(feature = "linebased")]
542    /// # {
543    /// use debian_watch::parse::ParsedWatchFile;
544    ///
545    /// let mut wf = ParsedWatchFile::new(4).unwrap();
546    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
547    /// entry.set_url("https://github.com/foo/bar/releases");
548    /// assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
549    /// # }
550    /// ```
551    pub fn set_url(&mut self, url: &str) {
552        match self {
553            #[cfg(feature = "linebased")]
554            ParsedEntry::LineBased(e) => e.set_url(url),
555            #[cfg(feature = "deb822")]
556            ParsedEntry::Deb822(e) => e.set_source(url),
557        }
558    }
559
560    /// Set the matching pattern of the entry
561    ///
562    /// # Examples
563    ///
564    /// ```
565    /// # #[cfg(feature = "linebased")]
566    /// # {
567    /// use debian_watch::parse::ParsedWatchFile;
568    ///
569    /// let mut wf = ParsedWatchFile::new(4).unwrap();
570    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
571    /// entry.set_matching_pattern(".*/release-([\\d.]+)\\.tar\\.gz");
572    /// assert_eq!(entry.matching_pattern(), Some(".*/release-([\\d.]+)\\.tar\\.gz".to_string()));
573    /// # }
574    /// ```
575    pub fn set_matching_pattern(&mut self, pattern: &str) {
576        match self {
577            #[cfg(feature = "linebased")]
578            ParsedEntry::LineBased(e) => e.set_matching_pattern(pattern),
579            #[cfg(feature = "deb822")]
580            ParsedEntry::Deb822(e) => e.set_matching_pattern(pattern),
581        }
582    }
583
584    /// Get the line number (0-indexed) where this entry starts
585    ///
586    /// For line-based formats (v1-4), this returns the actual line number in the file.
587    /// For deb822 format (v5), this returns the line where the paragraph starts.
588    ///
589    /// # Examples
590    ///
591    /// ```
592    /// # #[cfg(feature = "linebased")]
593    /// # {
594    /// use debian_watch::parse::parse;
595    ///
596    /// let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
597    /// let wf = parse(content).unwrap();
598    /// let entries: Vec<_> = wf.entries().collect();
599    /// assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
600    /// assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
601    /// # }
602    /// ```
603    pub fn line(&self) -> usize {
604        match self {
605            #[cfg(feature = "linebased")]
606            ParsedEntry::LineBased(e) => e.line(),
607            #[cfg(feature = "deb822")]
608            ParsedEntry::Deb822(e) => e.line(),
609        }
610    }
611
612    /// Remove/delete an option from the entry
613    ///
614    /// For v5 (deb822) entries, this removes a field from the paragraph.
615    /// For v1-4 (line-based) entries, this removes an option from the opts= list.
616    /// If this is the last option in a line-based entry, the entire opts= declaration is removed.
617    ///
618    /// # Examples
619    ///
620    /// ```
621    /// # #[cfg(feature = "linebased")]
622    /// # {
623    /// use debian_watch::parse::ParsedWatchFile;
624    /// use debian_watch::WatchOption;
625    ///
626    /// let mut wf = ParsedWatchFile::new(4).unwrap();
627    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
628    /// entry.set_option(WatchOption::Compression(debian_watch::Compression::Xz));
629    /// assert!(entry.has_option("compression"));
630    /// entry.remove_option(WatchOption::Compression(debian_watch::Compression::Xz));
631    /// assert!(!entry.has_option("compression"));
632    /// # }
633    /// ```
634    pub fn remove_option(&mut self, option: crate::types::WatchOption) {
635        match self {
636            #[cfg(feature = "linebased")]
637            ParsedEntry::LineBased(e) => e.del_opt(option),
638            #[cfg(feature = "deb822")]
639            ParsedEntry::Deb822(e) => e.delete_option(option),
640        }
641    }
642
643    /// Retrieve the mode of the watch file entry.
644    ///
645    /// Returns the mode with default fallback to `Mode::LWP` if not specified.
646    /// Returns an error if the mode value is invalid.
647    ///
648    /// # Examples
649    ///
650    /// ```
651    /// # #[cfg(feature = "linebased")]
652    /// # {
653    /// use debian_watch::parse::ParsedWatchFile;
654    /// use debian_watch::{WatchOption, Mode};
655    ///
656    /// let mut wf = ParsedWatchFile::new(4).unwrap();
657    /// let mut entry = wf.add_entry("https://github.com/foo/bar/tags", ".*/v?([\\d.]+)\\.tar\\.gz");
658    ///
659    /// // Default mode is LWP
660    /// assert_eq!(entry.mode().unwrap(), Mode::LWP);
661    ///
662    /// // Set git mode
663    /// entry.set_option(WatchOption::Mode(Mode::Git));
664    /// assert_eq!(entry.mode().unwrap(), Mode::Git);
665    /// # }
666    /// ```
667    pub fn mode(&self) -> Result<crate::types::Mode, crate::types::ParseError> {
668        match self {
669            #[cfg(feature = "linebased")]
670            ParsedEntry::LineBased(e) => e.try_mode(),
671            #[cfg(feature = "deb822")]
672            ParsedEntry::Deb822(e) => e.mode(),
673        }
674    }
675}
676
677/// Look up the byte range of a deb822 entry by trying each name in
678/// `names` in order. Returns the first match's range. Used by the
679/// watch-file range helpers to handle aliased fields (`Source` vs
680/// `URL`) without spelling out two lookups at every call site.
681#[cfg(feature = "deb822")]
682fn deb822_field_range(
683    paragraph: &deb822_lossless::Paragraph,
684    names: &[&str],
685) -> Option<rowan::TextRange> {
686    for name in names {
687        if let Some(entry) = paragraph.get_entry(name) {
688            return Some(entry.text_range());
689        }
690    }
691    None
692}
693
694impl std::fmt::Display for ParsedWatchFile {
695    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
696        match self {
697            #[cfg(feature = "linebased")]
698            ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
699            #[cfg(feature = "deb822")]
700            ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
701        }
702    }
703}
704
705/// Parse a watch file with automatic format detection
706///
707/// This function detects whether the input is line-based (v1-4) or
708/// deb822 format (v5) and parses it accordingly, returning a unified
709/// ParsedWatchFile enum.
710///
711/// # Examples
712///
713/// ```
714/// # #[cfg(feature = "linebased")]
715/// # {
716/// use debian_watch::parse::parse;
717///
718/// let content = "version=4\nhttps://example.com/ .*.tar.gz";
719/// let parsed = parse(content).unwrap();
720/// assert_eq!(parsed.version(), 4);
721/// # }
722/// ```
723pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
724    let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
725
726    match version {
727        #[cfg(feature = "linebased")]
728        WatchFileVersion::LineBased(_v) => {
729            let wf: crate::linebased::WatchFile = content.parse().map_err(ParseError::LineBased)?;
730            Ok(ParsedWatchFile::LineBased(wf))
731        }
732        #[cfg(not(feature = "linebased"))]
733        WatchFileVersion::LineBased(_v) => Err(ParseError::FeatureNotEnabled(
734            "linebased feature required for v1-4 formats".to_string(),
735        )),
736        #[cfg(feature = "deb822")]
737        WatchFileVersion::Deb822 => {
738            let wf: crate::deb822::WatchFile = content.parse().map_err(ParseError::Deb822)?;
739            Ok(ParsedWatchFile::Deb822(wf))
740        }
741        #[cfg(not(feature = "deb822"))]
742        WatchFileVersion::Deb822 => Err(ParseError::FeatureNotEnabled(
743            "deb822 feature required for v5 format".to_string(),
744        )),
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_detect_version_v1_default() {
754        let content = "https://example.com/ .*.tar.gz";
755        assert_eq!(
756            detect_version(content),
757            Some(WatchFileVersion::LineBased(1))
758        );
759    }
760
761    #[test]
762    fn test_detect_version_v4() {
763        let content = "version=4\nhttps://example.com/ .*.tar.gz";
764        assert_eq!(
765            detect_version(content),
766            Some(WatchFileVersion::LineBased(4))
767        );
768    }
769
770    #[test]
771    fn test_detect_version_v4_with_spaces() {
772        let content = "version = 4\nhttps://example.com/ .*.tar.gz";
773        assert_eq!(
774            detect_version(content),
775            Some(WatchFileVersion::LineBased(4))
776        );
777    }
778
779    #[test]
780    fn test_detect_version_v5() {
781        let content = "Version: 5\n\nSource: https://example.com/";
782        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
783    }
784
785    #[test]
786    fn test_detect_version_v5_lowercase() {
787        let content = "version: 5\n\nSource: https://example.com/";
788        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
789    }
790
791    #[test]
792    fn test_detect_version_with_leading_comments() {
793        let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
794        assert_eq!(
795            detect_version(content),
796            Some(WatchFileVersion::LineBased(4))
797        );
798    }
799
800    #[test]
801    fn test_detect_version_with_leading_whitespace() {
802        let content = "  \n  version=3\nhttps://example.com/ .*.tar.gz";
803        assert_eq!(
804            detect_version(content),
805            Some(WatchFileVersion::LineBased(3))
806        );
807    }
808
809    #[test]
810    fn test_detect_version_v2() {
811        let content = "version=2\nhttps://example.com/ .*.tar.gz";
812        assert_eq!(
813            detect_version(content),
814            Some(WatchFileVersion::LineBased(2))
815        );
816    }
817
818    #[cfg(feature = "linebased")]
819    #[test]
820    fn test_parse_linebased() {
821        let content = "version=4\nhttps://example.com/ .*.tar.gz";
822        let parsed = parse(content).unwrap();
823        assert_eq!(parsed.version(), 4);
824    }
825
826    #[cfg(feature = "deb822")]
827    #[test]
828    fn test_parse_deb822() {
829        let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
830        let parsed = parse(content).unwrap();
831        assert_eq!(parsed.version(), 5);
832    }
833
834    #[cfg(all(feature = "linebased", feature = "deb822"))]
835    #[test]
836    fn test_parse_both_formats() {
837        // Test v4
838        let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
839        let v4_parsed = parse(v4_content).unwrap();
840        assert_eq!(v4_parsed.version(), 4);
841
842        // Test v5
843        let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
844        let v5_parsed = parse(v5_content).unwrap();
845        assert_eq!(v5_parsed.version(), 5);
846    }
847
848    #[cfg(feature = "linebased")]
849    #[test]
850    fn test_parse_roundtrip() {
851        let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
852        let parsed = parse(content).unwrap();
853        let output = parsed.to_string();
854
855        // Parse again
856        let reparsed = parse(&output).unwrap();
857        assert_eq!(reparsed.version(), 4);
858    }
859
860    #[cfg(feature = "deb822")]
861    #[test]
862    fn test_parsed_watch_file_new_v5() {
863        let wf = ParsedWatchFile::new(5).unwrap();
864        assert_eq!(wf.version(), 5);
865        assert_eq!(wf.entries().count(), 0);
866    }
867
868    #[cfg(feature = "linebased")]
869    #[test]
870    fn test_parsed_watch_file_new_v4() {
871        let wf = ParsedWatchFile::new(4).unwrap();
872        assert_eq!(wf.version(), 4);
873        assert_eq!(wf.entries().count(), 0);
874    }
875
876    #[cfg(feature = "deb822")]
877    #[test]
878    fn test_parsed_watch_file_add_entry_v5() {
879        let mut wf = ParsedWatchFile::new(5).unwrap();
880        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
881
882        assert_eq!(wf.entries().count(), 1);
883        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
884        assert_eq!(
885            entry.matching_pattern(),
886            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
887        );
888
889        // Test setting options with enum
890        entry.set_option(crate::types::WatchOption::Component("upstream".to_string()));
891        entry.set_option(crate::types::WatchOption::Compression(
892            crate::types::Compression::Xz,
893        ));
894
895        assert_eq!(entry.get_option("Component"), Some("upstream".to_string()));
896        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
897    }
898
899    #[cfg(feature = "linebased")]
900    #[test]
901    fn test_parsed_watch_file_add_entry_v4() {
902        let mut wf = ParsedWatchFile::new(4).unwrap();
903        let entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
904
905        assert_eq!(wf.entries().count(), 1);
906        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
907        assert_eq!(
908            entry.matching_pattern(),
909            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
910        );
911    }
912
913    #[cfg(feature = "deb822")]
914    #[test]
915    fn test_parsed_watch_file_roundtrip_with_add_entry() {
916        let mut wf = ParsedWatchFile::new(5).unwrap();
917        let mut entry = wf.add_entry(
918            "https://github.com/owner/repo/tags",
919            r".*/v?([\d.]+)\.tar\.gz",
920        );
921        entry.set_option(crate::types::WatchOption::Compression(
922            crate::types::Compression::Xz,
923        ));
924
925        let output = wf.to_string();
926
927        // Parse again
928        let reparsed = parse(&output).unwrap();
929        assert_eq!(reparsed.version(), 5);
930
931        let entries: Vec<_> = reparsed.entries().collect();
932        assert_eq!(entries.len(), 1);
933        assert_eq!(entries[0].url(), "https://github.com/owner/repo/tags");
934        assert_eq!(entries[0].get_option("Compression"), Some("xz".to_string()));
935    }
936
937    #[cfg(feature = "linebased")]
938    #[test]
939    fn test_parsed_entry_set_url_v4() {
940        let mut wf = ParsedWatchFile::new(4).unwrap();
941        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
942
943        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
944
945        entry.set_url("https://github.com/foo/bar/releases");
946        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
947    }
948
949    #[cfg(feature = "deb822")]
950    #[test]
951    fn test_parsed_entry_set_url_v5() {
952        let mut wf = ParsedWatchFile::new(5).unwrap();
953        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
954
955        assert_eq!(entry.url(), "https://github.com/foo/bar/tags");
956
957        entry.set_url("https://github.com/foo/bar/releases");
958        assert_eq!(entry.url(), "https://github.com/foo/bar/releases");
959    }
960
961    #[cfg(feature = "linebased")]
962    #[test]
963    fn test_parsed_entry_set_matching_pattern_v4() {
964        let mut wf = ParsedWatchFile::new(4).unwrap();
965        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
966
967        assert_eq!(
968            entry.matching_pattern(),
969            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
970        );
971
972        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
973        assert_eq!(
974            entry.matching_pattern(),
975            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
976        );
977    }
978
979    #[cfg(feature = "deb822")]
980    #[test]
981    fn test_parsed_entry_set_matching_pattern_v5() {
982        let mut wf = ParsedWatchFile::new(5).unwrap();
983        let mut entry = wf.add_entry("https://github.com/foo/bar/tags", r".*/v?([\d.]+)\.tar\.gz");
984
985        assert_eq!(
986            entry.matching_pattern(),
987            Some(r".*/v?([\d.]+)\.tar\.gz".to_string())
988        );
989
990        entry.set_matching_pattern(r".*/release-([\d.]+)\.tar\.gz");
991        assert_eq!(
992            entry.matching_pattern(),
993            Some(r".*/release-([\d.]+)\.tar\.gz".to_string())
994        );
995    }
996
997    #[cfg(feature = "linebased")]
998    #[test]
999    fn test_parsed_entry_line_v4() {
1000        let content = "version=4\nhttps://example.com/ .*.tar.gz\nhttps://example2.com/ .*.tar.gz";
1001        let wf = parse(content).unwrap();
1002        let entries: Vec<_> = wf.entries().collect();
1003
1004        assert_eq!(entries[0].line(), 1); // Second line (0-indexed)
1005        assert_eq!(entries[1].line(), 2); // Third line (0-indexed)
1006    }
1007
1008    #[cfg(feature = "deb822")]
1009    #[test]
1010    fn test_parsed_entry_line_v5() {
1011        let content = r#"Version: 5
1012
1013Source: https://example.com/repo1
1014Matching-Pattern: .*\.tar\.gz
1015
1016Source: https://example.com/repo2
1017Matching-Pattern: .*\.tar\.xz
1018"#;
1019        let wf = parse(content).unwrap();
1020        let entries: Vec<_> = wf.entries().collect();
1021
1022        assert_eq!(entries[0].line(), 2); // Third line (0-indexed)
1023        assert_eq!(entries[1].line(), 5); // Sixth line (0-indexed)
1024    }
1025
1026    #[cfg(feature = "linebased")]
1027    #[test]
1028    fn test_url_range_linebased() {
1029        let content = "version=4\nhttps://example.com/ .*-([\\d.]+)\\.tar\\.gz\n";
1030        let wf = parse(content).unwrap();
1031        let entry = wf.entries().next().unwrap();
1032        let range = entry.url_range().expect("entry has url");
1033        let start: usize = range.start().into();
1034        let end: usize = range.end().into();
1035        assert_eq!(&content[start..end], "https://example.com/");
1036    }
1037
1038    #[cfg(feature = "linebased")]
1039    #[test]
1040    fn test_matching_pattern_range_linebased() {
1041        let content = "version=4\nhttps://example.com/ .*-([\\d.]+)\\.tar\\.gz\n";
1042        let wf = parse(content).unwrap();
1043        let entry = wf.entries().next().unwrap();
1044        let range = entry.matching_pattern_range().expect("has pattern");
1045        let start: usize = range.start().into();
1046        let end: usize = range.end().into();
1047        assert_eq!(&content[start..end], ".*-([\\d.]+)\\.tar\\.gz");
1048    }
1049
1050    #[cfg(feature = "linebased")]
1051    #[test]
1052    fn test_option_range_linebased() {
1053        let content = "version=4\nopts=mode=git,pretty=raw https://example.com/ .*\n";
1054        let wf = parse(content).unwrap();
1055        let entry = wf.entries().next().unwrap();
1056        let mode = entry.option_range("mode").expect("mode option");
1057        let start: usize = mode.start().into();
1058        let end: usize = mode.end().into();
1059        assert_eq!(&content[start..end], "mode=git");
1060
1061        let pretty = entry.option_range("pretty").expect("pretty option");
1062        let start: usize = pretty.start().into();
1063        let end: usize = pretty.end().into();
1064        assert_eq!(&content[start..end], "pretty=raw");
1065
1066        assert!(entry.option_range("not-a-real-option").is_none());
1067    }
1068
1069    #[cfg(feature = "linebased")]
1070    #[test]
1071    fn test_version_range_linebased() {
1072        let content = "version=4\nhttps://example.com/ .*\n";
1073        let wf = parse(content).unwrap();
1074        let range = wf.version_range().expect("has version");
1075        let start: usize = range.start().into();
1076        let end: usize = range.end().into();
1077        assert_eq!(&content[start..end], "version=4\n");
1078    }
1079
1080    #[cfg(feature = "deb822")]
1081    #[test]
1082    fn test_url_range_deb822() {
1083        let content =
1084            "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: .*\\.tar\\.gz\n";
1085        let wf = parse(content).unwrap();
1086        let entry = wf.entries().next().unwrap();
1087        let range = entry.url_range().expect("has source");
1088        let start: usize = range.start().into();
1089        let end: usize = range.end().into();
1090        // The range covers the whole `Source: ...` entry, ending after
1091        // the trailing newline.
1092        assert_eq!(&content[start..end], "Source: https://example.com/foo\n");
1093    }
1094
1095    #[cfg(feature = "deb822")]
1096    #[test]
1097    fn test_matching_pattern_range_deb822() {
1098        let content =
1099            "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: v(.+)\\.tar\\.gz\n";
1100        let wf = parse(content).unwrap();
1101        let entry = wf.entries().next().unwrap();
1102        let range = entry.matching_pattern_range().expect("has pattern");
1103        let start: usize = range.start().into();
1104        let end: usize = range.end().into();
1105        assert_eq!(&content[start..end], "Matching-Pattern: v(.+)\\.tar\\.gz\n");
1106    }
1107
1108    #[cfg(feature = "deb822")]
1109    #[test]
1110    fn test_option_range_deb822_lookup_capitalises_key() {
1111        // The line-based format uses `mode=git`; deb822 v5 spells the
1112        // same option as `Mode: git`. option_range looks up either
1113        // case, so callers using the line-based naming convention
1114        // still work against v5 files.
1115        let content =
1116            "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: x\nMode: git\n";
1117        let wf = parse(content).unwrap();
1118        let entry = wf.entries().next().unwrap();
1119        let range = entry.option_range("mode").expect("mode field");
1120        let start: usize = range.start().into();
1121        let end: usize = range.end().into();
1122        assert_eq!(&content[start..end], "Mode: git\n");
1123    }
1124
1125    #[cfg(feature = "deb822")]
1126    #[test]
1127    fn test_version_range_deb822() {
1128        let content = "Version: 5\n\nSource: https://example.com/foo\nMatching-Pattern: x\n";
1129        let wf = parse(content).unwrap();
1130        let range = wf.version_range().expect("has version");
1131        let start: usize = range.start().into();
1132        let end: usize = range.end().into();
1133        assert_eq!(&content[start..end], "Version: 5\n");
1134    }
1135
1136    #[cfg(feature = "deb822")]
1137    #[test]
1138    fn test_template_range_deb822() {
1139        let content = "Version: 5\n\nSource: https://github.com/foo/bar\nTemplate: GitHub\n";
1140        let wf = parse(content).unwrap();
1141        let entry = wf.entries().next().unwrap();
1142        let range = entry.template_range().expect("has template");
1143        let start: usize = range.start().into();
1144        let end: usize = range.end().into();
1145        assert_eq!(&content[start..end], "Template: GitHub\n");
1146        assert_eq!(entry.template_kind(), Some("GitHub".to_string()));
1147    }
1148}
1149
1150/// Thread-safe parse result for watch files, suitable for use in Salsa databases.
1151///
1152/// This wrapper provides a thread-safe interface around the parsed watch file,
1153/// storing either a line-based parse tree or the raw text for deb822 format.
1154/// The underlying lossless parse trees (based on rowan's GreenNode) are thread-safe.
1155#[derive(Clone, PartialEq, Eq)]
1156pub struct Parse {
1157    inner: ParseInner,
1158}
1159
1160#[derive(Clone, PartialEq, Eq)]
1161enum ParseInner {
1162    #[cfg(feature = "linebased")]
1163    LineBased(crate::linebased::Parse<crate::linebased::WatchFile>),
1164    #[cfg(feature = "deb822")]
1165    Deb822(deb822_lossless::Parse<deb822_lossless::Deb822>),
1166}
1167
1168impl Parse {
1169    /// Parse a watch file with automatic format detection
1170    pub fn parse(text: &str) -> Self {
1171        let version = detect_version(text);
1172
1173        let inner = match version {
1174            #[cfg(feature = "linebased")]
1175            Some(WatchFileVersion::LineBased(_)) => {
1176                ParseInner::LineBased(crate::linebased::parse_watch_file(text))
1177            }
1178            #[cfg(feature = "deb822")]
1179            Some(WatchFileVersion::Deb822) => {
1180                ParseInner::Deb822(deb822_lossless::Deb822::parse(text))
1181            }
1182            #[cfg(not(feature = "linebased"))]
1183            Some(WatchFileVersion::LineBased(_)) => {
1184                // Fallback to storing text if linebased feature is not enabled
1185                #[cfg(feature = "deb822")]
1186                {
1187                    ParseInner::Deb822(deb822_lossless::Deb822::parse(text))
1188                }
1189                #[cfg(not(feature = "deb822"))]
1190                {
1191                    panic!("No watch file parsing features enabled")
1192                }
1193            }
1194            #[cfg(not(feature = "deb822"))]
1195            Some(WatchFileVersion::Deb822) => {
1196                // Fallback to linebased if deb822 feature is not enabled
1197                #[cfg(feature = "linebased")]
1198                {
1199                    ParseInner::LineBased(crate::linebased::parse_watch_file(text))
1200                }
1201                #[cfg(not(feature = "linebased"))]
1202                {
1203                    panic!("No watch file parsing features enabled")
1204                }
1205            }
1206            None => {
1207                // Default to linebased v1 if we can't detect
1208                #[cfg(feature = "linebased")]
1209                {
1210                    ParseInner::LineBased(crate::linebased::parse_watch_file(text))
1211                }
1212                #[cfg(not(feature = "linebased"))]
1213                #[cfg(feature = "deb822")]
1214                {
1215                    ParseInner::Deb822(deb822_lossless::Deb822::parse(text))
1216                }
1217                #[cfg(not(any(feature = "linebased", feature = "deb822")))]
1218                {
1219                    panic!("No watch file parsing features enabled")
1220                }
1221            }
1222        };
1223
1224        Parse { inner }
1225    }
1226
1227    /// Get the parsed watch file
1228    pub fn to_watch_file(&self) -> ParsedWatchFile {
1229        match &self.inner {
1230            #[cfg(feature = "linebased")]
1231            ParseInner::LineBased(parse) => ParsedWatchFile::LineBased(parse.tree()),
1232            #[cfg(feature = "deb822")]
1233            ParseInner::Deb822(parse) => {
1234                let deb822 = parse.tree();
1235                ParsedWatchFile::Deb822(crate::deb822::WatchFile::from_deb822(deb822))
1236            }
1237        }
1238    }
1239
1240    /// Get the version of the watch file
1241    pub fn version(&self) -> u32 {
1242        match &self.inner {
1243            #[cfg(feature = "linebased")]
1244            ParseInner::LineBased(parse) => parse.tree().version(),
1245            #[cfg(feature = "deb822")]
1246            ParseInner::Deb822(_) => 5,
1247        }
1248    }
1249}
1250
1251// Implement Send + Sync since the underlying types are thread-safe
1252// Both variants store GreenNode (thread-safe) via their Parse types
1253unsafe impl Send for Parse {}
1254unsafe impl Sync for Parse {}