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