Skip to main content

debian_watch/
linebased.rs

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