debian_watch/
parse.rs

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