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