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