Skip to main content

debian_watch/
parse.rs

1//! Format detection and parsing for watch files
2
3/// Error type for parsing watch files
4#[derive(Debug)]
5pub enum ParseError {
6    /// Error parsing line-based format (v1-4)
7    #[cfg(feature = "linebased")]
8    LineBased(crate::linebased::ParseError),
9    /// Error parsing deb822 format (v5)
10    #[cfg(feature = "deb822")]
11    Deb822(crate::deb822::ParseError),
12    /// Could not detect version
13    UnknownVersion,
14    /// Feature not enabled
15    FeatureNotEnabled(String),
16}
17
18impl std::fmt::Display for ParseError {
19    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
20        match self {
21            #[cfg(feature = "linebased")]
22            ParseError::LineBased(e) => write!(f, "{}", e),
23            #[cfg(feature = "deb822")]
24            ParseError::Deb822(e) => write!(f, "{}", e),
25            ParseError::UnknownVersion => write!(f, "Could not detect watch file version"),
26            ParseError::FeatureNotEnabled(msg) => write!(f, "{}", msg),
27        }
28    }
29}
30
31impl std::error::Error for ParseError {}
32
33/// Detected watch file format
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WatchFileVersion {
36    /// Line-based format (versions 1-4)
37    LineBased(u32),
38    /// Deb822 format (version 5)
39    Deb822,
40}
41
42/// Detect the version/format of a watch file from its content
43///
44/// This function examines the content to determine if it's a line-based
45/// format (v1-4) or deb822 format (v5).
46///
47/// After detecting the version, you can either:
48/// - Use the `parse()` function to automatically parse and return a `ParsedWatchFile`
49/// - Parse directly: `content.parse::<debian_watch::linebased::WatchFile>()`
50///
51/// # Examples
52///
53/// ```
54/// use debian_watch::parse::{detect_version, WatchFileVersion};
55///
56/// let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
57/// assert_eq!(detect_version(v4_content), Some(WatchFileVersion::LineBased(4)));
58///
59/// let v5_content = "Version: 5\n\nSource: https://example.com/";
60/// assert_eq!(detect_version(v5_content), Some(WatchFileVersion::Deb822));
61/// ```
62pub fn detect_version(content: &str) -> Option<WatchFileVersion> {
63    let trimmed = content.trim_start();
64
65    // Check if it starts with RFC822-style "Version: 5"
66    if trimmed.starts_with("Version:") || trimmed.starts_with("version:") {
67        // Try to extract the version number
68        if let Some(first_line) = trimmed.lines().next() {
69            if let Some(colon_pos) = first_line.find(':') {
70                let version_str = first_line[colon_pos + 1..].trim();
71                if version_str == "5" {
72                    return Some(WatchFileVersion::Deb822);
73                }
74            }
75        }
76    }
77
78    // Otherwise, it's line-based format
79    // Try to detect the version from "version=N" line
80    for line in trimmed.lines() {
81        let line = line.trim();
82
83        // Skip comments and blank lines
84        if line.starts_with('#') || line.is_empty() {
85            continue;
86        }
87
88        // Check for version=N
89        if line.starts_with("version=") || line.starts_with("version =") {
90            let version_part = if line.starts_with("version=") {
91                &line[8..]
92            } else {
93                &line[9..]
94            };
95
96            if let Ok(version) = version_part.trim().parse::<u32>() {
97                return Some(WatchFileVersion::LineBased(version));
98            }
99        }
100
101        // If we hit a non-comment, non-version line, assume default version
102        break;
103    }
104
105    // Default to version 1 for line-based format
106    Some(WatchFileVersion::LineBased(crate::DEFAULT_VERSION))
107}
108
109/// Parsed watch file that can be either line-based or deb822 format
110#[derive(Debug)]
111pub enum ParsedWatchFile {
112    /// Line-based watch file (v1-4)
113    #[cfg(feature = "linebased")]
114    LineBased(crate::linebased::WatchFile),
115    /// Deb822 watch file (v5)
116    #[cfg(feature = "deb822")]
117    Deb822(crate::deb822::WatchFile),
118}
119
120/// Parsed watch entry that can be either line-based or deb822 format
121#[derive(Debug)]
122pub enum ParsedEntry {
123    /// Line-based entry (v1-4)
124    #[cfg(feature = "linebased")]
125    LineBased(crate::linebased::Entry),
126    /// Deb822 entry (v5)
127    #[cfg(feature = "deb822")]
128    Deb822(crate::deb822::Entry),
129}
130
131impl ParsedWatchFile {
132    /// Get the version of the watch file
133    pub fn version(&self) -> u32 {
134        match self {
135            #[cfg(feature = "linebased")]
136            ParsedWatchFile::LineBased(wf) => wf.version(),
137            #[cfg(feature = "deb822")]
138            ParsedWatchFile::Deb822(wf) => wf.version(),
139        }
140    }
141
142    /// Get an iterator over entries as ParsedEntry enum
143    pub fn entries(&self) -> impl Iterator<Item = ParsedEntry> + '_ {
144        // We need to collect because we can't return different iterator types from match arms
145        let entries: Vec<_> = match self {
146            #[cfg(feature = "linebased")]
147            ParsedWatchFile::LineBased(wf) => wf.entries().map(ParsedEntry::LineBased).collect(),
148            #[cfg(feature = "deb822")]
149            ParsedWatchFile::Deb822(wf) => wf.entries().map(ParsedEntry::Deb822).collect(),
150        };
151        entries.into_iter()
152    }
153}
154
155impl ParsedEntry {
156    /// Get the URL/Source of the entry
157    pub fn url(&self) -> String {
158        match self {
159            #[cfg(feature = "linebased")]
160            ParsedEntry::LineBased(e) => e.url(),
161            #[cfg(feature = "deb822")]
162            ParsedEntry::Deb822(e) => e.source().unwrap_or_default(),
163        }
164    }
165
166    /// Get the matching pattern
167    pub fn matching_pattern(&self) -> Option<String> {
168        match self {
169            #[cfg(feature = "linebased")]
170            ParsedEntry::LineBased(e) => e.matching_pattern(),
171            #[cfg(feature = "deb822")]
172            ParsedEntry::Deb822(e) => e.matching_pattern(),
173        }
174    }
175
176    /// Get a generic option/field value by key (case-insensitive)
177    ///
178    /// This handles the difference between line-based format (lowercase keys)
179    /// and deb822 format (capitalized keys). It tries the key as-is first,
180    /// then tries with the first letter capitalized.
181    pub fn get_option(&self, key: &str) -> Option<String> {
182        match self {
183            #[cfg(feature = "linebased")]
184            ParsedEntry::LineBased(e) => e.get_option(key),
185            #[cfg(feature = "deb822")]
186            ParsedEntry::Deb822(e) => {
187                // Try exact match first, then try capitalized
188                e.get_field(key).or_else(|| {
189                    let mut chars = key.chars();
190                    if let Some(first) = chars.next() {
191                        let capitalized = first.to_uppercase().chain(chars).collect::<String>();
192                        e.get_field(&capitalized)
193                    } else {
194                        None
195                    }
196                })
197            }
198        }
199    }
200
201    /// Check if an option/field is set (case-insensitive)
202    pub fn has_option(&self, key: &str) -> bool {
203        self.get_option(key).is_some()
204    }
205
206    /// Get the script
207    pub fn script(&self) -> Option<String> {
208        self.get_option("script")
209    }
210
211    /// Format the URL with package substitution
212    pub fn format_url(
213        &self,
214        package: impl FnOnce() -> String,
215    ) -> Result<url::Url, url::ParseError> {
216        crate::subst::subst(&self.url(), package).parse()
217    }
218
219    /// Get the user agent
220    pub fn user_agent(&self) -> Option<String> {
221        self.get_option("user-agent")
222    }
223
224    /// Get the pagemangle option
225    pub fn pagemangle(&self) -> Option<String> {
226        self.get_option("pagemangle")
227    }
228
229    /// Get the uversionmangle option
230    pub fn uversionmangle(&self) -> Option<String> {
231        self.get_option("uversionmangle")
232    }
233
234    /// Get the downloadurlmangle option
235    pub fn downloadurlmangle(&self) -> Option<String> {
236        self.get_option("downloadurlmangle")
237    }
238
239    /// Get the pgpsigurlmangle option
240    pub fn pgpsigurlmangle(&self) -> Option<String> {
241        self.get_option("pgpsigurlmangle")
242    }
243
244    /// Get the filenamemangle option
245    pub fn filenamemangle(&self) -> Option<String> {
246        self.get_option("filenamemangle")
247    }
248
249    /// Get the oversionmangle option
250    pub fn oversionmangle(&self) -> Option<String> {
251        self.get_option("oversionmangle")
252    }
253
254    /// Get the searchmode, with default fallback
255    pub fn searchmode(&self) -> crate::types::SearchMode {
256        self.get_option("searchmode")
257            .and_then(|s| s.parse().ok())
258            .unwrap_or_default()
259    }
260}
261
262impl std::fmt::Display for ParsedWatchFile {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            #[cfg(feature = "linebased")]
266            ParsedWatchFile::LineBased(wf) => write!(f, "{}", wf),
267            #[cfg(feature = "deb822")]
268            ParsedWatchFile::Deb822(wf) => write!(f, "{}", wf),
269        }
270    }
271}
272
273/// Parse a watch file with automatic format detection
274///
275/// This function detects whether the input is line-based (v1-4) or
276/// deb822 format (v5) and parses it accordingly, returning a unified
277/// ParsedWatchFile enum.
278///
279/// # Examples
280///
281/// ```
282/// # #[cfg(feature = "linebased")]
283/// # {
284/// use debian_watch::parse::parse;
285///
286/// let content = "version=4\nhttps://example.com/ .*.tar.gz";
287/// let parsed = parse(content).unwrap();
288/// assert_eq!(parsed.version(), 4);
289/// # }
290/// ```
291pub fn parse(content: &str) -> Result<ParsedWatchFile, ParseError> {
292    let version = detect_version(content).ok_or(ParseError::UnknownVersion)?;
293
294    match version {
295        #[cfg(feature = "linebased")]
296        WatchFileVersion::LineBased(_v) => {
297            let wf: crate::linebased::WatchFile = content
298                .parse()
299                .map_err(ParseError::LineBased)?;
300            Ok(ParsedWatchFile::LineBased(wf))
301        }
302        #[cfg(not(feature = "linebased"))]
303        WatchFileVersion::LineBased(_v) => {
304            Err(ParseError::FeatureNotEnabled("linebased feature required for v1-4 formats".to_string()))
305        }
306        #[cfg(feature = "deb822")]
307        WatchFileVersion::Deb822 => {
308            let wf: crate::deb822::WatchFile = content
309                .parse()
310                .map_err(ParseError::Deb822)?;
311            Ok(ParsedWatchFile::Deb822(wf))
312        }
313        #[cfg(not(feature = "deb822"))]
314        WatchFileVersion::Deb822 => {
315            Err(ParseError::FeatureNotEnabled("deb822 feature required for v5 format".to_string()))
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_detect_version_v1_default() {
326        let content = "https://example.com/ .*.tar.gz";
327        assert_eq!(
328            detect_version(content),
329            Some(WatchFileVersion::LineBased(1))
330        );
331    }
332
333    #[test]
334    fn test_detect_version_v4() {
335        let content = "version=4\nhttps://example.com/ .*.tar.gz";
336        assert_eq!(
337            detect_version(content),
338            Some(WatchFileVersion::LineBased(4))
339        );
340    }
341
342    #[test]
343    fn test_detect_version_v4_with_spaces() {
344        let content = "version = 4\nhttps://example.com/ .*.tar.gz";
345        assert_eq!(
346            detect_version(content),
347            Some(WatchFileVersion::LineBased(4))
348        );
349    }
350
351    #[test]
352    fn test_detect_version_v5() {
353        let content = "Version: 5\n\nSource: https://example.com/";
354        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
355    }
356
357    #[test]
358    fn test_detect_version_v5_lowercase() {
359        let content = "version: 5\n\nSource: https://example.com/";
360        assert_eq!(detect_version(content), Some(WatchFileVersion::Deb822));
361    }
362
363    #[test]
364    fn test_detect_version_with_leading_comments() {
365        let content = "# This is a comment\nversion=4\nhttps://example.com/ .*.tar.gz";
366        assert_eq!(
367            detect_version(content),
368            Some(WatchFileVersion::LineBased(4))
369        );
370    }
371
372    #[test]
373    fn test_detect_version_with_leading_whitespace() {
374        let content = "  \n  version=3\nhttps://example.com/ .*.tar.gz";
375        assert_eq!(
376            detect_version(content),
377            Some(WatchFileVersion::LineBased(3))
378        );
379    }
380
381    #[test]
382    fn test_detect_version_v2() {
383        let content = "version=2\nhttps://example.com/ .*.tar.gz";
384        assert_eq!(
385            detect_version(content),
386            Some(WatchFileVersion::LineBased(2))
387        );
388    }
389
390    #[cfg(feature = "linebased")]
391    #[test]
392    fn test_parse_linebased() {
393        let content = "version=4\nhttps://example.com/ .*.tar.gz";
394        let parsed = parse(content).unwrap();
395        assert_eq!(parsed.version(), 4);
396    }
397
398    #[cfg(feature = "deb822")]
399    #[test]
400    fn test_parse_deb822() {
401        let content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
402        let parsed = parse(content).unwrap();
403        assert_eq!(parsed.version(), 5);
404    }
405
406    #[cfg(all(feature = "linebased", feature = "deb822"))]
407    #[test]
408    fn test_parse_both_formats() {
409        // Test v4
410        let v4_content = "version=4\nhttps://example.com/ .*.tar.gz";
411        let v4_parsed = parse(v4_content).unwrap();
412        assert_eq!(v4_parsed.version(), 4);
413
414        // Test v5
415        let v5_content = "Version: 5\n\nSource: https://example.com/\nMatching-Pattern: .*.tar.gz";
416        let v5_parsed = parse(v5_content).unwrap();
417        assert_eq!(v5_parsed.version(), 5);
418    }
419
420    #[cfg(feature = "linebased")]
421    #[test]
422    fn test_parse_roundtrip() {
423        let content = "version=4\n# Comment\nhttps://example.com/ .*.tar.gz";
424        let parsed = parse(content).unwrap();
425        let output = parsed.to_string();
426
427        // Parse again
428        let reparsed = parse(&output).unwrap();
429        assert_eq!(reparsed.version(), 4);
430    }
431}