debian_watch/
parse.rs

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