Skip to main content

debian_watch/
linebased.rs

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