Skip to main content

debian_watch/
linebased.rs

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