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