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