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
527impl FromStr for WatchFile {
528    type Err = ParseError;
529
530    fn from_str(s: &str) -> Result<Self, Self::Err> {
531        let parsed = parse(s);
532        if parsed.errors.is_empty() {
533            Ok(parsed.root())
534        } else {
535            Err(ParseError(parsed.errors))
536        }
537    }
538}
539
540impl Version {
541    /// Returns the version of the watch file.
542    pub fn version(&self) -> u32 {
543        self.0
544            .children_with_tokens()
545            .find_map(|it| match it {
546                SyntaxElement::Token(token) => {
547                    if token.kind() == VALUE {
548                        Some(token.text().parse().unwrap())
549                    } else {
550                        None
551                    }
552                }
553                _ => None,
554            })
555            .unwrap_or(DEFAULT_VERSION)
556    }
557}
558
559impl Entry {
560    /// List of options
561    pub fn option_list(&self) -> Option<OptionList> {
562        self.0.children().find_map(OptionList::cast)
563    }
564
565    /// Get the value of an option
566    pub fn get_option(&self, key: &str) -> Option<String> {
567        self.option_list().and_then(|ol| ol.get_option(key))
568    }
569
570    /// Check if an option is set
571    pub fn has_option(&self, key: &str) -> bool {
572        self.option_list().map_or(false, |ol| ol.has_option(key))
573    }
574
575    /// The name of the secondary source tarball
576    pub fn component(&self) -> Option<String> {
577        self.get_option("component")
578    }
579
580    /// Component type
581    pub fn ctype(&self) -> Result<Option<ComponentType>, ()> {
582        self.get_option("ctype").map(|s| s.parse()).transpose()
583    }
584
585    /// Compression method
586    pub fn compression(&self) -> Result<Option<Compression>, ()> {
587        self.get_option("compression")
588            .map(|s| s.parse())
589            .transpose()
590    }
591
592    /// Repack the tarball
593    pub fn repack(&self) -> bool {
594        self.has_option("repack")
595    }
596
597    /// Repack suffix
598    pub fn repacksuffix(&self) -> Option<String> {
599        self.get_option("repacksuffix")
600    }
601
602    /// Retrieve the mode of the watch file entry.
603    pub fn mode(&self) -> Result<Mode, ()> {
604        Ok(self
605            .get_option("mode")
606            .map(|s| s.parse())
607            .transpose()?
608            .unwrap_or_default())
609    }
610
611    /// Return the git pretty mode
612    pub fn pretty(&self) -> Result<Pretty, ()> {
613        Ok(self
614            .get_option("pretty")
615            .map(|s| s.parse())
616            .transpose()?
617            .unwrap_or_default())
618    }
619
620    /// Set the date string used by the pretty option to an arbitrary format as an optional
621    /// opts argument when the matching-pattern is HEAD or heads/branch for git mode.
622    pub fn date(&self) -> String {
623        self.get_option("date")
624            .unwrap_or_else(|| "%Y%m%d".to_string())
625    }
626
627    /// Return the git export mode
628    pub fn gitexport(&self) -> Result<GitExport, ()> {
629        Ok(self
630            .get_option("gitexport")
631            .map(|s| s.parse())
632            .transpose()?
633            .unwrap_or_default())
634    }
635
636    /// Return the git mode
637    pub fn gitmode(&self) -> Result<GitMode, ()> {
638        Ok(self
639            .get_option("gitmode")
640            .map(|s| s.parse())
641            .transpose()?
642            .unwrap_or_default())
643    }
644
645    /// Return the pgp mode
646    pub fn pgpmode(&self) -> Result<PgpMode, ()> {
647        Ok(self
648            .get_option("pgpmode")
649            .map(|s| s.parse())
650            .transpose()?
651            .unwrap_or_default())
652    }
653
654    /// Return the search mode
655    pub fn searchmode(&self) -> Result<SearchMode, ()> {
656        Ok(self
657            .get_option("searchmode")
658            .map(|s| s.parse())
659            .transpose()?
660            .unwrap_or_default())
661    }
662
663    /// Return the decompression mode
664    pub fn decompress(&self) -> bool {
665        self.has_option("decompress")
666    }
667
668    /// Whether to disable all site specific special case code such as URL director uses and page
669    /// content alterations.
670    pub fn bare(&self) -> bool {
671        self.has_option("bare")
672    }
673
674    /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent)
675    pub fn user_agent(&self) -> Option<String> {
676        self.get_option("user-agent")
677    }
678
679    /// Use PASV mode for the FTP connection.
680    pub fn passive(&self) -> Option<bool> {
681        if self.has_option("passive") || self.has_option("pasv") {
682            Some(true)
683        } else if self.has_option("active") || self.has_option("nopasv") {
684            Some(false)
685        } else {
686            None
687        }
688    }
689
690    /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed
691    /// by mk-origtargz.
692    pub fn unzipoptions(&self) -> Option<String> {
693        self.get_option("unzipopt")
694    }
695
696    /// Normalize the downloaded web page string.
697    pub fn dversionmangle(&self) -> Option<String> {
698        self.get_option("dversionmangle")
699            .or_else(|| self.get_option("versionmangle"))
700    }
701
702    /// Normalize the directory path string matching the regex in a set of parentheses of
703    /// http://URL as the sortable version index string.  This is used
704    /// as the directory path sorting index only.
705    pub fn dirversionmangle(&self) -> Option<String> {
706        self.get_option("dirversionmangle")
707    }
708
709    /// Normalize the downloaded web page string.
710    pub fn pagemangle(&self) -> Option<String> {
711        self.get_option("pagemangle")
712    }
713
714    /// Normalize the candidate upstream version strings extracted from hrefs in the
715    /// source of the web page.  This is used as the version sorting index when selecting the
716    /// latest upstream version.
717    pub fn uversionmangle(&self) -> Option<String> {
718        self.get_option("uversionmangle")
719            .or_else(|| self.get_option("versionmangle"))
720    }
721
722    /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules
723    pub fn versionmangle(&self) -> Option<String> {
724        self.get_option("versionmangle")
725    }
726
727    /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal
728    /// string to the decoded normal URL  string  for  obfuscated
729    /// web sites.  Only percent-encoding is available and it is decoded with
730    /// s/%([A-Fa-f\d]{2})/chr hex $1/eg.
731    pub fn hrefdecode(&self) -> bool {
732        self.get_option("hrefdecode").is_some()
733    }
734
735    /// Convert the selected upstream tarball href string into the accessible URL for obfuscated
736    /// web sites.  This is run after hrefdecode.
737    pub fn downloadurlmangle(&self) -> Option<String> {
738        self.get_option("downloadurlmangle")
739    }
740
741    /// Generate the upstream tarball filename from the selected href string if matching-pattern
742    /// can extract the latest upstream version <uversion> from the  selected  href  string.
743    /// Otherwise, generate the upstream tarball filename from its full URL string and set the
744    /// missing <uversion> from the generated upstream tarball filename.
745    ///
746    /// Without this option, the default upstream tarball filename is generated by taking the last
747    /// component of the URL and  removing everything  after any '?' or '#'.
748    pub fn filenamemangle(&self) -> Option<String> {
749        self.get_option("filenamemangle")
750    }
751
752    /// Generate the candidate upstream signature file URL string from the upstream tarball URL.
753    pub fn pgpsigurlmangle(&self) -> Option<String> {
754        self.get_option("pgpsigurlmangle")
755    }
756
757    /// Generate the version string <oversion> of the source tarball <spkg>_<oversion>.orig.tar.gz
758    /// from <uversion>.  This should be used to add a suffix such as +dfsg to a MUT package.
759    pub fn oversionmangle(&self) -> Option<String> {
760        self.get_option("oversionmangle")
761    }
762
763    /// Apply uversionmangle to a version string
764    ///
765    /// # Examples
766    ///
767    /// ```
768    /// # use debian_watch::WatchFile;
769    /// let wf: WatchFile = r#"version=4
770    /// opts=uversionmangle=s/\+ds// https://example.com/ .*
771    /// "#.parse().unwrap();
772    /// let entry = wf.entries().next().unwrap();
773    /// assert_eq!(entry.apply_uversionmangle("1.0+ds").unwrap(), "1.0");
774    /// ```
775    pub fn apply_uversionmangle(
776        &self,
777        version: &str,
778    ) -> Result<String, crate::mangle::MangleError> {
779        if let Some(vm) = self.uversionmangle() {
780            crate::mangle::apply_mangle(&vm, version)
781        } else {
782            Ok(version.to_string())
783        }
784    }
785
786    /// Apply dversionmangle to a version string
787    ///
788    /// # Examples
789    ///
790    /// ```
791    /// # use debian_watch::WatchFile;
792    /// let wf: WatchFile = r#"version=4
793    /// opts=dversionmangle=s/\+dfsg$// https://example.com/ .*
794    /// "#.parse().unwrap();
795    /// let entry = wf.entries().next().unwrap();
796    /// assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0");
797    /// ```
798    pub fn apply_dversionmangle(
799        &self,
800        version: &str,
801    ) -> Result<String, crate::mangle::MangleError> {
802        if let Some(vm) = self.dversionmangle() {
803            crate::mangle::apply_mangle(&vm, version)
804        } else {
805            Ok(version.to_string())
806        }
807    }
808
809    /// Apply oversionmangle to a version string
810    ///
811    /// # Examples
812    ///
813    /// ```
814    /// # use debian_watch::WatchFile;
815    /// let wf: WatchFile = r#"version=4
816    /// opts=oversionmangle=s/$/-1/ https://example.com/ .*
817    /// "#.parse().unwrap();
818    /// let entry = wf.entries().next().unwrap();
819    /// assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1");
820    /// ```
821    pub fn apply_oversionmangle(
822        &self,
823        version: &str,
824    ) -> Result<String, crate::mangle::MangleError> {
825        if let Some(vm) = self.oversionmangle() {
826            crate::mangle::apply_mangle(&vm, version)
827        } else {
828            Ok(version.to_string())
829        }
830    }
831
832    /// Apply dirversionmangle to a directory path string
833    ///
834    /// # Examples
835    ///
836    /// ```
837    /// # use debian_watch::WatchFile;
838    /// let wf: WatchFile = r#"version=4
839    /// opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .*
840    /// "#.parse().unwrap();
841    /// let entry = wf.entries().next().unwrap();
842    /// assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
843    /// ```
844    pub fn apply_dirversionmangle(
845        &self,
846        version: &str,
847    ) -> Result<String, crate::mangle::MangleError> {
848        if let Some(vm) = self.dirversionmangle() {
849            crate::mangle::apply_mangle(&vm, version)
850        } else {
851            Ok(version.to_string())
852        }
853    }
854
855    /// Apply filenamemangle to a URL or filename string
856    ///
857    /// # Examples
858    ///
859    /// ```
860    /// # use debian_watch::WatchFile;
861    /// let wf: WatchFile = r#"version=4
862    /// opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .*
863    /// "#.parse().unwrap();
864    /// let entry = wf.entries().next().unwrap();
865    /// assert_eq!(
866    ///     entry.apply_filenamemangle("https://example.com/v1.0.tar.gz").unwrap(),
867    ///     "mypackage-1.0.tar.gz"
868    /// );
869    /// ```
870    pub fn apply_filenamemangle(&self, url: &str) -> Result<String, crate::mangle::MangleError> {
871        if let Some(vm) = self.filenamemangle() {
872            crate::mangle::apply_mangle(&vm, url)
873        } else {
874            Ok(url.to_string())
875        }
876    }
877
878    /// Apply pagemangle to page content bytes
879    ///
880    /// # Examples
881    ///
882    /// ```
883    /// # use debian_watch::WatchFile;
884    /// let wf: WatchFile = r#"version=4
885    /// opts=pagemangle=s/&amp;/&/g https://example.com/ .*
886    /// "#.parse().unwrap();
887    /// let entry = wf.entries().next().unwrap();
888    /// assert_eq!(
889    ///     entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
890    ///     b"foo & bar"
891    /// );
892    /// ```
893    pub fn apply_pagemangle(&self, page: &[u8]) -> Result<Vec<u8>, crate::mangle::MangleError> {
894        if let Some(vm) = self.pagemangle() {
895            let page_str = String::from_utf8_lossy(page);
896            let mangled = crate::mangle::apply_mangle(&vm, &page_str)?;
897            Ok(mangled.into_bytes())
898        } else {
899            Ok(page.to_vec())
900        }
901    }
902
903    /// Apply downloadurlmangle to a URL string
904    ///
905    /// # Examples
906    ///
907    /// ```
908    /// # use debian_watch::WatchFile;
909    /// let wf: WatchFile = r#"version=4
910    /// opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .*
911    /// "#.parse().unwrap();
912    /// let entry = wf.entries().next().unwrap();
913    /// assert_eq!(
914    ///     entry.apply_downloadurlmangle("https://example.com/archive/file.tar.gz").unwrap(),
915    ///     "https://example.com/download/file.tar.gz"
916    /// );
917    /// ```
918    pub fn apply_downloadurlmangle(&self, url: &str) -> Result<String, crate::mangle::MangleError> {
919        if let Some(vm) = self.downloadurlmangle() {
920            crate::mangle::apply_mangle(&vm, url)
921        } else {
922            Ok(url.to_string())
923        }
924    }
925
926    /// Discover releases for this entry (async version)
927    ///
928    /// Fetches the URL and searches for version matches.
929    /// Requires the 'discover' feature.
930    ///
931    /// # Examples
932    ///
933    /// ```ignore
934    /// # use debian_watch::WatchFile;
935    /// # async fn example() {
936    /// let wf: WatchFile = r#"version=4
937    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
938    /// "#.parse().unwrap();
939    /// let entry = wf.entries().next().unwrap();
940    /// let releases = entry.discover(|| "mypackage".to_string()).await.unwrap();
941    /// for release in releases {
942    ///     println!("{}: {}", release.version, release.url);
943    /// }
944    /// # }
945    /// ```
946    #[cfg(feature = "discover")]
947    pub async fn discover(
948        &self,
949        package: impl FnOnce() -> String,
950    ) -> Result<Vec<crate::Release>, Box<dyn std::error::Error>> {
951        let url = self.format_url(package);
952        let user_agent = self
953            .user_agent()
954            .unwrap_or_else(|| crate::DEFAULT_USER_AGENT.to_string());
955        let searchmode = self.searchmode().unwrap_or(crate::SearchMode::Html);
956
957        let client = reqwest::Client::builder().user_agent(user_agent).build()?;
958
959        let response = client.get(url.as_str()).send().await?;
960        let body = response.bytes().await?;
961
962        // Apply pagemangle if present
963        let mangled_body = self.apply_pagemangle(&body)?;
964
965        let matching_pattern = self
966            .matching_pattern()
967            .ok_or("matching_pattern is required")?;
968
969        let package_name = String::new(); // Not used in search currently
970        let results = crate::search::search(
971            match searchmode {
972                crate::SearchMode::Html => "html",
973                crate::SearchMode::Plain => "plain",
974            },
975            std::io::Cursor::new(mangled_body.as_ref() as &[u8]),
976            &subst(&matching_pattern, || package_name.clone()),
977            &package_name,
978            url.as_str(),
979        )?;
980
981        let mut releases = Vec::new();
982        for (version, full_url) in results {
983            // Apply uversionmangle
984            let mangled_version = self.apply_uversionmangle(&version)?;
985
986            // Apply downloadurlmangle
987            let mangled_url = self.apply_downloadurlmangle(&full_url)?;
988
989            // Apply pgpsigurlmangle if present
990            let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() {
991                Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?)
992            } else {
993                None
994            };
995
996            // Apply filenamemangle if present
997            let target_filename = if self.filenamemangle().is_some() {
998                Some(self.apply_filenamemangle(&mangled_url)?)
999            } else {
1000                None
1001            };
1002
1003            // Apply oversionmangle if present
1004            let package_version = if self.oversionmangle().is_some() {
1005                Some(self.apply_oversionmangle(&mangled_version)?)
1006            } else {
1007                None
1008            };
1009
1010            releases.push(crate::Release::new_full(
1011                mangled_version,
1012                mangled_url,
1013                pgpsigurl,
1014                target_filename,
1015                package_version,
1016            ));
1017        }
1018
1019        Ok(releases)
1020    }
1021
1022    /// Discover releases for this entry (blocking version)
1023    ///
1024    /// Fetches the URL and searches for version matches.
1025    /// Requires both 'discover' and 'blocking' features.
1026    ///
1027    /// # Examples
1028    ///
1029    /// ```ignore
1030    /// # use debian_watch::WatchFile;
1031    /// let wf: WatchFile = r#"version=4
1032    /// https://example.com/releases/ .*/v?(\d+\.\d+)\.tar\.gz
1033    /// "#.parse().unwrap();
1034    /// let entry = wf.entries().next().unwrap();
1035    /// let releases = entry.discover_blocking(|| "mypackage".to_string()).unwrap();
1036    /// for release in releases {
1037    ///     println!("{}: {}", release.version, release.url);
1038    /// }
1039    /// ```
1040    #[cfg(all(feature = "discover", feature = "blocking"))]
1041    pub fn discover_blocking(
1042        &self,
1043        package: impl FnOnce() -> String,
1044    ) -> Result<Vec<crate::Release>, Box<dyn std::error::Error>> {
1045        let url = self.format_url(package);
1046        let user_agent = self
1047            .user_agent()
1048            .unwrap_or_else(|| crate::DEFAULT_USER_AGENT.to_string());
1049        let searchmode = self.searchmode().unwrap_or(crate::SearchMode::Html);
1050
1051        let client = reqwest::blocking::Client::builder()
1052            .user_agent(user_agent)
1053            .build()?;
1054
1055        let response = client.get(url.as_str()).send()?;
1056        let body = response.bytes()?;
1057
1058        // Apply pagemangle if present
1059        let mangled_body = self.apply_pagemangle(&body)?;
1060
1061        let matching_pattern = self
1062            .matching_pattern()
1063            .ok_or("matching_pattern is required")?;
1064
1065        let package_name = String::new(); // Not used in search currently
1066        let results = crate::search::search(
1067            match searchmode {
1068                crate::SearchMode::Html => "html",
1069                crate::SearchMode::Plain => "plain",
1070            },
1071            std::io::Cursor::new(mangled_body.as_ref() as &[u8]),
1072            &subst(&matching_pattern, || package_name.clone()),
1073            &package_name,
1074            url.as_str(),
1075        )?;
1076
1077        let mut releases = Vec::new();
1078        for (version, full_url) in results {
1079            // Apply uversionmangle
1080            let mangled_version = self.apply_uversionmangle(&version)?;
1081
1082            // Apply downloadurlmangle
1083            let mangled_url = self.apply_downloadurlmangle(&full_url)?;
1084
1085            // Apply pgpsigurlmangle if present
1086            let pgpsigurl = if let Some(mangle) = self.pgpsigurlmangle() {
1087                Some(crate::mangle::apply_mangle(&mangle, &mangled_url)?)
1088            } else {
1089                None
1090            };
1091
1092            // Apply filenamemangle if present
1093            let target_filename = if self.filenamemangle().is_some() {
1094                Some(self.apply_filenamemangle(&mangled_url)?)
1095            } else {
1096                None
1097            };
1098
1099            // Apply oversionmangle if present
1100            let package_version = if self.oversionmangle().is_some() {
1101                Some(self.apply_oversionmangle(&mangled_version)?)
1102            } else {
1103                None
1104            };
1105
1106            releases.push(crate::Release::new_full(
1107                mangled_version,
1108                mangled_url,
1109                pgpsigurl,
1110                target_filename,
1111                package_version,
1112            ));
1113        }
1114
1115        Ok(releases)
1116    }
1117
1118    /// Returns options set
1119    pub fn opts(&self) -> std::collections::HashMap<String, String> {
1120        let mut options = std::collections::HashMap::new();
1121
1122        if let Some(ol) = self.option_list() {
1123            for opt in ol.children() {
1124                let key = opt.key();
1125                let value = opt.value();
1126                if let (Some(key), Some(value)) = (key, value) {
1127                    options.insert(key.to_string(), value.to_string());
1128                }
1129            }
1130        }
1131
1132        options
1133    }
1134
1135    fn items(&self) -> impl Iterator<Item = String> + '_ {
1136        self.0.children_with_tokens().filter_map(|it| match it {
1137            SyntaxElement::Token(token) => {
1138                if token.kind() == VALUE || token.kind() == KEY {
1139                    Some(token.text().to_string())
1140                } else {
1141                    None
1142                }
1143            }
1144            SyntaxElement::Node(node) => {
1145                // Extract values from entry field nodes
1146                match node.kind() {
1147                    URL => Url::cast(node).map(|n| n.url()),
1148                    MATCHING_PATTERN => MatchingPattern::cast(node).map(|n| n.pattern()),
1149                    VERSION_POLICY => VersionPolicyNode::cast(node).map(|n| n.policy()),
1150                    SCRIPT => ScriptNode::cast(node).map(|n| n.script()),
1151                    _ => None,
1152                }
1153            }
1154        })
1155    }
1156
1157    /// Returns the URL of the entry.
1158    pub fn url(&self) -> String {
1159        self.0
1160            .children()
1161            .find_map(Url::cast)
1162            .map(|it| it.url())
1163            .unwrap_or_else(|| {
1164                // Fallback for entries without URL node (shouldn't happen with new parser)
1165                self.items().next().unwrap()
1166            })
1167    }
1168
1169    /// Returns the matching pattern of the entry.
1170    pub fn matching_pattern(&self) -> Option<String> {
1171        self.0
1172            .children()
1173            .find_map(MatchingPattern::cast)
1174            .map(|it| it.pattern())
1175            .or_else(|| {
1176                // Fallback for entries without MATCHING_PATTERN node
1177                self.items().nth(1)
1178            })
1179    }
1180
1181    /// Returns the version policy
1182    pub fn version(&self) -> Result<Option<crate::VersionPolicy>, String> {
1183        self.0
1184            .children()
1185            .find_map(VersionPolicyNode::cast)
1186            .map(|it| it.policy().parse())
1187            .transpose()
1188            .or_else(|_e| {
1189                // Fallback for entries without VERSION_POLICY node
1190                self.items().nth(2).map(|it| it.parse()).transpose()
1191            })
1192    }
1193
1194    /// Returns the script of the entry.
1195    pub fn script(&self) -> Option<String> {
1196        self.0
1197            .children()
1198            .find_map(ScriptNode::cast)
1199            .map(|it| it.script())
1200            .or_else(|| {
1201                // Fallback for entries without SCRIPT node
1202                self.items().nth(3)
1203            })
1204    }
1205
1206    /// Replace all substitutions and return the resulting URL.
1207    pub fn format_url(&self, package: impl FnOnce() -> String) -> url::Url {
1208        subst(self.url().as_str(), package).parse().unwrap()
1209    }
1210
1211    /// Set the URL of the entry.
1212    pub fn set_url(&mut self, new_url: &str) {
1213        // Build the new URL node
1214        let mut builder = GreenNodeBuilder::new();
1215        builder.start_node(URL.into());
1216        builder.token(VALUE.into(), new_url);
1217        builder.finish_node();
1218        let new_url_green = builder.finish();
1219
1220        // Create a syntax node (splice_children will detach and reattach it)
1221        let new_url_node = SyntaxNode::new_root_mut(new_url_green);
1222
1223        // Find existing URL node position (need to use children_with_tokens for correct indexing)
1224        let url_pos = self
1225            .0
1226            .children_with_tokens()
1227            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
1228
1229        if let Some(pos) = url_pos {
1230            // Replace existing URL node
1231            self.0
1232                .splice_children(pos..pos + 1, vec![new_url_node.into()]);
1233        }
1234    }
1235
1236    /// Set the matching pattern of the entry.
1237    ///
1238    /// TODO: This currently only replaces an existing matching pattern.
1239    /// If the entry doesn't have a matching pattern, this method does nothing.
1240    /// Future implementation should insert the node at the correct position.
1241    pub fn set_matching_pattern(&mut self, new_pattern: &str) {
1242        // Build the new MATCHING_PATTERN node
1243        let mut builder = GreenNodeBuilder::new();
1244        builder.start_node(MATCHING_PATTERN.into());
1245        builder.token(VALUE.into(), new_pattern);
1246        builder.finish_node();
1247        let new_pattern_green = builder.finish();
1248
1249        // Create a syntax node (splice_children will detach and reattach it)
1250        let new_pattern_node = SyntaxNode::new_root_mut(new_pattern_green);
1251
1252        // Find existing MATCHING_PATTERN node position
1253        let pattern_pos = self.0.children_with_tokens().position(
1254            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == MATCHING_PATTERN),
1255        );
1256
1257        if let Some(pos) = pattern_pos {
1258            // Replace existing MATCHING_PATTERN node
1259            self.0
1260                .splice_children(pos..pos + 1, vec![new_pattern_node.into()]);
1261        }
1262        // TODO: else insert new node after URL
1263    }
1264
1265    /// Set the version policy of the entry.
1266    ///
1267    /// TODO: This currently only replaces an existing version policy.
1268    /// If the entry doesn't have a version policy, this method does nothing.
1269    /// Future implementation should insert the node at the correct position.
1270    pub fn set_version_policy(&mut self, new_policy: &str) {
1271        // Build the new VERSION_POLICY node
1272        let mut builder = GreenNodeBuilder::new();
1273        builder.start_node(VERSION_POLICY.into());
1274        // Version policy can be KEY (e.g., "debian") or VALUE
1275        builder.token(VALUE.into(), new_policy);
1276        builder.finish_node();
1277        let new_policy_green = builder.finish();
1278
1279        // Create a syntax node (splice_children will detach and reattach it)
1280        let new_policy_node = SyntaxNode::new_root_mut(new_policy_green);
1281
1282        // Find existing VERSION_POLICY node position
1283        let policy_pos = self.0.children_with_tokens().position(
1284            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == VERSION_POLICY),
1285        );
1286
1287        if let Some(pos) = policy_pos {
1288            // Replace existing VERSION_POLICY node
1289            self.0
1290                .splice_children(pos..pos + 1, vec![new_policy_node.into()]);
1291        }
1292        // TODO: else insert new node after MATCHING_PATTERN (or URL if no pattern)
1293    }
1294
1295    /// Set the script of the entry.
1296    ///
1297    /// TODO: This currently only replaces an existing script.
1298    /// If the entry doesn't have a script, this method does nothing.
1299    /// Future implementation should insert the node at the correct position.
1300    pub fn set_script(&mut self, new_script: &str) {
1301        // Build the new SCRIPT node
1302        let mut builder = GreenNodeBuilder::new();
1303        builder.start_node(SCRIPT.into());
1304        // Script can be KEY (e.g., "uupdate") or VALUE
1305        builder.token(VALUE.into(), new_script);
1306        builder.finish_node();
1307        let new_script_green = builder.finish();
1308
1309        // Create a syntax node (splice_children will detach and reattach it)
1310        let new_script_node = SyntaxNode::new_root_mut(new_script_green);
1311
1312        // Find existing SCRIPT node position
1313        let script_pos = self
1314            .0
1315            .children_with_tokens()
1316            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == SCRIPT));
1317
1318        if let Some(pos) = script_pos {
1319            // Replace existing SCRIPT node
1320            self.0
1321                .splice_children(pos..pos + 1, vec![new_script_node.into()]);
1322        }
1323        // TODO: else insert new node after VERSION_POLICY (or MATCHING_PATTERN/URL if no policy)
1324    }
1325
1326    /// Set or update an option value.
1327    ///
1328    /// If the option already exists, it will be updated with the new value.
1329    /// If the option doesn't exist, it will be added to the options list.
1330    /// If there's no options list, one will be created.
1331    pub fn set_opt(&mut self, key: &str, value: &str) {
1332        // Find the OPTS_LIST position in Entry
1333        let opts_pos = self.0.children_with_tokens().position(
1334            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == OPTS_LIST),
1335        );
1336
1337        if let Some(_opts_idx) = opts_pos {
1338            if let Some(mut ol) = self.option_list() {
1339                // Find if the option already exists
1340                if let Some(mut opt) = ol.find_option(key) {
1341                    // Update the existing option's value
1342                    opt.set_value(value);
1343                    // Mutations should propagate automatically - no need to replace
1344                } else {
1345                    // Add new option
1346                    ol.add_option(key, value);
1347                    // Mutations should propagate automatically - no need to replace
1348                }
1349            }
1350        } else {
1351            // Create a new options list
1352            let mut builder = GreenNodeBuilder::new();
1353            builder.start_node(OPTS_LIST.into());
1354            builder.token(KEY.into(), "opts");
1355            builder.token(EQUALS.into(), "=");
1356            builder.start_node(OPTION.into());
1357            builder.token(KEY.into(), key);
1358            builder.token(EQUALS.into(), "=");
1359            builder.token(VALUE.into(), value);
1360            builder.finish_node();
1361            builder.finish_node();
1362            let new_opts_green = builder.finish();
1363            let new_opts_node = SyntaxNode::new_root_mut(new_opts_green);
1364
1365            // Find position to insert (before URL if it exists, otherwise at start)
1366            let url_pos = self
1367                .0
1368                .children_with_tokens()
1369                .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
1370
1371            if let Some(url_idx) = url_pos {
1372                // Insert options list and a space before the URL
1373                // Build a parent node containing both space and whitespace to extract from
1374                let mut combined_builder = GreenNodeBuilder::new();
1375                combined_builder.start_node(ROOT.into()); // Temporary parent
1376                combined_builder.token(WHITESPACE.into(), " ");
1377                combined_builder.finish_node();
1378                let temp_green = combined_builder.finish();
1379                let temp_root = SyntaxNode::new_root_mut(temp_green);
1380                let space_element = temp_root.children_with_tokens().next().unwrap();
1381
1382                self.0
1383                    .splice_children(url_idx..url_idx, vec![new_opts_node.into(), space_element]);
1384            } else {
1385                self.0.splice_children(0..0, vec![new_opts_node.into()]);
1386            }
1387        }
1388    }
1389
1390    /// Delete an option.
1391    ///
1392    /// Removes the option with the specified key from the options list.
1393    /// If the option doesn't exist, this method does nothing.
1394    /// If deleting the option results in an empty options list, the entire
1395    /// opts= declaration is removed.
1396    pub fn del_opt(&mut self, key: &str) {
1397        if let Some(mut ol) = self.option_list() {
1398            let option_count = ol.0.children().filter(|n| n.kind() == OPTION).count();
1399
1400            if option_count == 1 && ol.has_option(key) {
1401                // This is the last option, remove the entire OPTS_LIST from Entry
1402                let opts_pos = self.0.children().position(|node| node.kind() == OPTS_LIST);
1403
1404                if let Some(opts_idx) = opts_pos {
1405                    // Remove the OPTS_LIST
1406                    self.0.splice_children(opts_idx..opts_idx + 1, vec![]);
1407
1408                    // Remove any leading whitespace/continuation that was after the OPTS_LIST
1409                    while self.0.children_with_tokens().next().map_or(false, |e| {
1410                        matches!(
1411                            e,
1412                            SyntaxElement::Token(t) if t.kind() == WHITESPACE || t.kind() == CONTINUATION
1413                        )
1414                    }) {
1415                        self.0.splice_children(0..1, vec![]);
1416                    }
1417                }
1418            } else {
1419                // Defer to OptionList to remove the option
1420                ol.remove_option(key);
1421            }
1422        }
1423    }
1424}
1425
1426const SUBSTITUTIONS: &[(&str, &str)] = &[
1427    // This is substituted with the source package name found in the first line
1428    // of the debian/changelog file.
1429    // "@PACKAGE@": None,
1430    // This is substituted by the legal upstream version regex (capturing).
1431    ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"),
1432    // This is substituted by the typical archive file extension regex
1433    // (non-capturing).
1434    (
1435        "@ARCHIVE_EXT@",
1436        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)",
1437    ),
1438    // This is substituted by the typical signature file extension regex
1439    // (non-capturing).
1440    (
1441        "@SIGNATURE_EXT@",
1442        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)",
1443    ),
1444    // This is substituted by the typical Debian extension regexp (capturing).
1445    ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"),
1446];
1447
1448pub fn subst(text: &str, package: impl FnOnce() -> String) -> String {
1449    let mut substs = SUBSTITUTIONS.to_vec();
1450    let package_name;
1451    if text.contains("@PACKAGE@") {
1452        package_name = Some(package());
1453        substs.push(("@PACKAGE@", package_name.as_deref().unwrap()));
1454    }
1455
1456    let mut text = text.to_string();
1457
1458    for (k, v) in substs {
1459        text = text.replace(k, v);
1460    }
1461
1462    text
1463}
1464
1465#[test]
1466fn test_subst() {
1467    assert_eq!(
1468        subst("@ANY_VERSION@", || unreachable!()),
1469        r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"
1470    );
1471    assert_eq!(subst("@PACKAGE@", || "dulwich".to_string()), "dulwich");
1472}
1473
1474impl std::fmt::Debug for OptionList {
1475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1476        f.debug_struct("OptionList")
1477            .field("text", &self.0.text().to_string())
1478            .finish()
1479    }
1480}
1481
1482impl OptionList {
1483    fn children(&self) -> impl Iterator<Item = _Option> + '_ {
1484        self.0.children().filter_map(_Option::cast)
1485    }
1486
1487    pub fn has_option(&self, key: &str) -> bool {
1488        self.children().any(|it| it.key().as_deref() == Some(key))
1489    }
1490
1491    pub fn get_option(&self, key: &str) -> Option<String> {
1492        for child in self.children() {
1493            if child.key().as_deref() == Some(key) {
1494                return child.value();
1495            }
1496        }
1497        None
1498    }
1499
1500    /// Find an option by key.
1501    fn find_option(&self, key: &str) -> Option<_Option> {
1502        self.children()
1503            .find(|opt| opt.key().as_deref() == Some(key))
1504    }
1505
1506    /// Add a new option to the end of the options list.
1507    fn add_option(&mut self, key: &str, value: &str) {
1508        let option_count = self.0.children().filter(|n| n.kind() == OPTION).count();
1509
1510        // Build a structure containing separator (if needed) + option wrapped in a temporary parent
1511        let mut builder = GreenNodeBuilder::new();
1512        builder.start_node(ROOT.into()); // Temporary parent
1513
1514        if option_count > 0 {
1515            builder.start_node(OPTION_SEPARATOR.into());
1516            builder.token(COMMA.into(), ",");
1517            builder.finish_node();
1518        }
1519
1520        builder.start_node(OPTION.into());
1521        builder.token(KEY.into(), key);
1522        builder.token(EQUALS.into(), "=");
1523        builder.token(VALUE.into(), value);
1524        builder.finish_node();
1525
1526        builder.finish_node(); // Close temporary parent
1527        let combined_green = builder.finish();
1528
1529        // Create a temporary root to extract children from
1530        let temp_root = SyntaxNode::new_root_mut(combined_green);
1531        let new_children: Vec<_> = temp_root.children_with_tokens().collect();
1532
1533        let insert_pos = self.0.children_with_tokens().count();
1534        self.0.splice_children(insert_pos..insert_pos, new_children);
1535    }
1536
1537    /// Remove an option by key. Returns true if an option was removed.
1538    fn remove_option(&mut self, key: &str) -> bool {
1539        if let Some(mut opt) = self.find_option(key) {
1540            opt.remove();
1541            true
1542        } else {
1543            false
1544        }
1545    }
1546}
1547
1548impl _Option {
1549    /// Returns the key of the option.
1550    pub fn key(&self) -> Option<String> {
1551        self.0.children_with_tokens().find_map(|it| match it {
1552            SyntaxElement::Token(token) => {
1553                if token.kind() == KEY {
1554                    Some(token.text().to_string())
1555                } else {
1556                    None
1557                }
1558            }
1559            _ => None,
1560        })
1561    }
1562
1563    /// Returns the value of the option.
1564    pub fn value(&self) -> Option<String> {
1565        self.0
1566            .children_with_tokens()
1567            .filter_map(|it| match it {
1568                SyntaxElement::Token(token) => {
1569                    if token.kind() == VALUE || token.kind() == KEY {
1570                        Some(token.text().to_string())
1571                    } else {
1572                        None
1573                    }
1574                }
1575                _ => None,
1576            })
1577            .nth(1)
1578    }
1579
1580    /// Set the value of the option.
1581    pub fn set_value(&mut self, new_value: &str) {
1582        let key = self.key().expect("Option must have a key");
1583
1584        // Build a new OPTION node with the updated value
1585        let mut builder = GreenNodeBuilder::new();
1586        builder.start_node(OPTION.into());
1587        builder.token(KEY.into(), &key);
1588        builder.token(EQUALS.into(), "=");
1589        builder.token(VALUE.into(), new_value);
1590        builder.finish_node();
1591        let new_option_green = builder.finish();
1592        let new_option_node = SyntaxNode::new_root_mut(new_option_green);
1593
1594        // Replace this option in the parent OptionList
1595        if let Some(parent) = self.0.parent() {
1596            let idx = self.0.index();
1597            parent.splice_children(idx..idx + 1, vec![new_option_node.into()]);
1598        }
1599    }
1600
1601    /// Remove this option and its associated separator from the parent OptionList.
1602    pub fn remove(&mut self) {
1603        // Find adjacent separator to remove before detaching this node
1604        let next_sep = self
1605            .0
1606            .next_sibling()
1607            .filter(|n| n.kind() == OPTION_SEPARATOR);
1608        let prev_sep = self
1609            .0
1610            .prev_sibling()
1611            .filter(|n| n.kind() == OPTION_SEPARATOR);
1612
1613        // Detach separator first if it exists
1614        if let Some(sep) = next_sep {
1615            sep.detach();
1616        } else if let Some(sep) = prev_sep {
1617            sep.detach();
1618        }
1619
1620        // Now detach the option itself
1621        self.0.detach();
1622    }
1623}
1624
1625impl Url {
1626    /// Returns the URL string.
1627    pub fn url(&self) -> String {
1628        self.0
1629            .children_with_tokens()
1630            .find_map(|it| match it {
1631                SyntaxElement::Token(token) => {
1632                    if token.kind() == VALUE {
1633                        Some(token.text().to_string())
1634                    } else {
1635                        None
1636                    }
1637                }
1638                _ => None,
1639            })
1640            .unwrap()
1641    }
1642}
1643
1644impl MatchingPattern {
1645    /// Returns the matching pattern string.
1646    pub fn pattern(&self) -> String {
1647        self.0
1648            .children_with_tokens()
1649            .find_map(|it| match it {
1650                SyntaxElement::Token(token) => {
1651                    if token.kind() == VALUE {
1652                        Some(token.text().to_string())
1653                    } else {
1654                        None
1655                    }
1656                }
1657                _ => None,
1658            })
1659            .unwrap()
1660    }
1661}
1662
1663impl VersionPolicyNode {
1664    /// Returns the version policy string.
1665    pub fn policy(&self) -> String {
1666        self.0
1667            .children_with_tokens()
1668            .find_map(|it| match it {
1669                SyntaxElement::Token(token) => {
1670                    // Can be KEY (e.g., "debian") or VALUE
1671                    if token.kind() == VALUE || token.kind() == KEY {
1672                        Some(token.text().to_string())
1673                    } else {
1674                        None
1675                    }
1676                }
1677                _ => None,
1678            })
1679            .unwrap()
1680    }
1681}
1682
1683impl ScriptNode {
1684    /// Returns the script string.
1685    pub fn script(&self) -> String {
1686        self.0
1687            .children_with_tokens()
1688            .find_map(|it| match it {
1689                SyntaxElement::Token(token) => {
1690                    // Can be KEY (e.g., "uupdate") or VALUE
1691                    if token.kind() == VALUE || token.kind() == KEY {
1692                        Some(token.text().to_string())
1693                    } else {
1694                        None
1695                    }
1696                }
1697                _ => None,
1698            })
1699            .unwrap()
1700    }
1701}
1702
1703#[test]
1704fn test_entry_node_structure() {
1705    // Test that entries properly use the new node types
1706    let wf: super::WatchFile = r#"version=4
1707opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1708"#
1709    .parse()
1710    .unwrap();
1711
1712    let entry = wf.entries().next().unwrap();
1713
1714    // Verify URL node exists and works
1715    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1716    assert_eq!(entry.url(), "https://example.com/releases");
1717
1718    // Verify MATCHING_PATTERN node exists and works
1719    assert_eq!(
1720        entry
1721            .0
1722            .children()
1723            .find(|n| n.kind() == MATCHING_PATTERN)
1724            .is_some(),
1725        true
1726    );
1727    assert_eq!(
1728        entry.matching_pattern(),
1729        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1730    );
1731
1732    // Verify VERSION_POLICY node exists and works
1733    assert_eq!(
1734        entry
1735            .0
1736            .children()
1737            .find(|n| n.kind() == VERSION_POLICY)
1738            .is_some(),
1739        true
1740    );
1741    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1742
1743    // Verify SCRIPT node exists and works
1744    assert_eq!(
1745        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1746        true
1747    );
1748    assert_eq!(entry.script(), Some("uupdate".into()));
1749}
1750
1751#[test]
1752fn test_entry_node_structure_partial() {
1753    // Test entry with only URL and pattern (no version or script)
1754    let wf: super::WatchFile = r#"version=4
1755https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
1756"#
1757    .parse()
1758    .unwrap();
1759
1760    let entry = wf.entries().next().unwrap();
1761
1762    // Should have URL and MATCHING_PATTERN nodes
1763    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1764    assert_eq!(
1765        entry
1766            .0
1767            .children()
1768            .find(|n| n.kind() == MATCHING_PATTERN)
1769            .is_some(),
1770        true
1771    );
1772
1773    // Should NOT have VERSION_POLICY or SCRIPT nodes
1774    assert_eq!(
1775        entry
1776            .0
1777            .children()
1778            .find(|n| n.kind() == VERSION_POLICY)
1779            .is_some(),
1780        false
1781    );
1782    assert_eq!(
1783        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1784        false
1785    );
1786
1787    // Verify accessors work correctly
1788    assert_eq!(entry.url(), "https://github.com/example/tags");
1789    assert_eq!(
1790        entry.matching_pattern(),
1791        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1792    );
1793    assert_eq!(entry.version(), Ok(None));
1794    assert_eq!(entry.script(), None);
1795}
1796
1797#[test]
1798fn test_parse_v1() {
1799    const WATCHV1: &str = r#"version=4
1800opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1801  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1802"#;
1803    let parsed = parse(WATCHV1);
1804    //assert_eq!(parsed.errors, Vec::<String>::new());
1805    let node = parsed.syntax();
1806    assert_eq!(
1807        format!("{:#?}", node),
1808        r#"ROOT@0..161
1809  VERSION@0..10
1810    KEY@0..7 "version"
1811    EQUALS@7..8 "="
1812    VALUE@8..9 "4"
1813    NEWLINE@9..10 "\n"
1814  ENTRY@10..161
1815    OPTS_LIST@10..86
1816      KEY@10..14 "opts"
1817      EQUALS@14..15 "="
1818      OPTION@15..19
1819        KEY@15..19 "bare"
1820      OPTION_SEPARATOR@19..20
1821        COMMA@19..20 ","
1822      OPTION@20..86
1823        KEY@20..34 "filenamemangle"
1824        EQUALS@34..35 "="
1825        VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..."
1826    WHITESPACE@86..87 " "
1827    CONTINUATION@87..89 "\\\n"
1828    WHITESPACE@89..91 "  "
1829    URL@91..138
1830      VALUE@91..138 "https://github.com/sy ..."
1831    WHITESPACE@138..139 " "
1832    MATCHING_PATTERN@139..160
1833      VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz"
1834    NEWLINE@160..161 "\n"
1835"#
1836    );
1837
1838    let root = parsed.root();
1839    assert_eq!(root.version(), 4);
1840    let entries = root.entries().collect::<Vec<_>>();
1841    assert_eq!(entries.len(), 1);
1842    let entry = &entries[0];
1843    assert_eq!(
1844        entry.url(),
1845        "https://github.com/syncthing/syncthing-gtk/tags"
1846    );
1847    assert_eq!(
1848        entry.matching_pattern(),
1849        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1850    );
1851    assert_eq!(entry.version(), Ok(None));
1852    assert_eq!(entry.script(), None);
1853
1854    assert_eq!(node.text(), WATCHV1);
1855}
1856
1857#[test]
1858fn test_parse_v2() {
1859    let parsed = parse(
1860        r#"version=4
1861https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1862# comment
1863"#,
1864    );
1865    assert_eq!(parsed.errors, Vec::<String>::new());
1866    let node = parsed.syntax();
1867    assert_eq!(
1868        format!("{:#?}", node),
1869        r###"ROOT@0..90
1870  VERSION@0..10
1871    KEY@0..7 "version"
1872    EQUALS@7..8 "="
1873    VALUE@8..9 "4"
1874    NEWLINE@9..10 "\n"
1875  ENTRY@10..80
1876    URL@10..57
1877      VALUE@10..57 "https://github.com/sy ..."
1878    WHITESPACE@57..58 " "
1879    MATCHING_PATTERN@58..79
1880      VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz"
1881    NEWLINE@79..80 "\n"
1882  COMMENT@80..89 "# comment"
1883  NEWLINE@89..90 "\n"
1884"###
1885    );
1886
1887    let root = parsed.root();
1888    assert_eq!(root.version(), 4);
1889    let entries = root.entries().collect::<Vec<_>>();
1890    assert_eq!(entries.len(), 1);
1891    let entry = &entries[0];
1892    assert_eq!(
1893        entry.url(),
1894        "https://github.com/syncthing/syncthing-gtk/tags"
1895    );
1896    assert_eq!(
1897        entry.format_url(|| "syncthing-gtk".to_string()),
1898        "https://github.com/syncthing/syncthing-gtk/tags"
1899            .parse()
1900            .unwrap()
1901    );
1902}
1903
1904#[test]
1905fn test_parse_v3() {
1906    let parsed = parse(
1907        r#"version=4
1908https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz
1909# comment
1910"#,
1911    );
1912    assert_eq!(parsed.errors, Vec::<String>::new());
1913    let root = parsed.root();
1914    assert_eq!(root.version(), 4);
1915    let entries = root.entries().collect::<Vec<_>>();
1916    assert_eq!(entries.len(), 1);
1917    let entry = &entries[0];
1918    assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags");
1919    assert_eq!(
1920        entry.format_url(|| "syncthing-gtk".to_string()),
1921        "https://github.com/syncthing/syncthing-gtk/tags"
1922            .parse()
1923            .unwrap()
1924    );
1925}
1926
1927#[test]
1928fn test_parse_v4() {
1929    let cl: super::WatchFile = r#"version=4
1930opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1931    https://github.com/example/example-cat/tags \
1932        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1933"#
1934    .parse()
1935    .unwrap();
1936    assert_eq!(cl.version(), 4);
1937    let entries = cl.entries().collect::<Vec<_>>();
1938    assert_eq!(entries.len(), 1);
1939    let entry = &entries[0];
1940    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1941    assert_eq!(
1942        entry.matching_pattern(),
1943        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1944    );
1945    assert!(entry.repack());
1946    assert_eq!(entry.compression(), Ok(Some(Compression::Xz)));
1947    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1948    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1949    assert_eq!(entry.script(), Some("uupdate".into()));
1950    assert_eq!(
1951        entry.format_url(|| "example-cat".to_string()),
1952        "https://github.com/example/example-cat/tags"
1953            .parse()
1954            .unwrap()
1955    );
1956    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1957}
1958
1959#[test]
1960fn test_git_mode() {
1961    let text = r#"version=3
1962opts="mode=git, gitmode=shallow, pgpmode=gittag" \
1963https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \
1964refs/tags/(.*) debian
1965"#;
1966    let parsed = parse(text);
1967    assert_eq!(parsed.errors, Vec::<String>::new());
1968    let cl = parsed.root();
1969    assert_eq!(cl.version(), 3);
1970    let entries = cl.entries().collect::<Vec<_>>();
1971    assert_eq!(entries.len(), 1);
1972    let entry = &entries[0];
1973    assert_eq!(
1974        entry.url(),
1975        "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git"
1976    );
1977    assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into()));
1978    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1979    assert_eq!(entry.script(), None);
1980    assert_eq!(entry.gitmode(), Ok(GitMode::Shallow));
1981    assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag));
1982    assert_eq!(entry.mode(), Ok(Mode::Git));
1983}
1984
1985#[test]
1986fn test_parse_quoted() {
1987    const WATCHV1: &str = r#"version=4
1988opts="bare, filenamemangle=blah" \
1989  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1990"#;
1991    let parsed = parse(WATCHV1);
1992    //assert_eq!(parsed.errors, Vec::<String>::new());
1993    let node = parsed.syntax();
1994
1995    let root = parsed.root();
1996    assert_eq!(root.version(), 4);
1997    let entries = root.entries().collect::<Vec<_>>();
1998    assert_eq!(entries.len(), 1);
1999    let entry = &entries[0];
2000
2001    assert_eq!(
2002        entry.url(),
2003        "https://github.com/syncthing/syncthing-gtk/tags"
2004    );
2005    assert_eq!(
2006        entry.matching_pattern(),
2007        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2008    );
2009    assert_eq!(entry.version(), Ok(None));
2010    assert_eq!(entry.script(), None);
2011
2012    assert_eq!(node.text(), WATCHV1);
2013}
2014
2015#[test]
2016fn test_set_url() {
2017    // Test setting URL on a simple entry without options
2018    let wf: super::WatchFile = r#"version=4
2019https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2020"#
2021    .parse()
2022    .unwrap();
2023
2024    let mut entry = wf.entries().next().unwrap();
2025    assert_eq!(
2026        entry.url(),
2027        "https://github.com/syncthing/syncthing-gtk/tags"
2028    );
2029
2030    entry.set_url("https://newurl.example.org/path");
2031    assert_eq!(entry.url(), "https://newurl.example.org/path");
2032    assert_eq!(
2033        entry.matching_pattern(),
2034        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2035    );
2036
2037    // Verify the exact serialized output
2038    assert_eq!(
2039        entry.to_string(),
2040        "https://newurl.example.org/path .*/v?(\\d\\S+)\\.tar\\.gz\n"
2041    );
2042}
2043
2044#[test]
2045fn test_set_url_with_options() {
2046    // Test setting URL on an entry with options
2047    let wf: super::WatchFile = r#"version=4
2048opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz
2049"#
2050    .parse()
2051    .unwrap();
2052
2053    let mut entry = wf.entries().next().unwrap();
2054    assert_eq!(entry.url(), "https://foo.com/bar");
2055    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2056
2057    entry.set_url("https://example.com/baz");
2058    assert_eq!(entry.url(), "https://example.com/baz");
2059
2060    // Verify options are preserved
2061    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2062    assert_eq!(
2063        entry.matching_pattern(),
2064        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2065    );
2066
2067    // Verify the exact serialized output
2068    assert_eq!(
2069        entry.to_string(),
2070        "opts=foo=blah https://example.com/baz .*/v?(\\d\\S+)\\.tar\\.gz\n"
2071    );
2072}
2073
2074#[test]
2075fn test_set_url_complex() {
2076    // Test with a complex watch file with multiple options and continuation
2077    let wf: super::WatchFile = r#"version=4
2078opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
2079  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2080"#
2081    .parse()
2082    .unwrap();
2083
2084    let mut entry = wf.entries().next().unwrap();
2085    assert_eq!(
2086        entry.url(),
2087        "https://github.com/syncthing/syncthing-gtk/tags"
2088    );
2089
2090    entry.set_url("https://gitlab.com/newproject/tags");
2091    assert_eq!(entry.url(), "https://gitlab.com/newproject/tags");
2092
2093    // Verify all options are preserved
2094    assert!(entry.bare());
2095    assert_eq!(
2096        entry.filenamemangle(),
2097        Some("s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into())
2098    );
2099    assert_eq!(
2100        entry.matching_pattern(),
2101        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2102    );
2103
2104    // Verify the exact serialized output preserves structure
2105    assert_eq!(
2106        entry.to_string(),
2107        r#"opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
2108  https://gitlab.com/newproject/tags .*/v?(\d\S+)\.tar\.gz
2109"#
2110    );
2111}
2112
2113#[test]
2114fn test_set_url_with_all_fields() {
2115    // Test with all fields: options, URL, matching pattern, version, and script
2116    let wf: super::WatchFile = r#"version=4
2117opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
2118    https://github.com/example/example-cat/tags \
2119        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2120"#
2121    .parse()
2122    .unwrap();
2123
2124    let mut entry = wf.entries().next().unwrap();
2125    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2126    assert_eq!(
2127        entry.matching_pattern(),
2128        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2129    );
2130    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2131    assert_eq!(entry.script(), Some("uupdate".into()));
2132
2133    entry.set_url("https://gitlab.example.org/project/releases");
2134    assert_eq!(entry.url(), "https://gitlab.example.org/project/releases");
2135
2136    // Verify all other fields are preserved
2137    assert!(entry.repack());
2138    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2139    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
2140    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
2141    assert_eq!(
2142        entry.matching_pattern(),
2143        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2144    );
2145    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2146    assert_eq!(entry.script(), Some("uupdate".into()));
2147
2148    // Verify the exact serialized output
2149    assert_eq!(
2150        entry.to_string(),
2151        r#"opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
2152    https://gitlab.example.org/project/releases \
2153        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2154"#
2155    );
2156}
2157
2158#[test]
2159fn test_set_url_quoted_options() {
2160    // Test with quoted options
2161    let wf: super::WatchFile = r#"version=4
2162opts="bare, filenamemangle=blah" \
2163  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
2164"#
2165    .parse()
2166    .unwrap();
2167
2168    let mut entry = wf.entries().next().unwrap();
2169    assert_eq!(
2170        entry.url(),
2171        "https://github.com/syncthing/syncthing-gtk/tags"
2172    );
2173
2174    entry.set_url("https://example.org/new/path");
2175    assert_eq!(entry.url(), "https://example.org/new/path");
2176
2177    // Verify the exact serialized output
2178    assert_eq!(
2179        entry.to_string(),
2180        r#"opts="bare, filenamemangle=blah" \
2181  https://example.org/new/path .*/v?(\d\S+)\.tar\.gz
2182"#
2183    );
2184}
2185
2186#[test]
2187fn test_set_opt_update_existing() {
2188    // Test updating an existing option
2189    let wf: super::WatchFile = r#"version=4
2190opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2191"#
2192    .parse()
2193    .unwrap();
2194
2195    let mut entry = wf.entries().next().unwrap();
2196    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2197    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2198
2199    entry.set_opt("foo", "updated");
2200    assert_eq!(entry.get_option("foo"), Some("updated".to_string()));
2201    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2202
2203    // Verify the exact serialized output
2204    assert_eq!(
2205        entry.to_string(),
2206        "opts=foo=updated,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2207    );
2208}
2209
2210#[test]
2211fn test_set_opt_add_new() {
2212    // Test adding a new option to existing options
2213    let wf: super::WatchFile = r#"version=4
2214opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2215"#
2216    .parse()
2217    .unwrap();
2218
2219    let mut entry = wf.entries().next().unwrap();
2220    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2221    assert_eq!(entry.get_option("bar"), None);
2222
2223    entry.set_opt("bar", "baz");
2224    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2225    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2226
2227    // Verify the exact serialized output
2228    assert_eq!(
2229        entry.to_string(),
2230        "opts=foo=blah,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2231    );
2232}
2233
2234#[test]
2235fn test_set_opt_create_options_list() {
2236    // Test creating a new options list when none exists
2237    let wf: super::WatchFile = r#"version=4
2238https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2239"#
2240    .parse()
2241    .unwrap();
2242
2243    let mut entry = wf.entries().next().unwrap();
2244    assert_eq!(entry.option_list(), None);
2245
2246    entry.set_opt("compression", "xz");
2247    assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
2248
2249    // Verify the exact serialized output
2250    assert_eq!(
2251        entry.to_string(),
2252        "opts=compression=xz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2253    );
2254}
2255
2256#[test]
2257fn test_del_opt_remove_single() {
2258    // Test removing a single option from multiple options
2259    let wf: super::WatchFile = r#"version=4
2260opts=foo=blah,bar=baz,qux=quux https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2261"#
2262    .parse()
2263    .unwrap();
2264
2265    let mut entry = wf.entries().next().unwrap();
2266    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2267    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2268    assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
2269
2270    entry.del_opt("bar");
2271    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2272    assert_eq!(entry.get_option("bar"), None);
2273    assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
2274
2275    // Verify the exact serialized output
2276    assert_eq!(
2277        entry.to_string(),
2278        "opts=foo=blah,qux=quux https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2279    );
2280}
2281
2282#[test]
2283fn test_del_opt_remove_first() {
2284    // Test removing the first option
2285    let wf: super::WatchFile = r#"version=4
2286opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2287"#
2288    .parse()
2289    .unwrap();
2290
2291    let mut entry = wf.entries().next().unwrap();
2292    entry.del_opt("foo");
2293    assert_eq!(entry.get_option("foo"), None);
2294    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
2295
2296    // Verify the exact serialized output
2297    assert_eq!(
2298        entry.to_string(),
2299        "opts=bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2300    );
2301}
2302
2303#[test]
2304fn test_del_opt_remove_last() {
2305    // Test removing the last option
2306    let wf: super::WatchFile = r#"version=4
2307opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2308"#
2309    .parse()
2310    .unwrap();
2311
2312    let mut entry = wf.entries().next().unwrap();
2313    entry.del_opt("bar");
2314    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2315    assert_eq!(entry.get_option("bar"), None);
2316
2317    // Verify the exact serialized output
2318    assert_eq!(
2319        entry.to_string(),
2320        "opts=foo=blah https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2321    );
2322}
2323
2324#[test]
2325fn test_del_opt_remove_only_option() {
2326    // Test removing the only option (should remove entire opts list)
2327    let wf: super::WatchFile = r#"version=4
2328opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2329"#
2330    .parse()
2331    .unwrap();
2332
2333    let mut entry = wf.entries().next().unwrap();
2334    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
2335
2336    entry.del_opt("foo");
2337    assert_eq!(entry.get_option("foo"), None);
2338    assert_eq!(entry.option_list(), None);
2339
2340    // Verify the exact serialized output (opts should be gone)
2341    assert_eq!(
2342        entry.to_string(),
2343        "https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
2344    );
2345}
2346
2347#[test]
2348fn test_del_opt_nonexistent() {
2349    // Test deleting a non-existent option (should do nothing)
2350    let wf: super::WatchFile = r#"version=4
2351opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2352"#
2353    .parse()
2354    .unwrap();
2355
2356    let mut entry = wf.entries().next().unwrap();
2357    let original = entry.to_string();
2358
2359    entry.del_opt("nonexistent");
2360    assert_eq!(entry.to_string(), original);
2361}
2362
2363#[test]
2364fn test_set_opt_multiple_operations() {
2365    // Test multiple set_opt operations
2366    let wf: super::WatchFile = r#"version=4
2367https://example.com/releases .*/v?(\d\S+)\.tar\.gz
2368"#
2369    .parse()
2370    .unwrap();
2371
2372    let mut entry = wf.entries().next().unwrap();
2373
2374    entry.set_opt("compression", "xz");
2375    entry.set_opt("repack", "");
2376    entry.set_opt("dversionmangle", "s/\\+ds//");
2377
2378    assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
2379    assert_eq!(
2380        entry.get_option("dversionmangle"),
2381        Some("s/\\+ds//".to_string())
2382    );
2383}
2384
2385#[test]
2386fn test_set_matching_pattern() {
2387    // Test setting matching pattern on a simple entry
2388    let wf: super::WatchFile = r#"version=4
2389https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
2390"#
2391    .parse()
2392    .unwrap();
2393
2394    let mut entry = wf.entries().next().unwrap();
2395    assert_eq!(
2396        entry.matching_pattern(),
2397        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
2398    );
2399
2400    entry.set_matching_pattern("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz");
2401    assert_eq!(
2402        entry.matching_pattern(),
2403        Some("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz".into())
2404    );
2405
2406    // Verify URL is preserved
2407    assert_eq!(entry.url(), "https://github.com/example/tags");
2408
2409    // Verify the exact serialized output
2410    assert_eq!(
2411        entry.to_string(),
2412        "https://github.com/example/tags (?:.*?/)?v?([\\d.]+)\\.tar\\.gz\n"
2413    );
2414}
2415
2416#[test]
2417fn test_set_matching_pattern_with_all_fields() {
2418    // Test with all fields present
2419    let wf: super::WatchFile = r#"version=4
2420opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2421"#
2422    .parse()
2423    .unwrap();
2424
2425    let mut entry = wf.entries().next().unwrap();
2426    assert_eq!(
2427        entry.matching_pattern(),
2428        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2429    );
2430
2431    entry.set_matching_pattern(".*/version-([\\d.]+)\\.tar\\.xz");
2432    assert_eq!(
2433        entry.matching_pattern(),
2434        Some(".*/version-([\\d.]+)\\.tar\\.xz".into())
2435    );
2436
2437    // Verify all other fields are preserved
2438    assert_eq!(entry.url(), "https://example.com/releases");
2439    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2440    assert_eq!(entry.script(), Some("uupdate".into()));
2441    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2442
2443    // Verify the exact serialized output
2444    assert_eq!(
2445        entry.to_string(),
2446        "opts=compression=xz https://example.com/releases .*/version-([\\d.]+)\\.tar\\.xz debian uupdate\n"
2447    );
2448}
2449
2450#[test]
2451fn test_set_version_policy() {
2452    // Test setting version policy
2453    let wf: super::WatchFile = r#"version=4
2454https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2455"#
2456    .parse()
2457    .unwrap();
2458
2459    let mut entry = wf.entries().next().unwrap();
2460    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2461
2462    entry.set_version_policy("previous");
2463    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Previous)));
2464
2465    // Verify all other fields are preserved
2466    assert_eq!(entry.url(), "https://example.com/releases");
2467    assert_eq!(
2468        entry.matching_pattern(),
2469        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2470    );
2471    assert_eq!(entry.script(), Some("uupdate".into()));
2472
2473    // Verify the exact serialized output
2474    assert_eq!(
2475        entry.to_string(),
2476        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz previous uupdate\n"
2477    );
2478}
2479
2480#[test]
2481fn test_set_version_policy_with_options() {
2482    // Test with options and continuation
2483    let wf: super::WatchFile = r#"version=4
2484opts=repack,compression=xz \
2485    https://github.com/example/example-cat/tags \
2486        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2487"#
2488    .parse()
2489    .unwrap();
2490
2491    let mut entry = wf.entries().next().unwrap();
2492    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2493
2494    entry.set_version_policy("ignore");
2495    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Ignore)));
2496
2497    // Verify all other fields are preserved
2498    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2499    assert_eq!(
2500        entry.matching_pattern(),
2501        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2502    );
2503    assert_eq!(entry.script(), Some("uupdate".into()));
2504    assert!(entry.repack());
2505
2506    // Verify the exact serialized output
2507    assert_eq!(
2508        entry.to_string(),
2509        r#"opts=repack,compression=xz \
2510    https://github.com/example/example-cat/tags \
2511        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz ignore uupdate
2512"#
2513    );
2514}
2515
2516#[test]
2517fn test_set_script() {
2518    // Test setting script
2519    let wf: super::WatchFile = r#"version=4
2520https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2521"#
2522    .parse()
2523    .unwrap();
2524
2525    let mut entry = wf.entries().next().unwrap();
2526    assert_eq!(entry.script(), Some("uupdate".into()));
2527
2528    entry.set_script("uscan");
2529    assert_eq!(entry.script(), Some("uscan".into()));
2530
2531    // Verify all other fields are preserved
2532    assert_eq!(entry.url(), "https://example.com/releases");
2533    assert_eq!(
2534        entry.matching_pattern(),
2535        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2536    );
2537    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2538
2539    // Verify the exact serialized output
2540    assert_eq!(
2541        entry.to_string(),
2542        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian uscan\n"
2543    );
2544}
2545
2546#[test]
2547fn test_set_script_with_options() {
2548    // Test with options
2549    let wf: super::WatchFile = r#"version=4
2550opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2551"#
2552    .parse()
2553    .unwrap();
2554
2555    let mut entry = wf.entries().next().unwrap();
2556    assert_eq!(entry.script(), Some("uupdate".into()));
2557
2558    entry.set_script("custom-script.sh");
2559    assert_eq!(entry.script(), Some("custom-script.sh".into()));
2560
2561    // Verify all other fields are preserved
2562    assert_eq!(entry.url(), "https://example.com/releases");
2563    assert_eq!(
2564        entry.matching_pattern(),
2565        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2566    );
2567    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2568    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2569
2570    // Verify the exact serialized output
2571    assert_eq!(
2572        entry.to_string(),
2573        "opts=compression=xz https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian custom-script.sh\n"
2574    );
2575}
2576
2577#[test]
2578fn test_apply_dversionmangle() {
2579    // Test basic dversionmangle
2580    let wf: super::WatchFile = r#"version=4
2581opts=dversionmangle=s/\+dfsg$// https://example.com/ .*
2582"#
2583    .parse()
2584    .unwrap();
2585    let entry = wf.entries().next().unwrap();
2586    assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0");
2587    assert_eq!(entry.apply_dversionmangle("1.0").unwrap(), "1.0");
2588
2589    // Test with versionmangle (fallback)
2590    let wf: super::WatchFile = r#"version=4
2591opts=versionmangle=s/^v// https://example.com/ .*
2592"#
2593    .parse()
2594    .unwrap();
2595    let entry = wf.entries().next().unwrap();
2596    assert_eq!(entry.apply_dversionmangle("v1.0").unwrap(), "1.0");
2597
2598    // Test with both dversionmangle and versionmangle (dversionmangle takes precedence)
2599    let wf: super::WatchFile = r#"version=4
2600opts=dversionmangle=s/\+ds//,versionmangle=s/^v// https://example.com/ .*
2601"#
2602    .parse()
2603    .unwrap();
2604    let entry = wf.entries().next().unwrap();
2605    assert_eq!(entry.apply_dversionmangle("1.0+ds").unwrap(), "1.0");
2606
2607    // Test without any mangle options
2608    let wf: super::WatchFile = r#"version=4
2609https://example.com/ .*
2610"#
2611    .parse()
2612    .unwrap();
2613    let entry = wf.entries().next().unwrap();
2614    assert_eq!(entry.apply_dversionmangle("1.0+dfsg").unwrap(), "1.0+dfsg");
2615}
2616
2617#[test]
2618fn test_apply_oversionmangle() {
2619    // Test basic oversionmangle - adding suffix
2620    let wf: super::WatchFile = r#"version=4
2621opts=oversionmangle=s/$/-1/ https://example.com/ .*
2622"#
2623    .parse()
2624    .unwrap();
2625    let entry = wf.entries().next().unwrap();
2626    assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0-1");
2627    assert_eq!(entry.apply_oversionmangle("2.5.3").unwrap(), "2.5.3-1");
2628
2629    // Test oversionmangle for adding +dfsg suffix
2630    let wf: super::WatchFile = r#"version=4
2631opts=oversionmangle=s/$/.dfsg/ https://example.com/ .*
2632"#
2633    .parse()
2634    .unwrap();
2635    let entry = wf.entries().next().unwrap();
2636    assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0.dfsg");
2637
2638    // Test without any mangle options
2639    let wf: super::WatchFile = r#"version=4
2640https://example.com/ .*
2641"#
2642    .parse()
2643    .unwrap();
2644    let entry = wf.entries().next().unwrap();
2645    assert_eq!(entry.apply_oversionmangle("1.0").unwrap(), "1.0");
2646}
2647
2648#[test]
2649fn test_apply_dirversionmangle() {
2650    // Test basic dirversionmangle - removing 'v' prefix
2651    let wf: super::WatchFile = r#"version=4
2652opts=dirversionmangle=s/^v// https://example.com/ .*
2653"#
2654    .parse()
2655    .unwrap();
2656    let entry = wf.entries().next().unwrap();
2657    assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
2658    assert_eq!(entry.apply_dirversionmangle("v2.5.3").unwrap(), "2.5.3");
2659
2660    // Test dirversionmangle with capture groups
2661    let wf: super::WatchFile = r#"version=4
2662opts=dirversionmangle=s/v(\d)/$1/ https://example.com/ .*
2663"#
2664    .parse()
2665    .unwrap();
2666    let entry = wf.entries().next().unwrap();
2667    assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "1.0");
2668
2669    // Test without any mangle options
2670    let wf: super::WatchFile = r#"version=4
2671https://example.com/ .*
2672"#
2673    .parse()
2674    .unwrap();
2675    let entry = wf.entries().next().unwrap();
2676    assert_eq!(entry.apply_dirversionmangle("v1.0").unwrap(), "v1.0");
2677}
2678
2679#[test]
2680fn test_apply_filenamemangle() {
2681    // Test filenamemangle to generate tarball filename
2682    let wf: super::WatchFile = r#"version=4
2683opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/mypackage-$1.tar.gz/ https://example.com/ .*
2684"#
2685    .parse()
2686    .unwrap();
2687    let entry = wf.entries().next().unwrap();
2688    assert_eq!(
2689        entry
2690            .apply_filenamemangle("https://example.com/v1.0.tar.gz")
2691            .unwrap(),
2692        "mypackage-1.0.tar.gz"
2693    );
2694    assert_eq!(
2695        entry
2696            .apply_filenamemangle("https://example.com/2.5.3.tar.gz")
2697            .unwrap(),
2698        "mypackage-2.5.3.tar.gz"
2699    );
2700
2701    // Test filenamemangle with different pattern
2702    let wf: super::WatchFile = r#"version=4
2703opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/ .*
2704"#
2705    .parse()
2706    .unwrap();
2707    let entry = wf.entries().next().unwrap();
2708    assert_eq!(
2709        entry
2710            .apply_filenamemangle("https://example.com/path/to/file.tar.gz")
2711            .unwrap(),
2712        "file.tar.gz"
2713    );
2714
2715    // Test without any mangle options
2716    let wf: super::WatchFile = r#"version=4
2717https://example.com/ .*
2718"#
2719    .parse()
2720    .unwrap();
2721    let entry = wf.entries().next().unwrap();
2722    assert_eq!(
2723        entry
2724            .apply_filenamemangle("https://example.com/file.tar.gz")
2725            .unwrap(),
2726        "https://example.com/file.tar.gz"
2727    );
2728}
2729
2730#[test]
2731fn test_apply_pagemangle() {
2732    // Test pagemangle to decode HTML entities
2733    let wf: super::WatchFile = r#"version=4
2734opts=pagemangle=s/&amp;/&/g https://example.com/ .*
2735"#
2736    .parse()
2737    .unwrap();
2738    let entry = wf.entries().next().unwrap();
2739    assert_eq!(
2740        entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
2741        b"foo & bar"
2742    );
2743    assert_eq!(
2744        entry
2745            .apply_pagemangle(b"&amp; foo &amp; bar &amp;")
2746            .unwrap(),
2747        b"& foo & bar &"
2748    );
2749
2750    // Test pagemangle with different pattern
2751    let wf: super::WatchFile = r#"version=4
2752opts=pagemangle=s/<[^>]+>//g https://example.com/ .*
2753"#
2754    .parse()
2755    .unwrap();
2756    let entry = wf.entries().next().unwrap();
2757    assert_eq!(entry.apply_pagemangle(b"<div>text</div>").unwrap(), b"text");
2758
2759    // Test without any mangle options
2760    let wf: super::WatchFile = r#"version=4
2761https://example.com/ .*
2762"#
2763    .parse()
2764    .unwrap();
2765    let entry = wf.entries().next().unwrap();
2766    assert_eq!(
2767        entry.apply_pagemangle(b"foo &amp; bar").unwrap(),
2768        b"foo &amp; bar"
2769    );
2770}
2771
2772#[test]
2773fn test_apply_downloadurlmangle() {
2774    // Test downloadurlmangle to change URL path
2775    let wf: super::WatchFile = r#"version=4
2776opts=downloadurlmangle=s|/archive/|/download/| https://example.com/ .*
2777"#
2778    .parse()
2779    .unwrap();
2780    let entry = wf.entries().next().unwrap();
2781    assert_eq!(
2782        entry
2783            .apply_downloadurlmangle("https://example.com/archive/file.tar.gz")
2784            .unwrap(),
2785        "https://example.com/download/file.tar.gz"
2786    );
2787
2788    // Test downloadurlmangle with different pattern
2789    let wf: super::WatchFile = r#"version=4
2790opts=downloadurlmangle=s/github\.com/raw.githubusercontent.com/ https://example.com/ .*
2791"#
2792    .parse()
2793    .unwrap();
2794    let entry = wf.entries().next().unwrap();
2795    assert_eq!(
2796        entry
2797            .apply_downloadurlmangle("https://github.com/user/repo/file.tar.gz")
2798            .unwrap(),
2799        "https://raw.githubusercontent.com/user/repo/file.tar.gz"
2800    );
2801
2802    // Test without any mangle options
2803    let wf: super::WatchFile = r#"version=4
2804https://example.com/ .*
2805"#
2806    .parse()
2807    .unwrap();
2808    let entry = wf.entries().next().unwrap();
2809    assert_eq!(
2810        entry
2811            .apply_downloadurlmangle("https://example.com/archive/file.tar.gz")
2812            .unwrap(),
2813        "https://example.com/archive/file.tar.gz"
2814    );
2815}