Skip to main content

debian_watch/
linebased.rs

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