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
461impl FromStr for WatchFile {
462    type Err = ParseError;
463
464    fn from_str(s: &str) -> Result<Self, Self::Err> {
465        let parsed = parse(s);
466        if parsed.errors.is_empty() {
467            Ok(parsed.root())
468        } else {
469            Err(ParseError(parsed.errors))
470        }
471    }
472}
473
474impl Version {
475    /// Returns the version of the watch file.
476    pub fn version(&self) -> u32 {
477        self.0
478            .children_with_tokens()
479            .find_map(|it| match it {
480                SyntaxElement::Token(token) => {
481                    if token.kind() == VALUE {
482                        Some(token.text().parse().unwrap())
483                    } else {
484                        None
485                    }
486                }
487                _ => None,
488            })
489            .unwrap_or(DEFAULT_VERSION)
490    }
491}
492
493impl Entry {
494    /// List of options
495    pub fn option_list(&self) -> Option<OptionList> {
496        self.0.children().find_map(OptionList::cast)
497    }
498
499    /// Get the value of an option
500    pub fn get_option(&self, key: &str) -> Option<String> {
501        self.option_list().and_then(|ol| ol.get_option(key))
502    }
503
504    /// Check if an option is set
505    pub fn has_option(&self, key: &str) -> bool {
506        self.option_list().map_or(false, |ol| ol.has_option(key))
507    }
508
509    /// The name of the secondary source tarball
510    pub fn component(&self) -> Option<String> {
511        self.get_option("component")
512    }
513
514    /// Component type
515    pub fn ctype(&self) -> Result<Option<ComponentType>, ()> {
516        self.get_option("ctype").map(|s| s.parse()).transpose()
517    }
518
519    /// Compression method
520    pub fn compression(&self) -> Result<Option<Compression>, ()> {
521        self.get_option("compression")
522            .map(|s| s.parse())
523            .transpose()
524    }
525
526    /// Repack the tarball
527    pub fn repack(&self) -> bool {
528        self.has_option("repack")
529    }
530
531    /// Repack suffix
532    pub fn repacksuffix(&self) -> Option<String> {
533        self.get_option("repacksuffix")
534    }
535
536    /// Retrieve the mode of the watch file entry.
537    pub fn mode(&self) -> Result<Mode, ()> {
538        Ok(self
539            .get_option("mode")
540            .map(|s| s.parse())
541            .transpose()?
542            .unwrap_or_default())
543    }
544
545    /// Return the git pretty mode
546    pub fn pretty(&self) -> Result<Pretty, ()> {
547        Ok(self
548            .get_option("pretty")
549            .map(|s| s.parse())
550            .transpose()?
551            .unwrap_or_default())
552    }
553
554    /// Set the date string used by the pretty option to an arbitrary format as an optional
555    /// opts argument when the matching-pattern is HEAD or heads/branch for git mode.
556    pub fn date(&self) -> String {
557        self.get_option("date")
558            .unwrap_or_else(|| "%Y%m%d".to_string())
559    }
560
561    /// Return the git export mode
562    pub fn gitexport(&self) -> Result<GitExport, ()> {
563        Ok(self
564            .get_option("gitexport")
565            .map(|s| s.parse())
566            .transpose()?
567            .unwrap_or_default())
568    }
569
570    /// Return the git mode
571    pub fn gitmode(&self) -> Result<GitMode, ()> {
572        Ok(self
573            .get_option("gitmode")
574            .map(|s| s.parse())
575            .transpose()?
576            .unwrap_or_default())
577    }
578
579    /// Return the pgp mode
580    pub fn pgpmode(&self) -> Result<PgpMode, ()> {
581        Ok(self
582            .get_option("pgpmode")
583            .map(|s| s.parse())
584            .transpose()?
585            .unwrap_or_default())
586    }
587
588    /// Return the search mode
589    pub fn searchmode(&self) -> Result<SearchMode, ()> {
590        Ok(self
591            .get_option("searchmode")
592            .map(|s| s.parse())
593            .transpose()?
594            .unwrap_or_default())
595    }
596
597    /// Return the decompression mode
598    pub fn decompress(&self) -> bool {
599        self.has_option("decompress")
600    }
601
602    /// Whether to disable all site specific special case code such as URL director uses and page
603    /// content alterations.
604    pub fn bare(&self) -> bool {
605        self.has_option("bare")
606    }
607
608    /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent)
609    pub fn user_agent(&self) -> Option<String> {
610        self.get_option("user-agent")
611    }
612
613    /// Use PASV mode for the FTP connection.
614    pub fn passive(&self) -> Option<bool> {
615        if self.has_option("passive") || self.has_option("pasv") {
616            Some(true)
617        } else if self.has_option("active") || self.has_option("nopasv") {
618            Some(false)
619        } else {
620            None
621        }
622    }
623
624    /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed
625    /// by mk-origtargz.
626    pub fn unzipoptions(&self) -> Option<String> {
627        self.get_option("unzipopt")
628    }
629
630    /// Normalize the downloaded web page string.
631    pub fn dversionmangle(&self) -> Option<String> {
632        self.get_option("dversionmangle")
633            .or_else(|| self.get_option("versionmangle"))
634    }
635
636    /// Normalize the directory path string matching the regex in a set of parentheses of
637    /// http://URL as the sortable version index string.  This is used
638    /// as the directory path sorting index only.
639    pub fn dirversionmangle(&self) -> Option<String> {
640        self.get_option("dirversionmangle")
641    }
642
643    /// Normalize the downloaded web page string.
644    pub fn pagemangle(&self) -> Option<String> {
645        self.get_option("pagemangle")
646    }
647
648    /// Normalize the candidate upstream version strings extracted from hrefs in the
649    /// source of the web page.  This is used as the version sorting index when selecting the
650    /// latest upstream version.
651    pub fn uversionmangle(&self) -> Option<String> {
652        self.get_option("uversionmangle")
653            .or_else(|| self.get_option("versionmangle"))
654    }
655
656    /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules
657    pub fn versionmangle(&self) -> Option<String> {
658        self.get_option("versionmangle")
659    }
660
661    /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal
662    /// string to the decoded normal URL  string  for  obfuscated
663    /// web sites.  Only percent-encoding is available and it is decoded with
664    /// s/%([A-Fa-f\d]{2})/chr hex $1/eg.
665    pub fn hrefdecode(&self) -> bool {
666        self.get_option("hrefdecode").is_some()
667    }
668
669    /// Convert the selected upstream tarball href string into the accessible URL for obfuscated
670    /// web sites.  This is run after hrefdecode.
671    pub fn downloadurlmangle(&self) -> Option<String> {
672        self.get_option("downloadurlmangle")
673    }
674
675    /// Generate the upstream tarball filename from the selected href string if matching-pattern
676    /// can extract the latest upstream version <uversion> from the  selected  href  string.
677    /// Otherwise, generate the upstream tarball filename from its full URL string and set the
678    /// missing <uversion> from the generated upstream tarball filename.
679    ///
680    /// Without this option, the default upstream tarball filename is generated by taking the last
681    /// component of the URL and  removing everything  after any '?' or '#'.
682    pub fn filenamemangle(&self) -> Option<String> {
683        self.get_option("filenamemangle")
684    }
685
686    /// Generate the candidate upstream signature file URL string from the upstream tarball URL.
687    pub fn pgpsigurlmangle(&self) -> Option<String> {
688        self.get_option("pgpsigurlmangle")
689    }
690
691    /// Generate the version string <oversion> of the source tarball <spkg>_<oversion>.orig.tar.gz
692    /// from <uversion>.  This should be used to add a suffix such as +dfsg to a MUT package.
693    pub fn oversionmangle(&self) -> Option<String> {
694        self.get_option("oversionmangle")
695    }
696
697    /// Returns options set
698    pub fn opts(&self) -> std::collections::HashMap<String, String> {
699        let mut options = std::collections::HashMap::new();
700
701        if let Some(ol) = self.option_list() {
702            for opt in ol.children() {
703                let key = opt.key();
704                let value = opt.value();
705                if let (Some(key), Some(value)) = (key, value) {
706                    options.insert(key.to_string(), value.to_string());
707                }
708            }
709        }
710
711        options
712    }
713
714    fn items(&self) -> impl Iterator<Item = String> + '_ {
715        self.0.children_with_tokens().filter_map(|it| match it {
716            SyntaxElement::Token(token) => {
717                if token.kind() == VALUE || token.kind() == KEY {
718                    Some(token.text().to_string())
719                } else {
720                    None
721                }
722            }
723            SyntaxElement::Node(node) => {
724                // Extract values from entry field nodes
725                match node.kind() {
726                    URL => Url::cast(node).map(|n| n.url()),
727                    MATCHING_PATTERN => MatchingPattern::cast(node).map(|n| n.pattern()),
728                    VERSION_POLICY => VersionPolicyNode::cast(node).map(|n| n.policy()),
729                    SCRIPT => ScriptNode::cast(node).map(|n| n.script()),
730                    _ => None,
731                }
732            }
733        })
734    }
735
736    /// Returns the URL of the entry.
737    pub fn url(&self) -> String {
738        self.0
739            .children()
740            .find_map(Url::cast)
741            .map(|it| it.url())
742            .unwrap_or_else(|| {
743                // Fallback for entries without URL node (shouldn't happen with new parser)
744                self.items().next().unwrap()
745            })
746    }
747
748    /// Returns the matching pattern of the entry.
749    pub fn matching_pattern(&self) -> Option<String> {
750        self.0
751            .children()
752            .find_map(MatchingPattern::cast)
753            .map(|it| it.pattern())
754            .or_else(|| {
755                // Fallback for entries without MATCHING_PATTERN node
756                self.items().nth(1)
757            })
758    }
759
760    /// Returns the version policy
761    pub fn version(&self) -> Result<Option<crate::VersionPolicy>, String> {
762        self.0
763            .children()
764            .find_map(VersionPolicyNode::cast)
765            .map(|it| it.policy().parse())
766            .transpose()
767            .or_else(|_e| {
768                // Fallback for entries without VERSION_POLICY node
769                self.items().nth(2).map(|it| it.parse()).transpose()
770            })
771    }
772
773    /// Returns the script of the entry.
774    pub fn script(&self) -> Option<String> {
775        self.0
776            .children()
777            .find_map(ScriptNode::cast)
778            .map(|it| it.script())
779            .or_else(|| {
780                // Fallback for entries without SCRIPT node
781                self.items().nth(3)
782            })
783    }
784
785    /// Replace all substitutions and return the resulting URL.
786    pub fn format_url(&self, package: impl FnOnce() -> String) -> url::Url {
787        subst(self.url().as_str(), package).parse().unwrap()
788    }
789
790    /// Set the URL of the entry.
791    pub fn set_url(&mut self, new_url: &str) {
792        // Build the new URL node
793        let mut builder = GreenNodeBuilder::new();
794        builder.start_node(URL.into());
795        builder.token(VALUE.into(), new_url);
796        builder.finish_node();
797        let new_url_green = builder.finish();
798
799        // Create a syntax node (splice_children will detach and reattach it)
800        let new_url_node = SyntaxNode::new_root_mut(new_url_green);
801
802        // Find existing URL node position (need to use children_with_tokens for correct indexing)
803        let url_pos = self
804            .0
805            .children_with_tokens()
806            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
807
808        if let Some(pos) = url_pos {
809            // Replace existing URL node
810            self.0
811                .splice_children(pos..pos + 1, vec![new_url_node.into()]);
812        }
813    }
814
815    /// Set the matching pattern of the entry.
816    ///
817    /// TODO: This currently only replaces an existing matching pattern.
818    /// If the entry doesn't have a matching pattern, this method does nothing.
819    /// Future implementation should insert the node at the correct position.
820    pub fn set_matching_pattern(&mut self, new_pattern: &str) {
821        // Build the new MATCHING_PATTERN node
822        let mut builder = GreenNodeBuilder::new();
823        builder.start_node(MATCHING_PATTERN.into());
824        builder.token(VALUE.into(), new_pattern);
825        builder.finish_node();
826        let new_pattern_green = builder.finish();
827
828        // Create a syntax node (splice_children will detach and reattach it)
829        let new_pattern_node = SyntaxNode::new_root_mut(new_pattern_green);
830
831        // Find existing MATCHING_PATTERN node position
832        let pattern_pos = self.0.children_with_tokens().position(
833            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == MATCHING_PATTERN),
834        );
835
836        if let Some(pos) = pattern_pos {
837            // Replace existing MATCHING_PATTERN node
838            self.0
839                .splice_children(pos..pos + 1, vec![new_pattern_node.into()]);
840        }
841        // TODO: else insert new node after URL
842    }
843
844    /// Set the version policy of the entry.
845    ///
846    /// TODO: This currently only replaces an existing version policy.
847    /// If the entry doesn't have a version policy, this method does nothing.
848    /// Future implementation should insert the node at the correct position.
849    pub fn set_version_policy(&mut self, new_policy: &str) {
850        // Build the new VERSION_POLICY node
851        let mut builder = GreenNodeBuilder::new();
852        builder.start_node(VERSION_POLICY.into());
853        // Version policy can be KEY (e.g., "debian") or VALUE
854        builder.token(VALUE.into(), new_policy);
855        builder.finish_node();
856        let new_policy_green = builder.finish();
857
858        // Create a syntax node (splice_children will detach and reattach it)
859        let new_policy_node = SyntaxNode::new_root_mut(new_policy_green);
860
861        // Find existing VERSION_POLICY node position
862        let policy_pos = self.0.children_with_tokens().position(
863            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == VERSION_POLICY),
864        );
865
866        if let Some(pos) = policy_pos {
867            // Replace existing VERSION_POLICY node
868            self.0
869                .splice_children(pos..pos + 1, vec![new_policy_node.into()]);
870        }
871        // TODO: else insert new node after MATCHING_PATTERN (or URL if no pattern)
872    }
873
874    /// Set the script of the entry.
875    ///
876    /// TODO: This currently only replaces an existing script.
877    /// If the entry doesn't have a script, this method does nothing.
878    /// Future implementation should insert the node at the correct position.
879    pub fn set_script(&mut self, new_script: &str) {
880        // Build the new SCRIPT node
881        let mut builder = GreenNodeBuilder::new();
882        builder.start_node(SCRIPT.into());
883        // Script can be KEY (e.g., "uupdate") or VALUE
884        builder.token(VALUE.into(), new_script);
885        builder.finish_node();
886        let new_script_green = builder.finish();
887
888        // Create a syntax node (splice_children will detach and reattach it)
889        let new_script_node = SyntaxNode::new_root_mut(new_script_green);
890
891        // Find existing SCRIPT node position
892        let script_pos = self
893            .0
894            .children_with_tokens()
895            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == SCRIPT));
896
897        if let Some(pos) = script_pos {
898            // Replace existing SCRIPT node
899            self.0
900                .splice_children(pos..pos + 1, vec![new_script_node.into()]);
901        }
902        // TODO: else insert new node after VERSION_POLICY (or MATCHING_PATTERN/URL if no policy)
903    }
904
905    /// Set or update an option value.
906    ///
907    /// If the option already exists, it will be updated with the new value.
908    /// If the option doesn't exist, it will be added to the options list.
909    /// If there's no options list, one will be created.
910    pub fn set_opt(&mut self, key: &str, value: &str) {
911        // Find the OPTS_LIST position in Entry
912        let opts_pos = self.0.children_with_tokens().position(
913            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == OPTS_LIST),
914        );
915
916        if let Some(_opts_idx) = opts_pos {
917            if let Some(mut ol) = self.option_list() {
918                // Find if the option already exists
919                if let Some(mut opt) = ol.find_option(key) {
920                    // Update the existing option's value
921                    opt.set_value(value);
922                    // Mutations should propagate automatically - no need to replace
923                } else {
924                    // Add new option
925                    ol.add_option(key, value);
926                    // Mutations should propagate automatically - no need to replace
927                }
928            }
929        } else {
930            // Create a new options list
931            let mut builder = GreenNodeBuilder::new();
932            builder.start_node(OPTS_LIST.into());
933            builder.token(KEY.into(), "opts");
934            builder.token(EQUALS.into(), "=");
935            builder.start_node(OPTION.into());
936            builder.token(KEY.into(), key);
937            builder.token(EQUALS.into(), "=");
938            builder.token(VALUE.into(), value);
939            builder.finish_node();
940            builder.finish_node();
941            let new_opts_green = builder.finish();
942            let new_opts_node = SyntaxNode::new_root_mut(new_opts_green);
943
944            // Find position to insert (before URL if it exists, otherwise at start)
945            let url_pos = self
946                .0
947                .children_with_tokens()
948                .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
949
950            if let Some(url_idx) = url_pos {
951                // Insert options list and a space before the URL
952                // Build a parent node containing both space and whitespace to extract from
953                let mut combined_builder = GreenNodeBuilder::new();
954                combined_builder.start_node(ROOT.into()); // Temporary parent
955                combined_builder.token(WHITESPACE.into(), " ");
956                combined_builder.finish_node();
957                let temp_green = combined_builder.finish();
958                let temp_root = SyntaxNode::new_root_mut(temp_green);
959                let space_element = temp_root.children_with_tokens().next().unwrap();
960
961                self.0
962                    .splice_children(url_idx..url_idx, vec![new_opts_node.into(), space_element]);
963            } else {
964                self.0.splice_children(0..0, vec![new_opts_node.into()]);
965            }
966        }
967    }
968
969    /// Delete an option.
970    ///
971    /// Removes the option with the specified key from the options list.
972    /// If the option doesn't exist, this method does nothing.
973    /// If deleting the option results in an empty options list, the entire
974    /// opts= declaration is removed.
975    pub fn del_opt(&mut self, key: &str) {
976        if let Some(mut ol) = self.option_list() {
977            let option_count = ol.0.children().filter(|n| n.kind() == OPTION).count();
978
979            if option_count == 1 && ol.has_option(key) {
980                // This is the last option, remove the entire OPTS_LIST from Entry
981                let opts_pos = self.0.children().position(|node| node.kind() == OPTS_LIST);
982
983                if let Some(opts_idx) = opts_pos {
984                    // Remove the OPTS_LIST
985                    self.0.splice_children(opts_idx..opts_idx + 1, vec![]);
986
987                    // Remove any leading whitespace/continuation that was after the OPTS_LIST
988                    while self.0.children_with_tokens().next().map_or(false, |e| {
989                        matches!(
990                            e,
991                            SyntaxElement::Token(t) if t.kind() == WHITESPACE || t.kind() == CONTINUATION
992                        )
993                    }) {
994                        self.0.splice_children(0..1, vec![]);
995                    }
996                }
997            } else {
998                // Defer to OptionList to remove the option
999                ol.remove_option(key);
1000            }
1001        }
1002    }
1003}
1004
1005const SUBSTITUTIONS: &[(&str, &str)] = &[
1006    // This is substituted with the source package name found in the first line
1007    // of the debian/changelog file.
1008    // "@PACKAGE@": None,
1009    // This is substituted by the legal upstream version regex (capturing).
1010    ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"),
1011    // This is substituted by the typical archive file extension regex
1012    // (non-capturing).
1013    (
1014        "@ARCHIVE_EXT@",
1015        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)",
1016    ),
1017    // This is substituted by the typical signature file extension regex
1018    // (non-capturing).
1019    (
1020        "@SIGNATURE_EXT@",
1021        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)",
1022    ),
1023    // This is substituted by the typical Debian extension regexp (capturing).
1024    ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"),
1025];
1026
1027pub fn subst(text: &str, package: impl FnOnce() -> String) -> String {
1028    let mut substs = SUBSTITUTIONS.to_vec();
1029    let package_name;
1030    if text.contains("@PACKAGE@") {
1031        package_name = Some(package());
1032        substs.push(("@PACKAGE@", package_name.as_deref().unwrap()));
1033    }
1034
1035    let mut text = text.to_string();
1036
1037    for (k, v) in substs {
1038        text = text.replace(k, v);
1039    }
1040
1041    text
1042}
1043
1044#[test]
1045fn test_subst() {
1046    assert_eq!(
1047        subst("@ANY_VERSION@", || unreachable!()),
1048        r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"
1049    );
1050    assert_eq!(subst("@PACKAGE@", || "dulwich".to_string()), "dulwich");
1051}
1052
1053impl std::fmt::Debug for OptionList {
1054    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1055        f.debug_struct("OptionList")
1056            .field("text", &self.0.text().to_string())
1057            .finish()
1058    }
1059}
1060
1061impl OptionList {
1062    fn children(&self) -> impl Iterator<Item = _Option> + '_ {
1063        self.0.children().filter_map(_Option::cast)
1064    }
1065
1066    pub fn has_option(&self, key: &str) -> bool {
1067        self.children().any(|it| it.key().as_deref() == Some(key))
1068    }
1069
1070    pub fn get_option(&self, key: &str) -> Option<String> {
1071        for child in self.children() {
1072            if child.key().as_deref() == Some(key) {
1073                return child.value();
1074            }
1075        }
1076        None
1077    }
1078
1079    /// Find an option by key.
1080    fn find_option(&self, key: &str) -> Option<_Option> {
1081        self.children()
1082            .find(|opt| opt.key().as_deref() == Some(key))
1083    }
1084
1085    /// Add a new option to the end of the options list.
1086    fn add_option(&mut self, key: &str, value: &str) {
1087        let option_count = self.0.children().filter(|n| n.kind() == OPTION).count();
1088
1089        // Build a structure containing separator (if needed) + option wrapped in a temporary parent
1090        let mut builder = GreenNodeBuilder::new();
1091        builder.start_node(ROOT.into()); // Temporary parent
1092
1093        if option_count > 0 {
1094            builder.start_node(OPTION_SEPARATOR.into());
1095            builder.token(COMMA.into(), ",");
1096            builder.finish_node();
1097        }
1098
1099        builder.start_node(OPTION.into());
1100        builder.token(KEY.into(), key);
1101        builder.token(EQUALS.into(), "=");
1102        builder.token(VALUE.into(), value);
1103        builder.finish_node();
1104
1105        builder.finish_node(); // Close temporary parent
1106        let combined_green = builder.finish();
1107
1108        // Create a temporary root to extract children from
1109        let temp_root = SyntaxNode::new_root_mut(combined_green);
1110        let new_children: Vec<_> = temp_root.children_with_tokens().collect();
1111
1112        let insert_pos = self.0.children_with_tokens().count();
1113        self.0.splice_children(insert_pos..insert_pos, new_children);
1114    }
1115
1116    /// Remove an option by key. Returns true if an option was removed.
1117    fn remove_option(&mut self, key: &str) -> bool {
1118        if let Some(mut opt) = self.find_option(key) {
1119            opt.remove();
1120            true
1121        } else {
1122            false
1123        }
1124    }
1125}
1126
1127impl _Option {
1128    /// Returns the key of the option.
1129    pub fn key(&self) -> Option<String> {
1130        self.0.children_with_tokens().find_map(|it| match it {
1131            SyntaxElement::Token(token) => {
1132                if token.kind() == KEY {
1133                    Some(token.text().to_string())
1134                } else {
1135                    None
1136                }
1137            }
1138            _ => None,
1139        })
1140    }
1141
1142    /// Returns the value of the option.
1143    pub fn value(&self) -> Option<String> {
1144        self.0
1145            .children_with_tokens()
1146            .filter_map(|it| match it {
1147                SyntaxElement::Token(token) => {
1148                    if token.kind() == VALUE || token.kind() == KEY {
1149                        Some(token.text().to_string())
1150                    } else {
1151                        None
1152                    }
1153                }
1154                _ => None,
1155            })
1156            .nth(1)
1157    }
1158
1159    /// Set the value of the option.
1160    pub fn set_value(&mut self, new_value: &str) {
1161        let key = self.key().expect("Option must have a key");
1162
1163        // Build a new OPTION node with the updated value
1164        let mut builder = GreenNodeBuilder::new();
1165        builder.start_node(OPTION.into());
1166        builder.token(KEY.into(), &key);
1167        builder.token(EQUALS.into(), "=");
1168        builder.token(VALUE.into(), new_value);
1169        builder.finish_node();
1170        let new_option_green = builder.finish();
1171        let new_option_node = SyntaxNode::new_root_mut(new_option_green);
1172
1173        // Replace this option in the parent OptionList
1174        if let Some(parent) = self.0.parent() {
1175            let idx = self.0.index();
1176            parent.splice_children(idx..idx + 1, vec![new_option_node.into()]);
1177        }
1178    }
1179
1180    /// Remove this option and its associated separator from the parent OptionList.
1181    pub fn remove(&mut self) {
1182        // Find adjacent separator to remove before detaching this node
1183        let next_sep = self
1184            .0
1185            .next_sibling()
1186            .filter(|n| n.kind() == OPTION_SEPARATOR);
1187        let prev_sep = self
1188            .0
1189            .prev_sibling()
1190            .filter(|n| n.kind() == OPTION_SEPARATOR);
1191
1192        // Detach separator first if it exists
1193        if let Some(sep) = next_sep {
1194            sep.detach();
1195        } else if let Some(sep) = prev_sep {
1196            sep.detach();
1197        }
1198
1199        // Now detach the option itself
1200        self.0.detach();
1201    }
1202}
1203
1204impl Url {
1205    /// Returns the URL string.
1206    pub fn url(&self) -> String {
1207        self.0
1208            .children_with_tokens()
1209            .find_map(|it| match it {
1210                SyntaxElement::Token(token) => {
1211                    if token.kind() == VALUE {
1212                        Some(token.text().to_string())
1213                    } else {
1214                        None
1215                    }
1216                }
1217                _ => None,
1218            })
1219            .unwrap()
1220    }
1221}
1222
1223impl MatchingPattern {
1224    /// Returns the matching pattern string.
1225    pub fn pattern(&self) -> String {
1226        self.0
1227            .children_with_tokens()
1228            .find_map(|it| match it {
1229                SyntaxElement::Token(token) => {
1230                    if token.kind() == VALUE {
1231                        Some(token.text().to_string())
1232                    } else {
1233                        None
1234                    }
1235                }
1236                _ => None,
1237            })
1238            .unwrap()
1239    }
1240}
1241
1242impl VersionPolicyNode {
1243    /// Returns the version policy string.
1244    pub fn policy(&self) -> String {
1245        self.0
1246            .children_with_tokens()
1247            .find_map(|it| match it {
1248                SyntaxElement::Token(token) => {
1249                    // Can be KEY (e.g., "debian") or VALUE
1250                    if token.kind() == VALUE || token.kind() == KEY {
1251                        Some(token.text().to_string())
1252                    } else {
1253                        None
1254                    }
1255                }
1256                _ => None,
1257            })
1258            .unwrap()
1259    }
1260}
1261
1262impl ScriptNode {
1263    /// Returns the script string.
1264    pub fn script(&self) -> String {
1265        self.0
1266            .children_with_tokens()
1267            .find_map(|it| match it {
1268                SyntaxElement::Token(token) => {
1269                    // Can be KEY (e.g., "uupdate") or VALUE
1270                    if token.kind() == VALUE || token.kind() == KEY {
1271                        Some(token.text().to_string())
1272                    } else {
1273                        None
1274                    }
1275                }
1276                _ => None,
1277            })
1278            .unwrap()
1279    }
1280}
1281
1282#[test]
1283fn test_entry_node_structure() {
1284    // Test that entries properly use the new node types
1285    let wf: super::WatchFile = r#"version=4
1286opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1287"#
1288    .parse()
1289    .unwrap();
1290
1291    let entry = wf.entries().next().unwrap();
1292
1293    // Verify URL node exists and works
1294    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1295    assert_eq!(entry.url(), "https://example.com/releases");
1296
1297    // Verify MATCHING_PATTERN node exists and works
1298    assert_eq!(
1299        entry
1300            .0
1301            .children()
1302            .find(|n| n.kind() == MATCHING_PATTERN)
1303            .is_some(),
1304        true
1305    );
1306    assert_eq!(
1307        entry.matching_pattern(),
1308        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1309    );
1310
1311    // Verify VERSION_POLICY node exists and works
1312    assert_eq!(
1313        entry
1314            .0
1315            .children()
1316            .find(|n| n.kind() == VERSION_POLICY)
1317            .is_some(),
1318        true
1319    );
1320    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1321
1322    // Verify SCRIPT node exists and works
1323    assert_eq!(
1324        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1325        true
1326    );
1327    assert_eq!(entry.script(), Some("uupdate".into()));
1328}
1329
1330#[test]
1331fn test_entry_node_structure_partial() {
1332    // Test entry with only URL and pattern (no version or script)
1333    let wf: super::WatchFile = r#"version=4
1334https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
1335"#
1336    .parse()
1337    .unwrap();
1338
1339    let entry = wf.entries().next().unwrap();
1340
1341    // Should have URL and MATCHING_PATTERN nodes
1342    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1343    assert_eq!(
1344        entry
1345            .0
1346            .children()
1347            .find(|n| n.kind() == MATCHING_PATTERN)
1348            .is_some(),
1349        true
1350    );
1351
1352    // Should NOT have VERSION_POLICY or SCRIPT nodes
1353    assert_eq!(
1354        entry
1355            .0
1356            .children()
1357            .find(|n| n.kind() == VERSION_POLICY)
1358            .is_some(),
1359        false
1360    );
1361    assert_eq!(
1362        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1363        false
1364    );
1365
1366    // Verify accessors work correctly
1367    assert_eq!(entry.url(), "https://github.com/example/tags");
1368    assert_eq!(
1369        entry.matching_pattern(),
1370        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1371    );
1372    assert_eq!(entry.version(), Ok(None));
1373    assert_eq!(entry.script(), None);
1374}
1375
1376#[test]
1377fn test_parse_v1() {
1378    const WATCHV1: &str = r#"version=4
1379opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1380  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1381"#;
1382    let parsed = parse(WATCHV1);
1383    //assert_eq!(parsed.errors, Vec::<String>::new());
1384    let node = parsed.syntax();
1385    assert_eq!(
1386        format!("{:#?}", node),
1387        r#"ROOT@0..161
1388  VERSION@0..10
1389    KEY@0..7 "version"
1390    EQUALS@7..8 "="
1391    VALUE@8..9 "4"
1392    NEWLINE@9..10 "\n"
1393  ENTRY@10..161
1394    OPTS_LIST@10..86
1395      KEY@10..14 "opts"
1396      EQUALS@14..15 "="
1397      OPTION@15..19
1398        KEY@15..19 "bare"
1399      OPTION_SEPARATOR@19..20
1400        COMMA@19..20 ","
1401      OPTION@20..86
1402        KEY@20..34 "filenamemangle"
1403        EQUALS@34..35 "="
1404        VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..."
1405    WHITESPACE@86..87 " "
1406    CONTINUATION@87..89 "\\\n"
1407    WHITESPACE@89..91 "  "
1408    URL@91..138
1409      VALUE@91..138 "https://github.com/sy ..."
1410    WHITESPACE@138..139 " "
1411    MATCHING_PATTERN@139..160
1412      VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz"
1413    NEWLINE@160..161 "\n"
1414"#
1415    );
1416
1417    let root = parsed.root();
1418    assert_eq!(root.version(), 4);
1419    let entries = root.entries().collect::<Vec<_>>();
1420    assert_eq!(entries.len(), 1);
1421    let entry = &entries[0];
1422    assert_eq!(
1423        entry.url(),
1424        "https://github.com/syncthing/syncthing-gtk/tags"
1425    );
1426    assert_eq!(
1427        entry.matching_pattern(),
1428        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1429    );
1430    assert_eq!(entry.version(), Ok(None));
1431    assert_eq!(entry.script(), None);
1432
1433    assert_eq!(node.text(), WATCHV1);
1434}
1435
1436#[test]
1437fn test_parse_v2() {
1438    let parsed = parse(
1439        r#"version=4
1440https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1441# comment
1442"#,
1443    );
1444    assert_eq!(parsed.errors, Vec::<String>::new());
1445    let node = parsed.syntax();
1446    assert_eq!(
1447        format!("{:#?}", node),
1448        r###"ROOT@0..90
1449  VERSION@0..10
1450    KEY@0..7 "version"
1451    EQUALS@7..8 "="
1452    VALUE@8..9 "4"
1453    NEWLINE@9..10 "\n"
1454  ENTRY@10..80
1455    URL@10..57
1456      VALUE@10..57 "https://github.com/sy ..."
1457    WHITESPACE@57..58 " "
1458    MATCHING_PATTERN@58..79
1459      VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz"
1460    NEWLINE@79..80 "\n"
1461  COMMENT@80..89 "# comment"
1462  NEWLINE@89..90 "\n"
1463"###
1464    );
1465
1466    let root = parsed.root();
1467    assert_eq!(root.version(), 4);
1468    let entries = root.entries().collect::<Vec<_>>();
1469    assert_eq!(entries.len(), 1);
1470    let entry = &entries[0];
1471    assert_eq!(
1472        entry.url(),
1473        "https://github.com/syncthing/syncthing-gtk/tags"
1474    );
1475    assert_eq!(
1476        entry.format_url(|| "syncthing-gtk".to_string()),
1477        "https://github.com/syncthing/syncthing-gtk/tags"
1478            .parse()
1479            .unwrap()
1480    );
1481}
1482
1483#[test]
1484fn test_parse_v3() {
1485    let parsed = parse(
1486        r#"version=4
1487https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz
1488# comment
1489"#,
1490    );
1491    assert_eq!(parsed.errors, Vec::<String>::new());
1492    let root = parsed.root();
1493    assert_eq!(root.version(), 4);
1494    let entries = root.entries().collect::<Vec<_>>();
1495    assert_eq!(entries.len(), 1);
1496    let entry = &entries[0];
1497    assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags");
1498    assert_eq!(
1499        entry.format_url(|| "syncthing-gtk".to_string()),
1500        "https://github.com/syncthing/syncthing-gtk/tags"
1501            .parse()
1502            .unwrap()
1503    );
1504}
1505
1506#[test]
1507fn test_parse_v4() {
1508    let cl: super::WatchFile = r#"version=4
1509opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1510    https://github.com/example/example-cat/tags \
1511        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1512"#
1513    .parse()
1514    .unwrap();
1515    assert_eq!(cl.version(), 4);
1516    let entries = cl.entries().collect::<Vec<_>>();
1517    assert_eq!(entries.len(), 1);
1518    let entry = &entries[0];
1519    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1520    assert_eq!(
1521        entry.matching_pattern(),
1522        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1523    );
1524    assert!(entry.repack());
1525    assert_eq!(entry.compression(), Ok(Some(Compression::Xz)));
1526    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1527    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1528    assert_eq!(entry.script(), Some("uupdate".into()));
1529    assert_eq!(
1530        entry.format_url(|| "example-cat".to_string()),
1531        "https://github.com/example/example-cat/tags"
1532            .parse()
1533            .unwrap()
1534    );
1535    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1536}
1537
1538#[test]
1539fn test_git_mode() {
1540    let text = r#"version=3
1541opts="mode=git, gitmode=shallow, pgpmode=gittag" \
1542https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \
1543refs/tags/(.*) debian
1544"#;
1545    let parsed = parse(text);
1546    assert_eq!(parsed.errors, Vec::<String>::new());
1547    let cl = parsed.root();
1548    assert_eq!(cl.version(), 3);
1549    let entries = cl.entries().collect::<Vec<_>>();
1550    assert_eq!(entries.len(), 1);
1551    let entry = &entries[0];
1552    assert_eq!(
1553        entry.url(),
1554        "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git"
1555    );
1556    assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into()));
1557    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1558    assert_eq!(entry.script(), None);
1559    assert_eq!(entry.gitmode(), Ok(GitMode::Shallow));
1560    assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag));
1561    assert_eq!(entry.mode(), Ok(Mode::Git));
1562}
1563
1564#[test]
1565fn test_parse_quoted() {
1566    const WATCHV1: &str = r#"version=4
1567opts="bare, filenamemangle=blah" \
1568  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1569"#;
1570    let parsed = parse(WATCHV1);
1571    //assert_eq!(parsed.errors, Vec::<String>::new());
1572    let node = parsed.syntax();
1573
1574    let root = parsed.root();
1575    assert_eq!(root.version(), 4);
1576    let entries = root.entries().collect::<Vec<_>>();
1577    assert_eq!(entries.len(), 1);
1578    let entry = &entries[0];
1579
1580    assert_eq!(
1581        entry.url(),
1582        "https://github.com/syncthing/syncthing-gtk/tags"
1583    );
1584    assert_eq!(
1585        entry.matching_pattern(),
1586        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1587    );
1588    assert_eq!(entry.version(), Ok(None));
1589    assert_eq!(entry.script(), None);
1590
1591    assert_eq!(node.text(), WATCHV1);
1592}
1593
1594#[test]
1595fn test_set_url() {
1596    // Test setting URL on a simple entry without options
1597    let wf: super::WatchFile = r#"version=4
1598https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1599"#
1600    .parse()
1601    .unwrap();
1602
1603    let mut entry = wf.entries().next().unwrap();
1604    assert_eq!(
1605        entry.url(),
1606        "https://github.com/syncthing/syncthing-gtk/tags"
1607    );
1608
1609    entry.set_url("https://newurl.example.org/path");
1610    assert_eq!(entry.url(), "https://newurl.example.org/path");
1611    assert_eq!(
1612        entry.matching_pattern(),
1613        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1614    );
1615
1616    // Verify the exact serialized output
1617    assert_eq!(
1618        entry.to_string(),
1619        "https://newurl.example.org/path .*/v?(\\d\\S+)\\.tar\\.gz\n"
1620    );
1621}
1622
1623#[test]
1624fn test_set_url_with_options() {
1625    // Test setting URL on an entry with options
1626    let wf: super::WatchFile = r#"version=4
1627opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz
1628"#
1629    .parse()
1630    .unwrap();
1631
1632    let mut entry = wf.entries().next().unwrap();
1633    assert_eq!(entry.url(), "https://foo.com/bar");
1634    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1635
1636    entry.set_url("https://example.com/baz");
1637    assert_eq!(entry.url(), "https://example.com/baz");
1638
1639    // Verify options are preserved
1640    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1641    assert_eq!(
1642        entry.matching_pattern(),
1643        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1644    );
1645
1646    // Verify the exact serialized output
1647    assert_eq!(
1648        entry.to_string(),
1649        "opts=foo=blah https://example.com/baz .*/v?(\\d\\S+)\\.tar\\.gz\n"
1650    );
1651}
1652
1653#[test]
1654fn test_set_url_complex() {
1655    // Test with a complex watch file with multiple options and continuation
1656    let wf: super::WatchFile = r#"version=4
1657opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1658  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1659"#
1660    .parse()
1661    .unwrap();
1662
1663    let mut entry = wf.entries().next().unwrap();
1664    assert_eq!(
1665        entry.url(),
1666        "https://github.com/syncthing/syncthing-gtk/tags"
1667    );
1668
1669    entry.set_url("https://gitlab.com/newproject/tags");
1670    assert_eq!(entry.url(), "https://gitlab.com/newproject/tags");
1671
1672    // Verify all options are preserved
1673    assert!(entry.bare());
1674    assert_eq!(
1675        entry.filenamemangle(),
1676        Some("s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into())
1677    );
1678    assert_eq!(
1679        entry.matching_pattern(),
1680        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1681    );
1682
1683    // Verify the exact serialized output preserves structure
1684    assert_eq!(
1685        entry.to_string(),
1686        r#"opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1687  https://gitlab.com/newproject/tags .*/v?(\d\S+)\.tar\.gz
1688"#
1689    );
1690}
1691
1692#[test]
1693fn test_set_url_with_all_fields() {
1694    // Test with all fields: options, URL, matching pattern, version, and script
1695    let wf: super::WatchFile = r#"version=4
1696opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1697    https://github.com/example/example-cat/tags \
1698        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1699"#
1700    .parse()
1701    .unwrap();
1702
1703    let mut entry = wf.entries().next().unwrap();
1704    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1705    assert_eq!(
1706        entry.matching_pattern(),
1707        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1708    );
1709    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1710    assert_eq!(entry.script(), Some("uupdate".into()));
1711
1712    entry.set_url("https://gitlab.example.org/project/releases");
1713    assert_eq!(entry.url(), "https://gitlab.example.org/project/releases");
1714
1715    // Verify all other fields are preserved
1716    assert!(entry.repack());
1717    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
1718    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1719    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1720    assert_eq!(
1721        entry.matching_pattern(),
1722        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1723    );
1724    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1725    assert_eq!(entry.script(), Some("uupdate".into()));
1726
1727    // Verify the exact serialized output
1728    assert_eq!(
1729        entry.to_string(),
1730        r#"opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1731    https://gitlab.example.org/project/releases \
1732        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1733"#
1734    );
1735}
1736
1737#[test]
1738fn test_set_url_quoted_options() {
1739    // Test with quoted options
1740    let wf: super::WatchFile = r#"version=4
1741opts="bare, filenamemangle=blah" \
1742  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1743"#
1744    .parse()
1745    .unwrap();
1746
1747    let mut entry = wf.entries().next().unwrap();
1748    assert_eq!(
1749        entry.url(),
1750        "https://github.com/syncthing/syncthing-gtk/tags"
1751    );
1752
1753    entry.set_url("https://example.org/new/path");
1754    assert_eq!(entry.url(), "https://example.org/new/path");
1755
1756    // Verify the exact serialized output
1757    assert_eq!(
1758        entry.to_string(),
1759        r#"opts="bare, filenamemangle=blah" \
1760  https://example.org/new/path .*/v?(\d\S+)\.tar\.gz
1761"#
1762    );
1763}
1764
1765#[test]
1766fn test_set_opt_update_existing() {
1767    // Test updating an existing option
1768    let wf: super::WatchFile = r#"version=4
1769opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1770"#
1771    .parse()
1772    .unwrap();
1773
1774    let mut entry = wf.entries().next().unwrap();
1775    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1776    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
1777
1778    entry.set_opt("foo", "updated");
1779    assert_eq!(entry.get_option("foo"), Some("updated".to_string()));
1780    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
1781
1782    // Verify the exact serialized output
1783    assert_eq!(
1784        entry.to_string(),
1785        "opts=foo=updated,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1786    );
1787}
1788
1789#[test]
1790fn test_set_opt_add_new() {
1791    // Test adding a new option to existing options
1792    let wf: super::WatchFile = r#"version=4
1793opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1794"#
1795    .parse()
1796    .unwrap();
1797
1798    let mut entry = wf.entries().next().unwrap();
1799    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1800    assert_eq!(entry.get_option("bar"), None);
1801
1802    entry.set_opt("bar", "baz");
1803    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1804    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
1805
1806    // Verify the exact serialized output
1807    assert_eq!(
1808        entry.to_string(),
1809        "opts=foo=blah,bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1810    );
1811}
1812
1813#[test]
1814fn test_set_opt_create_options_list() {
1815    // Test creating a new options list when none exists
1816    let wf: super::WatchFile = r#"version=4
1817https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1818"#
1819    .parse()
1820    .unwrap();
1821
1822    let mut entry = wf.entries().next().unwrap();
1823    assert_eq!(entry.option_list(), None);
1824
1825    entry.set_opt("compression", "xz");
1826    assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
1827
1828    // Verify the exact serialized output
1829    assert_eq!(
1830        entry.to_string(),
1831        "opts=compression=xz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1832    );
1833}
1834
1835#[test]
1836fn test_del_opt_remove_single() {
1837    // Test removing a single option from multiple options
1838    let wf: super::WatchFile = r#"version=4
1839opts=foo=blah,bar=baz,qux=quux https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1840"#
1841    .parse()
1842    .unwrap();
1843
1844    let mut entry = wf.entries().next().unwrap();
1845    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1846    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
1847    assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
1848
1849    entry.del_opt("bar");
1850    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1851    assert_eq!(entry.get_option("bar"), None);
1852    assert_eq!(entry.get_option("qux"), Some("quux".to_string()));
1853
1854    // Verify the exact serialized output
1855    assert_eq!(
1856        entry.to_string(),
1857        "opts=foo=blah,qux=quux https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1858    );
1859}
1860
1861#[test]
1862fn test_del_opt_remove_first() {
1863    // Test removing the first option
1864    let wf: super::WatchFile = r#"version=4
1865opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1866"#
1867    .parse()
1868    .unwrap();
1869
1870    let mut entry = wf.entries().next().unwrap();
1871    entry.del_opt("foo");
1872    assert_eq!(entry.get_option("foo"), None);
1873    assert_eq!(entry.get_option("bar"), Some("baz".to_string()));
1874
1875    // Verify the exact serialized output
1876    assert_eq!(
1877        entry.to_string(),
1878        "opts=bar=baz https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1879    );
1880}
1881
1882#[test]
1883fn test_del_opt_remove_last() {
1884    // Test removing the last option
1885    let wf: super::WatchFile = r#"version=4
1886opts=foo=blah,bar=baz https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1887"#
1888    .parse()
1889    .unwrap();
1890
1891    let mut entry = wf.entries().next().unwrap();
1892    entry.del_opt("bar");
1893    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1894    assert_eq!(entry.get_option("bar"), None);
1895
1896    // Verify the exact serialized output
1897    assert_eq!(
1898        entry.to_string(),
1899        "opts=foo=blah https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1900    );
1901}
1902
1903#[test]
1904fn test_del_opt_remove_only_option() {
1905    // Test removing the only option (should remove entire opts list)
1906    let wf: super::WatchFile = r#"version=4
1907opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1908"#
1909    .parse()
1910    .unwrap();
1911
1912    let mut entry = wf.entries().next().unwrap();
1913    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1914
1915    entry.del_opt("foo");
1916    assert_eq!(entry.get_option("foo"), None);
1917    assert_eq!(entry.option_list(), None);
1918
1919    // Verify the exact serialized output (opts should be gone)
1920    assert_eq!(
1921        entry.to_string(),
1922        "https://example.com/releases .*/v?(\\d\\S+)\\.tar\\.gz\n"
1923    );
1924}
1925
1926#[test]
1927fn test_del_opt_nonexistent() {
1928    // Test deleting a non-existent option (should do nothing)
1929    let wf: super::WatchFile = r#"version=4
1930opts=foo=blah https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1931"#
1932    .parse()
1933    .unwrap();
1934
1935    let mut entry = wf.entries().next().unwrap();
1936    let original = entry.to_string();
1937
1938    entry.del_opt("nonexistent");
1939    assert_eq!(entry.to_string(), original);
1940}
1941
1942#[test]
1943fn test_set_opt_multiple_operations() {
1944    // Test multiple set_opt operations
1945    let wf: super::WatchFile = r#"version=4
1946https://example.com/releases .*/v?(\d\S+)\.tar\.gz
1947"#
1948    .parse()
1949    .unwrap();
1950
1951    let mut entry = wf.entries().next().unwrap();
1952
1953    entry.set_opt("compression", "xz");
1954    entry.set_opt("repack", "");
1955    entry.set_opt("dversionmangle", "s/\\+ds//");
1956
1957    assert_eq!(entry.get_option("compression"), Some("xz".to_string()));
1958    assert_eq!(
1959        entry.get_option("dversionmangle"),
1960        Some("s/\\+ds//".to_string())
1961    );
1962}
1963
1964#[test]
1965fn test_set_matching_pattern() {
1966    // Test setting matching pattern on a simple entry
1967    let wf: super::WatchFile = r#"version=4
1968https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
1969"#
1970    .parse()
1971    .unwrap();
1972
1973    let mut entry = wf.entries().next().unwrap();
1974    assert_eq!(
1975        entry.matching_pattern(),
1976        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1977    );
1978
1979    entry.set_matching_pattern("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz");
1980    assert_eq!(
1981        entry.matching_pattern(),
1982        Some("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz".into())
1983    );
1984
1985    // Verify URL is preserved
1986    assert_eq!(entry.url(), "https://github.com/example/tags");
1987
1988    // Verify the exact serialized output
1989    assert_eq!(
1990        entry.to_string(),
1991        "https://github.com/example/tags (?:.*?/)?v?([\\d.]+)\\.tar\\.gz\n"
1992    );
1993}
1994
1995#[test]
1996fn test_set_matching_pattern_with_all_fields() {
1997    // Test with all fields present
1998    let wf: super::WatchFile = r#"version=4
1999opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2000"#
2001    .parse()
2002    .unwrap();
2003
2004    let mut entry = wf.entries().next().unwrap();
2005    assert_eq!(
2006        entry.matching_pattern(),
2007        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2008    );
2009
2010    entry.set_matching_pattern(".*/version-([\\d.]+)\\.tar\\.xz");
2011    assert_eq!(
2012        entry.matching_pattern(),
2013        Some(".*/version-([\\d.]+)\\.tar\\.xz".into())
2014    );
2015
2016    // Verify all other fields are preserved
2017    assert_eq!(entry.url(), "https://example.com/releases");
2018    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2019    assert_eq!(entry.script(), Some("uupdate".into()));
2020    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2021
2022    // Verify the exact serialized output
2023    assert_eq!(
2024        entry.to_string(),
2025        "opts=compression=xz https://example.com/releases .*/version-([\\d.]+)\\.tar\\.xz debian uupdate\n"
2026    );
2027}
2028
2029#[test]
2030fn test_set_version_policy() {
2031    // Test setting version policy
2032    let wf: super::WatchFile = r#"version=4
2033https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2034"#
2035    .parse()
2036    .unwrap();
2037
2038    let mut entry = wf.entries().next().unwrap();
2039    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2040
2041    entry.set_version_policy("previous");
2042    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Previous)));
2043
2044    // Verify all other fields are preserved
2045    assert_eq!(entry.url(), "https://example.com/releases");
2046    assert_eq!(
2047        entry.matching_pattern(),
2048        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2049    );
2050    assert_eq!(entry.script(), Some("uupdate".into()));
2051
2052    // Verify the exact serialized output
2053    assert_eq!(
2054        entry.to_string(),
2055        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz previous uupdate\n"
2056    );
2057}
2058
2059#[test]
2060fn test_set_version_policy_with_options() {
2061    // Test with options and continuation
2062    let wf: super::WatchFile = r#"version=4
2063opts=repack,compression=xz \
2064    https://github.com/example/example-cat/tags \
2065        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2066"#
2067    .parse()
2068    .unwrap();
2069
2070    let mut entry = wf.entries().next().unwrap();
2071    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2072
2073    entry.set_version_policy("ignore");
2074    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Ignore)));
2075
2076    // Verify all other fields are preserved
2077    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
2078    assert_eq!(
2079        entry.matching_pattern(),
2080        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2081    );
2082    assert_eq!(entry.script(), Some("uupdate".into()));
2083    assert!(entry.repack());
2084
2085    // Verify the exact serialized output
2086    assert_eq!(
2087        entry.to_string(),
2088        r#"opts=repack,compression=xz \
2089    https://github.com/example/example-cat/tags \
2090        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz ignore uupdate
2091"#
2092    );
2093}
2094
2095#[test]
2096fn test_set_script() {
2097    // Test setting script
2098    let wf: super::WatchFile = r#"version=4
2099https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2100"#
2101    .parse()
2102    .unwrap();
2103
2104    let mut entry = wf.entries().next().unwrap();
2105    assert_eq!(entry.script(), Some("uupdate".into()));
2106
2107    entry.set_script("uscan");
2108    assert_eq!(entry.script(), Some("uscan".into()));
2109
2110    // Verify all other fields are preserved
2111    assert_eq!(entry.url(), "https://example.com/releases");
2112    assert_eq!(
2113        entry.matching_pattern(),
2114        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2115    );
2116    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2117
2118    // Verify the exact serialized output
2119    assert_eq!(
2120        entry.to_string(),
2121        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian uscan\n"
2122    );
2123}
2124
2125#[test]
2126fn test_set_script_with_options() {
2127    // Test with options
2128    let wf: super::WatchFile = r#"version=4
2129opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
2130"#
2131    .parse()
2132    .unwrap();
2133
2134    let mut entry = wf.entries().next().unwrap();
2135    assert_eq!(entry.script(), Some("uupdate".into()));
2136
2137    entry.set_script("custom-script.sh");
2138    assert_eq!(entry.script(), Some("custom-script.sh".into()));
2139
2140    // Verify all other fields are preserved
2141    assert_eq!(entry.url(), "https://example.com/releases");
2142    assert_eq!(
2143        entry.matching_pattern(),
2144        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
2145    );
2146    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
2147    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
2148
2149    // Verify the exact serialized output
2150    assert_eq!(
2151        entry.to_string(),
2152        "opts=compression=xz https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian custom-script.sh\n"
2153    );
2154}