debian_watch/
parse.rs

1use crate::lex::lex;
2use crate::types::*;
3use crate::SyntaxKind;
4use crate::SyntaxKind::*;
5use crate::DEFAULT_VERSION;
6use std::io::Read;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10/// Error type for parse errors
11pub struct ParseError(Vec<String>);
12
13impl std::fmt::Display for ParseError {
14    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
15        for err in &self.0 {
16            writeln!(f, "{}", err)?;
17        }
18        Ok(())
19    }
20}
21
22impl std::error::Error for ParseError {}
23
24/// Second, implementing the `Language` trait teaches rowan to convert between
25/// these two SyntaxKind types, allowing for a nicer SyntaxNode API where
26/// "kinds" are values from our `enum SyntaxKind`, instead of plain u16 values.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28enum Lang {}
29impl rowan::Language for Lang {
30    type Kind = SyntaxKind;
31    fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind {
32        unsafe { std::mem::transmute::<u16, SyntaxKind>(raw.0) }
33    }
34    fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind {
35        kind.into()
36    }
37}
38
39/// GreenNode is an immutable tree, which is cheap to change,
40/// but doesn't contain offsets and parent pointers.
41use rowan::GreenNode;
42
43/// You can construct GreenNodes by hand, but a builder
44/// is helpful for top-down parsers: it maintains a stack
45/// of currently in-progress nodes
46use rowan::GreenNodeBuilder;
47
48/// The parse results are stored as a "green tree".
49/// We'll discuss working with the results later
50struct Parse {
51    green_node: GreenNode,
52    #[allow(unused)]
53    errors: Vec<String>,
54    #[allow(unused)]
55    version: i32,
56}
57
58fn parse(text: &str) -> Parse {
59    struct Parser {
60        /// input tokens, including whitespace,
61        /// in *reverse* order.
62        tokens: Vec<(SyntaxKind, String)>,
63        /// the in-progress tree.
64        builder: GreenNodeBuilder<'static>,
65        /// the list of syntax errors we've accumulated
66        /// so far.
67        errors: Vec<String>,
68    }
69
70    impl Parser {
71        fn parse_version(&mut self) -> Option<i32> {
72            let mut version = None;
73            if self.tokens.last() == Some(&(KEY, "version".to_string())) {
74                self.builder.start_node(VERSION.into());
75                self.bump();
76                self.skip_ws();
77                if self.current() != Some(EQUALS) {
78                    self.builder.start_node(ERROR.into());
79                    self.errors.push("expected `=`".to_string());
80                    self.bump();
81                    self.builder.finish_node();
82                } else {
83                    self.bump();
84                }
85                if self.current() != Some(VALUE) {
86                    self.builder.start_node(ERROR.into());
87                    self.errors
88                        .push(format!("expected value, got {:?}", self.current()));
89                    self.bump();
90                    self.builder.finish_node();
91                } else {
92                    let version_str = self.tokens.last().unwrap().1.clone();
93                    match version_str.parse() {
94                        Ok(v) => {
95                            version = Some(v);
96                            self.bump();
97                        }
98                        Err(_) => {
99                            self.builder.start_node(ERROR.into());
100                            self.errors
101                                .push(format!("invalid version: {}", version_str));
102                            self.bump();
103                            self.builder.finish_node();
104                        }
105                    }
106                }
107                if self.current() != Some(NEWLINE) {
108                    self.builder.start_node(ERROR.into());
109                    self.errors.push("expected newline".to_string());
110                    self.bump();
111                    self.builder.finish_node();
112                } else {
113                    self.bump();
114                }
115                self.builder.finish_node();
116            }
117            version
118        }
119
120        fn parse_watch_entry(&mut self) -> bool {
121            self.skip_ws();
122            if self.current().is_none() {
123                return false;
124            }
125            if self.current() == Some(NEWLINE) {
126                self.bump();
127                return false;
128            }
129            self.builder.start_node(ENTRY.into());
130            self.parse_options_list();
131            for i in 0..4 {
132                if self.current() == Some(NEWLINE) {
133                    break;
134                }
135                if self.current() == Some(CONTINUATION) {
136                    self.bump();
137                    self.skip_ws();
138                    continue;
139                }
140                if self.current() != Some(VALUE) && self.current() != Some(KEY) {
141                    self.builder.start_node(ERROR.into());
142                    self.errors.push(format!(
143                        "expected value, got {:?} (i={})",
144                        self.current(),
145                        i
146                    ));
147                    if self.current().is_some() {
148                        self.bump();
149                    }
150                    self.builder.finish_node();
151                } else {
152                    // Wrap each field in its appropriate node
153                    match i {
154                        0 => {
155                            // URL
156                            self.builder.start_node(URL.into());
157                            self.bump();
158                            self.builder.finish_node();
159                        }
160                        1 => {
161                            // Matching pattern
162                            self.builder.start_node(MATCHING_PATTERN.into());
163                            self.bump();
164                            self.builder.finish_node();
165                        }
166                        2 => {
167                            // Version policy
168                            self.builder.start_node(VERSION_POLICY.into());
169                            self.bump();
170                            self.builder.finish_node();
171                        }
172                        3 => {
173                            // Script
174                            self.builder.start_node(SCRIPT.into());
175                            self.bump();
176                            self.builder.finish_node();
177                        }
178                        _ => {
179                            self.bump();
180                        }
181                    }
182                }
183                self.skip_ws();
184            }
185            if self.current() != Some(NEWLINE) && self.current().is_some() {
186                self.builder.start_node(ERROR.into());
187                self.errors
188                    .push(format!("expected newline, not {:?}", self.current()));
189                if self.current().is_some() {
190                    self.bump();
191                }
192                self.builder.finish_node();
193            } else {
194                self.bump();
195            }
196            self.builder.finish_node();
197            true
198        }
199
200        fn parse_option(&mut self) -> bool {
201            if self.current().is_none() {
202                return false;
203            }
204            while self.current() == Some(CONTINUATION) {
205                self.bump();
206            }
207            if self.current() == Some(WHITESPACE) {
208                return false;
209            }
210            self.builder.start_node(OPTION.into());
211            if self.current() != Some(KEY) {
212                self.builder.start_node(ERROR.into());
213                self.errors.push("expected key".to_string());
214                self.bump();
215                self.builder.finish_node();
216            } else {
217                self.bump();
218            }
219            if self.current() == Some(EQUALS) {
220                self.bump();
221                if self.current() != Some(VALUE) && self.current() != Some(KEY) {
222                    self.builder.start_node(ERROR.into());
223                    self.errors
224                        .push(format!("expected value, got {:?}", self.current()));
225                    self.bump();
226                    self.builder.finish_node();
227                } else {
228                    self.bump();
229                }
230            } else if self.current() == Some(COMMA) {
231            } else {
232                self.builder.start_node(ERROR.into());
233                self.errors.push("expected `=`".to_string());
234                if self.current().is_some() {
235                    self.bump();
236                }
237                self.builder.finish_node();
238            }
239            self.builder.finish_node();
240            true
241        }
242
243        fn parse_options_list(&mut self) {
244            self.skip_ws();
245            if self.tokens.last() == Some(&(KEY, "opts".to_string()))
246                || self.tokens.last() == Some(&(KEY, "options".to_string()))
247            {
248                self.builder.start_node(OPTS_LIST.into());
249                self.bump();
250                self.skip_ws();
251                if self.current() != Some(EQUALS) {
252                    self.builder.start_node(ERROR.into());
253                    self.errors.push("expected `=`".to_string());
254                    if self.current().is_some() {
255                        self.bump();
256                    }
257                    self.builder.finish_node();
258                } else {
259                    self.bump();
260                }
261                let quoted = if self.current() == Some(QUOTE) {
262                    self.bump();
263                    true
264                } else {
265                    false
266                };
267                loop {
268                    if quoted {
269                        if self.current() == Some(QUOTE) {
270                            self.bump();
271                            break;
272                        }
273                        self.skip_ws();
274                    }
275                    if !self.parse_option() {
276                        break;
277                    }
278                    if self.current() == Some(COMMA) {
279                        self.builder.start_node(OPTION_SEPARATOR.into());
280                        self.bump();
281                        self.builder.finish_node();
282                    } else if !quoted {
283                        break;
284                    }
285                }
286                self.builder.finish_node();
287                self.skip_ws();
288            }
289        }
290
291        fn parse(mut self) -> Parse {
292            let mut version = 1;
293            // Make sure that the root node covers all source
294            self.builder.start_node(ROOT.into());
295            if let Some(v) = self.parse_version() {
296                version = v;
297            }
298            // TODO: use version to influence parsing
299            loop {
300                if !self.parse_watch_entry() {
301                    break;
302                }
303            }
304            // Don't forget to eat *trailing* whitespace
305            self.skip_ws();
306            // Close the root node.
307            self.builder.finish_node();
308
309            // Turn the builder into a GreenNode
310            Parse {
311                green_node: self.builder.finish(),
312                errors: self.errors,
313                version,
314            }
315        }
316        /// Advance one token, adding it to the current branch of the tree builder.
317        fn bump(&mut self) {
318            let (kind, text) = self.tokens.pop().unwrap();
319            self.builder.token(kind.into(), text.as_str());
320        }
321        /// Peek at the first unprocessed token
322        fn current(&self) -> Option<SyntaxKind> {
323            self.tokens.last().map(|(kind, _)| *kind)
324        }
325        fn skip_ws(&mut self) {
326            while self.current() == Some(WHITESPACE)
327                || self.current() == Some(CONTINUATION)
328                || self.current() == Some(COMMENT)
329            {
330                self.bump()
331            }
332        }
333    }
334
335    let mut tokens = lex(text);
336    tokens.reverse();
337    Parser {
338        tokens,
339        builder: GreenNodeBuilder::new(),
340        errors: Vec::new(),
341    }
342    .parse()
343}
344
345/// To work with the parse results we need a view into the
346/// green tree - the Syntax tree.
347/// It is also immutable, like a GreenNode,
348/// but it contains parent pointers, offsets, and
349/// has identity semantics.
350
351type SyntaxNode = rowan::SyntaxNode<Lang>;
352#[allow(unused)]
353type SyntaxToken = rowan::SyntaxToken<Lang>;
354#[allow(unused)]
355type SyntaxElement = rowan::NodeOrToken<SyntaxNode, SyntaxToken>;
356
357impl Parse {
358    fn syntax(&self) -> SyntaxNode {
359        SyntaxNode::new_root_mut(self.green_node.clone())
360    }
361
362    fn root(&self) -> WatchFile {
363        WatchFile::cast(self.syntax()).unwrap()
364    }
365}
366
367/// Calculate line and column (both 0-indexed) for the given offset in the tree.
368/// Column is measured in bytes from the start of the line.
369fn line_col_at_offset(node: &SyntaxNode, offset: rowan::TextSize) -> (usize, usize) {
370    let root = node.ancestors().last().unwrap_or_else(|| node.clone());
371    let mut line = 0;
372    let mut last_newline_offset = rowan::TextSize::from(0);
373
374    for element in root.preorder_with_tokens() {
375        if let rowan::WalkEvent::Enter(rowan::NodeOrToken::Token(token)) = element {
376            if token.text_range().start() >= offset {
377                break;
378            }
379
380            // Count newlines and track position of last one
381            for (idx, _) in token.text().match_indices('\n') {
382                line += 1;
383                last_newline_offset =
384                    token.text_range().start() + rowan::TextSize::from((idx + 1) as u32);
385            }
386        }
387    }
388
389    let column: usize = (offset - last_newline_offset).into();
390    (line, column)
391}
392
393macro_rules! ast_node {
394    ($ast:ident, $kind:ident) => {
395        #[derive(Clone, PartialEq, Eq, Hash)]
396        #[repr(transparent)]
397        /// A node in the syntax tree for $ast
398        pub struct $ast(SyntaxNode);
399        impl $ast {
400            #[allow(unused)]
401            fn cast(node: SyntaxNode) -> Option<Self> {
402                if node.kind() == $kind {
403                    Some(Self(node))
404                } else {
405                    None
406                }
407            }
408
409            /// Get the line number (0-indexed) where this node starts.
410            pub fn line(&self) -> usize {
411                line_col_at_offset(&self.0, self.0.text_range().start()).0
412            }
413
414            /// Get the column number (0-indexed, in bytes) where this node starts.
415            pub fn column(&self) -> usize {
416                line_col_at_offset(&self.0, self.0.text_range().start()).1
417            }
418
419            /// Get both line and column (0-indexed) where this node starts.
420            /// Returns (line, column) where column is measured in bytes from the start of the line.
421            pub fn line_col(&self) -> (usize, usize) {
422                line_col_at_offset(&self.0, self.0.text_range().start())
423            }
424        }
425
426        impl ToString for $ast {
427            fn to_string(&self) -> String {
428                self.0.text().to_string()
429            }
430        }
431    };
432}
433
434ast_node!(WatchFile, ROOT);
435ast_node!(Version, VERSION);
436ast_node!(Entry, ENTRY);
437ast_node!(OptionList, OPTS_LIST);
438ast_node!(_Option, OPTION);
439ast_node!(Url, URL);
440ast_node!(MatchingPattern, MATCHING_PATTERN);
441ast_node!(VersionPolicyNode, VERSION_POLICY);
442ast_node!(ScriptNode, SCRIPT);
443
444impl WatchFile {
445    /// Create a new watch file with specified version
446    pub fn new(version: Option<u32>) -> WatchFile {
447        let mut builder = GreenNodeBuilder::new();
448
449        builder.start_node(ROOT.into());
450        if let Some(version) = version {
451            builder.start_node(VERSION.into());
452            builder.token(KEY.into(), "version");
453            builder.token(EQUALS.into(), "=");
454            builder.token(VALUE.into(), version.to_string().as_str());
455            builder.token(NEWLINE.into(), "\n");
456            builder.finish_node();
457        }
458        builder.finish_node();
459        WatchFile(SyntaxNode::new_root_mut(builder.finish()))
460    }
461
462    /// Returns the version AST node of the watch file.
463    pub fn version_node(&self) -> Option<Version> {
464        self.0.children().find_map(Version::cast)
465    }
466
467    /// Returns the version of the watch file.
468    pub fn version(&self) -> u32 {
469        self.version_node()
470            .map(|it| it.version())
471            .unwrap_or(DEFAULT_VERSION)
472    }
473
474    /// Returns an iterator over all entries in the watch file.
475    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
476        self.0.children().filter_map(Entry::cast)
477    }
478
479    /// Set the version of the watch file.
480    pub fn set_version(&mut self, new_version: u32) {
481        // Build the new version node
482        let mut builder = GreenNodeBuilder::new();
483        builder.start_node(VERSION.into());
484        builder.token(KEY.into(), "version");
485        builder.token(EQUALS.into(), "=");
486        builder.token(VALUE.into(), new_version.to_string().as_str());
487        builder.token(NEWLINE.into(), "\n");
488        builder.finish_node();
489        let new_version_green = builder.finish();
490
491        // Create a syntax node (splice_children will detach and reattach it)
492        let new_version_node = SyntaxNode::new_root_mut(new_version_green);
493
494        // Find existing version node if any
495        let version_pos = self.0.children().position(|child| child.kind() == VERSION);
496
497        if let Some(pos) = version_pos {
498            // Replace existing version node
499            self.0
500                .splice_children(pos..pos + 1, vec![new_version_node.into()]);
501        } else {
502            // Insert version node at the beginning
503            self.0.splice_children(0..0, vec![new_version_node.into()]);
504        }
505    }
506
507    /// Discover releases for all entries in the watch file (async version)
508    ///
509    /// Fetches URLs and searches for version matches for all entries.
510    /// Requires the 'discover' feature.
511    ///
512    /// # Examples
513    ///
514    /// ```ignore
515    /// # use debian_watch::WatchFile;
516    /// # async fn example() {
517    /// let wf: WatchFile = r#"version=4
518    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
519    /// "#.parse().unwrap();
520    /// let all_releases = wf.uscan(|| "mypackage".to_string()).await.unwrap();
521    /// for (entry_idx, releases) in all_releases.iter().enumerate() {
522    ///     println!("Entry {}: {} releases found", entry_idx, releases.len());
523    /// }
524    /// # }
525    /// ```
526    #[cfg(feature = "discover")]
527    pub async fn uscan(
528        &self,
529        package: impl Fn() -> String,
530    ) -> Result<Vec<Vec<crate::Release>>, Box<dyn std::error::Error>> {
531        let mut all_releases = Vec::new();
532
533        for entry in self.entries() {
534            let releases = entry.discover(|| package()).await?;
535            all_releases.push(releases);
536        }
537
538        Ok(all_releases)
539    }
540
541    /// Discover releases for all entries in the watch file (blocking version)
542    ///
543    /// Fetches URLs and searches for version matches for all entries.
544    /// Requires both 'discover' and 'blocking' features.
545    ///
546    /// # Examples
547    ///
548    /// ```ignore
549    /// # use debian_watch::WatchFile;
550    /// let wf: WatchFile = r#"version=4
551    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
552    /// "#.parse().unwrap();
553    /// let all_releases = wf.uscan_blocking(|| "mypackage".to_string()).unwrap();
554    /// for (entry_idx, releases) in all_releases.iter().enumerate() {
555    ///     println!("Entry {}: {} releases found", entry_idx, releases.len());
556    /// }
557    /// ```
558    #[cfg(all(feature = "discover", feature = "blocking"))]
559    pub fn uscan_blocking(
560        &self,
561        package: impl Fn() -> String,
562    ) -> Result<Vec<Vec<crate::Release>>, Box<dyn std::error::Error>> {
563        let mut all_releases = Vec::new();
564
565        for entry in self.entries() {
566            let releases = entry.discover_blocking(|| package())?;
567            all_releases.push(releases);
568        }
569
570        Ok(all_releases)
571    }
572
573    /// Add an entry to the watch file.
574    ///
575    /// Appends a new entry to the end of the watch file.
576    ///
577    /// # Examples
578    ///
579    /// ```
580    /// use debian_watch::{WatchFile, EntryBuilder};
581    ///
582    /// let mut wf = WatchFile::new(Some(4));
583    ///
584    /// // Add an entry using EntryBuilder
585    /// let entry = EntryBuilder::new("https://github.com/example/tags")
586    ///     .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
587    ///     .build();
588    /// wf.add_entry(entry);
589    ///
590    /// // Or use the builder pattern directly
591    /// wf.add_entry(
592    ///     EntryBuilder::new("https://example.com/releases")
593    ///         .matching_pattern(".*/(\\d+\\.\\d+)\\.tar\\.gz")
594    ///         .opt("compression", "xz")
595    ///         .version_policy("debian")
596    ///         .build()
597    /// );
598    /// ```
599    pub fn add_entry(&mut self, entry: Entry) {
600        // Find the position to insert (after the last entry or after version)
601        let insert_pos = self.0.children_with_tokens().count();
602
603        // Detach the entry node from its current parent and get its green node
604        let entry_green = entry.0.green().into_owned();
605        let new_entry_node = SyntaxNode::new_root_mut(entry_green);
606
607        // Insert the entry at the end
608        self.0
609            .splice_children(insert_pos..insert_pos, vec![new_entry_node.into()]);
610    }
611
612    /// Read a watch file from a Read object.
613    pub fn from_reader<R: std::io::Read>(reader: R) -> Result<WatchFile, ParseError> {
614        let mut buf_reader = std::io::BufReader::new(reader);
615        let mut content = String::new();
616        buf_reader
617            .read_to_string(&mut content)
618            .map_err(|e| ParseError(vec![e.to_string()]))?;
619        content.parse()
620    }
621
622    /// Read a watch file from a Read object, allowing syntax errors.
623    pub fn from_reader_relaxed<R: std::io::Read>(mut r: R) -> Result<Self, std::io::Error> {
624        let mut content = String::new();
625        r.read_to_string(&mut content)?;
626        let parsed = parse(&content);
627        Ok(parsed.root())
628    }
629
630    /// Parse a debian watch file from a string, allowing syntax errors.
631    pub fn from_str_relaxed(s: &str) -> Self {
632        let parsed = parse(s);
633        parsed.root()
634    }
635}
636
637impl FromStr for WatchFile {
638    type Err = ParseError;
639
640    fn from_str(s: &str) -> Result<Self, Self::Err> {
641        let parsed = parse(s);
642        if parsed.errors.is_empty() {
643            Ok(parsed.root())
644        } else {
645            Err(ParseError(parsed.errors))
646        }
647    }
648}
649
650impl Version {
651    /// Returns the version of the watch file.
652    pub fn version(&self) -> u32 {
653        self.0
654            .children_with_tokens()
655            .find_map(|it| match it {
656                SyntaxElement::Token(token) => {
657                    if token.kind() == VALUE {
658                        Some(token.text().parse().unwrap())
659                    } else {
660                        None
661                    }
662                }
663                _ => None,
664            })
665            .unwrap_or(DEFAULT_VERSION)
666    }
667}
668
669/// Builder for creating new watchfile entries.
670///
671/// Provides a fluent API for constructing entries with various components.
672///
673/// # Examples
674///
675/// ```
676/// use debian_watch::EntryBuilder;
677///
678/// // Minimal entry with just URL and pattern
679/// let entry = EntryBuilder::new("https://github.com/example/tags")
680///     .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
681///     .build();
682///
683/// // Entry with options
684/// let entry = EntryBuilder::new("https://github.com/example/tags")
685///     .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
686///     .opt("compression", "xz")
687///     .flag("repack")
688///     .version_policy("debian")
689///     .script("uupdate")
690///     .build();
691/// ```
692#[derive(Debug, Clone, Default)]
693pub struct EntryBuilder {
694    url: Option<String>,
695    matching_pattern: Option<String>,
696    version_policy: Option<String>,
697    script: Option<String>,
698    opts: std::collections::HashMap<String, String>,
699}
700
701impl EntryBuilder {
702    /// Create a new entry builder with the specified URL.
703    pub fn new(url: impl Into<String>) -> Self {
704        EntryBuilder {
705            url: Some(url.into()),
706            matching_pattern: None,
707            version_policy: None,
708            script: None,
709            opts: std::collections::HashMap::new(),
710        }
711    }
712
713    /// Set the matching pattern for the entry.
714    pub fn matching_pattern(mut self, pattern: impl Into<String>) -> Self {
715        self.matching_pattern = Some(pattern.into());
716        self
717    }
718
719    /// Set the version policy for the entry.
720    pub fn version_policy(mut self, policy: impl Into<String>) -> Self {
721        self.version_policy = Some(policy.into());
722        self
723    }
724
725    /// Set the script for the entry.
726    pub fn script(mut self, script: impl Into<String>) -> Self {
727        self.script = Some(script.into());
728        self
729    }
730
731    /// Add an option to the entry.
732    pub fn opt(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
733        self.opts.insert(key.into(), value.into());
734        self
735    }
736
737    /// Add a boolean flag option to the entry.
738    ///
739    /// Boolean options like "repack", "bare", "decompress" don't have values.
740    pub fn flag(mut self, key: impl Into<String>) -> Self {
741        self.opts.insert(key.into(), String::new());
742        self
743    }
744
745    /// Build the entry.
746    ///
747    /// # Panics
748    ///
749    /// Panics if no URL was provided.
750    pub fn build(self) -> Entry {
751        let url = self.url.expect("URL is required for entry");
752
753        let mut builder = GreenNodeBuilder::new();
754
755        builder.start_node(ENTRY.into());
756
757        // Add options list if provided
758        if !self.opts.is_empty() {
759            builder.start_node(OPTS_LIST.into());
760            builder.token(KEY.into(), "opts");
761            builder.token(EQUALS.into(), "=");
762
763            let mut first = true;
764            for (key, value) in self.opts.iter() {
765                if !first {
766                    builder.token(COMMA.into(), ",");
767                }
768                first = false;
769
770                builder.start_node(OPTION.into());
771                builder.token(KEY.into(), key);
772                if !value.is_empty() {
773                    builder.token(EQUALS.into(), "=");
774                    builder.token(VALUE.into(), value);
775                }
776                builder.finish_node();
777            }
778
779            builder.finish_node();
780            builder.token(WHITESPACE.into(), " ");
781        }
782
783        // Add URL (required)
784        builder.start_node(URL.into());
785        builder.token(VALUE.into(), &url);
786        builder.finish_node();
787
788        // Add matching pattern if provided
789        if let Some(pattern) = self.matching_pattern {
790            builder.token(WHITESPACE.into(), " ");
791            builder.start_node(MATCHING_PATTERN.into());
792            builder.token(VALUE.into(), &pattern);
793            builder.finish_node();
794        }
795
796        // Add version policy if provided
797        if let Some(policy) = self.version_policy {
798            builder.token(WHITESPACE.into(), " ");
799            builder.start_node(VERSION_POLICY.into());
800            builder.token(VALUE.into(), &policy);
801            builder.finish_node();
802        }
803
804        // Add script if provided
805        if let Some(script_val) = self.script {
806            builder.token(WHITESPACE.into(), " ");
807            builder.start_node(SCRIPT.into());
808            builder.token(VALUE.into(), &script_val);
809            builder.finish_node();
810        }
811
812        builder.token(NEWLINE.into(), "\n");
813        builder.finish_node();
814
815        Entry(SyntaxNode::new_root_mut(builder.finish()))
816    }
817}
818
819impl Entry {
820    /// Create a new entry builder.
821    ///
822    /// This is a convenience method that returns an `EntryBuilder`.
823    ///
824    /// # Examples
825    ///
826    /// ```
827    /// use debian_watch::Entry;
828    ///
829    /// let entry = Entry::builder("https://github.com/example/tags")
830    ///     .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
831    ///     .build();
832    /// ```
833    pub fn builder(url: impl Into<String>) -> EntryBuilder {
834        EntryBuilder::new(url)
835    }
836
837    /// List of options
838    pub fn option_list(&self) -> Option<OptionList> {
839        self.0.children().find_map(OptionList::cast)
840    }
841
842    /// Get the value of an option
843    pub fn get_option(&self, key: &str) -> Option<String> {
844        self.option_list().and_then(|ol| ol.get_option(key))
845    }
846
847    /// Check if an option is set
848    pub fn has_option(&self, key: &str) -> bool {
849        self.option_list().map_or(false, |ol| ol.has_option(key))
850    }
851
852    /// The name of the secondary source tarball
853    pub fn component(&self) -> Option<String> {
854        self.get_option("component")
855    }
856
857    /// Component type
858    pub fn ctype(&self) -> Result<Option<ComponentType>, ()> {
859        self.get_option("ctype").map(|s| s.parse()).transpose()
860    }
861
862    /// Compression method
863    pub fn compression(&self) -> Result<Option<Compression>, ()> {
864        self.get_option("compression")
865            .map(|s| s.parse())
866            .transpose()
867    }
868
869    /// Repack the tarball
870    pub fn repack(&self) -> bool {
871        self.has_option("repack")
872    }
873
874    /// Repack suffix
875    pub fn repacksuffix(&self) -> Option<String> {
876        self.get_option("repacksuffix")
877    }
878
879    /// Retrieve the mode of the watch file entry.
880    pub fn mode(&self) -> Result<Mode, ()> {
881        Ok(self
882            .get_option("mode")
883            .map(|s| s.parse())
884            .transpose()?
885            .unwrap_or_default())
886    }
887
888    /// Return the git pretty mode
889    pub fn pretty(&self) -> Result<Pretty, ()> {
890        Ok(self
891            .get_option("pretty")
892            .map(|s| s.parse())
893            .transpose()?
894            .unwrap_or_default())
895    }
896
897    /// Set the date string used by the pretty option to an arbitrary format as an optional
898    /// opts argument when the matching-pattern is HEAD or heads/branch for git mode.
899    pub fn date(&self) -> String {
900        self.get_option("date")
901            .unwrap_or_else(|| "%Y%m%d".to_string())
902    }
903
904    /// Return the git export mode
905    pub fn gitexport(&self) -> Result<GitExport, ()> {
906        Ok(self
907            .get_option("gitexport")
908            .map(|s| s.parse())
909            .transpose()?
910            .unwrap_or_default())
911    }
912
913    /// Return the git mode
914    pub fn gitmode(&self) -> Result<GitMode, ()> {
915        Ok(self
916            .get_option("gitmode")
917            .map(|s| s.parse())
918            .transpose()?
919            .unwrap_or_default())
920    }
921
922    /// Return the pgp mode
923    pub fn pgpmode(&self) -> Result<PgpMode, ()> {
924        Ok(self
925            .get_option("pgpmode")
926            .map(|s| s.parse())
927            .transpose()?
928            .unwrap_or_default())
929    }
930
931    /// Return the search mode
932    pub fn searchmode(&self) -> Result<SearchMode, ()> {
933        Ok(self
934            .get_option("searchmode")
935            .map(|s| s.parse())
936            .transpose()?
937            .unwrap_or_default())
938    }
939
940    /// Return the decompression mode
941    pub fn decompress(&self) -> bool {
942        self.has_option("decompress")
943    }
944
945    /// Whether to disable all site specific special case code such as URL director uses and page
946    /// content alterations.
947    pub fn bare(&self) -> bool {
948        self.has_option("bare")
949    }
950
951    /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent)
952    pub fn user_agent(&self) -> Option<String> {
953        self.get_option("user-agent")
954    }
955
956    /// Use PASV mode for the FTP connection.
957    pub fn passive(&self) -> Option<bool> {
958        if self.has_option("passive") || self.has_option("pasv") {
959            Some(true)
960        } else if self.has_option("active") || self.has_option("nopasv") {
961            Some(false)
962        } else {
963            None
964        }
965    }
966
967    /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed
968    /// by mk-origtargz.
969    pub fn unzipoptions(&self) -> Option<String> {
970        self.get_option("unzipopt")
971    }
972
973    /// Normalize the downloaded web page string.
974    pub fn dversionmangle(&self) -> Option<String> {
975        self.get_option("dversionmangle")
976            .or_else(|| self.get_option("versionmangle"))
977    }
978
979    /// Normalize the directory path string matching the regex in a set of parentheses of
980    /// http://URL as the sortable version index string.  This is used
981    /// as the directory path sorting index only.
982    pub fn dirversionmangle(&self) -> Option<String> {
983        self.get_option("dirversionmangle")
984    }
985
986    /// Normalize the downloaded web page string.
987    pub fn pagemangle(&self) -> Option<String> {
988        self.get_option("pagemangle")
989    }
990
991    /// Normalize the candidate upstream version strings extracted from hrefs in the
992    /// source of the web page.  This is used as the version sorting index when selecting the
993    /// latest upstream version.
994    pub fn uversionmangle(&self) -> Option<String> {
995        self.get_option("uversionmangle")
996            .or_else(|| self.get_option("versionmangle"))
997    }
998
999    /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules
1000    pub fn versionmangle(&self) -> Option<String> {
1001        self.get_option("versionmangle")
1002    }
1003
1004    /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal
1005    /// string to the decoded normal URL  string  for  obfuscated
1006    /// web sites.  Only percent-encoding is available and it is decoded with
1007    /// s/%([A-Fa-f\d]{2})/chr hex $1/eg.
1008    pub fn hrefdecode(&self) -> bool {
1009        self.get_option("hrefdecode").is_some()
1010    }
1011
1012    /// Convert the selected upstream tarball href string into the accessible URL for obfuscated
1013    /// web sites.  This is run after hrefdecode.
1014    pub fn downloadurlmangle(&self) -> Option<String> {
1015        self.get_option("downloadurlmangle")
1016    }
1017
1018    /// Generate the upstream tarball filename from the selected href string if matching-pattern
1019    /// can extract the latest upstream version <uversion> from the  selected  href  string.
1020    /// Otherwise, generate the upstream tarball filename from its full URL string and set the
1021    /// missing <uversion> from the generated upstream tarball filename.
1022    ///
1023    /// Without this option, the default upstream tarball filename is generated by taking the last
1024    /// component of the URL and  removing everything  after any '?' or '#'.
1025    pub fn filenamemangle(&self) -> Option<String> {
1026        self.get_option("filenamemangle")
1027    }
1028
1029    /// Generate the candidate upstream signature file URL string from the upstream tarball URL.
1030    pub fn pgpsigurlmangle(&self) -> Option<String> {
1031        self.get_option("pgpsigurlmangle")
1032    }
1033
1034    /// Generate the version string <oversion> of the source tarball <spkg>_<oversion>.orig.tar.gz
1035    /// from <uversion>.  This should be used to add a suffix such as +dfsg to a MUT package.
1036    pub fn oversionmangle(&self) -> Option<String> {
1037        self.get_option("oversionmangle")
1038    }
1039
1040    /// Apply uversionmangle to a version string
1041    ///
1042    /// # Examples
1043    ///
1044    /// ```
1045    /// # use debian_watch::WatchFile;
1046    /// let wf: WatchFile = r#"version=4
1047    /// opts=uversionmangle=s/\+ds// https://example.com/ .*
1048    /// "#.parse().unwrap();
1049    /// let entry = wf.entries().next().unwrap();
1050    /// assert_eq!(entry.apply_uversionmangle("1.0+ds").unwrap(), "1.0");
1051    /// ```
1052    pub fn apply_uversionmangle(
1053        &self,
1054        version: &str,
1055    ) -> Result<String, crate::mangle::MangleError> {
1056        if let Some(vm) = self.uversionmangle() {
1057            crate::mangle::apply_mangle(&vm, version)
1058        } else {
1059            Ok(version.to_string())
1060        }
1061    }
1062
1063    /// Apply dversionmangle to a version string
1064    ///
1065    /// # Examples
1066    ///
1067    /// ```
1068    /// # use debian_watch::WatchFile;
1069    /// let wf: WatchFile = r#"version=4
1070    /// opts=dversionmangle=s/\+dfsg$// https://example.com/ .*
1071    /// "#.parse().unwrap();
1072    /// let entry = wf.entries().next().unwrap();
1073    /// assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0");
1074    /// ```
1075    pub fn apply_dversionmangle(
1076        &self,
1077        version: &str,
1078    ) -> Result<String, crate::mangle::MangleError> {
1079        if let Some(vm) = self.dversionmangle() {
1080            crate::mangle::apply_mangle(&vm, version)
1081        } else {
1082            Ok(version.to_string())
1083        }
1084    }
1085
1086    /// Apply oversionmangle to a version string
1087    ///
1088    /// # Examples
1089    ///
1090    /// ```
1091    /// # use debian_watch::WatchFile;
1092    /// let wf: WatchFile = r#"version=4
1093    /// opts=oversionmangle=s/$/-1/ https://example.com/ .*
1094    /// "#.parse().unwrap();
1095    /// let entry = wf.entries().next().unwrap();
1096    /// assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1");
1097    /// ```
1098    pub fn apply_oversionmangle(
1099        &self,
1100        version: &str,
1101    ) -> Result<String, crate::mangle::MangleError> {
1102        if let Some(vm) = self.oversionmangle() {
1103            crate::mangle::apply_mangle(&vm, version)
1104        } else {
1105            Ok(version.to_string())
1106        }
1107    }
1108
1109    /// Apply dirversionmangle to a directory path string
1110    ///
1111    /// # Examples
1112    ///
1113    /// ```
1114    /// # use debian_watch::WatchFile;
1115    /// let wf: WatchFile = r#"version=4
1116    /// opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .*
1117    /// "#.parse().unwrap();
1118    /// let entry = wf.entries().next().unwrap();
1119    /// assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
1120    /// ```
1121    pub fn apply_dirversionmangle(
1122        &self,
1123        version: &str,
1124    ) -> Result<String, crate::mangle::MangleError> {
1125        if let Some(vm) = self.dirversionmangle() {
1126            crate::mangle::apply_mangle(&vm, version)
1127        } else {
1128            Ok(version.to_string())
1129        }
1130    }
1131
1132    /// Apply filenamemangle to a URL or filename string
1133    ///
1134    /// # Examples
1135    ///
1136    /// ```
1137    /// # use debian_watch::WatchFile;
1138    /// let wf: WatchFile = r#"version=4
1139    /// opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .*
1140    /// "#.parse().unwrap();
1141    /// let entry = wf.entries().next().unwrap();
1142    /// assert_eq!(
1143    ///     entry.apply_filenamemangle("https://example.com/v1.0.tar.gz").unwrap(),
1144    ///     "mypackage-1.0.tar.gz"
1145    /// );
1146    /// ```
1147    pub fn apply_filenamemangle(&self, url: &str) -> Result<String, crate::mangle::MangleError> {
1148        if let Some(vm) = self.filenamemangle() {
1149            crate::mangle::apply_mangle(&vm, url)
1150        } else {
1151            Ok(url.to_string())
1152        }
1153    }
1154
1155    /// Apply pagemangle to page content bytes
1156    ///
1157    /// # Examples
1158    ///
1159    /// ```
1160    /// # use debian_watch::WatchFile;
1161    /// let wf: WatchFile = r#"version=4
1162    /// opts=pagemangle=s/&amp;/&/g https://example.com/ .*
1163    /// "#.parse().unwrap();
1164    /// let entry = wf.entries().next().unwrap();
1165    /// assert_eq!(
1166    ///     entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
1167    ///     b"foo & bar"
1168    /// );
1169    /// ```
1170    pub fn apply_pagemangle(&self, page: &[u8]) -> Result<Vec<u8>, crate::mangle::MangleError> {
1171        if let Some(vm) = self.pagemangle() {
1172            let page_str = String::from_utf8_lossy(page);
1173            let mangled = crate::mangle::apply_mangle(&vm, &page_str)?;
1174            Ok(mangled.into_bytes())
1175        } else {
1176            Ok(page.to_vec())
1177        }
1178    }
1179
1180    /// Apply downloadurlmangle to a URL string
1181    ///
1182    /// # Examples
1183    ///
1184    /// ```
1185    /// # use debian_watch::WatchFile;
1186    /// let wf: WatchFile = r#"version=4
1187    /// opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .*
1188    /// "#.parse().unwrap();
1189    /// let entry = wf.entries().next().unwrap();
1190    /// assert_eq!(
1191    ///     entry.apply_downloadurlmangle("https://example.com/archive/file.tar.gz").unwrap(),
1192    ///     "https://example.com/download/file.tar.gz"
1193    /// );
1194    /// ```
1195    pub fn apply_downloadurlmangle(&self, url: &str) -> Result<String, crate::mangle::MangleError> {
1196        if let Some(vm) = self.downloadurlmangle() {
1197            crate::mangle::apply_mangle(&vm, url)
1198        } else {
1199            Ok(url.to_string())
1200        }
1201    }
1202
1203    /// Discover releases for this entry (async version)
1204    ///
1205    /// Fetches the URL and searches for version matches.
1206    /// Requires the 'discover' feature.
1207    ///
1208    /// # Examples
1209    ///
1210    /// ```ignore
1211    /// # use debian_watch::WatchFile;
1212    /// # async fn example() {
1213    /// let wf: WatchFile = r#"version=4
1214    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
1215    /// "#.parse().unwrap();
1216    /// let entry = wf.entries().next().unwrap();
1217    /// let releases = entry.discover(|| "mypackage".to_string()).await.unwrap();
1218    /// for release in releases {
1219    ///     println!("{}: {}", release.version, release.url);
1220    /// }
1221    /// # }
1222    /// ```
1223    #[cfg(feature = "discover")]
1224    pub async fn discover(
1225        &self,
1226        package: impl FnOnce() -> String,
1227    ) -> Result<Vec<crate::Release>, Box<dyn std::error::Error>> {
1228        let url = self.format_url(package);
1229        let user_agent = self
1230            .user_agent()
1231            .unwrap_or_else(|| crate::DEFAULT_USER_AGENT.to_string());
1232        let searchmode = self.searchmode().unwrap_or(crate::SearchMode::Html);
1233
1234        let client = reqwest::Client::builder().user_agent(user_agent).build()?;
1235
1236        let response = client.get(url.as_str()).send().await?;
1237        let body = response.bytes().await?;
1238
1239        // Apply pagemangle if present
1240        let mangled_body = self.apply_pagemangle(&body)?;
1241
1242        let matching_pattern = self
1243            .matching_pattern()
1244            .ok_or("matching_pattern is required")?;
1245
1246        let package_name = String::new(); // Not used in search currently
1247        let results = crate::search::search(
1248            match searchmode {
1249                crate::SearchMode::Html => "html",
1250                crate::SearchMode::Plain => "plain",
1251            },
1252            std::io::Cursor::new(mangled_body.as_ref() as &[u8]),
1253            &subst(&matching_pattern, || package_name.clone()),
1254            &package_name,
1255            url.as_str(),
1256        )?;
1257
1258        let mut releases = Vec::new();
1259        for (version, full_url) in results {
1260            // Apply uversionmangle
1261            let mangled_version = self.apply_uversionmangle(&version)?;
1262
1263            // Apply downloadurlmangle
1264            let mangled_url = self.apply_downloadurlmangle(&full_url)?;
1265
1266            // Apply pgpsigurlmangle if present
1267            let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() {
1268                Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?)
1269            } else {
1270                None
1271            };
1272
1273            // Apply filenamemangle if present
1274            let target_filename = if self.filenamemangle().is_some() {
1275                Some(self.apply_filenamemangle(&mangled_url)?)
1276            } else {
1277                None
1278            };
1279
1280            // Apply oversionmangle if present
1281            let package_version = if self.oversionmangle().is_some() {
1282                Some(self.apply_oversionmangle(&mangled_version)?)
1283            } else {
1284                None
1285            };
1286
1287            releases.push(crate::Release::new_full(
1288                mangled_version,
1289                mangled_url,
1290                pgpsigurl,
1291                target_filename,
1292                package_version,
1293            ));
1294        }
1295
1296        Ok(releases)
1297    }
1298
1299    /// Discover releases for this entry (blocking version)
1300    ///
1301    /// Fetches the URL and searches for version matches.
1302    /// Requires both 'discover' and 'blocking' features.
1303    ///
1304    /// # Examples
1305    ///
1306    /// ```ignore
1307    /// # use debian_watch::WatchFile;
1308    /// let wf: WatchFile = r#"version=4
1309    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
1310    /// "#.parse().unwrap();
1311    /// let entry = wf.entries().next().unwrap();
1312    /// let releases = entry.discover_blocking(|| "mypackage".to_string()).unwrap();
1313    /// for release in releases {
1314    ///     println!("{}: {}", release.version, release.url);
1315    /// }
1316    /// ```
1317    #[cfg(all(feature = "discover", feature = "blocking"))]
1318    pub fn discover_blocking(
1319        &self,
1320        package: impl FnOnce() -> String,
1321    ) -> Result<Vec<crate::Release>, Box<dyn std::error::Error>> {
1322        let url = self.format_url(package);
1323        let user_agent = self
1324            .user_agent()
1325            .unwrap_or_else(|| crate::DEFAULT_USER_AGENT.to_string());
1326        let searchmode = self.searchmode().unwrap_or(crate::SearchMode::Html);
1327
1328        let client = reqwest::blocking::Client::builder()
1329            .user_agent(user_agent)
1330            .build()?;
1331
1332        let response = client.get(url.as_str()).send()?;
1333        let body = response.bytes()?;
1334
1335        // Apply pagemangle if present
1336        let mangled_body = self.apply_pagemangle(&body)?;
1337
1338        let matching_pattern = self
1339            .matching_pattern()
1340            .ok_or("matching_pattern is required")?;
1341
1342        let package_name = String::new(); // Not used in search currently
1343        let results = crate::search::search(
1344            match searchmode {
1345                crate::SearchMode::Html => "html",
1346                crate::SearchMode::Plain => "plain",
1347            },
1348            std::io::Cursor::new(mangled_body.as_ref() as &[u8]),
1349            &subst(&matching_pattern, || package_name.clone()),
1350            &package_name,
1351            url.as_str(),
1352        )?;
1353
1354        let mut releases = Vec::new();
1355        for (version, full_url) in results {
1356            // Apply uversionmangle
1357            let mangled_version = self.apply_uversionmangle(&version)?;
1358
1359            // Apply downloadurlmangle
1360            let mangled_url = self.apply_downloadurlmangle(&full_url)?;
1361
1362            // Apply pgpsigurlmangle if present
1363            let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() {
1364                Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?)
1365            } else {
1366                None
1367            };
1368
1369            // Apply filenamemangle if present
1370            let target_filename = if self.filenamemangle().is_some() {
1371                Some(self.apply_filenamemangle(&mangled_url)?)
1372            } else {
1373                None
1374            };
1375
1376            // Apply oversionmangle if present
1377            let package_version = if self.oversionmangle().is_some() {
1378                Some(self.apply_oversionmangle(&mangled_version)?)
1379            } else {
1380                None
1381            };
1382
1383            releases.push(crate::Release::new_full(
1384                mangled_version,
1385                mangled_url,
1386                pgpsigurl,
1387                target_filename,
1388                package_version,
1389            ));
1390        }
1391
1392        Ok(releases)
1393    }
1394
1395    /// Returns options set
1396    pub fn opts(&self) -> std::collections::HashMap<String, String> {
1397        let mut options = std::collections::HashMap::new();
1398
1399        if let Some(ol) = self.option_list() {
1400            for opt in ol.options() {
1401                let key = opt.key();
1402                let value = opt.value();
1403                if let (Some(key), Some(value)) = (key, value) {
1404                    options.insert(key.to_string(), value.to_string());
1405                }
1406            }
1407        }
1408
1409        options
1410    }
1411
1412    fn items(&self) -> impl Iterator<Item = String> + '_ {
1413        self.0.children_with_tokens().filter_map(|it| match it {
1414            SyntaxElement::Token(token) => {
1415                if token.kind() == VALUE || token.kind() == KEY {
1416                    Some(token.text().to_string())
1417                } else {
1418                    None
1419                }
1420            }
1421            SyntaxElement::Node(node) => {
1422                // Extract values from entry field nodes
1423                match node.kind() {
1424                    URL => Url::cast(node).map(|n| n.url()),
1425                    MATCHING_PATTERN => MatchingPattern::cast(node).map(|n| n.pattern()),
1426                    VERSION_POLICY => VersionPolicyNode::cast(node).map(|n| n.policy()),
1427                    SCRIPT => ScriptNode::cast(node).map(|n| n.script()),
1428                    _ => None,
1429                }
1430            }
1431        })
1432    }
1433
1434    /// Returns the URL AST node of the entry.
1435    pub fn url_node(&self) -> Option<Url> {
1436        self.0.children().find_map(Url::cast)
1437    }
1438
1439    /// Returns the URL of the entry.
1440    pub fn url(&self) -> String {
1441        self.url_node().map(|it| it.url()).unwrap_or_else(|| {
1442            // Fallback for entries without URL node (shouldn't happen with new parser)
1443            self.items().next().unwrap()
1444        })
1445    }
1446
1447    /// Returns the matching pattern AST node of the entry.
1448    pub fn matching_pattern_node(&self) -> Option<MatchingPattern> {
1449        self.0.children().find_map(MatchingPattern::cast)
1450    }
1451
1452    /// Returns the matching pattern of the entry.
1453    pub fn matching_pattern(&self) -> Option<String> {
1454        self.matching_pattern_node()
1455            .map(|it| it.pattern())
1456            .or_else(|| {
1457                // Fallback for entries without MATCHING_PATTERN node
1458                self.items().nth(1)
1459            })
1460    }
1461
1462    /// Returns the version policy AST node of the entry.
1463    pub fn version_node(&self) -> Option<VersionPolicyNode> {
1464        self.0.children().find_map(VersionPolicyNode::cast)
1465    }
1466
1467    /// Returns the version policy
1468    pub fn version(&self) -> Result<Option<crate::VersionPolicy>, String> {
1469        self.version_node()
1470            .map(|it| it.policy().parse())
1471            .transpose()
1472            .or_else(|_e| {
1473                // Fallback for entries without VERSION_POLICY node
1474                self.items().nth(2).map(|it| it.parse()).transpose()
1475            })
1476    }
1477
1478    /// Returns the script AST node of the entry.
1479    pub fn script_node(&self) -> Option<ScriptNode> {
1480        self.0.children().find_map(ScriptNode::cast)
1481    }
1482
1483    /// Returns the script of the entry.
1484    pub fn script(&self) -> Option<String> {
1485        self.script_node().map(|it| it.script()).or_else(|| {
1486            // Fallback for entries without SCRIPT node
1487            self.items().nth(3)
1488        })
1489    }
1490
1491    /// Replace all substitutions and return the resulting URL.
1492    pub fn format_url(&self, package: impl FnOnce() -> String) -> url::Url {
1493        subst(self.url().as_str(), package).parse().unwrap()
1494    }
1495
1496    /// Set the URL of the entry.
1497    pub fn set_url(&mut self, new_url: &str) {
1498        // Build the new URL node
1499        let mut builder = GreenNodeBuilder::new();
1500        builder.start_node(URL.into());
1501        builder.token(VALUE.into(), new_url);
1502        builder.finish_node();
1503        let new_url_green = builder.finish();
1504
1505        // Create a syntax node (splice_children will detach and reattach it)
1506        let new_url_node = SyntaxNode::new_root_mut(new_url_green);
1507
1508        // Find existing URL node position (need to use children_with_tokens for correct indexing)
1509        let url_pos = self
1510            .0
1511            .children_with_tokens()
1512            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
1513
1514        if let Some(pos) = url_pos {
1515            // Replace existing URL node
1516            self.0
1517                .splice_children(pos..pos + 1, vec![new_url_node.into()]);
1518        }
1519    }
1520
1521    /// Set the matching pattern of the entry.
1522    ///
1523    /// TODO: This currently only replaces an existing matching pattern.
1524    /// If the entry doesn't have a matching pattern, this method does nothing.
1525    /// Future implementation should insert the node at the correct position.
1526    pub fn set_matching_pattern(&mut self, new_pattern: &str) {
1527        // Build the new MATCHING_PATTERN node
1528        let mut builder = GreenNodeBuilder::new();
1529        builder.start_node(MATCHING_PATTERN.into());
1530        builder.token(VALUE.into(), new_pattern);
1531        builder.finish_node();
1532        let new_pattern_green = builder.finish();
1533
1534        // Create a syntax node (splice_children will detach and reattach it)
1535        let new_pattern_node = SyntaxNode::new_root_mut(new_pattern_green);
1536
1537        // Find existing MATCHING_PATTERN node position
1538        let pattern_pos = self.0.children_with_tokens().position(
1539            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == MATCHING_PATTERN),
1540        );
1541
1542        if let Some(pos) = pattern_pos {
1543            // Replace existing MATCHING_PATTERN node
1544            self.0
1545                .splice_children(pos..pos + 1, vec![new_pattern_node.into()]);
1546        }
1547        // TODO: else insert new node after URL
1548    }
1549
1550    /// Set the version policy of the entry.
1551    ///
1552    /// TODO: This currently only replaces an existing version policy.
1553    /// If the entry doesn't have a version policy, this method does nothing.
1554    /// Future implementation should insert the node at the correct position.
1555    pub fn set_version_policy(&mut self, new_policy: &str) {
1556        // Build the new VERSION_POLICY node
1557        let mut builder = GreenNodeBuilder::new();
1558        builder.start_node(VERSION_POLICY.into());
1559        // Version policy can be KEY (e.g., "debian") or VALUE
1560        builder.token(VALUE.into(), new_policy);
1561        builder.finish_node();
1562        let new_policy_green = builder.finish();
1563
1564        // Create a syntax node (splice_children will detach and reattach it)
1565        let new_policy_node = SyntaxNode::new_root_mut(new_policy_green);
1566
1567        // Find existing VERSION_POLICY node position
1568        let policy_pos = self.0.children_with_tokens().position(
1569            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == VERSION_POLICY),
1570        );
1571
1572        if let Some(pos) = policy_pos {
1573            // Replace existing VERSION_POLICY node
1574            self.0
1575                .splice_children(pos..pos + 1, vec![new_policy_node.into()]);
1576        }
1577        // TODO: else insert new node after MATCHING_PATTERN (or URL if no pattern)
1578    }
1579
1580    /// Set the script of the entry.
1581    ///
1582    /// TODO: This currently only replaces an existing script.
1583    /// If the entry doesn't have a script, this method does nothing.
1584    /// Future implementation should insert the node at the correct position.
1585    pub fn set_script(&mut self, new_script: &str) {
1586        // Build the new SCRIPT node
1587        let mut builder = GreenNodeBuilder::new();
1588        builder.start_node(SCRIPT.into());
1589        // Script can be KEY (e.g., "uupdate") or VALUE
1590        builder.token(VALUE.into(), new_script);
1591        builder.finish_node();
1592        let new_script_green = builder.finish();
1593
1594        // Create a syntax node (splice_children will detach and reattach it)
1595        let new_script_node = SyntaxNode::new_root_mut(new_script_green);
1596
1597        // Find existing SCRIPT node position
1598        let script_pos = self
1599            .0
1600            .children_with_tokens()
1601            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == SCRIPT));
1602
1603        if let Some(pos) = script_pos {
1604            // Replace existing SCRIPT node
1605            self.0
1606                .splice_children(pos..pos + 1, vec![new_script_node.into()]);
1607        }
1608        // TODO: else insert new node after VERSION_POLICY (or MATCHING_PATTERN/URL if no policy)
1609    }
1610
1611    /// Set or update an option value.
1612    ///
1613    /// If the option already exists, it will be updated with the new value.
1614    /// If the option doesn't exist, it will be added to the options list.
1615    /// If there's no options list, one will be created.
1616    pub fn set_opt(&mut self, key: &str, value: &str) {
1617        // Find the OPTS_LIST position in Entry
1618        let opts_pos = self.0.children_with_tokens().position(
1619            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == OPTS_LIST),
1620        );
1621
1622        if let Some(_opts_idx) = opts_pos {
1623            if let Some(mut ol) = self.option_list() {
1624                // Find if the option already exists
1625                if let Some(mut opt) = ol.find_option(key) {
1626                    // Update the existing option's value
1627                    opt.set_value(value);
1628                    // Mutations should propagate automatically - no need to replace
1629                } else {
1630                    // Add new option
1631                    ol.add_option(key, value);
1632                    // Mutations should propagate automatically - no need to replace
1633                }
1634            }
1635        } else {
1636            // Create a new options list
1637            let mut builder = GreenNodeBuilder::new();
1638            builder.start_node(OPTS_LIST.into());
1639            builder.token(KEY.into(), "opts");
1640            builder.token(EQUALS.into(), "=");
1641            builder.start_node(OPTION.into());
1642            builder.token(KEY.into(), key);
1643            builder.token(EQUALS.into(), "=");
1644            builder.token(VALUE.into(), value);
1645            builder.finish_node();
1646            builder.finish_node();
1647            let new_opts_green = builder.finish();
1648            let new_opts_node = SyntaxNode::new_root_mut(new_opts_green);
1649
1650            // Find position to insert (before URL if it exists, otherwise at start)
1651            let url_pos = self
1652                .0
1653                .children_with_tokens()
1654                .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
1655
1656            if let Some(url_idx) = url_pos {
1657                // Insert options list and a space before the URL
1658                // Build a parent node containing both space and whitespace to extract from
1659                let mut combined_builder = GreenNodeBuilder::new();
1660                combined_builder.start_node(ROOT.into()); // Temporary parent
1661                combined_builder.token(WHITESPACE.into(), " ");
1662                combined_builder.finish_node();
1663                let temp_green = combined_builder.finish();
1664                let temp_root = SyntaxNode::new_root_mut(temp_green);
1665                let space_element = temp_root.children_with_tokens().next().unwrap();
1666
1667                self.0
1668                    .splice_children(url_idx..url_idx, vec![new_opts_node.into(), space_element]);
1669            } else {
1670                self.0.splice_children(0..0, vec![new_opts_node.into()]);
1671            }
1672        }
1673    }
1674
1675    /// Delete an option.
1676    ///
1677    /// Removes the option with the specified key from the options list.
1678    /// If the option doesn't exist, this method does nothing.
1679    /// If deleting the option results in an empty options list, the entire
1680    /// opts= declaration is removed.
1681    pub fn del_opt(&mut self, key: &str) {
1682        if let Some(mut ol) = self.option_list() {
1683            let option_count = ol.0.children().filter(|n| n.kind() == OPTION).count();
1684
1685            if option_count == 1 && ol.has_option(key) {
1686                // This is the last option, remove the entire OPTS_LIST from Entry
1687                let opts_pos = self.0.children().position(|node| node.kind() == OPTS_LIST);
1688
1689                if let Some(opts_idx) = opts_pos {
1690                    // Remove the OPTS_LIST
1691                    self.0.splice_children(opts_idx..opts_idx + 1, vec![]);
1692
1693                    // Remove any leading whitespace/continuation that was after the OPTS_LIST
1694                    while self.0.children_with_tokens().next().map_or(false, |e| {
1695                        matches!(
1696                            e,
1697                            SyntaxElement::Token(t) if t.kind() == WHITESPACE || t.kind() == CONTINUATION
1698                        )
1699                    }) {
1700                        self.0.splice_children(0..1, vec![]);
1701                    }
1702                }
1703            } else {
1704                // Defer to OptionList to remove the option
1705                ol.remove_option(key);
1706            }
1707        }
1708    }
1709}
1710
1711const SUBSTITUTIONS: &[(&str, &str)] = &[
1712    // This is substituted with the source package name found in the first line
1713    // of the debian/changelog file.
1714    // "@PACKAGE@": None,
1715    // This is substituted by the legal upstream version regex (capturing).
1716    ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"),
1717    // This is substituted by the typical archive file extension regex
1718    // (non-capturing).
1719    (
1720        "@ARCHIVE_EXT@",
1721        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)",
1722    ),
1723    // This is substituted by the typical signature file extension regex
1724    // (non-capturing).
1725    (
1726        "@SIGNATURE_EXT@",
1727        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)",
1728    ),
1729    // This is substituted by the typical Debian extension regexp (capturing).
1730    ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"),
1731];
1732
1733pub fn subst(text: &str, package: impl FnOnce() -> String) -> String {
1734    let mut substs = SUBSTITUTIONS.to_vec();
1735    let package_name;
1736    if text.contains("@PACKAGE@") {
1737        package_name = Some(package());
1738        substs.push(("@PACKAGE@", package_name.as_deref().unwrap()));
1739    }
1740
1741    let mut text = text.to_string();
1742
1743    for (k, v) in substs {
1744        text = text.replace(k, v);
1745    }
1746
1747    text
1748}
1749
1750#[test]
1751fn test_subst() {
1752    assert_eq!(
1753        subst("@ANY_VERSION@", || unreachable!()),
1754        r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"
1755    );
1756    assert_eq!(subst("@PACKAGE@", || "dulwich".to_string()), "dulwich");
1757}
1758
1759impl std::fmt::Debug for OptionList {
1760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1761        f.debug_struct("OptionList")
1762            .field("text", &self.0.text().to_string())
1763            .finish()
1764    }
1765}
1766
1767impl OptionList {
1768    /// Returns an iterator over all option nodes in the options list.
1769    pub fn options(&self) -> impl Iterator<Item = _Option> + '_ {
1770        self.0.children().filter_map(_Option::cast)
1771    }
1772
1773    /// Find an option node by key.
1774    pub fn find_option(&self, key: &str) -> Option<_Option> {
1775        self.options().find(|opt| opt.key().as_deref() == Some(key))
1776    }
1777
1778    pub fn has_option(&self, key: &str) -> bool {
1779        self.options().any(|it| it.key().as_deref() == Some(key))
1780    }
1781
1782    pub fn get_option(&self, key: &str) -> Option<String> {
1783        for child in self.options() {
1784            if child.key().as_deref() == Some(key) {
1785                return child.value();
1786            }
1787        }
1788        None
1789    }
1790
1791    /// Add a new option to the end of the options list.
1792    fn add_option(&mut self, key: &str, value: &str) {
1793        let option_count = self.0.children().filter(|n| n.kind() == OPTION).count();
1794
1795        // Build a structure containing separator (if needed) + option wrapped in a temporary parent
1796        let mut builder = GreenNodeBuilder::new();
1797        builder.start_node(ROOT.into()); // Temporary parent
1798
1799        if option_count > 0 {
1800            builder.start_node(OPTION_SEPARATOR.into());
1801            builder.token(COMMA.into(), ",");
1802            builder.finish_node();
1803        }
1804
1805        builder.start_node(OPTION.into());
1806        builder.token(KEY.into(), key);
1807        builder.token(EQUALS.into(), "=");
1808        builder.token(VALUE.into(), value);
1809        builder.finish_node();
1810
1811        builder.finish_node(); // Close temporary parent
1812        let combined_green = builder.finish();
1813
1814        // Create a temporary root to extract children from
1815        let temp_root = SyntaxNode::new_root_mut(combined_green);
1816        let new_children: Vec<_> = temp_root.children_with_tokens().collect();
1817
1818        let insert_pos = self.0.children_with_tokens().count();
1819        self.0.splice_children(insert_pos..insert_pos, new_children);
1820    }
1821
1822    /// Remove an option by key. Returns true if an option was removed.
1823    fn remove_option(&mut self, key: &str) -> bool {
1824        if let Some(mut opt) = self.find_option(key) {
1825            opt.remove();
1826            true
1827        } else {
1828            false
1829        }
1830    }
1831}
1832
1833impl _Option {
1834    /// Returns the key of the option.
1835    pub fn key(&self) -> Option<String> {
1836        self.0.children_with_tokens().find_map(|it| match it {
1837            SyntaxElement::Token(token) => {
1838                if token.kind() == KEY {
1839                    Some(token.text().to_string())
1840                } else {
1841                    None
1842                }
1843            }
1844            _ => None,
1845        })
1846    }
1847
1848    /// Returns the value of the option.
1849    pub fn value(&self) -> Option<String> {
1850        self.0
1851            .children_with_tokens()
1852            .filter_map(|it| match it {
1853                SyntaxElement::Token(token) => {
1854                    if token.kind() == VALUE || token.kind() == KEY {
1855                        Some(token.text().to_string())
1856                    } else {
1857                        None
1858                    }
1859                }
1860                _ => None,
1861            })
1862            .nth(1)
1863    }
1864
1865    /// Set the value of the option.
1866    pub fn set_value(&mut self, new_value: &str) {
1867        let key = self.key().expect("Option must have a key");
1868
1869        // Build a new OPTION node with the updated value
1870        let mut builder = GreenNodeBuilder::new();
1871        builder.start_node(OPTION.into());
1872        builder.token(KEY.into(), &key);
1873        builder.token(EQUALS.into(), "=");
1874        builder.token(VALUE.into(), new_value);
1875        builder.finish_node();
1876        let new_option_green = builder.finish();
1877        let new_option_node = SyntaxNode::new_root_mut(new_option_green);
1878
1879        // Replace this option in the parent OptionList
1880        if let Some(parent) = self.0.parent() {
1881            let idx = self.0.index();
1882            parent.splice_children(idx..idx + 1, vec![new_option_node.into()]);
1883        }
1884    }
1885
1886    /// Remove this option and its associated separator from the parent OptionList.
1887    pub fn remove(&mut self) {
1888        // Find adjacent separator to remove before detaching this node
1889        let next_sep = self
1890            .0
1891            .next_sibling()
1892            .filter(|n| n.kind() == OPTION_SEPARATOR);
1893        let prev_sep = self
1894            .0
1895            .prev_sibling()
1896            .filter(|n| n.kind() == OPTION_SEPARATOR);
1897
1898        // Detach separator first if it exists
1899        if let Some(sep) = next_sep {
1900            sep.detach();
1901        } else if let Some(sep) = prev_sep {
1902            sep.detach();
1903        }
1904
1905        // Now detach the option itself
1906        self.0.detach();
1907    }
1908}
1909
1910impl Url {
1911    /// Returns the URL string.
1912    pub fn url(&self) -> String {
1913        self.0
1914            .children_with_tokens()
1915            .find_map(|it| match it {
1916                SyntaxElement::Token(token) => {
1917                    if token.kind() == VALUE {
1918                        Some(token.text().to_string())
1919                    } else {
1920                        None
1921                    }
1922                }
1923                _ => None,
1924            })
1925            .unwrap()
1926    }
1927}
1928
1929impl MatchingPattern {
1930    /// Returns the matching pattern string.
1931    pub fn pattern(&self) -> String {
1932        self.0
1933            .children_with_tokens()
1934            .find_map(|it| match it {
1935                SyntaxElement::Token(token) => {
1936                    if token.kind() == VALUE {
1937                        Some(token.text().to_string())
1938                    } else {
1939                        None
1940                    }
1941                }
1942                _ => None,
1943            })
1944            .unwrap()
1945    }
1946}
1947
1948impl VersionPolicyNode {
1949    /// Returns the version policy string.
1950    pub fn policy(&self) -> String {
1951        self.0
1952            .children_with_tokens()
1953            .find_map(|it| match it {
1954                SyntaxElement::Token(token) => {
1955                    // Can be KEY (e.g., "debian") or VALUE
1956                    if token.kind() == VALUE || token.kind() == KEY {
1957                        Some(token.text().to_string())
1958                    } else {
1959                        None
1960                    }
1961                }
1962                _ => None,
1963            })
1964            .unwrap()
1965    }
1966}
1967
1968impl ScriptNode {
1969    /// Returns the script string.
1970    pub fn script(&self) -> String {
1971        self.0
1972            .children_with_tokens()
1973            .find_map(|it| match it {
1974                SyntaxElement::Token(token) => {
1975                    // Can be KEY (e.g., "uupdate") or VALUE
1976                    if token.kind() == VALUE || token.kind() == KEY {
1977                        Some(token.text().to_string())
1978                    } else {
1979                        None
1980                    }
1981                }
1982                _ => None,
1983            })
1984            .unwrap()
1985    }
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990    use super::*;
1991
1992    #[test]
1993    fn test_entry_node_structure() {
1994        // Test that entries properly use the new node types
1995        let wf: super::WatchFile = r#"version=4
1996opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1997"#
1998        .parse()
1999        .unwrap();
2000
2001        let entry = wf.entries().next().unwrap();
2002
2003        // Verify URL node exists and works
2004        assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
2005        assert_eq!(entry.url(), "https://example.com/releases");
2006
2007        // Verify MATCHING_PATTERN node exists and works
2008        assert_eq!(
2009            entry
2010                .0
2011                .children()
2012                .find(|n| n.kind() == MATCHING_PATTERN)
2013                .is_some(),
2014            true
2015        );
2016        assert_eq!(
2017            entry.matching_pattern(),
2018            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2019        );
2020
2021        // Verify VERSION_POLICY node exists and works
2022        assert_eq!(
2023            entry
2024                .0
2025                .children()
2026                .find(|n| n.kind() == VERSION_POLICY)
2027                .is_some(),
2028            true
2029        );
2030        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2031
2032        // Verify SCRIPT node exists and works
2033        assert_eq!(
2034            entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
2035            true
2036        );
2037        assert_eq!(entry.script(), Some("uupdate".into()));
2038    }
2039
2040    #[test]
2041    fn test_entry_node_structure_partial() {
2042        // Test entry with only URL and pattern (no version or script)
2043        let wf: super::WatchFile = r#"version=4
2044https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
2045"#
2046        .parse()
2047        .unwrap();
2048
2049        let entry = wf.entries().next().unwrap();
2050
2051        // Should have URL and MATCHING_PATTERN nodes
2052        assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
2053        assert_eq!(
2054            entry
2055                .0
2056                .children()
2057                .find(|n| n.kind() == MATCHING_PATTERN)
2058                .is_some(),
2059            true
2060        );
2061
2062        // Should NOT have VERSION_POLICY or SCRIPT nodes
2063        assert_eq!(
2064            entry
2065                .0
2066                .children()
2067                .find(|n| n.kind() == VERSION_POLICY)
2068                .is_some(),
2069            false
2070        );
2071        assert_eq!(
2072            entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
2073            false
2074        );
2075
2076        // Verify accessors work correctly
2077        assert_eq!(entry.url(), "https://github.com/example/tags");
2078        assert_eq!(
2079            entry.matching_pattern(),
2080            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2081        );
2082        assert_eq!(entry.version(), Ok(None));
2083        assert_eq!(entry.script(), None);
2084    }
2085
2086    #[test]
2087    fn test_parse_v1() {
2088        const WATCHV1: &str = r#"version=4
2089opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
2090  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2091"#;
2092        let parsed = parse(WATCHV1);
2093        //assert_eq!(parsed.errors, Vec::<String>::new());
2094        let node = parsed.syntax();
2095        assert_eq!(
2096            format!("{:#?}", node),
2097            r#"ROOT@0..161
2098  VERSION@0..10
2099    KEY@0..7 "version"
2100    EQUALS@7..8 "="
2101    VALUE@8..9 "4"
2102    NEWLINE@9..10 "\n"
2103  ENTRY@10..161
2104    OPTS_LIST@10..86
2105      KEY@10..14 "opts"
2106      EQUALS@14..15 "="
2107      OPTION@15..19
2108        KEY@15..19 "bare"
2109      OPTION_SEPARATOR@19..20
2110        COMMA@19..20 ","
2111      OPTION@20..86
2112        KEY@20..34 "filenamemangle"
2113        EQUALS@34..35 "="
2114        VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..."
2115    WHITESPACE@86..87 " "
2116    CONTINUATION@87..89 "\\\n"
2117    WHITESPACE@89..91 "  "
2118    URL@91..138
2119      VALUE@91..138 "https://github.com/sy ..."
2120    WHITESPACE@138..139 " "
2121    MATCHING_PATTERN@139..160
2122      VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz"
2123    NEWLINE@160..161 "\n"
2124"#
2125        );
2126
2127        let root = parsed.root();
2128        assert_eq!(root.version(), 4);
2129        let entries = root.entries().collect::<Vec<_>>();
2130        assert_eq!(entries.len(), 1);
2131        let entry = &entries[0];
2132        assert_eq!(
2133            entry.url(),
2134            "https://github.com/syncthing/syncthing-gtk/tags"
2135        );
2136        assert_eq!(
2137            entry.matching_pattern(),
2138            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2139        );
2140        assert_eq!(entry.version(), Ok(None));
2141        assert_eq!(entry.script(), None);
2142
2143        assert_eq!(node.text(), WATCHV1);
2144    }
2145
2146    #[test]
2147    fn test_parse_v2() {
2148        let parsed = parse(
2149            r#"version=4
2150https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2151# comment
2152"#,
2153        );
2154        assert_eq!(parsed.errors, Vec::<String>::new());
2155        let node = parsed.syntax();
2156        assert_eq!(
2157            format!("{:#?}", node),
2158            r###"ROOT@0..90
2159  VERSION@0..10
2160    KEY@0..7 "version"
2161    EQUALS@7..8 "="
2162    VALUE@8..9 "4"
2163    NEWLINE@9..10 "\n"
2164  ENTRY@10..80
2165    URL@10..57
2166      VALUE@10..57 "https://github.com/sy ..."
2167    WHITESPACE@57..58 " "
2168    MATCHING_PATTERN@58..79
2169      VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz"
2170    NEWLINE@79..80 "\n"
2171  COMMENT@80..89 "# comment"
2172  NEWLINE@89..90 "\n"
2173"###
2174        );
2175
2176        let root = parsed.root();
2177        assert_eq!(root.version(), 4);
2178        let entries = root.entries().collect::<Vec<_>>();
2179        assert_eq!(entries.len(), 1);
2180        let entry = &entries[0];
2181        assert_eq!(
2182            entry.url(),
2183            "https://github.com/syncthing/syncthing-gtk/tags"
2184        );
2185        assert_eq!(
2186            entry.format_url(|| "syncthing-gtk".to_string()),
2187            "https://github.com/syncthing/syncthing-gtk/tags"
2188                .parse()
2189                .unwrap()
2190        );
2191    }
2192
2193    #[test]
2194    fn test_parse_v3() {
2195        let parsed = parse(
2196            r#"version=4
2197https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz
2198# comment
2199"#,
2200        );
2201        assert_eq!(parsed.errors, Vec::<String>::new());
2202        let root = parsed.root();
2203        assert_eq!(root.version(), 4);
2204        let entries = root.entries().collect::<Vec<_>>();
2205        assert_eq!(entries.len(), 1);
2206        let entry = &entries[0];
2207        assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags");
2208        assert_eq!(
2209            entry.format_url(|| "syncthing-gtk".to_string()),
2210            "https://github.com/syncthing/syncthing-gtk/tags"
2211                .parse()
2212                .unwrap()
2213        );
2214    }
2215
2216    #[test]
2217    fn test_parse_v4() {
2218        let cl: super::WatchFile = r#"version=4
2219opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
2220    https://github.com/example/example-cat/tags \
2221        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2222"#
2223        .parse()
2224        .unwrap();
2225        assert_eq!(cl.version(), 4);
2226        let entries = cl.entries().collect::<Vec<_>>();
2227        assert_eq!(entries.len(), 1);
2228        let entry = &entries[0];
2229        assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2230        assert_eq!(
2231            entry.matching_pattern(),
2232            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2233        );
2234        assert!(entry.repack());
2235        assert_eq!(entry.compression(), Ok(Some(Compression::Xz)));
2236        assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
2237        assert_eq!(entry.repacksuffix(), Some("+ds".into()));
2238        assert_eq!(entry.script(), Some("uupdate".into()));
2239        assert_eq!(
2240            entry.format_url(|| "example-cat".to_string()),
2241            "https://github.com/example/example-cat/tags"
2242                .parse()
2243                .unwrap()
2244        );
2245        assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
2246    }
2247
2248    #[test]
2249    fn test_git_mode() {
2250        let text = r#"version=3
2251opts="mode=git, gitmode=shallow, pgpmode=gittag" \
2252https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \
2253refs/tags/(.*) debian
2254"#;
2255        let parsed = parse(text);
2256        assert_eq!(parsed.errors, Vec::<String>::new());
2257        let cl = parsed.root();
2258        assert_eq!(cl.version(), 3);
2259        let entries = cl.entries().collect::<Vec<_>>();
2260        assert_eq!(entries.len(), 1);
2261        let entry = &entries[0];
2262        assert_eq!(
2263            entry.url(),
2264            "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git"
2265        );
2266        assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into()));
2267        assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
2268        assert_eq!(entry.script(), None);
2269        assert_eq!(entry.gitmode(), Ok(GitMode::Shallow));
2270        assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag));
2271        assert_eq!(entry.mode(), Ok(Mode::Git));
2272    }
2273
2274    #[test]
2275    fn test_parse_quoted() {
2276        const WATCHV1: &str = r#"version=4
2277opts="bare, filenamemangle=blah" \
2278  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2279"#;
2280        let parsed = parse(WATCHV1);
2281        //assert_eq!(parsed.errors, Vec::<String>::new());
2282        let node = parsed.syntax();
2283
2284        let root = parsed.root();
2285        assert_eq!(root.version(), 4);
2286        let entries = root.entries().collect::<Vec<_>>();
2287        assert_eq!(entries.len(), 1);
2288        let entry = &entries[0];
2289
2290        assert_eq!(
2291            entry.url(),
2292            "https://github.com/syncthing/syncthing-gtk/tags"
2293        );
2294        assert_eq!(
2295            entry.matching_pattern(),
2296            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2297        );
2298        assert_eq!(entry.version(), Ok(None));
2299        assert_eq!(entry.script(), None);
2300
2301        assert_eq!(node.text(), WATCHV1);
2302    }
2303
2304    #[test]
2305    fn test_set_url() {
2306        // Test setting URL on a simple entry without options
2307        let wf: super::WatchFile = r#"version=4
2308https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2309"#
2310        .parse()
2311        .unwrap();
2312
2313        let mut entry = wf.entries().next().unwrap();
2314        assert_eq!(
2315            entry.url(),
2316            "https://github.com/syncthing/syncthing-gtk/tags"
2317        );
2318
2319        entry.set_url("https://newurl.example.org/path");
2320        assert_eq!(entry.url(), "https://newurl.example.org/path");
2321        assert_eq!(
2322            entry.matching_pattern(),
2323            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2324        );
2325
2326        // Verify the exact serialized output
2327        assert_eq!(
2328            entry.to_string(),
2329            "https://newurl.example.org/path .*/v?(\\d\\S+)\\.tar\\.gz\n"
2330        );
2331    }
2332
2333    #[test]
2334    fn test_set_url_with_options() {
2335        // Test setting URL on an entry with options
2336        let wf: super::WatchFile = r#"version=4
2337opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz
2338"#
2339        .parse()
2340        .unwrap();
2341
2342        let mut entry = wf.entries().next().unwrap();
2343        assert_eq!(entry.url(), "https://foo.com/bar");
2344        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2345
2346        entry.set_url("https://example.com/baz");
2347        assert_eq!(entry.url(), "https://example.com/baz");
2348
2349        // Verify options are preserved
2350        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2351        assert_eq!(
2352            entry.matching_pattern(),
2353            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2354        );
2355
2356        // Verify the exact serialized output
2357        assert_eq!(
2358            entry.to_string(),
2359            "opts=foo=blah https://example.com/baz .*/v?(\\d\\S+)\\.tar\\.gz\n"
2360        );
2361    }
2362
2363    #[test]
2364    fn test_set_url_complex() {
2365        // Test with a complex watch file with multiple options and continuation
2366        let wf: super::WatchFile = r#"version=4
2367opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
2368  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2369"#
2370        .parse()
2371        .unwrap();
2372
2373        let mut entry = wf.entries().next().unwrap();
2374        assert_eq!(
2375            entry.url(),
2376            "https://github.com/syncthing/syncthing-gtk/tags"
2377        );
2378
2379        entry.set_url("https://gitlab.com/newproject/tags");
2380        assert_eq!(entry.url(), "https://gitlab.com/newproject/tags");
2381
2382        // Verify all options are preserved
2383        assert!(entry.bare());
2384        assert_eq!(
2385            entry.filenamemangle(),
2386            Some("s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into())
2387        );
2388        assert_eq!(
2389            entry.matching_pattern(),
2390            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2391        );
2392
2393        // Verify the exact serialized output preserves structure
2394        assert_eq!(
2395            entry.to_string(),
2396            r#"opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
2397  https://gitlab.com/newproject/tags .*/v?(\d\S+)\.tar\.gz
2398"#
2399        );
2400    }
2401
2402    #[test]
2403    fn test_set_url_with_all_fields() {
2404        // Test with all fields: options, URL, matching pattern, version, and script
2405        let wf: super::WatchFile = r#"version=4
2406opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
2407    https://github.com/example/example-cat/tags \
2408        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2409"#
2410        .parse()
2411        .unwrap();
2412
2413        let mut entry = wf.entries().next().unwrap();
2414        assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2415        assert_eq!(
2416            entry.matching_pattern(),
2417            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2418        );
2419        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2420        assert_eq!(entry.script(), Some("uupdate".into()));
2421
2422        entry.set_url("https://gitlab.example.org/project/releases");
2423        assert_eq!(entry.url(), "https://gitlab.example.org/project/releases");
2424
2425        // Verify all other fields are preserved
2426        assert!(entry.repack());
2427        assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2428        assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
2429        assert_eq!(entry.repacksuffix(), Some("+ds".into()));
2430        assert_eq!(
2431            entry.matching_pattern(),
2432            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2433        );
2434        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2435        assert_eq!(entry.script(), Some("uupdate".into()));
2436
2437        // Verify the exact serialized output
2438        assert_eq!(
2439            entry.to_string(),
2440            r#"opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
2441    https://gitlab.example.org/project/releases \
2442        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2443"#
2444        );
2445    }
2446
2447    #[test]
2448    fn test_set_url_quoted_options() {
2449        // Test with quoted options
2450        let wf: super::WatchFile = r#"version=4
2451opts="bare, filenamemangle=blah" \
2452  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2453"#
2454        .parse()
2455        .unwrap();
2456
2457        let mut entry = wf.entries().next().unwrap();
2458        assert_eq!(
2459            entry.url(),
2460            "https://github.com/syncthing/syncthing-gtk/tags"
2461        );
2462
2463        entry.set_url("https://example.org/new/path");
2464        assert_eq!(entry.url(), "https://example.org/new/path");
2465
2466        // Verify the exact serialized output
2467        assert_eq!(
2468            entry.to_string(),
2469            r#"opts="bare, filenamemangle=blah" \
2470  https://example.org/new/path .*/v?(\d\S+)\.tar\.gz
2471"#
2472        );
2473    }
2474
2475    #[test]
2476    fn test_set_opt_update_existing() {
2477        // Test updating an existing option
2478        let wf: super::WatchFile = r#"version=4
2479opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2480"#
2481        .parse()
2482        .unwrap();
2483
2484        let mut entry = wf.entries().next().unwrap();
2485        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2486        assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2487
2488        entry.set_opt("foo", "updated");
2489        assert_eq!(entry.get_option("foo"), Some("updated".to_string()));
2490        assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2491
2492        // Verify the exact serialized output
2493        assert_eq!(
2494            entry.to_string(),
2495            "opts=foo=updated,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2496        );
2497    }
2498
2499    #[test]
2500    fn test_set_opt_add_new() {
2501        // Test adding a new option to existing options
2502        let wf: super::WatchFile = r#"version=4
2503opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2504"#
2505        .parse()
2506        .unwrap();
2507
2508        let mut entry = wf.entries().next().unwrap();
2509        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2510        assert_eq!(entry.get_option("bar"), None);
2511
2512        entry.set_opt("bar", "baz");
2513        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2514        assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2515
2516        // Verify the exact serialized output
2517        assert_eq!(
2518            entry.to_string(),
2519            "opts=foo=blah,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2520        );
2521    }
2522
2523    #[test]
2524    fn test_set_opt_create_options_list() {
2525        // Test creating a new options list when none exists
2526        let wf: super::WatchFile = r#"version=4
2527https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2528"#
2529        .parse()
2530        .unwrap();
2531
2532        let mut entry = wf.entries().next().unwrap();
2533        assert_eq!(entry.option_list(), None);
2534
2535        entry.set_opt("compression", "xz");
2536        assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
2537
2538        // Verify the exact serialized output
2539        assert_eq!(
2540            entry.to_string(),
2541            "opts=compression=xz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2542        );
2543    }
2544
2545    #[test]
2546    fn test_del_opt_remove_single() {
2547        // Test removing a single option from multiple options
2548        let wf: super::WatchFile = r#"version=4
2549opts=foo=blah,bar=baz,qux=quux https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2550"#
2551        .parse()
2552        .unwrap();
2553
2554        let mut entry = wf.entries().next().unwrap();
2555        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2556        assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2557        assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
2558
2559        entry.del_opt("bar");
2560        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2561        assert_eq!(entry.get_option("bar"), None);
2562        assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
2563
2564        // Verify the exact serialized output
2565        assert_eq!(
2566            entry.to_string(),
2567            "opts=foo=blah,qux=quux https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2568        );
2569    }
2570
2571    #[test]
2572    fn test_del_opt_remove_first() {
2573        // Test removing the first option
2574        let wf: super::WatchFile = r#"version=4
2575opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2576"#
2577        .parse()
2578        .unwrap();
2579
2580        let mut entry = wf.entries().next().unwrap();
2581        entry.del_opt("foo");
2582        assert_eq!(entry.get_option("foo"), None);
2583        assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2584
2585        // Verify the exact serialized output
2586        assert_eq!(
2587            entry.to_string(),
2588            "opts=bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_del_opt_remove_last() {
2594        // Test removing the last option
2595        let wf: super::WatchFile = r#"version=4
2596opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2597"#
2598        .parse()
2599        .unwrap();
2600
2601        let mut entry = wf.entries().next().unwrap();
2602        entry.del_opt("bar");
2603        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2604        assert_eq!(entry.get_option("bar"), None);
2605
2606        // Verify the exact serialized output
2607        assert_eq!(
2608            entry.to_string(),
2609            "opts=foo=blah https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2610        );
2611    }
2612
2613    #[test]
2614    fn test_del_opt_remove_only_option() {
2615        // Test removing the only option (should remove entire opts list)
2616        let wf: super::WatchFile = r#"version=4
2617opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2618"#
2619        .parse()
2620        .unwrap();
2621
2622        let mut entry = wf.entries().next().unwrap();
2623        assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2624
2625        entry.del_opt("foo");
2626        assert_eq!(entry.get_option("foo"), None);
2627        assert_eq!(entry.option_list(), None);
2628
2629        // Verify the exact serialized output (opts should be gone)
2630        assert_eq!(
2631            entry.to_string(),
2632            "https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2633        );
2634    }
2635
2636    #[test]
2637    fn test_del_opt_nonexistent() {
2638        // Test deleting a non-existent option (should do nothing)
2639        let wf: super::WatchFile = r#"version=4
2640opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2641"#
2642        .parse()
2643        .unwrap();
2644
2645        let mut entry = wf.entries().next().unwrap();
2646        let original = entry.to_string();
2647
2648        entry.del_opt("nonexistent");
2649        assert_eq!(entry.to_string(), original);
2650    }
2651
2652    #[test]
2653    fn test_set_opt_multiple_operations() {
2654        // Test multiple set_opt operations
2655        let wf: super::WatchFile = r#"version=4
2656https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2657"#
2658        .parse()
2659        .unwrap();
2660
2661        let mut entry = wf.entries().next().unwrap();
2662
2663        entry.set_opt("compression", "xz");
2664        entry.set_opt("repack", "");
2665        entry.set_opt("dversionmangle", "s/\\+ds//");
2666
2667        assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
2668        assert_eq!(
2669            entry.get_option("dversionmangle"),
2670            Some("s/\\+ds//".to_string())
2671        );
2672    }
2673
2674    #[test]
2675    fn test_set_matching_pattern() {
2676        // Test setting matching pattern on a simple entry
2677        let wf: super::WatchFile = r#"version=4
2678https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
2679"#
2680        .parse()
2681        .unwrap();
2682
2683        let mut entry = wf.entries().next().unwrap();
2684        assert_eq!(
2685            entry.matching_pattern(),
2686            Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2687        );
2688
2689        entry.set_matching_pattern("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz");
2690        assert_eq!(
2691            entry.matching_pattern(),
2692            Some("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz".into())
2693        );
2694
2695        // Verify URL is preserved
2696        assert_eq!(entry.url(), "https://github.com/example/tags");
2697
2698        // Verify the exact serialized output
2699        assert_eq!(
2700            entry.to_string(),
2701            "https://github.com/example/tags (?:.*?/)?v?([\\d.]+)\\.tar\\.gz\n"
2702        );
2703    }
2704
2705    #[test]
2706    fn test_set_matching_pattern_with_all_fields() {
2707        // Test with all fields present
2708        let wf: super::WatchFile = r#"version=4
2709opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2710"#
2711        .parse()
2712        .unwrap();
2713
2714        let mut entry = wf.entries().next().unwrap();
2715        assert_eq!(
2716            entry.matching_pattern(),
2717            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2718        );
2719
2720        entry.set_matching_pattern(".*/version-([\\d.]+)\\.tar\\.xz");
2721        assert_eq!(
2722            entry.matching_pattern(),
2723            Some(".*/version-([\\d.]+)\\.tar\\.xz".into())
2724        );
2725
2726        // Verify all other fields are preserved
2727        assert_eq!(entry.url(), "https://example.com/releases");
2728        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2729        assert_eq!(entry.script(), Some("uupdate".into()));
2730        assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2731
2732        // Verify the exact serialized output
2733        assert_eq!(
2734        entry.to_string(),
2735        "opts=compression=xz https://example.com/releases .*/version-([\\d.]+)\\.tar\\.xz debian uupdate\n"
2736    );
2737    }
2738
2739    #[test]
2740    fn test_set_version_policy() {
2741        // Test setting version policy
2742        let wf: super::WatchFile = r#"version=4
2743https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2744"#
2745        .parse()
2746        .unwrap();
2747
2748        let mut entry = wf.entries().next().unwrap();
2749        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2750
2751        entry.set_version_policy("previous");
2752        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Previous)));
2753
2754        // Verify all other fields are preserved
2755        assert_eq!(entry.url(), "https://example.com/releases");
2756        assert_eq!(
2757            entry.matching_pattern(),
2758            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2759        );
2760        assert_eq!(entry.script(), Some("uupdate".into()));
2761
2762        // Verify the exact serialized output
2763        assert_eq!(
2764            entry.to_string(),
2765            "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz previous uupdate\n"
2766        );
2767    }
2768
2769    #[test]
2770    fn test_set_version_policy_with_options() {
2771        // Test with options and continuation
2772        let wf: super::WatchFile = r#"version=4
2773opts=repack,compression=xz \
2774    https://github.com/example/example-cat/tags \
2775        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2776"#
2777        .parse()
2778        .unwrap();
2779
2780        let mut entry = wf.entries().next().unwrap();
2781        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2782
2783        entry.set_version_policy("ignore");
2784        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Ignore)));
2785
2786        // Verify all other fields are preserved
2787        assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2788        assert_eq!(
2789            entry.matching_pattern(),
2790            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2791        );
2792        assert_eq!(entry.script(), Some("uupdate".into()));
2793        assert!(entry.repack());
2794
2795        // Verify the exact serialized output
2796        assert_eq!(
2797            entry.to_string(),
2798            r#"opts=repack,compression=xz \
2799    https://github.com/example/example-cat/tags \
2800        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz ignore uupdate
2801"#
2802        );
2803    }
2804
2805    #[test]
2806    fn test_set_script() {
2807        // Test setting script
2808        let wf: super::WatchFile = r#"version=4
2809https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2810"#
2811        .parse()
2812        .unwrap();
2813
2814        let mut entry = wf.entries().next().unwrap();
2815        assert_eq!(entry.script(), Some("uupdate".into()));
2816
2817        entry.set_script("uscan");
2818        assert_eq!(entry.script(), Some("uscan".into()));
2819
2820        // Verify all other fields are preserved
2821        assert_eq!(entry.url(), "https://example.com/releases");
2822        assert_eq!(
2823            entry.matching_pattern(),
2824            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2825        );
2826        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2827
2828        // Verify the exact serialized output
2829        assert_eq!(
2830            entry.to_string(),
2831            "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian uscan\n"
2832        );
2833    }
2834
2835    #[test]
2836    fn test_set_script_with_options() {
2837        // Test with options
2838        let wf: super::WatchFile = r#"version=4
2839opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2840"#
2841        .parse()
2842        .unwrap();
2843
2844        let mut entry = wf.entries().next().unwrap();
2845        assert_eq!(entry.script(), Some("uupdate".into()));
2846
2847        entry.set_script("custom-script.sh");
2848        assert_eq!(entry.script(), Some("custom-script.sh".into()));
2849
2850        // Verify all other fields are preserved
2851        assert_eq!(entry.url(), "https://example.com/releases");
2852        assert_eq!(
2853            entry.matching_pattern(),
2854            Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2855        );
2856        assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2857        assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2858
2859        // Verify the exact serialized output
2860        assert_eq!(
2861        entry.to_string(),
2862        "opts=compression=xz https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian custom-script.sh\n"
2863    );
2864    }
2865
2866    #[test]
2867    fn test_apply_dversionmangle() {
2868        // Test basic dversionmangle
2869        let wf: super::WatchFile = r#"version=4
2870opts=dversionmangle=s/\+dfsg$// https://example.com/ .*
2871"#
2872        .parse()
2873        .unwrap();
2874        let entry = wf.entries().next().unwrap();
2875        assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0");
2876        assert_eq!(entry.apply_dversionmangle("1.0").unwrap(), "1.0");
2877
2878        // Test with versionmangle (fallback)
2879        let wf: super::WatchFile = r#"version=4
2880opts=versionmangle=s/^v// https://example.com/ .*
2881"#
2882        .parse()
2883        .unwrap();
2884        let entry = wf.entries().next().unwrap();
2885        assert_eq!(entry.apply_dversionmangle("v1.0").unwrap(), "1.0");
2886
2887        // Test with both dversionmangle and versionmangle (dversionmangle takes precedence)
2888        let wf: super::WatchFile = r#"version=4
2889opts=dversionmangle=s/\+ds//,versionmangle=s/^v// https://example.com/ .*
2890"#
2891        .parse()
2892        .unwrap();
2893        let entry = wf.entries().next().unwrap();
2894        assert_eq!(entry.apply_dversionmangle("1.0+ds").unwrap(), "1.0");
2895
2896        // Test without any mangle options
2897        let wf: super::WatchFile = r#"version=4
2898https://example.com/ .*
2899"#
2900        .parse()
2901        .unwrap();
2902        let entry = wf.entries().next().unwrap();
2903        assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0+dfsg");
2904    }
2905
2906    #[test]
2907    fn test_apply_oversionmangle() {
2908        // Test basic oversionmangle - adding suffix
2909        let wf: super::WatchFile = r#"version=4
2910opts=oversionmangle=s/$/-1/ https://example.com/ .*
2911"#
2912        .parse()
2913        .unwrap();
2914        let entry = wf.entries().next().unwrap();
2915        assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1");
2916        assert_eq!(entry.apply_oversionmangle("2.5.3").unwrap(), "2.5.3-1");
2917
2918        // Test oversionmangle for adding +dfsg suffix
2919        let wf: super::WatchFile = r#"version=4
2920opts=oversionmangle=s/$/.dfsg/ https://example.com/ .*
2921"#
2922        .parse()
2923        .unwrap();
2924        let entry = wf.entries().next().unwrap();
2925        assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0.dfsg");
2926
2927        // Test without any mangle options
2928        let wf: super::WatchFile = r#"version=4
2929https://example.com/ .*
2930"#
2931        .parse()
2932        .unwrap();
2933        let entry = wf.entries().next().unwrap();
2934        assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0");
2935    }
2936
2937    #[test]
2938    fn test_apply_dirversionmangle() {
2939        // Test basic dirversionmangle - removing 'v' prefix
2940        let wf: super::WatchFile = r#"version=4
2941opts=dirversionmangle=s/^v// https://example.com/ .*
2942"#
2943        .parse()
2944        .unwrap();
2945        let entry = wf.entries().next().unwrap();
2946        assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
2947        assert_eq!(entry.apply_dirversionmangle("v2.5.3").unwrap(), "2.5.3");
2948
2949        // Test dirversionmangle with capture groups
2950        let wf: super::WatchFile = r#"version=4
2951opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .*
2952"#
2953        .parse()
2954        .unwrap();
2955        let entry = wf.entries().next().unwrap();
2956        assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
2957
2958        // Test without any mangle options
2959        let wf: super::WatchFile = r#"version=4
2960https://example.com/ .*
2961"#
2962        .parse()
2963        .unwrap();
2964        let entry = wf.entries().next().unwrap();
2965        assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "v1.0");
2966    }
2967
2968    #[test]
2969    fn test_apply_filenamemangle() {
2970        // Test filenamemangle to generate tarball filename
2971        let wf: super::WatchFile = r#"version=4
2972opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .*
2973"#
2974        .parse()
2975        .unwrap();
2976        let entry = wf.entries().next().unwrap();
2977        assert_eq!(
2978            entry
2979                .apply_filenamemangle("https://example.com/v1.0.tar.gz")
2980                .unwrap(),
2981            "mypackage-1.0.tar.gz"
2982        );
2983        assert_eq!(
2984            entry
2985                .apply_filenamemangle("https://example.com/2.5.3.tar.gz")
2986                .unwrap(),
2987            "mypackage-2.5.3.tar.gz"
2988        );
2989
2990        // Test filenamemangle with different pattern
2991        let wf: super::WatchFile = r#"version=4
2992opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/ .*
2993"#
2994        .parse()
2995        .unwrap();
2996        let entry = wf.entries().next().unwrap();
2997        assert_eq!(
2998            entry
2999                .apply_filenamemangle("https://example.com/path/to/file.tar.gz")
3000                .unwrap(),
3001            "file.tar.gz"
3002        );
3003
3004        // Test without any mangle options
3005        let wf: super::WatchFile = r#"version=4
3006https://example.com/ .*
3007"#
3008        .parse()
3009        .unwrap();
3010        let entry = wf.entries().next().unwrap();
3011        assert_eq!(
3012            entry
3013                .apply_filenamemangle("https://example.com/file.tar.gz")
3014                .unwrap(),
3015            "https://example.com/file.tar.gz"
3016        );
3017    }
3018
3019    #[test]
3020    fn test_apply_pagemangle() {
3021        // Test pagemangle to decode HTML entities
3022        let wf: super::WatchFile = r#"version=4
3023opts=pagemangle=s/&amp;/&/g https://example.com/ .*
3024"#
3025        .parse()
3026        .unwrap();
3027        let entry = wf.entries().next().unwrap();
3028        assert_eq!(
3029            entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
3030            b"foo & bar"
3031        );
3032        assert_eq!(
3033            entry
3034                .apply_pagemangle(b"&amp; foo &amp; bar &amp;")
3035                .unwrap(),
3036            b"& foo & bar &"
3037        );
3038
3039        // Test pagemangle with different pattern
3040        let wf: super::WatchFile = r#"version=4
3041opts=pagemangle=s/<[^>]+>//g https://example.com/ .*
3042"#
3043        .parse()
3044        .unwrap();
3045        let entry = wf.entries().next().unwrap();
3046        assert_eq!(entry.apply_pagemangle(b"<div>text</div>").unwrap(), b"text");
3047
3048        // Test without any mangle options
3049        let wf: super::WatchFile = r#"version=4
3050https://example.com/ .*
3051"#
3052        .parse()
3053        .unwrap();
3054        let entry = wf.entries().next().unwrap();
3055        assert_eq!(
3056            entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
3057            b"foo &amp; bar"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_apply_downloadurlmangle() {
3063        // Test downloadurlmangle to change URL path
3064        let wf: super::WatchFile = r#"version=4
3065opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .*
3066"#
3067        .parse()
3068        .unwrap();
3069        let entry = wf.entries().next().unwrap();
3070        assert_eq!(
3071            entry
3072                .apply_downloadurlmangle("https://example.com/archive/file.tar.gz")
3073                .unwrap(),
3074            "https://example.com/download/file.tar.gz"
3075        );
3076
3077        // Test downloadurlmangle with different pattern
3078        let wf: super::WatchFile = r#"version=4
3079opts=downloadurlmangle=s/github\.com/raw.githubusercontent.com/ https://example.com/ .*
3080"#
3081        .parse()
3082        .unwrap();
3083        let entry = wf.entries().next().unwrap();
3084        assert_eq!(
3085            entry
3086                .apply_downloadurlmangle("https://github.com/user/repo/file.tar.gz")
3087                .unwrap(),
3088            "https://raw.githubusercontent.com/user/repo/file.tar.gz"
3089        );
3090
3091        // Test without any mangle options
3092        let wf: super::WatchFile = r#"version=4
3093https://example.com/ .*
3094"#
3095        .parse()
3096        .unwrap();
3097        let entry = wf.entries().next().unwrap();
3098        assert_eq!(
3099            entry
3100                .apply_downloadurlmangle("https://example.com/archive/file.tar.gz")
3101                .unwrap(),
3102            "https://example.com/archive/file.tar.gz"
3103        );
3104    }
3105
3106    #[test]
3107    fn test_entry_builder_minimal() {
3108        // Test creating a minimal entry with just URL and pattern
3109        let entry = super::EntryBuilder::new("https://github.com/example/tags")
3110            .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3111            .build();
3112
3113        assert_eq!(entry.url(), "https://github.com/example/tags");
3114        assert_eq!(
3115            entry.matching_pattern().as_deref(),
3116            Some(".*/v?(\\d\\S+)\\.tar\\.gz")
3117        );
3118        assert_eq!(entry.version(), Ok(None));
3119        assert_eq!(entry.script(), None);
3120        assert!(entry.opts().is_empty());
3121    }
3122
3123    #[test]
3124    fn test_entry_builder_url_only() {
3125        // Test creating an entry with just URL
3126        let entry = super::EntryBuilder::new("https://example.com/releases").build();
3127
3128        assert_eq!(entry.url(), "https://example.com/releases");
3129        assert_eq!(entry.matching_pattern(), None);
3130        assert_eq!(entry.version(), Ok(None));
3131        assert_eq!(entry.script(), None);
3132        assert!(entry.opts().is_empty());
3133    }
3134
3135    #[test]
3136    fn test_entry_builder_with_all_fields() {
3137        // Test creating an entry with all fields
3138        let entry = super::EntryBuilder::new("https://github.com/example/tags")
3139            .matching_pattern(".*/v?(\\d[\\d.]*)\\.tar\\.gz")
3140            .version_policy("debian")
3141            .script("uupdate")
3142            .opt("compression", "xz")
3143            .flag("repack")
3144            .build();
3145
3146        assert_eq!(entry.url(), "https://github.com/example/tags");
3147        assert_eq!(
3148            entry.matching_pattern().as_deref(),
3149            Some(".*/v?(\\d[\\d.]*)\\.tar\\.gz")
3150        );
3151        assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
3152        assert_eq!(entry.script(), Some("uupdate".into()));
3153        assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
3154        assert!(entry.has_option("repack"));
3155        assert!(entry.repack());
3156    }
3157
3158    #[test]
3159    fn test_entry_builder_multiple_options() {
3160        // Test creating an entry with multiple options
3161        let entry = super::EntryBuilder::new("https://example.com/tags")
3162            .matching_pattern(".*/v?(\\d+\\.\\d+)\\.tar\\.gz")
3163            .opt("compression", "xz")
3164            .opt("dversionmangle", "s/\\+ds//")
3165            .opt("repacksuffix", "+ds")
3166            .build();
3167
3168        assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
3169        assert_eq!(
3170            entry.get_option("dversionmangle"),
3171            Some("s/\\+ds//".to_string())
3172        );
3173        assert_eq!(entry.get_option("repacksuffix"), Some("+ds".to_string()));
3174    }
3175
3176    #[test]
3177    fn test_entry_builder_via_entry() {
3178        // Test using Entry::builder() convenience method
3179        let entry = super::Entry::builder("https://github.com/example/tags")
3180            .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3181            .version_policy("debian")
3182            .build();
3183
3184        assert_eq!(entry.url(), "https://github.com/example/tags");
3185        assert_eq!(
3186            entry.matching_pattern().as_deref(),
3187            Some(".*/v?(\\d\\S+)\\.tar\\.gz")
3188        );
3189        assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
3190    }
3191
3192    #[test]
3193    fn test_watchfile_add_entry_to_empty() {
3194        // Test adding an entry to an empty watchfile
3195        let mut wf = super::WatchFile::new(Some(4));
3196
3197        let entry = super::EntryBuilder::new("https://github.com/example/tags")
3198            .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3199            .build();
3200
3201        wf.add_entry(entry);
3202
3203        assert_eq!(wf.version(), 4);
3204        assert_eq!(wf.entries().count(), 1);
3205
3206        let added_entry = wf.entries().next().unwrap();
3207        assert_eq!(added_entry.url(), "https://github.com/example/tags");
3208        assert_eq!(
3209            added_entry.matching_pattern().as_deref(),
3210            Some(".*/v?(\\d\\S+)\\.tar\\.gz")
3211        );
3212    }
3213
3214    #[test]
3215    fn test_watchfile_add_multiple_entries() {
3216        // Test adding multiple entries to a watchfile
3217        let mut wf = super::WatchFile::new(Some(4));
3218
3219        wf.add_entry(
3220            super::EntryBuilder::new("https://github.com/example1/tags")
3221                .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3222                .build(),
3223        );
3224
3225        wf.add_entry(
3226            super::EntryBuilder::new("https://github.com/example2/releases")
3227                .matching_pattern(".*/(\\d+\\.\\d+)\\.tar\\.gz")
3228                .opt("compression", "xz")
3229                .build(),
3230        );
3231
3232        assert_eq!(wf.entries().count(), 2);
3233
3234        let entries: Vec<_> = wf.entries().collect();
3235        assert_eq!(entries[0].url(), "https://github.com/example1/tags");
3236        assert_eq!(entries[1].url(), "https://github.com/example2/releases");
3237        assert_eq!(entries[1].get_option("compression"), Some("xz".to_string()));
3238    }
3239
3240    #[test]
3241    fn test_watchfile_add_entry_to_existing() {
3242        // Test adding an entry to a watchfile that already has entries
3243        let mut wf: super::WatchFile = r#"version=4
3244https://example.com/old .*/v?(\\d\\S+)\\.tar\\.gz
3245"#
3246        .parse()
3247        .unwrap();
3248
3249        assert_eq!(wf.entries().count(), 1);
3250
3251        wf.add_entry(
3252            super::EntryBuilder::new("https://github.com/example/new")
3253                .matching_pattern(".*/v?(\\d+\\.\\d+)\\.tar\\.gz")
3254                .opt("compression", "xz")
3255                .version_policy("debian")
3256                .build(),
3257        );
3258
3259        assert_eq!(wf.entries().count(), 2);
3260
3261        let entries: Vec<_> = wf.entries().collect();
3262        assert_eq!(entries[0].url(), "https://example.com/old");
3263        assert_eq!(entries[1].url(), "https://github.com/example/new");
3264        assert_eq!(entries[1].version(), Ok(Some(VersionPolicy::Debian)));
3265    }
3266
3267    #[test]
3268    fn test_entry_builder_formatting() {
3269        // Test that the builder produces correctly formatted entries
3270        let entry = super::EntryBuilder::new("https://github.com/example/tags")
3271            .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3272            .opt("compression", "xz")
3273            .flag("repack")
3274            .version_policy("debian")
3275            .script("uupdate")
3276            .build();
3277
3278        let entry_str = entry.to_string();
3279
3280        // Should start with opts=
3281        assert!(entry_str.starts_with("opts="));
3282        // Should contain the URL
3283        assert!(entry_str.contains("https://github.com/example/tags"));
3284        // Should contain the pattern
3285        assert!(entry_str.contains(".*/v?(\\d\\S+)\\.tar\\.gz"));
3286        // Should contain version policy
3287        assert!(entry_str.contains("debian"));
3288        // Should contain script
3289        assert!(entry_str.contains("uupdate"));
3290        // Should end with newline
3291        assert!(entry_str.ends_with('\n'));
3292    }
3293
3294    #[test]
3295    fn test_watchfile_add_entry_preserves_format() {
3296        // Test that adding entries preserves the watchfile format
3297        let mut wf = super::WatchFile::new(Some(4));
3298
3299        wf.add_entry(
3300            super::EntryBuilder::new("https://github.com/example/tags")
3301                .matching_pattern(".*/v?(\\d\\S+)\\.tar\\.gz")
3302                .build(),
3303        );
3304
3305        let wf_str = wf.to_string();
3306
3307        // Should have version line
3308        assert!(wf_str.starts_with("version=4\n"));
3309        // Should have the entry
3310        assert!(wf_str.contains("https://github.com/example/tags"));
3311
3312        // Parse it back and ensure it's still valid
3313        let reparsed: super::WatchFile = wf_str.parse().unwrap();
3314        assert_eq!(reparsed.version(), 4);
3315        assert_eq!(reparsed.entries().count(), 1);
3316    }
3317
3318    #[test]
3319    fn test_line_col() {
3320        let text = r#"version=4
3321opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
3322"#;
3323        let wf = text.parse::<super::WatchFile>().unwrap();
3324
3325        // Test version line position
3326        let version_node = wf.version_node().unwrap();
3327        assert_eq!(version_node.line(), 0);
3328        assert_eq!(version_node.column(), 0);
3329        assert_eq!(version_node.line_col(), (0, 0));
3330
3331        // Test entry line numbers
3332        let entries: Vec<_> = wf.entries().collect();
3333        assert_eq!(entries.len(), 1);
3334
3335        // Entry starts at line 1
3336        assert_eq!(entries[0].line(), 1);
3337        assert_eq!(entries[0].column(), 0);
3338        assert_eq!(entries[0].line_col(), (1, 0));
3339
3340        // Test node accessors
3341        let option_list = entries[0].option_list().unwrap();
3342        assert_eq!(option_list.line(), 1); // Option list is on line 1
3343
3344        let url_node = entries[0].url_node().unwrap();
3345        assert_eq!(url_node.line(), 1); // URL is on line 1
3346
3347        let pattern_node = entries[0].matching_pattern_node().unwrap();
3348        assert_eq!(pattern_node.line(), 1); // Pattern is on line 1
3349
3350        let version_policy_node = entries[0].version_node().unwrap();
3351        assert_eq!(version_policy_node.line(), 1); // Version policy is on line 1
3352
3353        let script_node = entries[0].script_node().unwrap();
3354        assert_eq!(script_node.line(), 1); // Script is on line 1
3355
3356        // Test individual option nodes
3357        let options: Vec<_> = option_list.options().collect();
3358        assert_eq!(options.len(), 1);
3359        assert_eq!(options[0].key(), Some("compression".to_string()));
3360        assert_eq!(options[0].value(), Some("xz".to_string()));
3361        assert_eq!(options[0].line(), 1); // Option is on line 1
3362
3363        // Test find_option
3364        let compression_opt = option_list.find_option("compression").unwrap();
3365        assert_eq!(compression_opt.line(), 1);
3366        assert_eq!(compression_opt.column(), 5); // After "opts="
3367        assert_eq!(compression_opt.line_col(), (1, 5));
3368    }
3369
3370    #[test]
3371    fn test_parse_str_relaxed() {
3372        let wf: super::WatchFile = super::WatchFile::from_str_relaxed(
3373            r#"version=4
3374ERRORS IN THIS LINE
3375opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d
3376"#,
3377        );
3378        assert_eq!(wf.version(), 4);
3379        assert_eq!(wf.entries().count(), 2);
3380
3381        let entries = wf.entries().collect::<Vec<_>>();
3382
3383        let entry = &entries[0];
3384        assert_eq!(entry.url(), "ERRORS");
3385
3386        let entry = &entries[1];
3387        assert_eq!(entry.url(), "https://example.com/releases");
3388        assert_eq!(entry.matching_pattern().as_deref(), Some("(?:.*?/)?v?(\\d"));
3389        assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
3390    }
3391}