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.bump();
278                    } else if !quoted {
279                        break;
280                    }
281                }
282                self.builder.finish_node();
283                self.skip_ws();
284            }
285        }
286
287        fn parse(mut self) -> Parse {
288            let mut version = 1;
289            // Make sure that the root node covers all source
290            self.builder.start_node(ROOT.into());
291            if let Some(v) = self.parse_version() {
292                version = v;
293            }
294            // TODO: use version to influence parsing
295            loop {
296                if !self.parse_watch_entry() {
297                    break;
298                }
299            }
300            // Don't forget to eat *trailing* whitespace
301            self.skip_ws();
302            // Close the root node.
303            self.builder.finish_node();
304
305            // Turn the builder into a GreenNode
306            Parse {
307                green_node: self.builder.finish(),
308                errors: self.errors,
309                version,
310            }
311        }
312        /// Advance one token, adding it to the current branch of the tree builder.
313        fn bump(&mut self) {
314            let (kind, text) = self.tokens.pop().unwrap();
315            self.builder.token(kind.into(), text.as_str());
316        }
317        /// Peek at the first unprocessed token
318        fn current(&self) -> Option<SyntaxKind> {
319            self.tokens.last().map(|(kind, _)| *kind)
320        }
321        fn skip_ws(&mut self) {
322            while self.current() == Some(WHITESPACE)
323                || self.current() == Some(CONTINUATION)
324                || self.current() == Some(COMMENT)
325            {
326                self.bump()
327            }
328        }
329    }
330
331    let mut tokens = lex(text);
332    tokens.reverse();
333    Parser {
334        tokens,
335        builder: GreenNodeBuilder::new(),
336        errors: Vec::new(),
337    }
338    .parse()
339}
340
341/// To work with the parse results we need a view into the
342/// green tree - the Syntax tree.
343/// It is also immutable, like a GreenNode,
344/// but it contains parent pointers, offsets, and
345/// has identity semantics.
346
347type SyntaxNode = rowan::SyntaxNode<Lang>;
348#[allow(unused)]
349type SyntaxToken = rowan::SyntaxToken<Lang>;
350#[allow(unused)]
351type SyntaxElement = rowan::NodeOrToken<SyntaxNode, SyntaxToken>;
352
353impl Parse {
354    fn syntax(&self) -> SyntaxNode {
355        SyntaxNode::new_root_mut(self.green_node.clone())
356    }
357
358    fn root(&self) -> WatchFile {
359        WatchFile::cast(self.syntax()).unwrap()
360    }
361}
362
363macro_rules! ast_node {
364    ($ast:ident, $kind:ident) => {
365        #[derive(PartialEq, Eq, Hash)]
366        #[repr(transparent)]
367        /// A node in the syntax tree for $ast
368        pub struct $ast(SyntaxNode);
369        impl $ast {
370            #[allow(unused)]
371            fn cast(node: SyntaxNode) -> Option<Self> {
372                if node.kind() == $kind {
373                    Some(Self(node))
374                } else {
375                    None
376                }
377            }
378        }
379
380        impl ToString for $ast {
381            fn to_string(&self) -> String {
382                self.0.text().to_string()
383            }
384        }
385    };
386}
387
388ast_node!(WatchFile, ROOT);
389ast_node!(Version, VERSION);
390ast_node!(Entry, ENTRY);
391ast_node!(OptionList, OPTS_LIST);
392ast_node!(_Option, OPTION);
393ast_node!(Url, URL);
394ast_node!(MatchingPattern, MATCHING_PATTERN);
395ast_node!(VersionPolicyNode, VERSION_POLICY);
396ast_node!(ScriptNode, SCRIPT);
397
398impl WatchFile {
399    /// Create a new watch file with specified version
400    pub fn new(version: Option<u32>) -> WatchFile {
401        let mut builder = GreenNodeBuilder::new();
402
403        builder.start_node(ROOT.into());
404        if let Some(version) = version {
405            builder.start_node(VERSION.into());
406            builder.token(KEY.into(), "version");
407            builder.token(EQUALS.into(), "=");
408            builder.token(VALUE.into(), version.to_string().as_str());
409            builder.token(NEWLINE.into(), "\n");
410            builder.finish_node();
411        }
412        builder.finish_node();
413        WatchFile(SyntaxNode::new_root_mut(builder.finish()))
414    }
415
416    /// Returns the version of the watch file.
417    pub fn version(&self) -> u32 {
418        self.0
419            .children()
420            .find_map(Version::cast)
421            .map(|it| it.version())
422            .unwrap_or(DEFAULT_VERSION)
423    }
424
425    /// Returns an iterator over all entries in the watch file.
426    pub fn entries(&self) -> impl Iterator<Item = Entry> + '_ {
427        self.0.children().filter_map(Entry::cast)
428    }
429
430    /// Set the version of the watch file.
431    pub fn set_version(&mut self, new_version: u32) {
432        // Build the new version node
433        let mut builder = GreenNodeBuilder::new();
434        builder.start_node(VERSION.into());
435        builder.token(KEY.into(), "version");
436        builder.token(EQUALS.into(), "=");
437        builder.token(VALUE.into(), new_version.to_string().as_str());
438        builder.token(NEWLINE.into(), "\n");
439        builder.finish_node();
440        let new_version_green = builder.finish();
441
442        // Create a syntax node (splice_children will detach and reattach it)
443        let new_version_node = SyntaxNode::new_root_mut(new_version_green);
444
445        // Find existing version node if any
446        let version_pos = self.0.children().position(|child| child.kind() == VERSION);
447
448        if let Some(pos) = version_pos {
449            // Replace existing version node
450            self.0
451                .splice_children(pos..pos + 1, vec![new_version_node.into()]);
452        } else {
453            // Insert version node at the beginning
454            self.0.splice_children(0..0, vec![new_version_node.into()]);
455        }
456    }
457}
458
459impl FromStr for WatchFile {
460    type Err = ParseError;
461
462    fn from_str(s: &str) -> Result<Self, Self::Err> {
463        let parsed = parse(s);
464        if parsed.errors.is_empty() {
465            Ok(parsed.root())
466        } else {
467            Err(ParseError(parsed.errors))
468        }
469    }
470}
471
472impl Version {
473    /// Returns the version of the watch file.
474    pub fn version(&self) -> u32 {
475        self.0
476            .children_with_tokens()
477            .find_map(|it| match it {
478                SyntaxElement::Token(token) => {
479                    if token.kind() == VALUE {
480                        Some(token.text().parse().unwrap())
481                    } else {
482                        None
483                    }
484                }
485                _ => None,
486            })
487            .unwrap_or(DEFAULT_VERSION)
488    }
489}
490
491impl Entry {
492    /// List of options
493    pub fn option_list(&self) -> Option<OptionList> {
494        self.0.children().find_map(OptionList::cast)
495    }
496
497    /// Get the value of an option
498    pub fn get_option(&self, key: &str) -> Option<String> {
499        self.option_list().and_then(|ol| ol.get_option(key))
500    }
501
502    /// Check if an option is set
503    pub fn has_option(&self, key: &str) -> bool {
504        self.option_list().map_or(false, |ol| ol.has_option(key))
505    }
506
507    /// The name of the secondary source tarball
508    pub fn component(&self) -> Option<String> {
509        self.get_option("component")
510    }
511
512    /// Component type
513    pub fn ctype(&self) -> Result<Option<ComponentType>, ()> {
514        self.get_option("ctype").map(|s| s.parse()).transpose()
515    }
516
517    /// Compression method
518    pub fn compression(&self) -> Result<Option<Compression>, ()> {
519        self.get_option("compression")
520            .map(|s| s.parse())
521            .transpose()
522    }
523
524    /// Repack the tarball
525    pub fn repack(&self) -> bool {
526        self.has_option("repack")
527    }
528
529    /// Repack suffix
530    pub fn repacksuffix(&self) -> Option<String> {
531        self.get_option("repacksuffix")
532    }
533
534    /// Retrieve the mode of the watch file entry.
535    pub fn mode(&self) -> Result<Mode, ()> {
536        Ok(self
537            .get_option("mode")
538            .map(|s| s.parse())
539            .transpose()?
540            .unwrap_or_default())
541    }
542
543    /// Return the git pretty mode
544    pub fn pretty(&self) -> Result<Pretty, ()> {
545        Ok(self
546            .get_option("pretty")
547            .map(|s| s.parse())
548            .transpose()?
549            .unwrap_or_default())
550    }
551
552    /// Set the date string used by the pretty option to an arbitrary format as an optional
553    /// opts argument when the matching-pattern is HEAD or heads/branch for git mode.
554    pub fn date(&self) -> String {
555        self.get_option("date")
556            .unwrap_or_else(|| "%Y%m%d".to_string())
557    }
558
559    /// Return the git export mode
560    pub fn gitexport(&self) -> Result<GitExport, ()> {
561        Ok(self
562            .get_option("gitexport")
563            .map(|s| s.parse())
564            .transpose()?
565            .unwrap_or_default())
566    }
567
568    /// Return the git mode
569    pub fn gitmode(&self) -> Result<GitMode, ()> {
570        Ok(self
571            .get_option("gitmode")
572            .map(|s| s.parse())
573            .transpose()?
574            .unwrap_or_default())
575    }
576
577    /// Return the pgp mode
578    pub fn pgpmode(&self) -> Result<PgpMode, ()> {
579        Ok(self
580            .get_option("pgpmode")
581            .map(|s| s.parse())
582            .transpose()?
583            .unwrap_or_default())
584    }
585
586    /// Return the search mode
587    pub fn searchmode(&self) -> Result<SearchMode, ()> {
588        Ok(self
589            .get_option("searchmode")
590            .map(|s| s.parse())
591            .transpose()?
592            .unwrap_or_default())
593    }
594
595    /// Return the decompression mode
596    pub fn decompress(&self) -> bool {
597        self.has_option("decompress")
598    }
599
600    /// Whether to disable all site specific special case code such as URL director uses and page
601    /// content alterations.
602    pub fn bare(&self) -> bool {
603        self.has_option("bare")
604    }
605
606    /// Set the user-agent string used to contact the HTTP(S) server as user-agent-string. (persistent)
607    pub fn user_agent(&self) -> Option<String> {
608        self.get_option("user-agent")
609    }
610
611    /// Use PASV mode for the FTP connection.
612    pub fn passive(&self) -> Option<bool> {
613        if self.has_option("passive") || self.has_option("pasv") {
614            Some(true)
615        } else if self.has_option("active") || self.has_option("nopasv") {
616            Some(false)
617        } else {
618            None
619        }
620    }
621
622    /// Add the extra options to use with the unzip command, such as -a, -aa, and -b, when executed
623    /// by mk-origtargz.
624    pub fn unzipoptions(&self) -> Option<String> {
625        self.get_option("unzipopt")
626    }
627
628    /// Normalize the downloaded web page string.
629    pub fn dversionmangle(&self) -> Option<String> {
630        self.get_option("dversionmangle")
631            .or_else(|| self.get_option("versionmangle"))
632    }
633
634    /// Normalize the directory path string matching the regex in a set of parentheses of
635    /// http://URL as the sortable version index string.  This is used
636    /// as the directory path sorting index only.
637    pub fn dirversionmangle(&self) -> Option<String> {
638        self.get_option("dirversionmangle")
639    }
640
641    /// Normalize the downloaded web page string.
642    pub fn pagemangle(&self) -> Option<String> {
643        self.get_option("pagemangle")
644    }
645
646    /// Normalize the candidate upstream version strings extracted from hrefs in the
647    /// source of the web page.  This is used as the version sorting index when selecting the
648    /// latest upstream version.
649    pub fn uversionmangle(&self) -> Option<String> {
650        self.get_option("uversionmangle")
651            .or_else(|| self.get_option("versionmangle"))
652    }
653
654    /// Syntactic shorthand for uversionmangle=rules, dversionmangle=rules
655    pub fn versionmangle(&self) -> Option<String> {
656        self.get_option("versionmangle")
657    }
658
659    /// Convert the selected upstream tarball href string from the percent-encoded hexadecimal
660    /// string to the decoded normal URL  string  for  obfuscated
661    /// web sites.  Only percent-encoding is available and it is decoded with
662    /// s/%([A-Fa-f\d]{2})/chr hex $1/eg.
663    pub fn hrefdecode(&self) -> bool {
664        self.get_option("hrefdecode").is_some()
665    }
666
667    /// Convert the selected upstream tarball href string into the accessible URL for obfuscated
668    /// web sites.  This is run after hrefdecode.
669    pub fn downloadurlmangle(&self) -> Option<String> {
670        self.get_option("downloadurlmangle")
671    }
672
673    /// Generate the upstream tarball filename from the selected href string if matching-pattern
674    /// can extract the latest upstream version <uversion> from the  selected  href  string.
675    /// Otherwise, generate the upstream tarball filename from its full URL string and set the
676    /// missing <uversion> from the generated upstream tarball filename.
677    ///
678    /// Without this option, the default upstream tarball filename is generated by taking the last
679    /// component of the URL and  removing everything  after any '?' or '#'.
680    pub fn filenamemangle(&self) -> Option<String> {
681        self.get_option("filenamemangle")
682    }
683
684    /// Generate the candidate upstream signature file URL string from the upstream tarball URL.
685    pub fn pgpsigurlmangle(&self) -> Option<String> {
686        self.get_option("pgpsigurlmangle")
687    }
688
689    /// Generate the version string <oversion> of the source tarball <spkg>_<oversion>.orig.tar.gz
690    /// from <uversion>.  This should be used to add a suffix such as +dfsg to a MUT package.
691    pub fn oversionmangle(&self) -> Option<String> {
692        self.get_option("oversionmangle")
693    }
694
695    /// Returns options set
696    pub fn opts(&self) -> std::collections::HashMap<String, String> {
697        let mut options = std::collections::HashMap::new();
698
699        if let Some(ol) = self.option_list() {
700            for opt in ol.children() {
701                let key = opt.key();
702                let value = opt.value();
703                if let (Some(key), Some(value)) = (key, value) {
704                    options.insert(key.to_string(), value.to_string());
705                }
706            }
707        }
708
709        options
710    }
711
712    fn items(&self) -> impl Iterator<Item = String> + '_ {
713        self.0.children_with_tokens().filter_map(|it| match it {
714            SyntaxElement::Token(token) => {
715                if token.kind() == VALUE || token.kind() == KEY {
716                    Some(token.text().to_string())
717                } else {
718                    None
719                }
720            }
721            SyntaxElement::Node(node) => {
722                // Extract values from entry field nodes
723                match node.kind() {
724                    URL => Url::cast(node).map(|n| n.url()),
725                    MATCHING_PATTERN => MatchingPattern::cast(node).map(|n| n.pattern()),
726                    VERSION_POLICY => VersionPolicyNode::cast(node).map(|n| n.policy()),
727                    SCRIPT => ScriptNode::cast(node).map(|n| n.script()),
728                    _ => None,
729                }
730            }
731        })
732    }
733
734    /// Returns the URL of the entry.
735    pub fn url(&self) -> String {
736        self.0
737            .children()
738            .find_map(Url::cast)
739            .map(|it| it.url())
740            .unwrap_or_else(|| {
741                // Fallback for entries without URL node (shouldn't happen with new parser)
742                self.items().next().unwrap()
743            })
744    }
745
746    /// Returns the matching pattern of the entry.
747    pub fn matching_pattern(&self) -> Option<String> {
748        self.0
749            .children()
750            .find_map(MatchingPattern::cast)
751            .map(|it| it.pattern())
752            .or_else(|| {
753                // Fallback for entries without MATCHING_PATTERN node
754                self.items().nth(1)
755            })
756    }
757
758    /// Returns the version policy
759    pub fn version(&self) -> Result<Option<crate::VersionPolicy>, String> {
760        self.0
761            .children()
762            .find_map(VersionPolicyNode::cast)
763            .map(|it| it.policy().parse())
764            .transpose()
765            .or_else(|_e| {
766                // Fallback for entries without VERSION_POLICY node
767                self.items().nth(2).map(|it| it.parse()).transpose()
768            })
769    }
770
771    /// Returns the script of the entry.
772    pub fn script(&self) -> Option<String> {
773        self.0
774            .children()
775            .find_map(ScriptNode::cast)
776            .map(|it| it.script())
777            .or_else(|| {
778                // Fallback for entries without SCRIPT node
779                self.items().nth(3)
780            })
781    }
782
783    /// Replace all substitutions and return the resulting URL.
784    pub fn format_url(&self, package: impl FnOnce() -> String) -> url::Url {
785        subst(self.url().as_str(), package).parse().unwrap()
786    }
787
788    /// Set the URL of the entry.
789    pub fn set_url(&mut self, new_url: &str) {
790        // Build the new URL node
791        let mut builder = GreenNodeBuilder::new();
792        builder.start_node(URL.into());
793        builder.token(VALUE.into(), new_url);
794        builder.finish_node();
795        let new_url_green = builder.finish();
796
797        // Create a syntax node (splice_children will detach and reattach it)
798        let new_url_node = SyntaxNode::new_root_mut(new_url_green);
799
800        // Find existing URL node position (need to use children_with_tokens for correct indexing)
801        let url_pos = self
802            .0
803            .children_with_tokens()
804            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == URL));
805
806        if let Some(pos) = url_pos {
807            // Replace existing URL node
808            self.0
809                .splice_children(pos..pos + 1, vec![new_url_node.into()]);
810        }
811    }
812
813    /// Set the matching pattern of the entry.
814    ///
815    /// TODO: This currently only replaces an existing matching pattern.
816    /// If the entry doesn't have a matching pattern, this method does nothing.
817    /// Future implementation should insert the node at the correct position.
818    pub fn set_matching_pattern(&mut self, new_pattern: &str) {
819        // Build the new MATCHING_PATTERN node
820        let mut builder = GreenNodeBuilder::new();
821        builder.start_node(MATCHING_PATTERN.into());
822        builder.token(VALUE.into(), new_pattern);
823        builder.finish_node();
824        let new_pattern_green = builder.finish();
825
826        // Create a syntax node (splice_children will detach and reattach it)
827        let new_pattern_node = SyntaxNode::new_root_mut(new_pattern_green);
828
829        // Find existing MATCHING_PATTERN node position
830        let pattern_pos = self.0.children_with_tokens().position(
831            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == MATCHING_PATTERN),
832        );
833
834        if let Some(pos) = pattern_pos {
835            // Replace existing MATCHING_PATTERN node
836            self.0
837                .splice_children(pos..pos + 1, vec![new_pattern_node.into()]);
838        }
839        // TODO: else insert new node after URL
840    }
841
842    /// Set the version policy of the entry.
843    ///
844    /// TODO: This currently only replaces an existing version policy.
845    /// If the entry doesn't have a version policy, this method does nothing.
846    /// Future implementation should insert the node at the correct position.
847    pub fn set_version_policy(&mut self, new_policy: &str) {
848        // Build the new VERSION_POLICY node
849        let mut builder = GreenNodeBuilder::new();
850        builder.start_node(VERSION_POLICY.into());
851        // Version policy can be KEY (e.g., "debian") or VALUE
852        builder.token(VALUE.into(), new_policy);
853        builder.finish_node();
854        let new_policy_green = builder.finish();
855
856        // Create a syntax node (splice_children will detach and reattach it)
857        let new_policy_node = SyntaxNode::new_root_mut(new_policy_green);
858
859        // Find existing VERSION_POLICY node position
860        let policy_pos = self.0.children_with_tokens().position(
861            |child| matches!(child, SyntaxElement::Node(node) if node.kind() == VERSION_POLICY),
862        );
863
864        if let Some(pos) = policy_pos {
865            // Replace existing VERSION_POLICY node
866            self.0
867                .splice_children(pos..pos + 1, vec![new_policy_node.into()]);
868        }
869        // TODO: else insert new node after MATCHING_PATTERN (or URL if no pattern)
870    }
871
872    /// Set the script of the entry.
873    ///
874    /// TODO: This currently only replaces an existing script.
875    /// If the entry doesn't have a script, this method does nothing.
876    /// Future implementation should insert the node at the correct position.
877    pub fn set_script(&mut self, new_script: &str) {
878        // Build the new SCRIPT node
879        let mut builder = GreenNodeBuilder::new();
880        builder.start_node(SCRIPT.into());
881        // Script can be KEY (e.g., "uupdate") or VALUE
882        builder.token(VALUE.into(), new_script);
883        builder.finish_node();
884        let new_script_green = builder.finish();
885
886        // Create a syntax node (splice_children will detach and reattach it)
887        let new_script_node = SyntaxNode::new_root_mut(new_script_green);
888
889        // Find existing SCRIPT node position
890        let script_pos = self
891            .0
892            .children_with_tokens()
893            .position(|child| matches!(child, SyntaxElement::Node(node) if node.kind() == SCRIPT));
894
895        if let Some(pos) = script_pos {
896            // Replace existing SCRIPT node
897            self.0
898                .splice_children(pos..pos + 1, vec![new_script_node.into()]);
899        }
900        // TODO: else insert new node after VERSION_POLICY (or MATCHING_PATTERN/URL if no policy)
901    }
902}
903
904const SUBSTITUTIONS: &[(&str, &str)] = &[
905    // This is substituted with the source package name found in the first line
906    // of the debian/changelog file.
907    // "@PACKAGE@": None,
908    // This is substituted by the legal upstream version regex (capturing).
909    ("@ANY_VERSION@", r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"),
910    // This is substituted by the typical archive file extension regex
911    // (non-capturing).
912    (
913        "@ARCHIVE_EXT@",
914        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)",
915    ),
916    // This is substituted by the typical signature file extension regex
917    // (non-capturing).
918    (
919        "@SIGNATURE_EXT@",
920        r"(?i)\.(?:tar\.xz|tar\.bz2|tar\.gz|zip|tgz|tbz|txz)\.(?:asc|pgp|gpg|sig|sign)",
921    ),
922    // This is substituted by the typical Debian extension regexp (capturing).
923    ("@DEB_EXT@", r"[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$"),
924];
925
926pub fn subst(text: &str, package: impl FnOnce() -> String) -> String {
927    let mut substs = SUBSTITUTIONS.to_vec();
928    let package_name;
929    if text.contains("@PACKAGE@") {
930        package_name = Some(package());
931        substs.push(("@PACKAGE@", package_name.as_deref().unwrap()));
932    }
933
934    let mut text = text.to_string();
935
936    for (k, v) in substs {
937        text = text.replace(k, v);
938    }
939
940    text
941}
942
943#[test]
944fn test_subst() {
945    assert_eq!(
946        subst("@ANY_VERSION@", || unreachable!()),
947        r"[-_]?(\d[\-+\.:\~\da-zA-Z]*)"
948    );
949    assert_eq!(subst("@PACKAGE@", || "dulwich".to_string()), "dulwich");
950}
951
952impl OptionList {
953    fn children(&self) -> impl Iterator<Item = _Option> + '_ {
954        self.0.children().filter_map(_Option::cast)
955    }
956
957    pub fn has_option(&self, key: &str) -> bool {
958        self.children().any(|it| it.key().as_deref() == Some(key))
959    }
960
961    pub fn get_option(&self, key: &str) -> Option<String> {
962        for child in self.children() {
963            if child.key().as_deref() == Some(key) {
964                return child.value();
965            }
966        }
967        None
968    }
969}
970
971impl _Option {
972    /// Returns the key of the option.
973    pub fn key(&self) -> Option<String> {
974        self.0.children_with_tokens().find_map(|it| match it {
975            SyntaxElement::Token(token) => {
976                if token.kind() == KEY {
977                    Some(token.text().to_string())
978                } else {
979                    None
980                }
981            }
982            _ => None,
983        })
984    }
985
986    /// Returns the value of the option.
987    pub fn value(&self) -> Option<String> {
988        self.0
989            .children_with_tokens()
990            .filter_map(|it| match it {
991                SyntaxElement::Token(token) => {
992                    if token.kind() == VALUE || token.kind() == KEY {
993                        Some(token.text().to_string())
994                    } else {
995                        None
996                    }
997                }
998                _ => None,
999            })
1000            .nth(1)
1001    }
1002}
1003
1004impl Url {
1005    /// Returns the URL string.
1006    pub fn url(&self) -> String {
1007        self.0
1008            .children_with_tokens()
1009            .find_map(|it| match it {
1010                SyntaxElement::Token(token) => {
1011                    if token.kind() == VALUE {
1012                        Some(token.text().to_string())
1013                    } else {
1014                        None
1015                    }
1016                }
1017                _ => None,
1018            })
1019            .unwrap()
1020    }
1021}
1022
1023impl MatchingPattern {
1024    /// Returns the matching pattern string.
1025    pub fn pattern(&self) -> String {
1026        self.0
1027            .children_with_tokens()
1028            .find_map(|it| match it {
1029                SyntaxElement::Token(token) => {
1030                    if token.kind() == VALUE {
1031                        Some(token.text().to_string())
1032                    } else {
1033                        None
1034                    }
1035                }
1036                _ => None,
1037            })
1038            .unwrap()
1039    }
1040}
1041
1042impl VersionPolicyNode {
1043    /// Returns the version policy string.
1044    pub fn policy(&self) -> String {
1045        self.0
1046            .children_with_tokens()
1047            .find_map(|it| match it {
1048                SyntaxElement::Token(token) => {
1049                    // Can be KEY (e.g., "debian") or VALUE
1050                    if token.kind() == VALUE || token.kind() == KEY {
1051                        Some(token.text().to_string())
1052                    } else {
1053                        None
1054                    }
1055                }
1056                _ => None,
1057            })
1058            .unwrap()
1059    }
1060}
1061
1062impl ScriptNode {
1063    /// Returns the script string.
1064    pub fn script(&self) -> String {
1065        self.0
1066            .children_with_tokens()
1067            .find_map(|it| match it {
1068                SyntaxElement::Token(token) => {
1069                    // Can be KEY (e.g., "uupdate") or VALUE
1070                    if token.kind() == VALUE || token.kind() == KEY {
1071                        Some(token.text().to_string())
1072                    } else {
1073                        None
1074                    }
1075                }
1076                _ => None,
1077            })
1078            .unwrap()
1079    }
1080}
1081
1082#[test]
1083fn test_entry_node_structure() {
1084    // Test that entries properly use the new node types
1085    let wf: super::WatchFile = r#"version=4
1086opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1087"#
1088    .parse()
1089    .unwrap();
1090
1091    let entry = wf.entries().next().unwrap();
1092
1093    // Verify URL node exists and works
1094    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1095    assert_eq!(entry.url(), "https://example.com/releases");
1096
1097    // Verify MATCHING_PATTERN node exists and works
1098    assert_eq!(
1099        entry
1100            .0
1101            .children()
1102            .find(|n| n.kind() == MATCHING_PATTERN)
1103            .is_some(),
1104        true
1105    );
1106    assert_eq!(
1107        entry.matching_pattern(),
1108        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1109    );
1110
1111    // Verify VERSION_POLICY node exists and works
1112    assert_eq!(
1113        entry
1114            .0
1115            .children()
1116            .find(|n| n.kind() == VERSION_POLICY)
1117            .is_some(),
1118        true
1119    );
1120    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1121
1122    // Verify SCRIPT node exists and works
1123    assert_eq!(
1124        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1125        true
1126    );
1127    assert_eq!(entry.script(), Some("uupdate".into()));
1128}
1129
1130#[test]
1131fn test_entry_node_structure_partial() {
1132    // Test entry with only URL and pattern (no version or script)
1133    let wf: super::WatchFile = r#"version=4
1134https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
1135"#
1136    .parse()
1137    .unwrap();
1138
1139    let entry = wf.entries().next().unwrap();
1140
1141    // Should have URL and MATCHING_PATTERN nodes
1142    assert_eq!(entry.0.children().find(|n| n.kind() == URL).is_some(), true);
1143    assert_eq!(
1144        entry
1145            .0
1146            .children()
1147            .find(|n| n.kind() == MATCHING_PATTERN)
1148            .is_some(),
1149        true
1150    );
1151
1152    // Should NOT have VERSION_POLICY or SCRIPT nodes
1153    assert_eq!(
1154        entry
1155            .0
1156            .children()
1157            .find(|n| n.kind() == VERSION_POLICY)
1158            .is_some(),
1159        false
1160    );
1161    assert_eq!(
1162        entry.0.children().find(|n| n.kind() == SCRIPT).is_some(),
1163        false
1164    );
1165
1166    // Verify accessors work correctly
1167    assert_eq!(entry.url(), "https://github.com/example/tags");
1168    assert_eq!(
1169        entry.matching_pattern(),
1170        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1171    );
1172    assert_eq!(entry.version(), Ok(None));
1173    assert_eq!(entry.script(), None);
1174}
1175
1176#[test]
1177fn test_parse_v1() {
1178    const WATCHV1: &str = r#"version=4
1179opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1180  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1181"#;
1182    let parsed = parse(WATCHV1);
1183    //assert_eq!(parsed.errors, Vec::<String>::new());
1184    let node = parsed.syntax();
1185    assert_eq!(
1186        format!("{:#?}", node),
1187        r#"ROOT@0..161
1188  VERSION@0..10
1189    KEY@0..7 "version"
1190    EQUALS@7..8 "="
1191    VALUE@8..9 "4"
1192    NEWLINE@9..10 "\n"
1193  ENTRY@10..161
1194    OPTS_LIST@10..86
1195      KEY@10..14 "opts"
1196      EQUALS@14..15 "="
1197      OPTION@15..19
1198        KEY@15..19 "bare"
1199      COMMA@19..20 ","
1200      OPTION@20..86
1201        KEY@20..34 "filenamemangle"
1202        EQUALS@34..35 "="
1203        VALUE@35..86 "s/.+\\/v?(\\d\\S+)\\.tar\\ ..."
1204    WHITESPACE@86..87 " "
1205    CONTINUATION@87..89 "\\\n"
1206    WHITESPACE@89..91 "  "
1207    URL@91..138
1208      VALUE@91..138 "https://github.com/sy ..."
1209    WHITESPACE@138..139 " "
1210    MATCHING_PATTERN@139..160
1211      VALUE@139..160 ".*/v?(\\d\\S+)\\.tar\\.gz"
1212    NEWLINE@160..161 "\n"
1213"#
1214    );
1215
1216    let root = parsed.root();
1217    assert_eq!(root.version(), 4);
1218    let entries = root.entries().collect::<Vec<_>>();
1219    assert_eq!(entries.len(), 1);
1220    let entry = &entries[0];
1221    assert_eq!(
1222        entry.url(),
1223        "https://github.com/syncthing/syncthing-gtk/tags"
1224    );
1225    assert_eq!(
1226        entry.matching_pattern(),
1227        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1228    );
1229    assert_eq!(entry.version(), Ok(None));
1230    assert_eq!(entry.script(), None);
1231
1232    assert_eq!(node.text(), WATCHV1);
1233}
1234
1235#[test]
1236fn test_parse_v2() {
1237    let parsed = parse(
1238        r#"version=4
1239https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1240# comment
1241"#,
1242    );
1243    assert_eq!(parsed.errors, Vec::<String>::new());
1244    let node = parsed.syntax();
1245    assert_eq!(
1246        format!("{:#?}", node),
1247        r###"ROOT@0..90
1248  VERSION@0..10
1249    KEY@0..7 "version"
1250    EQUALS@7..8 "="
1251    VALUE@8..9 "4"
1252    NEWLINE@9..10 "\n"
1253  ENTRY@10..80
1254    URL@10..57
1255      VALUE@10..57 "https://github.com/sy ..."
1256    WHITESPACE@57..58 " "
1257    MATCHING_PATTERN@58..79
1258      VALUE@58..79 ".*/v?(\\d\\S+)\\.tar\\.gz"
1259    NEWLINE@79..80 "\n"
1260  COMMENT@80..89 "# comment"
1261  NEWLINE@89..90 "\n"
1262"###
1263    );
1264
1265    let root = parsed.root();
1266    assert_eq!(root.version(), 4);
1267    let entries = root.entries().collect::<Vec<_>>();
1268    assert_eq!(entries.len(), 1);
1269    let entry = &entries[0];
1270    assert_eq!(
1271        entry.url(),
1272        "https://github.com/syncthing/syncthing-gtk/tags"
1273    );
1274    assert_eq!(
1275        entry.format_url(|| "syncthing-gtk".to_string()),
1276        "https://github.com/syncthing/syncthing-gtk/tags"
1277            .parse()
1278            .unwrap()
1279    );
1280}
1281
1282#[test]
1283fn test_parse_v3() {
1284    let parsed = parse(
1285        r#"version=4
1286https://github.com/syncthing/@PACKAGE@/tags .*/v?(\d\S+)\.tar\.gz
1287# comment
1288"#,
1289    );
1290    assert_eq!(parsed.errors, Vec::<String>::new());
1291    let root = parsed.root();
1292    assert_eq!(root.version(), 4);
1293    let entries = root.entries().collect::<Vec<_>>();
1294    assert_eq!(entries.len(), 1);
1295    let entry = &entries[0];
1296    assert_eq!(entry.url(), "https://github.com/syncthing/@PACKAGE@/tags");
1297    assert_eq!(
1298        entry.format_url(|| "syncthing-gtk".to_string()),
1299        "https://github.com/syncthing/syncthing-gtk/tags"
1300            .parse()
1301            .unwrap()
1302    );
1303}
1304
1305#[test]
1306fn test_parse_v4() {
1307    let cl: super::WatchFile = r#"version=4
1308opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1309    https://github.com/example/example-cat/tags \
1310        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1311"#
1312    .parse()
1313    .unwrap();
1314    assert_eq!(cl.version(), 4);
1315    let entries = cl.entries().collect::<Vec<_>>();
1316    assert_eq!(entries.len(), 1);
1317    let entry = &entries[0];
1318    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1319    assert_eq!(
1320        entry.matching_pattern(),
1321        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1322    );
1323    assert!(entry.repack());
1324    assert_eq!(entry.compression(), Ok(Some(Compression::Xz)));
1325    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1326    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1327    assert_eq!(entry.script(), Some("uupdate".into()));
1328    assert_eq!(
1329        entry.format_url(|| "example-cat".to_string()),
1330        "https://github.com/example/example-cat/tags"
1331            .parse()
1332            .unwrap()
1333    );
1334    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1335}
1336
1337#[test]
1338fn test_git_mode() {
1339    let text = r#"version=3
1340opts="mode=git, gitmode=shallow, pgpmode=gittag" \
1341https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git \
1342refs/tags/(.*) debian
1343"#;
1344    let parsed = parse(text);
1345    assert_eq!(parsed.errors, Vec::<String>::new());
1346    let cl = parsed.root();
1347    assert_eq!(cl.version(), 3);
1348    let entries = cl.entries().collect::<Vec<_>>();
1349    assert_eq!(entries.len(), 1);
1350    let entry = &entries[0];
1351    assert_eq!(
1352        entry.url(),
1353        "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git"
1354    );
1355    assert_eq!(entry.matching_pattern(), Some("refs/tags/(.*)".into()));
1356    assert_eq!(entry.version(), Ok(Some(VersionPolicy::Debian)));
1357    assert_eq!(entry.script(), None);
1358    assert_eq!(entry.gitmode(), Ok(GitMode::Shallow));
1359    assert_eq!(entry.pgpmode(), Ok(PgpMode::GitTag));
1360    assert_eq!(entry.mode(), Ok(Mode::Git));
1361}
1362
1363#[test]
1364fn test_parse_quoted() {
1365    const WATCHV1: &str = r#"version=4
1366opts="bare, filenamemangle=blah" \
1367  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1368"#;
1369    let parsed = parse(WATCHV1);
1370    //assert_eq!(parsed.errors, Vec::<String>::new());
1371    let node = parsed.syntax();
1372
1373    let root = parsed.root();
1374    assert_eq!(root.version(), 4);
1375    let entries = root.entries().collect::<Vec<_>>();
1376    assert_eq!(entries.len(), 1);
1377    let entry = &entries[0];
1378
1379    assert_eq!(
1380        entry.url(),
1381        "https://github.com/syncthing/syncthing-gtk/tags"
1382    );
1383    assert_eq!(
1384        entry.matching_pattern(),
1385        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1386    );
1387    assert_eq!(entry.version(), Ok(None));
1388    assert_eq!(entry.script(), None);
1389
1390    assert_eq!(node.text(), WATCHV1);
1391}
1392
1393#[test]
1394fn test_set_url() {
1395    // Test setting URL on a simple entry without options
1396    let wf: super::WatchFile = r#"version=4
1397https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1398"#
1399    .parse()
1400    .unwrap();
1401
1402    let mut entry = wf.entries().next().unwrap();
1403    assert_eq!(
1404        entry.url(),
1405        "https://github.com/syncthing/syncthing-gtk/tags"
1406    );
1407
1408    entry.set_url("https://newurl.example.org/path");
1409    assert_eq!(entry.url(), "https://newurl.example.org/path");
1410    assert_eq!(
1411        entry.matching_pattern(),
1412        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1413    );
1414
1415    // Verify the exact serialized output
1416    assert_eq!(
1417        entry.to_string(),
1418        "https://newurl.example.org/path .*/v?(\\d\\S+)\\.tar\\.gz\n"
1419    );
1420}
1421
1422#[test]
1423fn test_set_url_with_options() {
1424    // Test setting URL on an entry with options
1425    let wf: super::WatchFile = r#"version=4
1426opts=foo=blah https://foo.com/bar .*/v?(\d\S+)\.tar\.gz
1427"#
1428    .parse()
1429    .unwrap();
1430
1431    let mut entry = wf.entries().next().unwrap();
1432    assert_eq!(entry.url(), "https://foo.com/bar");
1433    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1434
1435    entry.set_url("https://example.com/baz");
1436    assert_eq!(entry.url(), "https://example.com/baz");
1437
1438    // Verify options are preserved
1439    assert_eq!(entry.get_option("foo"), Some("blah".to_string()));
1440    assert_eq!(
1441        entry.matching_pattern(),
1442        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1443    );
1444
1445    // Verify the exact serialized output
1446    assert_eq!(
1447        entry.to_string(),
1448        "opts=foo=blah https://example.com/baz .*/v?(\\d\\S+)\\.tar\\.gz\n"
1449    );
1450}
1451
1452#[test]
1453fn test_set_url_complex() {
1454    // Test with a complex watch file with multiple options and continuation
1455    let wf: super::WatchFile = r#"version=4
1456opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1457  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1458"#
1459    .parse()
1460    .unwrap();
1461
1462    let mut entry = wf.entries().next().unwrap();
1463    assert_eq!(
1464        entry.url(),
1465        "https://github.com/syncthing/syncthing-gtk/tags"
1466    );
1467
1468    entry.set_url("https://gitlab.com/newproject/tags");
1469    assert_eq!(entry.url(), "https://gitlab.com/newproject/tags");
1470
1471    // Verify all options are preserved
1472    assert!(entry.bare());
1473    assert_eq!(
1474        entry.filenamemangle(),
1475        Some("s/.+\\/v?(\\d\\S+)\\.tar\\.gz/syncthing-gtk-$1\\.tar\\.gz/".into())
1476    );
1477    assert_eq!(
1478        entry.matching_pattern(),
1479        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1480    );
1481
1482    // Verify the exact serialized output preserves structure
1483    assert_eq!(
1484        entry.to_string(),
1485        r#"opts=bare,filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1\.tar\.gz/ \
1486  https://gitlab.com/newproject/tags .*/v?(\d\S+)\.tar\.gz
1487"#
1488    );
1489}
1490
1491#[test]
1492fn test_set_url_with_all_fields() {
1493    // Test with all fields: options, URL, matching pattern, version, and script
1494    let wf: super::WatchFile = r#"version=4
1495opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1496    https://github.com/example/example-cat/tags \
1497        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1498"#
1499    .parse()
1500    .unwrap();
1501
1502    let mut entry = wf.entries().next().unwrap();
1503    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1504    assert_eq!(
1505        entry.matching_pattern(),
1506        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1507    );
1508    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1509    assert_eq!(entry.script(), Some("uupdate".into()));
1510
1511    entry.set_url("https://gitlab.example.org/project/releases");
1512    assert_eq!(entry.url(), "https://gitlab.example.org/project/releases");
1513
1514    // Verify all other fields are preserved
1515    assert!(entry.repack());
1516    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
1517    assert_eq!(entry.dversionmangle(), Some("s/\\+ds//".into()));
1518    assert_eq!(entry.repacksuffix(), Some("+ds".into()));
1519    assert_eq!(
1520        entry.matching_pattern(),
1521        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1522    );
1523    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1524    assert_eq!(entry.script(), Some("uupdate".into()));
1525
1526    // Verify the exact serialized output
1527    assert_eq!(
1528        entry.to_string(),
1529        r#"opts=repack,compression=xz,dversionmangle=s/\+ds//,repacksuffix=+ds \
1530    https://gitlab.example.org/project/releases \
1531        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1532"#
1533    );
1534}
1535
1536#[test]
1537fn test_set_url_quoted_options() {
1538    // Test with quoted options
1539    let wf: super::WatchFile = r#"version=4
1540opts="bare, filenamemangle=blah" \
1541  https://github.com/syncthing/syncthing-gtk/tags .*/v?(\d\S+)\.tar\.gz
1542"#
1543    .parse()
1544    .unwrap();
1545
1546    let mut entry = wf.entries().next().unwrap();
1547    assert_eq!(
1548        entry.url(),
1549        "https://github.com/syncthing/syncthing-gtk/tags"
1550    );
1551
1552    entry.set_url("https://example.org/new/path");
1553    assert_eq!(entry.url(), "https://example.org/new/path");
1554
1555    // Verify the exact serialized output
1556    assert_eq!(
1557        entry.to_string(),
1558        r#"opts="bare, filenamemangle=blah" \
1559  https://example.org/new/path .*/v?(\d\S+)\.tar\.gz
1560"#
1561    );
1562}
1563
1564#[test]
1565fn test_set_matching_pattern() {
1566    // Test setting matching pattern on a simple entry
1567    let wf: super::WatchFile = r#"version=4
1568https://github.com/example/tags .*/v?(\d\S+)\.tar\.gz
1569"#
1570    .parse()
1571    .unwrap();
1572
1573    let mut entry = wf.entries().next().unwrap();
1574    assert_eq!(
1575        entry.matching_pattern(),
1576        Some(".*/v?(\\d\\S+)\\.tar\\.gz".into())
1577    );
1578
1579    entry.set_matching_pattern("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz");
1580    assert_eq!(
1581        entry.matching_pattern(),
1582        Some("(?:.*?/)?v?([\\d.]+)\\.tar\\.gz".into())
1583    );
1584
1585    // Verify URL is preserved
1586    assert_eq!(entry.url(), "https://github.com/example/tags");
1587
1588    // Verify the exact serialized output
1589    assert_eq!(
1590        entry.to_string(),
1591        "https://github.com/example/tags (?:.*?/)?v?([\\d.]+)\\.tar\\.gz\n"
1592    );
1593}
1594
1595#[test]
1596fn test_set_matching_pattern_with_all_fields() {
1597    // Test with all fields present
1598    let wf: super::WatchFile = r#"version=4
1599opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1600"#
1601    .parse()
1602    .unwrap();
1603
1604    let mut entry = wf.entries().next().unwrap();
1605    assert_eq!(
1606        entry.matching_pattern(),
1607        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1608    );
1609
1610    entry.set_matching_pattern(".*/version-([\\d.]+)\\.tar\\.xz");
1611    assert_eq!(
1612        entry.matching_pattern(),
1613        Some(".*/version-([\\d.]+)\\.tar\\.xz".into())
1614    );
1615
1616    // Verify all other fields are preserved
1617    assert_eq!(entry.url(), "https://example.com/releases");
1618    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1619    assert_eq!(entry.script(), Some("uupdate".into()));
1620    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
1621
1622    // Verify the exact serialized output
1623    assert_eq!(
1624        entry.to_string(),
1625        "opts=compression=xz https://example.com/releases .*/version-([\\d.]+)\\.tar\\.xz debian uupdate\n"
1626    );
1627}
1628
1629#[test]
1630fn test_set_version_policy() {
1631    // Test setting version policy
1632    let wf: super::WatchFile = r#"version=4
1633https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1634"#
1635    .parse()
1636    .unwrap();
1637
1638    let mut entry = wf.entries().next().unwrap();
1639    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1640
1641    entry.set_version_policy("previous");
1642    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Previous)));
1643
1644    // Verify all other fields are preserved
1645    assert_eq!(entry.url(), "https://example.com/releases");
1646    assert_eq!(
1647        entry.matching_pattern(),
1648        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1649    );
1650    assert_eq!(entry.script(), Some("uupdate".into()));
1651
1652    // Verify the exact serialized output
1653    assert_eq!(
1654        entry.to_string(),
1655        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz previous uupdate\n"
1656    );
1657}
1658
1659#[test]
1660fn test_set_version_policy_with_options() {
1661    // Test with options and continuation
1662    let wf: super::WatchFile = r#"version=4
1663opts=repack,compression=xz \
1664    https://github.com/example/example-cat/tags \
1665        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1666"#
1667    .parse()
1668    .unwrap();
1669
1670    let mut entry = wf.entries().next().unwrap();
1671    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1672
1673    entry.set_version_policy("ignore");
1674    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Ignore)));
1675
1676    // Verify all other fields are preserved
1677    assert_eq!(entry.url(), "https://github.com/example/example-cat/tags");
1678    assert_eq!(
1679        entry.matching_pattern(),
1680        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1681    );
1682    assert_eq!(entry.script(), Some("uupdate".into()));
1683    assert!(entry.repack());
1684
1685    // Verify the exact serialized output
1686    assert_eq!(
1687        entry.to_string(),
1688        r#"opts=repack,compression=xz \
1689    https://github.com/example/example-cat/tags \
1690        (?:.*?/)?v?(\d[\d.]*)\.tar\.gz ignore uupdate
1691"#
1692    );
1693}
1694
1695#[test]
1696fn test_set_script() {
1697    // Test setting script
1698    let wf: super::WatchFile = r#"version=4
1699https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1700"#
1701    .parse()
1702    .unwrap();
1703
1704    let mut entry = wf.entries().next().unwrap();
1705    assert_eq!(entry.script(), Some("uupdate".into()));
1706
1707    entry.set_script("uscan");
1708    assert_eq!(entry.script(), Some("uscan".into()));
1709
1710    // Verify all other fields are preserved
1711    assert_eq!(entry.url(), "https://example.com/releases");
1712    assert_eq!(
1713        entry.matching_pattern(),
1714        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1715    );
1716    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1717
1718    // Verify the exact serialized output
1719    assert_eq!(
1720        entry.to_string(),
1721        "https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian uscan\n"
1722    );
1723}
1724
1725#[test]
1726fn test_set_script_with_options() {
1727    // Test with options
1728    let wf: super::WatchFile = r#"version=4
1729opts=compression=xz https://example.com/releases (?:.*?/)?v?(\d[\d.]*)\.tar\.gz debian uupdate
1730"#
1731    .parse()
1732    .unwrap();
1733
1734    let mut entry = wf.entries().next().unwrap();
1735    assert_eq!(entry.script(), Some("uupdate".into()));
1736
1737    entry.set_script("custom-script.sh");
1738    assert_eq!(entry.script(), Some("custom-script.sh".into()));
1739
1740    // Verify all other fields are preserved
1741    assert_eq!(entry.url(), "https://example.com/releases");
1742    assert_eq!(
1743        entry.matching_pattern(),
1744        Some("(?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz".into())
1745    );
1746    assert_eq!(entry.version(), Ok(Some(super::VersionPolicy::Debian)));
1747    assert_eq!(entry.compression(), Ok(Some(super::Compression::Xz)));
1748
1749    // Verify the exact serialized output
1750    assert_eq!(
1751        entry.to_string(),
1752        "opts=compression=xz https://example.com/releases (?:.*?/)?v?(\\d[\\d.]*)\\.tar\\.gz debian custom-script.sh\n"
1753    );
1754}