Skip to main content

debian_watch/
linebased.rs

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