debian_watch/
parse.rs

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