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