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