Skip to main content

debian_watch/
linebased.rs

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