debian_changelog/
lib.rs

1#![deny(missing_docs)]
2//! A lossless parser for Debian changelog files.
3//!
4//! See https://manpages.debian.org/bookworm/dpkg-dev/deb-changelog.5.en.html
5//!
6//! For its format specification, see [Debian Policy](https://www.debian.org/doc/debian-policy/ch-source.html#debian-changelog-debian-changelog).
7//!
8//! Example:
9//!
10//! ```rust
11//! use std::io::Read;
12//! let contents = r#"rustc (1.70.0+dfsg1-1) unstable; urgency=medium
13//!
14//!   * Upload to unstable
15//!
16//!  -- Jelmer Vernooij <jelmer@debian.org>  Wed, 20 Sep 2023 20:18:40 +0200
17//! "#;
18//! let changelog: debian_changelog::ChangeLog = contents.parse().unwrap();
19//! assert_eq!(
20//!     vec![("rustc".to_string(), "1.70.0+dfsg1-1".parse().unwrap())],
21//!     changelog.iter().map(
22//!         |e| (e.package().unwrap(), e.version().unwrap()))
23//!     .collect::<Vec<_>>());
24//! ```
25
26mod lex;
27mod parse;
28use lazy_regex::regex_captures;
29pub mod changes;
30pub mod textwrap;
31use crate::parse::{SyntaxNode, SyntaxToken};
32use debversion::Version;
33use rowan::ast::AstNode;
34
35pub use crate::changes::changes_by_author;
36pub use crate::parse::{
37    ChangeLog, Entry, EntryBody, EntryFooter, EntryHeader, Error, IntoTimestamp, Maintainer,
38    MetadataEntry, MetadataKey, MetadataValue, Parse, ParseError, Timestamp, Urgency,
39};
40
41/// Represents a logical change within a changelog entry.
42///
43/// This struct wraps specific DETAIL tokens within an Entry's syntax tree
44/// and provides methods to manipulate them while maintaining the AST structure.
45#[derive(Debug, Clone)]
46pub struct Change {
47    /// The parent entry containing this change
48    entry: Entry,
49    /// The author of the change (if attributed)
50    author: Option<String>,
51    /// Line numbers in the original entry where this change appears
52    line_numbers: Vec<usize>,
53    /// The actual change lines as tokens in the syntax tree
54    detail_tokens: Vec<SyntaxToken>,
55}
56
57impl Change {
58    /// Create a new Change instance.
59    pub(crate) fn new(
60        entry: Entry,
61        author: Option<String>,
62        line_numbers: Vec<usize>,
63        detail_tokens: Vec<SyntaxToken>,
64    ) -> Self {
65        Self {
66            entry,
67            author,
68            line_numbers,
69            detail_tokens,
70        }
71    }
72
73    /// Get the author of this change.
74    pub fn author(&self) -> Option<&str> {
75        self.author.as_deref()
76    }
77
78    /// Get the line numbers in the original entry where this change appears.
79    pub fn line_numbers(&self) -> &[usize] {
80        &self.line_numbers
81    }
82
83    /// Get the lines of this change.
84    pub fn lines(&self) -> Vec<String> {
85        self.detail_tokens
86            .iter()
87            .map(|token| token.text().to_string())
88            .collect()
89    }
90
91    /// Get the package name this change belongs to.
92    pub fn package(&self) -> Option<String> {
93        self.entry.package()
94    }
95
96    /// Get the version this change belongs to, returning an error if the version string is invalid.
97    ///
98    /// Returns:
99    /// - `Some(Ok(version))` if a valid version is found
100    /// - `Some(Err(err))` if a version token exists but cannot be parsed
101    /// - `None` if no version token is present
102    pub fn try_version(&self) -> Option<Result<Version, debversion::ParseError>> {
103        self.entry.try_version()
104    }
105
106    /// Get the version this change belongs to.
107    ///
108    /// Note: This method silently returns `None` if the version string is invalid.
109    /// Consider using [`try_version`](Self::try_version) instead to handle parsing errors properly.
110    pub fn version(&self) -> Option<Version> {
111        self.try_version().and_then(|r| r.ok())
112    }
113
114    /// Check if this change is attributed to a specific author.
115    pub fn is_attributed(&self) -> bool {
116        self.author.is_some()
117    }
118
119    /// Get a reference to the parent entry.
120    pub fn entry(&self) -> &Entry {
121        &self.entry
122    }
123
124    /// Get the line number (0-indexed) where this change starts.
125    ///
126    /// Returns the line number of the first detail token, or None if the change has no tokens.
127    pub fn line(&self) -> Option<usize> {
128        self.detail_tokens.first().map(|token| {
129            parse::line_col_at_offset(self.entry.syntax(), token.text_range().start()).0
130        })
131    }
132
133    /// Get the column number (0-indexed, in bytes) where this change starts.
134    ///
135    /// Returns the column number of the first detail token, or None if the change has no tokens.
136    pub fn column(&self) -> Option<usize> {
137        self.detail_tokens.first().map(|token| {
138            parse::line_col_at_offset(self.entry.syntax(), token.text_range().start()).1
139        })
140    }
141
142    /// Get both line and column (0-indexed) where this change starts.
143    ///
144    /// Returns (line, column) where column is measured in bytes from the start of the line,
145    /// or None if the change has no tokens.
146    pub fn line_col(&self) -> Option<(usize, usize)> {
147        self.detail_tokens
148            .first()
149            .map(|token| parse::line_col_at_offset(self.entry.syntax(), token.text_range().start()))
150    }
151
152    /// Remove this change from its parent entry.
153    ///
154    /// This removes all DETAIL tokens (ENTRY_BODY nodes) associated with this change
155    /// from the syntax tree. If this removes the last change in an author section,
156    /// the empty section header will also be removed.
157    pub fn remove(self) {
158        // Store info we'll need after removal
159        let author = self.author.clone();
160
161        // Collect the parent ENTRY_BODY nodes that contain our detail tokens
162        let mut body_nodes_to_remove = Vec::new();
163
164        for token in &self.detail_tokens {
165            if let Some(parent) = token.parent() {
166                if parent.kind() == SyntaxKind::ENTRY_BODY {
167                    // Check if we haven't already marked this node for removal
168                    if !body_nodes_to_remove
169                        .iter()
170                        .any(|n: &SyntaxNode| n == &parent)
171                    {
172                        body_nodes_to_remove.push(parent);
173                    }
174                }
175            }
176        }
177
178        // Find the section header node if this is an attributed change
179        // and capture its index BEFORE we remove any nodes
180        let section_header_index = if author.is_some() && !body_nodes_to_remove.is_empty() {
181            Self::find_section_header_for_changes(&self.entry, &body_nodes_to_remove)
182                .map(|node| node.index())
183        } else {
184            None
185        };
186
187        // Remove the ENTRY_BODY nodes from the entry's syntax tree
188        // We need to remove from highest index to lowest to avoid index shifting issues
189        let mut sorted_nodes = body_nodes_to_remove;
190        sorted_nodes.sort_by_key(|n| std::cmp::Reverse(n.index()));
191
192        // Track which indices to remove (ENTRY_BODY nodes and trailing EMPTY_LINE nodes)
193        let mut indices_to_remove = Vec::new();
194        let children: Vec<_> = self.entry.syntax().children().collect();
195
196        for body_node in &sorted_nodes {
197            let index = body_node.index();
198            indices_to_remove.push(index);
199
200            // Remove trailing EMPTY_LINE if it exists and would create consecutive blanks
201            if Self::should_remove_trailing_empty(&children, index) {
202                indices_to_remove.push(index + 1);
203            }
204        }
205
206        // Sort indices in reverse order and remove duplicates
207        indices_to_remove.sort_by_key(|&i| std::cmp::Reverse(i));
208        indices_to_remove.dedup();
209
210        // Remove the nodes
211        for index in indices_to_remove {
212            self.entry
213                .syntax()
214                .splice_children(index..index + 1, vec![]);
215        }
216
217        // Check if section is now empty and remove header if needed
218        // After removing bullets, we need to adjust the header index based on how many
219        // nodes were removed before it
220        if let Some(original_header_idx) = section_header_index {
221            // Count how many nodes we removed that were before the header
222            let nodes_removed_before_header = sorted_nodes
223                .iter()
224                .filter(|n| n.index() < original_header_idx)
225                .count();
226
227            // Adjust the header index
228            let adjusted_header_idx = original_header_idx - nodes_removed_before_header;
229
230            Self::remove_section_header_if_empty_at_index(&self.entry, adjusted_header_idx);
231        }
232    }
233
234    /// Check if a node is a section header (e.g., "[ Author Name ]")
235    fn is_section_header(node: &SyntaxNode) -> bool {
236        if node.kind() != SyntaxKind::ENTRY_BODY {
237            return false;
238        }
239
240        for token in node.descendants_with_tokens() {
241            if let Some(token) = token.as_token() {
242                if token.kind() == SyntaxKind::DETAIL
243                    && crate::changes::extract_author_name(token.text()).is_some()
244                {
245                    return true;
246                }
247            }
248        }
249
250        false
251    }
252
253    /// Check if the trailing EMPTY_LINE after an entry should be removed
254    /// Returns true if removing it would prevent consecutive blank lines
255    fn should_remove_trailing_empty(children: &[SyntaxNode], entry_index: usize) -> bool {
256        // Check if there's a trailing EMPTY_LINE
257        let has_trailing_empty = children
258            .get(entry_index + 1)
259            .is_some_and(|n| n.kind() == SyntaxKind::EMPTY_LINE);
260
261        if !has_trailing_empty {
262            return false;
263        }
264
265        // Remove if there's already an EMPTY_LINE before (would create consecutive blanks)
266        let has_preceding_empty = entry_index > 0
267            && children
268                .get(entry_index - 1)
269                .is_some_and(|n| n.kind() == SyntaxKind::EMPTY_LINE);
270
271        if has_preceding_empty {
272            return true;
273        }
274
275        // Remove if what follows would create consecutive blanks or be a section header
276        match children.get(entry_index + 2) {
277            Some(node) if node.kind() == SyntaxKind::EMPTY_LINE => true,
278            Some(node) if Self::is_section_header(node) => true,
279            _ => false,
280        }
281    }
282
283    /// Check if the preceding EMPTY_LINE before a section header should be removed
284    /// Preserves the blank line if it's the first one after the entry header
285    fn should_remove_preceding_empty(children: &[SyntaxNode], header_index: usize) -> bool {
286        if header_index == 0 {
287            return false;
288        }
289
290        // Check if there's a preceding EMPTY_LINE
291        let has_preceding_empty = children
292            .get(header_index - 1)
293            .is_some_and(|n| n.kind() == SyntaxKind::EMPTY_LINE);
294
295        if !has_preceding_empty {
296            return false;
297        }
298
299        // Don't remove if it's the first blank line after the entry header
300        let is_first_blank_after_header = header_index >= 2
301            && children
302                .get(header_index - 2)
303                .is_some_and(|n| n.kind() == SyntaxKind::ENTRY_HEADER);
304
305        !is_first_blank_after_header
306    }
307
308    /// Find the section header that precedes the given change nodes
309    fn find_section_header_for_changes(
310        entry: &Entry,
311        change_nodes: &[SyntaxNode],
312    ) -> Option<SyntaxNode> {
313        if change_nodes.is_empty() {
314            return None;
315        }
316
317        let first_change_index = change_nodes.iter().map(|n| n.index()).min().unwrap();
318        let mut header_node = None;
319
320        for child in entry.syntax().children() {
321            for token_or_node in child.children_with_tokens() {
322                let Some(token) = token_or_node.as_token() else {
323                    continue;
324                };
325                if token.kind() != SyntaxKind::DETAIL {
326                    continue;
327                }
328
329                let Some(parent) = token.parent() else {
330                    continue;
331                };
332                if parent.kind() != SyntaxKind::ENTRY_BODY {
333                    continue;
334                }
335
336                let parent_index = parent.index();
337                if parent_index >= first_change_index {
338                    continue;
339                }
340
341                if crate::changes::extract_author_name(token.text()).is_some() {
342                    header_node = Some(parent);
343                }
344            }
345        }
346
347        header_node
348    }
349
350    /// Remove a section header if its section is now empty
351    fn remove_section_header_if_empty_at_index(entry: &Entry, header_index: usize) {
352        // Check if there are any bullet points after this header and before the next header
353        let mut has_bullets_in_section = false;
354
355        'outer: for child in entry.syntax().children() {
356            for token_or_node in child.children_with_tokens() {
357                let Some(token) = token_or_node.as_token() else {
358                    continue;
359                };
360                if token.kind() != SyntaxKind::DETAIL {
361                    continue;
362                }
363
364                let Some(parent) = token.parent() else {
365                    continue;
366                };
367                if parent.kind() != SyntaxKind::ENTRY_BODY {
368                    continue;
369                }
370
371                let parent_index = parent.index();
372                if parent_index <= header_index {
373                    continue;
374                }
375
376                let text = token.text();
377                // If we hit another section header, stop searching
378                if crate::changes::extract_author_name(text).is_some() {
379                    break 'outer;
380                }
381
382                // If we find a bullet point, section is not empty
383                if text.starts_with("* ") {
384                    has_bullets_in_section = true;
385                    break 'outer;
386                }
387            }
388        }
389
390        // Remove the header if section is empty
391        if !has_bullets_in_section {
392            let children: Vec<_> = entry.syntax().children().collect();
393
394            // Determine if we should also remove the preceding EMPTY_LINE
395            // (but preserve the blank line right after the entry header)
396            let start_index = if Self::should_remove_preceding_empty(&children, header_index) {
397                header_index - 1
398            } else {
399                header_index
400            };
401
402            // Important: rowan's splice_children iterates and detaches nodes in order.
403            // When a node is detached, it changes the tree immediately, which can cause
404            // the iteration to skip nodes. Removing in reverse order avoids this issue.
405            for idx in (start_index..=header_index).rev() {
406                entry.syntax().splice_children(idx..idx + 1, vec![]);
407            }
408        }
409    }
410
411    /// Replace this change with new lines.
412    ///
413    /// This removes the current change lines and replaces them with the provided lines.
414    ///
415    /// # Arguments
416    /// * `new_lines` - The new change lines to replace with (e.g., `["* Updated feature"]`)
417    pub fn replace_with(&self, new_lines: Vec<&str>) {
418        use rowan::GreenNodeBuilder;
419
420        // Find the first ENTRY_BODY node to determine insertion point
421        let first_body_node = self
422            .detail_tokens
423            .first()
424            .and_then(|token| token.parent())
425            .filter(|parent| parent.kind() == SyntaxKind::ENTRY_BODY);
426
427        if let Some(_first_node) = first_body_node {
428            // Collect all ENTRY_BODY nodes to remove
429            let mut body_nodes_to_remove = Vec::new();
430            for token in &self.detail_tokens {
431                if let Some(parent) = token.parent() {
432                    if parent.kind() == SyntaxKind::ENTRY_BODY
433                        && !body_nodes_to_remove
434                            .iter()
435                            .any(|n: &SyntaxNode| n == &parent)
436                    {
437                        body_nodes_to_remove.push(parent);
438                    }
439                }
440            }
441
442            // Build replacement nodes
443            let mut new_nodes = Vec::new();
444            for line in new_lines {
445                let mut builder = GreenNodeBuilder::new();
446                builder.start_node(SyntaxKind::ENTRY_BODY.into());
447                if !line.is_empty() {
448                    builder.token(SyntaxKind::INDENT.into(), "  ");
449                    builder.token(SyntaxKind::DETAIL.into(), line);
450                }
451                builder.token(SyntaxKind::NEWLINE.into(), "\n");
452                builder.finish_node();
453
454                let syntax = SyntaxNode::new_root_mut(builder.finish());
455                new_nodes.push(syntax.into());
456            }
457
458            // Remove old nodes and insert new ones
459            // We need to remove from highest index to lowest to avoid index shifting issues
460            let mut sorted_nodes = body_nodes_to_remove.clone();
461            sorted_nodes.sort_by_key(|n| std::cmp::Reverse(n.index()));
462
463            for (i, node) in sorted_nodes.iter().enumerate() {
464                let idx = node.index();
465                if i == 0 {
466                    // For the first removal, insert the new nodes
467                    self.entry
468                        .syntax()
469                        .splice_children(idx..idx + 1, new_nodes.clone());
470                } else {
471                    // For subsequent removals, just remove
472                    self.entry.syntax().splice_children(idx..idx + 1, vec![]);
473                }
474            }
475        }
476    }
477
478    /// Replace a specific line in this change by index.
479    ///
480    /// # Arguments
481    /// * `index` - The zero-based index of the line to replace
482    /// * `new_text` - The new text for the line
483    ///
484    /// # Returns
485    /// * `Ok(())` if the line was replaced successfully
486    /// * `Err(Error)` if the index is out of bounds
487    ///
488    /// # Examples
489    /// ```
490    /// use debian_changelog::{ChangeLog, iter_changes_by_author};
491    ///
492    /// let changelog_text = r#"blah (1.0-1) unstable; urgency=low
493    ///
494    ///   * First change
495    ///   * Second change
496    ///
497    ///  -- Author <email@example.com>  Mon, 01 Jan 2024 00:00:00 +0000
498    /// "#;
499    ///
500    /// let changelog = ChangeLog::read_relaxed(changelog_text.as_bytes()).unwrap();
501    /// let changes = iter_changes_by_author(&changelog);
502    /// changes[0].replace_line(0, "* Updated first change").unwrap();
503    /// # Ok::<(), Box<dyn std::error::Error>>(())
504    /// ```
505    pub fn replace_line(&self, index: usize, new_text: &str) -> Result<(), Error> {
506        if index >= self.detail_tokens.len() {
507            return Err(Error::Io(std::io::Error::new(
508                std::io::ErrorKind::InvalidInput,
509                format!(
510                    "Line index {} out of bounds (0..{})",
511                    index,
512                    self.detail_tokens.len()
513                ),
514            )));
515        }
516
517        let mut new_lines = self.lines();
518        new_lines[index] = new_text.to_string();
519
520        self.replace_with(new_lines.iter().map(|s| s.as_str()).collect());
521        Ok(())
522    }
523
524    /// Update lines in this change that match a predicate.
525    ///
526    /// This method finds all lines that match the predicate function and replaces
527    /// them with the result of the updater function.
528    ///
529    /// # Arguments
530    /// * `predicate` - A function that returns true for lines that should be updated
531    /// * `updater` - A function that takes the old line text and returns the new line text
532    ///
533    /// # Returns
534    /// The number of lines that were updated
535    ///
536    /// # Examples
537    /// ```
538    /// use debian_changelog::{ChangeLog, iter_changes_by_author};
539    ///
540    /// let changelog_text = r#"blah (1.0-1) unstable; urgency=low
541    ///
542    ///   * First change
543    ///   * Second change
544    ///   * Third change
545    ///
546    ///  -- Author <email@example.com>  Mon, 01 Jan 2024 00:00:00 +0000
547    /// "#;
548    ///
549    /// let changelog = ChangeLog::read_relaxed(changelog_text.as_bytes()).unwrap();
550    /// let changes = iter_changes_by_author(&changelog);
551    ///
552    /// // Update lines containing "First" or "Second"
553    /// let count = changes[0].update_lines(
554    ///     |line| line.contains("First") || line.contains("Second"),
555    ///     |line| format!("{} (updated)", line)
556    /// );
557    /// assert_eq!(count, 2);
558    /// ```
559    pub fn update_lines<F, G>(&self, predicate: F, updater: G) -> usize
560    where
561        F: Fn(&str) -> bool,
562        G: Fn(&str) -> String,
563    {
564        let mut new_lines = self.lines();
565        let mut update_count = 0;
566
567        for line in &mut new_lines {
568            if predicate(line) {
569                *line = updater(line);
570                update_count += 1;
571            }
572        }
573
574        if update_count > 0 {
575            self.replace_with(new_lines.iter().map(|s| s.as_str()).collect());
576        }
577
578        update_count
579    }
580
581    /// Split this change into individual bullet points.
582    ///
583    /// Each bullet point (line starting with "* ") and its continuation lines
584    /// (indented lines that follow) become a separate Change object.
585    ///
586    /// # Returns
587    /// A vector of Change objects, one per bullet point. Each Change contains:
588    /// - The same entry and author as the parent
589    /// - Subset of line_numbers for that specific bullet
590    /// - Subset of detail_tokens for that bullet and its continuation lines
591    ///
592    /// # Examples
593    /// ```
594    /// use debian_changelog::{ChangeLog, iter_changes_by_author};
595    ///
596    /// let changelog_text = r#"blah (1.0-1) unstable; urgency=low
597    ///
598    ///   * First change
599    ///   * Second change
600    ///     with continuation
601    ///
602    ///  -- Author <email@example.com>  Mon, 01 Jan 2024 00:00:00 +0000
603    /// "#;
604    ///
605    /// let changelog = ChangeLog::read_relaxed(changelog_text.as_bytes()).unwrap();
606    /// let changes = iter_changes_by_author(&changelog);
607    /// let bullets = changes[0].split_into_bullets();
608    /// assert_eq!(bullets.len(), 2);
609    /// assert_eq!(bullets[0].lines(), vec!["* First change"]);
610    /// assert_eq!(bullets[1].lines(), vec!["* Second change", "  with continuation"]);
611    /// ```
612    pub fn split_into_bullets(&self) -> Vec<Change> {
613        let mut result = Vec::new();
614        let mut current_bullet_tokens = Vec::new();
615        let mut current_bullet_line_numbers = Vec::new();
616
617        for (i, token) in self.detail_tokens.iter().enumerate() {
618            let text = token.text();
619            let line_number = self.line_numbers.get(i).copied().unwrap_or(0);
620
621            // Check if this is a new bullet point (starts with "* ")
622            if text.starts_with("* ") {
623                // If we have a previous bullet, save it
624                if !current_bullet_tokens.is_empty() {
625                    result.push(Change::new(
626                        self.entry.clone(),
627                        self.author.clone(),
628                        current_bullet_line_numbers.clone(),
629                        current_bullet_tokens.clone(),
630                    ));
631                    current_bullet_tokens.clear();
632                    current_bullet_line_numbers.clear();
633                }
634
635                // Start a new bullet
636                current_bullet_tokens.push(token.clone());
637                current_bullet_line_numbers.push(line_number);
638            } else {
639                // This is a continuation line, add to current bullet
640                current_bullet_tokens.push(token.clone());
641                current_bullet_line_numbers.push(line_number);
642            }
643        }
644
645        // Don't forget the last bullet
646        if !current_bullet_tokens.is_empty() {
647            result.push(Change::new(
648                self.entry.clone(),
649                self.author.clone(),
650                current_bullet_line_numbers,
651                current_bullet_tokens,
652            ));
653        }
654
655        result
656    }
657}
658
659/// Let's start with defining all kinds of tokens and
660/// composite nodes.
661#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
662#[allow(non_camel_case_types)]
663#[repr(u16)]
664#[allow(missing_docs)]
665pub enum SyntaxKind {
666    IDENTIFIER = 0,
667    INDENT,
668    TEXT,
669    WHITESPACE,
670    VERSION,   // "(3.3.4-1)"
671    SEMICOLON, // ";"
672    EQUALS,    // "="
673    DETAIL,    // "* New upstream release."
674    NEWLINE,   // newlines are explicit
675    ERROR,     // as well as errors
676    COMMENT,   // "#"
677
678    // composite nodes
679    ROOT,  // The entire file
680    ENTRY, // A single entry
681    ENTRY_HEADER,
682    ENTRY_FOOTER,
683    METADATA,
684    METADATA_ENTRY,
685    METADATA_KEY,
686    METADATA_VALUE,
687    ENTRY_BODY,
688    DISTRIBUTIONS,
689    EMPTY_LINE,
690
691    TIMESTAMP,
692    MAINTAINER,
693    EMAIL,
694}
695
696/// Convert our `SyntaxKind` into the rowan `SyntaxKind`.
697impl From<SyntaxKind> for rowan::SyntaxKind {
698    fn from(kind: SyntaxKind) -> Self {
699        Self(kind as u16)
700    }
701}
702
703/// Parse a identity string
704///
705/// # Arguments
706/// * `s` - The string to parse
707///
708/// # Returns
709/// A tuple with name and email address
710pub fn parseaddr(s: &str) -> (Option<&str>, &str) {
711    if let Some((_, name, email)) = regex_captures!(r"^(.*)\s+<(.*)>$", s) {
712        if name.is_empty() {
713            (None, email)
714        } else {
715            (Some(name), email)
716        }
717    } else {
718        (None, s)
719    }
720}
721
722/// Get the maintainer information from the environment.
723pub fn get_maintainer_from_env(
724    get_env: impl Fn(&str) -> Option<String>,
725) -> Option<(String, String)> {
726    use std::io::BufRead;
727
728    let mut debemail = get_env("DEBEMAIL");
729    let mut debfullname = get_env("DEBFULLNAME");
730
731    // Split email and name
732    if let Some(email) = debemail.as_ref() {
733        let (parsed_name, parsed_email) = parseaddr(email);
734        if let Some(parsed_name) = parsed_name {
735            if debfullname.is_none() {
736                debfullname = Some(parsed_name.to_string());
737            }
738        }
739        debemail = Some(parsed_email.to_string());
740    }
741    if debfullname.is_none() || debemail.is_none() {
742        if let Some(email) = get_env("EMAIL") {
743            let (parsed_name, parsed_email) = parseaddr(email.as_str());
744            if let Some(parsed_name) = parsed_name {
745                if debfullname.is_none() {
746                    debfullname = Some(parsed_name.to_string());
747                }
748            }
749            debemail = Some(parsed_email.to_string());
750        }
751    }
752
753    // Get maintainer's name
754    let maintainer = if let Some(m) = debfullname {
755        Some(m.trim().to_string())
756    } else if let Some(m) = get_env("NAME") {
757        Some(m.trim().to_string())
758    } else {
759        Some(whoami::realname())
760    };
761
762    // Get maintainer's mail address
763    let email_address = if let Some(email) = debemail {
764        Some(email)
765    } else if let Some(email) = get_env("EMAIL") {
766        Some(email)
767    } else {
768        // Read /etc/mailname or use hostname
769        let mut addr: Option<String> = None;
770
771        if let Ok(mailname_file) = std::fs::File::open("/etc/mailname") {
772            let mut reader = std::io::BufReader::new(mailname_file);
773            if let Ok(line) = reader.fill_buf() {
774                if !line.is_empty() {
775                    addr = Some(String::from_utf8_lossy(line).trim().to_string());
776                }
777            }
778        }
779
780        if addr.is_none() {
781            match whoami::fallible::hostname() {
782                Ok(hostname) => {
783                    addr = Some(hostname);
784                }
785                Err(e) => {
786                    log::debug!("Failed to get hostname: {}", e);
787                    addr = None;
788                }
789            }
790        }
791
792        addr.map(|hostname| format!("{}@{}", whoami::username(), hostname))
793    };
794
795    if let (Some(maintainer), Some(email_address)) = (maintainer, email_address) {
796        Some((maintainer, email_address))
797    } else {
798        None
799    }
800}
801
802/// Get the maintainer information in the same manner as dch.
803///
804/// This function gets the information about the current user for
805/// the maintainer field using environment variables of gecos
806/// information as appropriate.
807///
808/// It uses the same algorithm as dch to get the information, namely
809/// DEBEMAIL, DEBFULLNAME, EMAIL, NAME, /etc/mailname and gecos.
810///
811/// # Returns
812///
813/// a tuple of the full name, email pair as strings.
814///     Either of the pair may be None if that value couldn't
815///     be determined.
816pub fn get_maintainer() -> Option<(String, String)> {
817    get_maintainer_from_env(|s| std::env::var(s).ok())
818}
819
820#[cfg(test)]
821mod get_maintainer_from_env_tests {
822    use super::*;
823
824    #[test]
825    fn test_normal() {
826        get_maintainer();
827    }
828
829    #[test]
830    fn test_deb_vars() {
831        let mut d = std::collections::HashMap::new();
832        d.insert("DEBFULLNAME".to_string(), "Jelmer".to_string());
833        d.insert("DEBEMAIL".to_string(), "jelmer@example.com".to_string());
834        let t = get_maintainer_from_env(|s| d.get(s).cloned());
835        assert_eq!(
836            Some(("Jelmer".to_string(), "jelmer@example.com".to_string())),
837            t
838        );
839    }
840
841    #[test]
842    fn test_email_var() {
843        let mut d = std::collections::HashMap::new();
844        d.insert("NAME".to_string(), "Jelmer".to_string());
845        d.insert("EMAIL".to_string(), "foo@example.com".to_string());
846        let t = get_maintainer_from_env(|s| d.get(s).cloned());
847        assert_eq!(
848            Some(("Jelmer".to_string(), "foo@example.com".to_string())),
849            t
850        );
851    }
852}
853
854/// Simple representation of an identity.
855#[derive(Debug, Clone, PartialEq, Eq, Hash)]
856pub struct Identity {
857    /// Name of the maintainer
858    pub name: String,
859
860    /// Email address of the maintainer
861    pub email: String,
862}
863
864impl Identity {
865    /// Create a new identity.
866    pub fn new(name: String, email: String) -> Self {
867        Self { name, email }
868    }
869
870    /// Get the maintainer information from the environment.
871    pub fn from_env() -> Option<Self> {
872        get_maintainer().map(|(name, email)| Self { name, email })
873    }
874}
875
876impl From<(String, String)> for Identity {
877    fn from((name, email): (String, String)) -> Self {
878        Self { name, email }
879    }
880}
881
882impl std::fmt::Display for Identity {
883    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
884        write!(f, "{} <{}>", self.name, self.email)
885    }
886}
887
888/// Constant for the unreleased distribution name
889pub const UNRELEASED: &str = "UNRELEASED";
890/// Prefix for unreleased distribution variants
891const UNRELEASED_PREFIX: &str = "UNRELEASED-";
892
893/// Check if the given distribution marks an unreleased entry.
894pub fn distribution_is_unreleased(distribution: &str) -> bool {
895    distribution == UNRELEASED || distribution.starts_with(UNRELEASED_PREFIX)
896}
897
898/// Check if any of the given distributions marks an unreleased entry.
899pub fn distributions_is_unreleased(distributions: &[&str]) -> bool {
900    distributions.iter().any(|x| distribution_is_unreleased(x))
901}
902
903#[test]
904fn test_distributions_is_unreleased() {
905    assert!(distributions_is_unreleased(&["UNRELEASED"]));
906    assert!(distributions_is_unreleased(&[
907        "UNRELEASED-1",
908        "UNRELEASED-2"
909    ]));
910    assert!(distributions_is_unreleased(&["UNRELEASED", "UNRELEASED-2"]));
911    assert!(!distributions_is_unreleased(&["stable"]));
912}
913
914/// Check whether this is a traditional inaugural release
915pub fn is_unreleased_inaugural(cl: &ChangeLog) -> bool {
916    let mut entries = cl.iter();
917    if let Some(entry) = entries.next() {
918        if entry.is_unreleased() == Some(false) {
919            return false;
920        }
921        let changes = entry.change_lines().collect::<Vec<_>>();
922        if changes.len() > 1 || !changes[0].starts_with("* Initial release") {
923            return false;
924        }
925        entries.next().is_none()
926    } else {
927        false
928    }
929}
930
931#[cfg(test)]
932mod is_unreleased_inaugural_tests {
933    use super::*;
934
935    #[test]
936    fn test_empty() {
937        assert!(!is_unreleased_inaugural(&ChangeLog::new()));
938    }
939
940    #[test]
941    fn test_unreleased_inaugural() {
942        let mut cl = ChangeLog::new();
943        cl.new_entry()
944            .maintainer(("Jelmer Vernooij".into(), "jelmer@debian.org".into()))
945            .distribution(UNRELEASED.to_string())
946            .version("1.0.0".parse().unwrap())
947            .change_line("* Initial release".to_string())
948            .finish();
949        assert!(is_unreleased_inaugural(&cl));
950    }
951
952    #[test]
953    fn test_not_unreleased_inaugural() {
954        let mut cl = ChangeLog::new();
955        cl.new_entry()
956            .maintainer(("Jelmer Vernooij".into(), "jelmer@debian.org".into()))
957            .distributions(vec!["unstable".to_string()])
958            .version("1.0.0".parse().unwrap())
959            .change_line("* Initial release".to_string())
960            .finish();
961        assert_eq!(cl.iter().next().unwrap().is_unreleased(), Some(false));
962
963        // Not unreleased
964        assert!(!is_unreleased_inaugural(&cl));
965
966        cl.new_entry()
967            .maintainer(("Jelmer Vernooij".into(), "jelmer@debian.org".into()))
968            .distribution(UNRELEASED.to_string())
969            .version("1.0.1".parse().unwrap())
970            .change_line("* Some change".to_string())
971            .finish();
972        // Not inaugural
973        assert!(!is_unreleased_inaugural(&cl));
974    }
975}
976
977const DEFAULT_DISTRIBUTION: &[&str] = &[UNRELEASED];
978
979/// Create a release for a changelog file.
980///
981/// # Arguments
982/// * `cl` - The changelog to release
983/// * `distribution` - The distribution to release to. If None, the distribution
984///   of the previous entry is used.
985/// * `timestamp` - The timestamp to use for the release. If None, the current time is used (requires chrono feature).
986/// * `maintainer` - The maintainer to use for the release. If None, the maintainer
987///   is extracted from the environment.
988///
989/// # Returns
990/// Whether a release was created.
991///
992/// # Panics
993/// Panics if timestamp is None and the chrono feature is not enabled.
994pub fn release(
995    cl: &mut ChangeLog,
996    distribution: Option<Vec<String>>,
997    timestamp: Option<impl IntoTimestamp>,
998    maintainer: Option<(String, String)>,
999) -> bool {
1000    let mut entries = cl.iter();
1001    let mut first_entry = entries.next().unwrap();
1002    let second_entry = entries.next();
1003    let distribution = distribution.unwrap_or_else(|| {
1004        // Inherit from previous entry
1005        second_entry
1006            .and_then(|e| e.distributions())
1007            .unwrap_or_else(|| {
1008                DEFAULT_DISTRIBUTION
1009                    .iter()
1010                    .map(|s| s.to_string())
1011                    .collect::<Vec<_>>()
1012            })
1013    });
1014    if first_entry.is_unreleased() == Some(false) {
1015        take_uploadership(&mut first_entry, maintainer);
1016        first_entry.set_distributions(distribution);
1017        let timestamp_str = if let Some(ts) = timestamp {
1018            ts.into_timestamp()
1019        } else {
1020            #[cfg(feature = "chrono")]
1021            {
1022                chrono::offset::Utc::now().into_timestamp()
1023            }
1024            #[cfg(not(feature = "chrono"))]
1025            {
1026                panic!("timestamp is required when chrono feature is disabled");
1027            }
1028        };
1029        first_entry.set_timestamp(timestamp_str);
1030        true
1031    } else {
1032        false
1033    }
1034}
1035
1036/// Take uploadership of a changelog entry, but attribute contributors.
1037///
1038/// # Arguments
1039/// * `entry` - Changelog entry to modify
1040/// * `maintainer` - Tuple with (name, email) of maintainer to take ownership
1041pub fn take_uploadership(entry: &mut Entry, maintainer: Option<(String, String)>) {
1042    let (maintainer_name, maintainer_email) = if let Some(m) = maintainer {
1043        m
1044    } else {
1045        get_maintainer().unwrap()
1046    };
1047    if let (Some(current_maintainer), Some(current_email)) = (entry.maintainer(), entry.email()) {
1048        if current_maintainer != maintainer_name || current_email != maintainer_email {
1049            if let Some(first_line) = entry.change_lines().next() {
1050                if first_line.starts_with("[ ") {
1051                    entry.prepend_change_line(
1052                        crate::changes::format_section_title(current_maintainer.as_str()).as_str(),
1053                    );
1054                }
1055            }
1056        }
1057    }
1058    entry.set_maintainer((maintainer_name, maintainer_email));
1059}
1060
1061/// Update changelog with commit messages from commits
1062pub fn gbp_dch(path: &std::path::Path) -> std::result::Result<(), std::io::Error> {
1063    // Run the "gbp dch" command with working copy at `path`
1064    let output = std::process::Command::new("gbp")
1065        .arg("dch")
1066        .arg("--ignore-branch")
1067        .current_dir(path)
1068        .output()?;
1069
1070    if !output.status.success() {
1071        return Err(std::io::Error::other(format!(
1072            "gbp dch failed: {}",
1073            String::from_utf8_lossy(&output.stderr)
1074        )));
1075    }
1076
1077    Ok(())
1078}
1079
1080/// Iterator over changelog entries grouped by author (maintainer).
1081///
1082/// This function returns an iterator that groups changelog entries by their maintainer
1083/// (author), similar to debmutate.changelog functionality.
1084///
1085/// # Arguments
1086/// * `changelog` - The changelog to iterate over
1087///
1088/// # Returns
1089/// An iterator over tuples of (author_name, author_email, Vec<Entry>)
1090pub fn iter_entries_by_author(
1091    changelog: &ChangeLog,
1092) -> impl Iterator<Item = (String, String, Vec<Entry>)> + '_ {
1093    use std::collections::BTreeMap;
1094
1095    let mut grouped: BTreeMap<(String, String), Vec<Entry>> = BTreeMap::new();
1096
1097    for entry in changelog.iter() {
1098        let maintainer_name = entry.maintainer().unwrap_or_else(|| "Unknown".to_string());
1099        let maintainer_email = entry
1100            .email()
1101            .unwrap_or_else(|| "unknown@unknown".to_string());
1102        let key = (maintainer_name, maintainer_email);
1103
1104        grouped.entry(key).or_default().push(entry);
1105    }
1106
1107    grouped
1108        .into_iter()
1109        .map(|((name, email), entries)| (name, email, entries))
1110}
1111
1112/// Iterator over all changes across all entries, grouped by author.
1113///
1114/// This function iterates through all entries in a changelog and returns changes
1115/// grouped by their attributed authors, including those in author sections like [ Author Name ].
1116///
1117/// # Arguments
1118/// * `changelog` - The changelog to iterate over
1119///
1120/// # Returns
1121/// A vector of Change objects that can be manipulated or filtered
1122pub fn iter_changes_by_author(changelog: &ChangeLog) -> Vec<Change> {
1123    let mut result = Vec::new();
1124
1125    for entry in changelog.iter() {
1126        let changes: Vec<String> = entry.change_lines().map(|s| s.to_string()).collect();
1127
1128        // Collect all DETAIL tokens from the entry with their text
1129        let all_detail_tokens: Vec<SyntaxToken> = entry
1130            .syntax()
1131            .children()
1132            .flat_map(|n| {
1133                n.children_with_tokens()
1134                    .filter_map(|it| it.as_token().cloned())
1135                    .filter(|token| token.kind() == SyntaxKind::DETAIL)
1136            })
1137            .collect();
1138
1139        // Track which tokens have been used to avoid matching duplicates to the same token
1140        let mut token_index = 0;
1141
1142        for (author, linenos, lines) in
1143            crate::changes::changes_by_author(changes.iter().map(|s| s.as_str()))
1144        {
1145            let author_name = author.map(|s| s.to_string());
1146
1147            // Extract the specific DETAIL tokens for this change by matching text content
1148            // We iterate through tokens in order to handle duplicate lines correctly
1149            let detail_tokens: Vec<SyntaxToken> = lines
1150                .iter()
1151                .filter_map(|line_text| {
1152                    // Find the next token matching this line's text
1153                    while token_index < all_detail_tokens.len() {
1154                        let token = &all_detail_tokens[token_index];
1155                        token_index += 1;
1156                        if token.text() == *line_text {
1157                            return Some(token.clone());
1158                        }
1159                    }
1160                    None
1161                })
1162                .collect();
1163
1164            let change = Change::new(entry.clone(), author_name, linenos, detail_tokens);
1165            result.push(change);
1166        }
1167    }
1168
1169    result
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn test_parseaddr() {
1178        assert_eq!(
1179            (Some("Jelmer"), "jelmer@jelmer.uk"),
1180            parseaddr("Jelmer <jelmer@jelmer.uk>")
1181        );
1182        assert_eq!((None, "jelmer@jelmer.uk"), parseaddr("jelmer@jelmer.uk"));
1183    }
1184
1185    #[test]
1186    fn test_parseaddr_empty() {
1187        assert_eq!((None, ""), parseaddr(""));
1188    }
1189
1190    #[test]
1191    #[cfg(feature = "chrono")]
1192    fn test_release_already_released() {
1193        use crate::parse::ChangeLog;
1194
1195        let mut changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1196
1197  * New upstream release.
1198
1199 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1200"#
1201        .parse()
1202        .unwrap();
1203
1204        let result = release(
1205            &mut changelog,
1206            Some(vec!["unstable".to_string()]),
1207            None::<String>,
1208            None,
1209        );
1210
1211        // The function returns true if the entry is NOT unreleased (already released)
1212        assert!(result);
1213    }
1214
1215    #[test]
1216    #[cfg(feature = "chrono")]
1217    fn test_release_unreleased() {
1218        use crate::parse::ChangeLog;
1219
1220        let mut changelog: ChangeLog = r#"breezy (3.3.4-1) UNRELEASED; urgency=low
1221
1222  * New upstream release.
1223
1224 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1225"#
1226        .parse()
1227        .unwrap();
1228
1229        let result = release(
1230            &mut changelog,
1231            Some(vec!["unstable".to_string()]),
1232            None::<String>,
1233            Some(("Test User".to_string(), "test@example.com".to_string())),
1234        );
1235
1236        // The function returns false if the entry is unreleased
1237        assert!(!result);
1238    }
1239
1240    #[test]
1241    fn test_take_uploadership_same_maintainer() {
1242        use crate::parse::ChangeLog;
1243
1244        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1245
1246  * New upstream release.
1247
1248 -- Test User <test@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1249"#
1250        .parse()
1251        .unwrap();
1252
1253        let mut entries: Vec<Entry> = changelog.into_iter().collect();
1254        take_uploadership(
1255            &mut entries[0],
1256            Some(("Test User".to_string(), "test@example.com".to_string())),
1257        );
1258
1259        // Should not add author section when maintainer is the same
1260        assert!(!entries[0].to_string().contains("[ Test User ]"));
1261    }
1262
1263    #[test]
1264    fn test_take_uploadership_different_maintainer() {
1265        use crate::parse::ChangeLog;
1266
1267        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1268
1269  * New upstream release.
1270
1271 -- Original User <original@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1272"#
1273        .parse()
1274        .unwrap();
1275
1276        let mut entries: Vec<Entry> = changelog.into_iter().collect();
1277
1278        take_uploadership(
1279            &mut entries[0],
1280            Some(("New User".to_string(), "new@example.com".to_string())),
1281        );
1282
1283        // The take_uploadership function updates the maintainer in the footer
1284        assert!(entries[0]
1285            .to_string()
1286            .contains("New User <new@example.com>"));
1287        assert_eq!(entries[0].email(), Some("new@example.com".to_string()));
1288    }
1289
1290    #[test]
1291    fn test_identity_display() {
1292        let identity = Identity {
1293            name: "Test User".to_string(),
1294            email: "test@example.com".to_string(),
1295        };
1296        assert_eq!(identity.to_string(), "Test User <test@example.com>");
1297
1298        let identity_empty_name = Identity {
1299            name: "".to_string(),
1300            email: "test@example.com".to_string(),
1301        };
1302        assert_eq!(identity_empty_name.to_string(), " <test@example.com>");
1303    }
1304
1305    #[test]
1306    fn test_gbp_dch_failure() {
1307        // Test with invalid path that would cause gbp dch to fail
1308        let result = gbp_dch(std::path::Path::new("/nonexistent/path"));
1309        assert!(result.is_err());
1310    }
1311
1312    #[test]
1313    fn test_iter_entries_by_author() {
1314        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1315
1316  * New upstream release.
1317
1318 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1319
1320breezy (3.3.3-1) unstable; urgency=low
1321
1322  * Bug fix release.
1323
1324 -- Jelmer Vernooij <jelmer@debian.org>  Sun, 03 Sep 2023 17:12:30 -0500
1325
1326breezy (3.3.2-1) unstable; urgency=low
1327
1328  * Another release.
1329
1330 -- Jane Doe <jane@example.com>  Sat, 02 Sep 2023 16:11:15 -0500
1331"#
1332        .parse()
1333        .unwrap();
1334
1335        let authors: Vec<(String, String, Vec<Entry>)> =
1336            iter_entries_by_author(&changelog).collect();
1337
1338        assert_eq!(authors.len(), 2);
1339        assert_eq!(authors[0].0, "Jane Doe");
1340        assert_eq!(authors[0].1, "jane@example.com");
1341        assert_eq!(authors[0].2.len(), 1);
1342        assert_eq!(authors[1].0, "Jelmer Vernooij");
1343        assert_eq!(authors[1].1, "jelmer@debian.org");
1344        assert_eq!(authors[1].2.len(), 2);
1345    }
1346
1347    #[test]
1348    fn test_iter_changes_by_author() {
1349        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1350
1351  [ Author 1 ]
1352  * Change by Author 1
1353
1354  [ Author 2 ]
1355  * Change by Author 2
1356
1357  * Unattributed change
1358
1359 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1360"#
1361        .parse()
1362        .unwrap();
1363
1364        let changes = iter_changes_by_author(&changelog);
1365
1366        assert_eq!(changes.len(), 3);
1367
1368        // First change attributed to Author 1
1369        assert_eq!(changes[0].author(), Some("Author 1"));
1370        assert_eq!(changes[0].package(), Some("breezy".to_string()));
1371        assert_eq!(changes[0].lines(), vec!["* Change by Author 1"]);
1372
1373        // Second change attributed to Author 2
1374        assert_eq!(changes[1].author(), Some("Author 2"));
1375        assert_eq!(changes[1].package(), Some("breezy".to_string()));
1376        assert_eq!(changes[1].lines(), vec!["* Change by Author 2"]);
1377
1378        // Third change unattributed
1379        assert_eq!(changes[2].author(), None);
1380        assert_eq!(changes[2].package(), Some("breezy".to_string()));
1381        assert_eq!(changes[2].lines(), vec!["* Unattributed change"]);
1382    }
1383
1384    #[test]
1385    fn test_change_remove() {
1386        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1387
1388  [ Author 1 ]
1389  * Change by Author 1
1390
1391  [ Author 2 ]
1392  * Change by Author 2
1393
1394  * Unattributed change
1395
1396 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1397"#
1398        .parse()
1399        .unwrap();
1400
1401        let changes = iter_changes_by_author(&changelog);
1402        assert_eq!(changes.len(), 3);
1403
1404        // Remove the second change (Author 2)
1405        changes[1].clone().remove();
1406
1407        // Re-read the changes
1408        let remaining_changes = iter_changes_by_author(&changelog);
1409
1410        // The Author 2 section header remains but with no changes,
1411        // so it will show up as an empty change for Author 2,
1412        // followed by the unattributed change
1413        assert_eq!(remaining_changes.len(), 2);
1414
1415        // Should have Author 1 and Author 2 (but with no lines)
1416        assert_eq!(remaining_changes[0].author(), Some("Author 1"));
1417        assert_eq!(remaining_changes[0].lines(), vec!["* Change by Author 1"]);
1418
1419        // Author 2's section header remains but the change is removed
1420        assert_eq!(remaining_changes[1].author(), Some("Author 2"));
1421        assert_eq!(remaining_changes[1].lines(), vec!["* Unattributed change"]);
1422    }
1423
1424    #[test]
1425    fn test_change_replace_with() {
1426        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1427
1428  [ Author 1 ]
1429  * Change by Author 1
1430
1431  [ Author 2 ]
1432  * Change by Author 2
1433
1434 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1435"#
1436        .parse()
1437        .unwrap();
1438
1439        let changes = iter_changes_by_author(&changelog);
1440        assert_eq!(changes.len(), 2);
1441
1442        // Replace Author 2's change
1443        changes[1].replace_with(vec!["* Updated change by Author 2", "* Another line"]);
1444
1445        // Re-read the changes
1446        let updated_changes = iter_changes_by_author(&changelog);
1447        assert_eq!(updated_changes.len(), 2);
1448
1449        // Author 1's change should be unchanged
1450        assert_eq!(updated_changes[0].author(), Some("Author 1"));
1451        assert_eq!(updated_changes[0].lines(), vec!["* Change by Author 1"]);
1452
1453        // Author 2's change should be replaced
1454        assert_eq!(updated_changes[1].author(), Some("Author 2"));
1455        assert_eq!(
1456            updated_changes[1].lines(),
1457            vec!["* Updated change by Author 2", "* Another line"]
1458        );
1459    }
1460
1461    #[test]
1462    fn test_change_replace_with_single_line() {
1463        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1464
1465  * Old change
1466
1467 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
1468"#
1469        .parse()
1470        .unwrap();
1471
1472        let changes = iter_changes_by_author(&changelog);
1473        assert_eq!(changes.len(), 1);
1474
1475        // Replace with a new single line
1476        changes[0].replace_with(vec!["* New change"]);
1477
1478        // Re-read the changes
1479        let updated_changes = iter_changes_by_author(&changelog);
1480        assert_eq!(updated_changes.len(), 1);
1481        assert_eq!(updated_changes[0].lines(), vec!["* New change"]);
1482    }
1483
1484    #[test]
1485    fn test_change_accessors() {
1486        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1487
1488  [ Alice ]
1489  * Change by Alice
1490
1491 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1492"#
1493        .parse()
1494        .unwrap();
1495
1496        let changes = iter_changes_by_author(&changelog);
1497        assert_eq!(changes.len(), 1);
1498
1499        let change = &changes[0];
1500
1501        // Test all accessors
1502        assert_eq!(change.author(), Some("Alice"));
1503        assert_eq!(change.package(), Some("breezy".to_string()));
1504        assert_eq!(
1505            change.version().map(|v| v.to_string()),
1506            Some("3.3.4-1".to_string())
1507        );
1508        assert_eq!(change.is_attributed(), true);
1509        assert_eq!(change.lines(), vec!["* Change by Alice"]);
1510
1511        // Test entry accessor
1512        assert_eq!(change.entry().package(), Some("breezy".to_string()));
1513    }
1514
1515    #[test]
1516    fn test_change_unattributed_accessors() {
1517        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1518
1519  * Unattributed change
1520
1521 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1522"#
1523        .parse()
1524        .unwrap();
1525
1526        let changes = iter_changes_by_author(&changelog);
1527        assert_eq!(changes.len(), 1);
1528
1529        let change = &changes[0];
1530        assert_eq!(change.author(), None);
1531        assert_eq!(change.is_attributed(), false);
1532    }
1533
1534    #[test]
1535    fn test_replace_single_line_with_multiple() {
1536        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1537
1538  * Single line change
1539
1540 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1541"#
1542        .parse()
1543        .unwrap();
1544
1545        let changes = iter_changes_by_author(&changelog);
1546        changes[0].replace_with(vec!["* First line", "* Second line", "* Third line"]);
1547
1548        let updated = iter_changes_by_author(&changelog);
1549        assert_eq!(
1550            updated[0].lines(),
1551            vec!["* First line", "* Second line", "* Third line"]
1552        );
1553    }
1554
1555    #[test]
1556    fn test_replace_multiple_lines_with_single() {
1557        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1558
1559  * First line
1560  * Second line
1561  * Third line
1562
1563 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1564"#
1565        .parse()
1566        .unwrap();
1567
1568        let changes = iter_changes_by_author(&changelog);
1569        assert_eq!(changes[0].lines().len(), 3);
1570
1571        changes[0].replace_with(vec!["* Single replacement line"]);
1572
1573        let updated = iter_changes_by_author(&changelog);
1574        assert_eq!(updated[0].lines(), vec!["* Single replacement line"]);
1575    }
1576
1577    #[test]
1578    fn test_split_into_bullets_single_line() {
1579        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1580
1581  * First change
1582  * Second change
1583  * Third change
1584
1585 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1586"#
1587        .parse()
1588        .unwrap();
1589
1590        let changes = iter_changes_by_author(&changelog);
1591        assert_eq!(changes.len(), 1);
1592
1593        // Split the single Change into individual bullets
1594        let bullets = changes[0].split_into_bullets();
1595
1596        assert_eq!(bullets.len(), 3);
1597        assert_eq!(bullets[0].lines(), vec!["* First change"]);
1598        assert_eq!(bullets[1].lines(), vec!["* Second change"]);
1599        assert_eq!(bullets[2].lines(), vec!["* Third change"]);
1600
1601        // Each bullet should have the same package and version
1602        for bullet in &bullets {
1603            assert_eq!(bullet.package(), Some("breezy".to_string()));
1604            assert_eq!(
1605                bullet.version().map(|v| v.to_string()),
1606                Some("3.3.4-1".to_string())
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn test_split_into_bullets_with_continuations() {
1613        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1614
1615  * First change
1616    with a continuation line
1617  * Second change
1618    with multiple
1619    continuation lines
1620  * Third change
1621
1622 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1623"#
1624        .parse()
1625        .unwrap();
1626
1627        let changes = iter_changes_by_author(&changelog);
1628        assert_eq!(changes.len(), 1);
1629
1630        let bullets = changes[0].split_into_bullets();
1631
1632        assert_eq!(bullets.len(), 3);
1633        assert_eq!(
1634            bullets[0].lines(),
1635            vec!["* First change", "  with a continuation line"]
1636        );
1637        assert_eq!(
1638            bullets[1].lines(),
1639            vec!["* Second change", "  with multiple", "  continuation lines"]
1640        );
1641        assert_eq!(bullets[2].lines(), vec!["* Third change"]);
1642    }
1643
1644    #[test]
1645    fn test_split_into_bullets_mixed() {
1646        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1647
1648  * Single line bullet
1649  * Multi-line bullet
1650    with continuation
1651  * Another single line
1652
1653 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1654"#
1655        .parse()
1656        .unwrap();
1657
1658        let changes = iter_changes_by_author(&changelog);
1659        let bullets = changes[0].split_into_bullets();
1660
1661        assert_eq!(bullets.len(), 3);
1662        assert_eq!(bullets[0].lines(), vec!["* Single line bullet"]);
1663        assert_eq!(
1664            bullets[1].lines(),
1665            vec!["* Multi-line bullet", "  with continuation"]
1666        );
1667        assert_eq!(bullets[2].lines(), vec!["* Another single line"]);
1668    }
1669
1670    #[test]
1671    fn test_split_into_bullets_with_author() {
1672        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1673
1674  [ Alice ]
1675  * Change by Alice
1676  * Another change by Alice
1677
1678  [ Bob ]
1679  * Change by Bob
1680
1681 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1682"#
1683        .parse()
1684        .unwrap();
1685
1686        let changes = iter_changes_by_author(&changelog);
1687        assert_eq!(changes.len(), 2);
1688
1689        // Split Alice's changes
1690        let alice_bullets = changes[0].split_into_bullets();
1691        assert_eq!(alice_bullets.len(), 2);
1692        assert_eq!(alice_bullets[0].lines(), vec!["* Change by Alice"]);
1693        assert_eq!(alice_bullets[1].lines(), vec!["* Another change by Alice"]);
1694
1695        // Both bullets should preserve the author
1696        for bullet in &alice_bullets {
1697            assert_eq!(bullet.author(), Some("Alice"));
1698        }
1699
1700        // Split Bob's changes
1701        let bob_bullets = changes[1].split_into_bullets();
1702        assert_eq!(bob_bullets.len(), 1);
1703        assert_eq!(bob_bullets[0].lines(), vec!["* Change by Bob"]);
1704        assert_eq!(bob_bullets[0].author(), Some("Bob"));
1705    }
1706
1707    #[test]
1708    fn test_split_into_bullets_single_bullet() {
1709        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1710
1711  * Single bullet point
1712
1713 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1714"#
1715        .parse()
1716        .unwrap();
1717
1718        let changes = iter_changes_by_author(&changelog);
1719        let bullets = changes[0].split_into_bullets();
1720
1721        assert_eq!(bullets.len(), 1);
1722        assert_eq!(bullets[0].lines(), vec!["* Single bullet point"]);
1723    }
1724
1725    #[test]
1726    fn test_split_into_bullets_and_remove() {
1727        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1728
1729  * First change
1730  * Duplicate change
1731  * Duplicate change
1732  * Last change
1733
1734 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1735"#
1736        .parse()
1737        .unwrap();
1738
1739        let changes = iter_changes_by_author(&changelog);
1740        let bullets = changes[0].split_into_bullets();
1741
1742        assert_eq!(bullets.len(), 4);
1743
1744        // Remove the second duplicate (index 2)
1745        bullets[2].clone().remove();
1746
1747        // Re-read and verify
1748        let updated_changes = iter_changes_by_author(&changelog);
1749        let updated_bullets = updated_changes[0].split_into_bullets();
1750
1751        assert_eq!(updated_bullets.len(), 3);
1752        assert_eq!(updated_bullets[0].lines(), vec!["* First change"]);
1753        assert_eq!(updated_bullets[1].lines(), vec!["* Duplicate change"]);
1754        assert_eq!(updated_bullets[2].lines(), vec!["* Last change"]);
1755    }
1756
1757    #[test]
1758    fn test_split_into_bullets_preserves_line_numbers() {
1759        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1760
1761  * First change
1762  * Second change
1763  * Third change
1764
1765 -- Bob <bob@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1766"#
1767        .parse()
1768        .unwrap();
1769
1770        let changes = iter_changes_by_author(&changelog);
1771        let bullets = changes[0].split_into_bullets();
1772
1773        // Each bullet should have distinct line numbers
1774        assert_eq!(bullets.len(), 3);
1775        assert_eq!(bullets[0].line_numbers().len(), 1);
1776        assert_eq!(bullets[1].line_numbers().len(), 1);
1777        assert_eq!(bullets[2].line_numbers().len(), 1);
1778
1779        // Line numbers should be in ascending order
1780        assert!(bullets[0].line_numbers()[0] < bullets[1].line_numbers()[0]);
1781        assert!(bullets[1].line_numbers()[0] < bullets[2].line_numbers()[0]);
1782    }
1783
1784    #[test]
1785    fn test_split_and_remove_from_multi_author_entry() {
1786        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1787
1788  [ Alice ]
1789  * Change 1 by Alice
1790  * Change 2 by Alice
1791  * Change 3 by Alice
1792
1793  [ Bob ]
1794  * Change 1 by Bob
1795  * Change 2 by Bob
1796
1797  [ Charlie ]
1798  * Change 1 by Charlie
1799
1800  * Unattributed change
1801
1802 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1803"#
1804        .parse()
1805        .unwrap();
1806
1807        let changes = iter_changes_by_author(&changelog);
1808        assert_eq!(changes.len(), 4); // Alice, Bob, Charlie, Unattributed
1809
1810        // Split Alice's changes and remove the second one
1811        let alice_bullets = changes[0].split_into_bullets();
1812        assert_eq!(alice_bullets.len(), 3);
1813        alice_bullets[1].clone().remove(); // Remove "Change 2 by Alice"
1814
1815        // Re-read and verify
1816        let updated_changes = iter_changes_by_author(&changelog);
1817        assert_eq!(updated_changes.len(), 4);
1818
1819        // Alice should now have 2 changes
1820        let updated_alice_bullets = updated_changes[0].split_into_bullets();
1821        assert_eq!(updated_alice_bullets.len(), 2);
1822        assert_eq!(
1823            updated_alice_bullets[0].lines(),
1824            vec!["* Change 1 by Alice"]
1825        );
1826        assert_eq!(
1827            updated_alice_bullets[1].lines(),
1828            vec!["* Change 3 by Alice"]
1829        );
1830        assert_eq!(updated_alice_bullets[0].author(), Some("Alice"));
1831
1832        // Bob should be unchanged
1833        let bob_bullets = updated_changes[1].split_into_bullets();
1834        assert_eq!(bob_bullets.len(), 2);
1835        assert_eq!(bob_bullets[0].lines(), vec!["* Change 1 by Bob"]);
1836        assert_eq!(bob_bullets[1].lines(), vec!["* Change 2 by Bob"]);
1837
1838        // Charlie should be unchanged
1839        let charlie_bullets = updated_changes[2].split_into_bullets();
1840        assert_eq!(charlie_bullets.len(), 1);
1841        assert_eq!(charlie_bullets[0].lines(), vec!["* Change 1 by Charlie"]);
1842
1843        // Unattributed should be unchanged
1844        let unattributed_bullets = updated_changes[3].split_into_bullets();
1845        assert_eq!(unattributed_bullets.len(), 1);
1846        assert_eq!(
1847            unattributed_bullets[0].lines(),
1848            vec!["* Unattributed change"]
1849        );
1850    }
1851
1852    #[test]
1853    fn test_remove_multiple_bullets_from_different_authors() {
1854        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1855
1856  [ Alice ]
1857  * Alice change 1
1858  * Alice change 2
1859  * Alice change 3
1860
1861  [ Bob ]
1862  * Bob change 1
1863  * Bob change 2
1864  * Bob change 3
1865
1866 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1867"#
1868        .parse()
1869        .unwrap();
1870
1871        let changes = iter_changes_by_author(&changelog);
1872        assert_eq!(changes.len(), 2);
1873
1874        // Remove Alice's first and third changes
1875        let alice_bullets = changes[0].split_into_bullets();
1876        alice_bullets[0].clone().remove();
1877        alice_bullets[2].clone().remove();
1878
1879        // Remove Bob's second change
1880        let bob_bullets = changes[1].split_into_bullets();
1881        bob_bullets[1].clone().remove();
1882
1883        // Re-read and verify
1884        let updated_changes = iter_changes_by_author(&changelog);
1885
1886        let updated_alice = updated_changes[0].split_into_bullets();
1887        assert_eq!(updated_alice.len(), 1);
1888        assert_eq!(updated_alice[0].lines(), vec!["* Alice change 2"]);
1889
1890        let updated_bob = updated_changes[1].split_into_bullets();
1891        assert_eq!(updated_bob.len(), 2);
1892        assert_eq!(updated_bob[0].lines(), vec!["* Bob change 1"]);
1893        assert_eq!(updated_bob[1].lines(), vec!["* Bob change 3"]);
1894    }
1895
1896    #[test]
1897    fn test_remove_bullet_with_continuation_from_multi_author() {
1898        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1899
1900  [ Alice ]
1901  * Simple change by Alice
1902
1903  [ Bob ]
1904  * Multi-line change by Bob
1905    with a continuation line
1906    and another continuation
1907  * Simple change by Bob
1908
1909  [ Charlie ]
1910  * Change by Charlie
1911
1912 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1913"#
1914        .parse()
1915        .unwrap();
1916
1917        let changes = iter_changes_by_author(&changelog);
1918        assert_eq!(changes.len(), 3);
1919
1920        // Remove Bob's multi-line change
1921        let bob_bullets = changes[1].split_into_bullets();
1922        assert_eq!(bob_bullets.len(), 2);
1923        assert_eq!(
1924            bob_bullets[0].lines(),
1925            vec![
1926                "* Multi-line change by Bob",
1927                "  with a continuation line",
1928                "  and another continuation"
1929            ]
1930        );
1931        bob_bullets[0].clone().remove();
1932
1933        // Re-read and verify
1934        let updated_changes = iter_changes_by_author(&changelog);
1935
1936        // Alice unchanged
1937        let alice_bullets = updated_changes[0].split_into_bullets();
1938        assert_eq!(alice_bullets.len(), 1);
1939        assert_eq!(alice_bullets[0].lines(), vec!["* Simple change by Alice"]);
1940
1941        // Bob now has only the simple change
1942        let updated_bob = updated_changes[1].split_into_bullets();
1943        assert_eq!(updated_bob.len(), 1);
1944        assert_eq!(updated_bob[0].lines(), vec!["* Simple change by Bob"]);
1945
1946        // Charlie unchanged
1947        let charlie_bullets = updated_changes[2].split_into_bullets();
1948        assert_eq!(charlie_bullets.len(), 1);
1949        assert_eq!(charlie_bullets[0].lines(), vec!["* Change by Charlie"]);
1950    }
1951
1952    #[test]
1953    fn test_remove_all_bullets_from_one_author_section() {
1954        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
1955
1956  [ Alice ]
1957  * Change 1 by Alice
1958  * Change 2 by Alice
1959
1960  [ Bob ]
1961  * Change 1 by Bob
1962
1963 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
1964"#
1965        .parse()
1966        .unwrap();
1967
1968        let changes = iter_changes_by_author(&changelog);
1969        assert_eq!(changes.len(), 2);
1970
1971        // Remove all of Alice's changes
1972        let alice_bullets = changes[0].split_into_bullets();
1973        for bullet in alice_bullets {
1974            bullet.remove();
1975        }
1976
1977        // Re-read and verify
1978        let updated_changes = iter_changes_by_author(&changelog);
1979
1980        // Alice's section header remains but with no changes
1981        // Bob's section follows with its change, so only Bob's change remains
1982        assert_eq!(updated_changes.len(), 1);
1983        assert_eq!(updated_changes[0].author(), Some("Bob"));
1984
1985        let bob_bullets = updated_changes[0].split_into_bullets();
1986        assert_eq!(bob_bullets.len(), 1);
1987        assert_eq!(bob_bullets[0].lines(), vec!["* Change 1 by Bob"]);
1988
1989        // Verify the section header was removed from the changelog text
1990        let changelog_text = changelog.to_string();
1991        assert!(
1992            !changelog_text.contains("[ Alice ]"),
1993            "Alice's empty section header should be removed"
1994        );
1995    }
1996
1997    #[test]
1998    fn test_remove_section_header_with_multiple_sections() {
1999        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2000
2001  [ Alice ]
2002  * Alice's first section change
2003
2004  [ Bob ]
2005  * Bob's change
2006
2007  [ Alice ]
2008  * Alice's second section change 1
2009  * Alice's second section change 2
2010
2011 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
2012"#
2013        .parse()
2014        .unwrap();
2015
2016        let changes = iter_changes_by_author(&changelog);
2017        assert_eq!(changes.len(), 3);
2018
2019        // Remove all changes from the second Alice section only
2020        let alice_second = &changes[2];
2021        assert_eq!(alice_second.author(), Some("Alice"));
2022
2023        let alice_second_bullets = alice_second.split_into_bullets();
2024        assert_eq!(alice_second_bullets.len(), 2);
2025
2026        // Remove all bullets from the second Alice section
2027        for bullet in alice_second_bullets {
2028            bullet.remove();
2029        }
2030
2031        // Re-read and verify
2032        let updated_changes = iter_changes_by_author(&changelog);
2033
2034        // Should now only have Alice's first section and Bob's section
2035        assert_eq!(updated_changes.len(), 2);
2036        assert_eq!(updated_changes[0].author(), Some("Alice"));
2037        assert_eq!(updated_changes[1].author(), Some("Bob"));
2038
2039        // Verify the first Alice section is intact
2040        let alice_first = updated_changes[0].split_into_bullets();
2041        assert_eq!(alice_first.len(), 1);
2042        assert_eq!(
2043            alice_first[0].lines(),
2044            vec!["* Alice's first section change"]
2045        );
2046
2047        // Verify the changelog text - second Alice header should be gone
2048        let changelog_text = changelog.to_string();
2049        let alice_header_count = changelog_text.matches("[ Alice ]").count();
2050        assert_eq!(
2051            alice_header_count, 1,
2052            "Should only have one Alice section header remaining"
2053        );
2054    }
2055
2056    #[test]
2057    fn test_remove_duplicate_from_specific_author() {
2058        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2059
2060  [ Alice ]
2061  * New upstream release
2062  * Fix typo in documentation
2063  * New upstream release
2064
2065  [ Bob ]
2066  * New upstream release
2067  * Update dependencies
2068
2069 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
2070"#
2071        .parse()
2072        .unwrap();
2073
2074        let changes = iter_changes_by_author(&changelog);
2075        assert_eq!(changes.len(), 2);
2076
2077        // Find and remove duplicate "New upstream release" from Alice
2078        let alice_bullets = changes[0].split_into_bullets();
2079        assert_eq!(alice_bullets.len(), 3);
2080
2081        // Verify the order before removal
2082        assert_eq!(alice_bullets[0].lines(), vec!["* New upstream release"]);
2083        assert_eq!(
2084            alice_bullets[1].lines(),
2085            vec!["* Fix typo in documentation"]
2086        );
2087        assert_eq!(alice_bullets[2].lines(), vec!["* New upstream release"]);
2088
2089        // Remove the duplicate (third item)
2090        alice_bullets[2].clone().remove();
2091
2092        // Re-read and verify
2093        let updated_changes = iter_changes_by_author(&changelog);
2094
2095        // Alice should now have 2 changes (first "New upstream release" and "Fix typo")
2096        let updated_alice = updated_changes[0].split_into_bullets();
2097        assert_eq!(updated_alice.len(), 2);
2098        assert_eq!(updated_alice[0].lines(), vec!["* New upstream release"]);
2099        assert_eq!(
2100            updated_alice[1].lines(),
2101            vec!["* Fix typo in documentation"]
2102        );
2103
2104        // Bob should be unchanged
2105        let bob_bullets = updated_changes[1].split_into_bullets();
2106        assert_eq!(bob_bullets.len(), 2);
2107        assert_eq!(bob_bullets[0].lines(), vec!["* New upstream release"]);
2108        assert_eq!(bob_bullets[1].lines(), vec!["* Update dependencies"]);
2109    }
2110
2111    #[test]
2112    fn test_remove_empty_section_headers_and_blank_lines() {
2113        // Test that when all bullets are removed from a section, the section header
2114        // and its preceding blank line are also removed
2115        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2116
2117  [ Alice ]
2118  * Change 1 by Alice
2119  * Change 2 by Alice
2120
2121  [ Bob ]
2122  * Change 1 by Bob
2123
2124 -- Maintainer <maint@example.com>  Mon, 04 Sep 2023 18:13:45 -0500
2125"#
2126        .parse()
2127        .unwrap();
2128
2129        let changes = iter_changes_by_author(&changelog);
2130        assert_eq!(changes.len(), 2);
2131
2132        // Remove all of Alice's changes
2133        let alice_bullets = changes[0].split_into_bullets();
2134        for bullet in alice_bullets {
2135            bullet.remove();
2136        }
2137
2138        // Verify Alice's section is completely gone
2139        let updated_changes = iter_changes_by_author(&changelog);
2140        assert_eq!(updated_changes.len(), 1);
2141        assert_eq!(updated_changes[0].author(), Some("Bob"));
2142
2143        // Verify the changelog text has no Alice header or extra blank lines
2144        let changelog_text = changelog.to_string();
2145        assert!(!changelog_text.contains("[ Alice ]"));
2146
2147        // Count blank lines before signature - should be exactly 1
2148        let lines: Vec<&str> = changelog_text.lines().collect();
2149        let sig_idx = lines.iter().position(|l| l.starts_with(" --")).unwrap();
2150        let mut blank_count = 0;
2151        for i in (0..sig_idx).rev() {
2152            if lines[i].trim().is_empty() {
2153                blank_count += 1;
2154            } else {
2155                break;
2156            }
2157        }
2158        assert_eq!(
2159            blank_count, 1,
2160            "Should have exactly 1 blank line before signature"
2161        );
2162    }
2163
2164    #[test]
2165    fn test_remove_first_entry_before_author_section() {
2166        // Test that when removing the first entry before an author section,
2167        // the extra newline is properly removed
2168        let changelog: ChangeLog = r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2169
2170  * Team upload.
2171
2172  [ Jelmer Vernooij ]
2173  * blah
2174
2175 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2176
2177lintian-brush (0.1-1) unstable; urgency=medium
2178
2179  * Initial release. (Closes: #XXXXXX)
2180
2181 -- Jelmer Vernooij <jelmer@debian.org>  Sun, 28 Oct 2018 00:09:52 +0000
2182"#
2183        .parse()
2184        .unwrap();
2185
2186        let changes = iter_changes_by_author(&changelog);
2187
2188        // Find and remove the "Team upload" entry (should be the first one, unattributed)
2189        let team_upload_change = changes
2190            .iter()
2191            .find(|c| c.lines().iter().any(|l| l.contains("Team upload")))
2192            .unwrap();
2193
2194        team_upload_change.clone().remove();
2195
2196        let expected = r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2197
2198  [ Jelmer Vernooij ]
2199  * blah
2200
2201 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2202
2203lintian-brush (0.1-1) unstable; urgency=medium
2204
2205  * Initial release. (Closes: #XXXXXX)
2206
2207 -- Jelmer Vernooij <jelmer@debian.org>  Sun, 28 Oct 2018 00:09:52 +0000
2208"#;
2209
2210        assert_eq!(changelog.to_string(), expected);
2211    }
2212
2213    // Helper function for remove tests to reduce repetition
2214    // Splits changes into individual bullets before applying filter
2215    fn test_remove_change(input: &str, change_filter: impl Fn(&Change) -> bool, expected: &str) {
2216        let changelog: ChangeLog = input.parse().unwrap();
2217        let changes = iter_changes_by_author(&changelog);
2218
2219        // Split all changes into individual bullets
2220        let mut all_bullets = Vec::new();
2221        for change in changes {
2222            all_bullets.extend(change.split_into_bullets());
2223        }
2224
2225        let change = all_bullets.iter().find(|c| change_filter(c)).unwrap();
2226        change.clone().remove();
2227        assert_eq!(changelog.to_string(), expected);
2228    }
2229
2230    #[test]
2231    fn test_remove_entry_followed_by_regular_bullet() {
2232        // Empty line should be preserved when followed by a regular bullet, not a section header
2233        test_remove_change(
2234            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2235
2236  * First change.
2237
2238  * Second change.
2239
2240 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2241"#,
2242            |c| c.lines().iter().any(|l| l.contains("First change")),
2243            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2244
2245  * Second change.
2246
2247 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2248"#,
2249        );
2250    }
2251
2252    #[test]
2253    fn test_remove_entry_not_followed_by_empty_line() {
2254        // No trailing empty line to remove
2255        test_remove_change(
2256            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2257
2258  * First change.
2259  * Second change.
2260
2261 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2262"#,
2263            |c| c.lines().iter().any(|l| l.contains("First change")),
2264            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2265
2266  * Second change.
2267
2268 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2269"#,
2270        );
2271    }
2272
2273    #[test]
2274    fn test_remove_only_entry() {
2275        // Empty line before footer should be preserved
2276        test_remove_change(
2277            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2278
2279  * Only change.
2280
2281 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2282"#,
2283            |c| c.lines().iter().any(|l| l.contains("Only change")),
2284            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2285
2286 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2287"#,
2288        );
2289    }
2290
2291    #[test]
2292    fn test_remove_middle_entry_between_bullets() {
2293        // Empty lines around remaining bullets should be preserved
2294        test_remove_change(
2295            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2296
2297  * First change.
2298
2299  * Middle change.
2300
2301  * Last change.
2302
2303 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2304"#,
2305            |c| c.lines().iter().any(|l| l.contains("Middle change")),
2306            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2307
2308  * First change.
2309
2310  * Last change.
2311
2312 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2313"#,
2314        );
2315    }
2316
2317    #[test]
2318    fn test_remove_entry_before_multiple_section_headers() {
2319        // Empty line before first section header should be removed
2320        test_remove_change(
2321            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2322
2323  * Team upload.
2324
2325  [ Author One ]
2326  * Change by author one.
2327
2328  [ Author Two ]
2329  * Change by author two.
2330
2331 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2332"#,
2333            |c| c.lines().iter().any(|l| l.contains("Team upload")),
2334            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2335
2336  [ Author One ]
2337  * Change by author one.
2338
2339  [ Author Two ]
2340  * Change by author two.
2341
2342 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2343"#,
2344        );
2345    }
2346
2347    #[test]
2348    fn test_remove_first_of_two_section_headers() {
2349        // Empty line before remaining section should be preserved
2350        test_remove_change(
2351            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2352
2353  [ Author One ]
2354  * Change by author one.
2355
2356  [ Author Two ]
2357  * Change by author two.
2358
2359 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2360"#,
2361            |c| c.author() == Some("Author One"),
2362            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2363
2364  [ Author Two ]
2365  * Change by author two.
2366
2367 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2368"#,
2369        );
2370    }
2371
2372    #[test]
2373    fn test_remove_last_entry_no_empty_line_follows() {
2374        // Edge case: last entry with no trailing empty before footer
2375        test_remove_change(
2376            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2377
2378  * First change.
2379  * Last change.
2380 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2381"#,
2382            |c| c.lines().iter().any(|l| l.contains("Last change")),
2383            r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2384
2385  * First change.
2386 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2387"#,
2388        );
2389    }
2390
2391    #[test]
2392    fn test_remove_first_unattributed_before_section_exact() {
2393        // Exact reproduction of the lintian-brush test case
2394        // Using the exact sequence: iter_changes_by_author -> split_into_bullets -> remove
2395        let changelog: ChangeLog = r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2396
2397  * Team upload.
2398
2399  [ Jelmer Vernooij ]
2400  * blah
2401
2402 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2403"#
2404        .parse()
2405        .unwrap();
2406
2407        // Exact sequence from lintian-brush: iter_changes_by_author -> split_into_bullets -> remove
2408        let changes = iter_changes_by_author(&changelog);
2409        let team_upload_change = changes
2410            .iter()
2411            .find(|c| c.author().is_none() && c.lines().iter().any(|l| l.contains("Team upload")))
2412            .unwrap();
2413
2414        let bullets = team_upload_change.split_into_bullets();
2415        bullets[0].clone().remove();
2416
2417        let result = changelog.to_string();
2418
2419        // Should have exactly one blank line after header, not two
2420        let expected = r#"lintian-brush (0.1-2) UNRELEASED; urgency=medium
2421
2422  [ Jelmer Vernooij ]
2423  * blah
2424
2425 -- Jelmer Vernooij <jelmer@debian.org>  Fri, 23 Nov 2018 14:00:02 +0000
2426"#;
2427        assert_eq!(result, expected);
2428    }
2429
2430    #[test]
2431    fn test_replace_with_preserves_first_blank_line() {
2432        // Test that replace_with preserves the blank line after the entry header
2433        // This reproduces the issue from debian-changelog-line-too-long/subitem test
2434        let changelog: ChangeLog = r#"blah (2.6.0) unstable; urgency=medium
2435
2436  * New upstream release.
2437   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to somebody who fixed it.
2438
2439 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2440"#
2441        .parse()
2442        .unwrap();
2443
2444        let changes = iter_changes_by_author(&changelog);
2445
2446        // Replace all changes with wrapped version
2447        changes[0].replace_with(vec![
2448            "* New upstream release.",
2449            " * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to",
2450            "   somebody who fixed it.",
2451        ]);
2452
2453        let result = changelog.to_string();
2454
2455        // The blank line after the header should be preserved
2456        let expected = r#"blah (2.6.0) unstable; urgency=medium
2457
2458  * New upstream release.
2459   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to
2460     somebody who fixed it.
2461
2462 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2463"#;
2464        assert_eq!(result, expected);
2465    }
2466
2467    #[test]
2468    fn test_parse_serialize_preserves_blank_line() {
2469        // Test that simply parsing and serializing preserves the blank line
2470        let input = r#"blah (2.6.0) unstable; urgency=medium
2471
2472  * New upstream release.
2473   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to somebody who fixed it.
2474
2475 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2476"#;
2477
2478        let changelog: ChangeLog = input.parse().unwrap();
2479        let output = changelog.to_string();
2480
2481        assert_eq!(output, input, "Parse/serialize should not modify changelog");
2482    }
2483
2484    #[test]
2485    fn test_replace_with_first_entry_preserves_blank() {
2486        // Simulate what a Rust line-too-long fixer would do:
2487        // Replace the changes in the first entry with wrapped versions
2488        let changelog: ChangeLog = r#"blah (2.6.0) unstable; urgency=medium
2489
2490  * New upstream release.
2491   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to somebody who fixed it.
2492
2493 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2494"#
2495        .parse()
2496        .unwrap();
2497
2498        let changes = iter_changes_by_author(&changelog);
2499        assert_eq!(changes.len(), 1);
2500
2501        // Replace with wrapped version (what the fixer would do)
2502        changes[0].replace_with(vec![
2503            "* New upstream release.",
2504            " * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to",
2505            "   somebody who fixed it.",
2506        ]);
2507
2508        let result = changelog.to_string();
2509
2510        // The blank line after header MUST be preserved
2511        let expected = r#"blah (2.6.0) unstable; urgency=medium
2512
2513  * New upstream release.
2514   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to
2515     somebody who fixed it.
2516
2517 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2518"#;
2519        assert_eq!(result, expected);
2520    }
2521
2522    #[test]
2523    fn test_pop_append_preserves_first_blank() {
2524        // Test the exact pattern used by the Rust line-too-long fixer:
2525        // pop all lines, then append wrapped ones
2526        let changelog: ChangeLog = r#"blah (2.6.0) unstable; urgency=medium
2527
2528  * New upstream release.
2529   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to somebody who fixed it.
2530
2531 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2532"#
2533        .parse()
2534        .unwrap();
2535
2536        let entry = changelog.iter().next().unwrap();
2537
2538        // Pop all change lines (simulating the fixer)
2539        while entry.pop_change_line().is_some() {}
2540
2541        // Append wrapped lines
2542        entry.append_change_line("* New upstream release.");
2543        entry.append_change_line(
2544            " * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to",
2545        );
2546        entry.append_change_line("   somebody who fixed it.");
2547
2548        let result = changelog.to_string();
2549
2550        // The blank line after header MUST be preserved
2551        let expected = r#"blah (2.6.0) unstable; urgency=medium
2552
2553  * New upstream release.
2554   * Fix blocks/blockedby of archived bugs (Closes: #XXXXXXX). Thanks to
2555     somebody who fixed it.
2556
2557 -- Joe Example <joe@example.com>  Mon, 26 Feb 2018 11:31:48 -0800
2558"#;
2559        assert_eq!(result, expected);
2560    }
2561
2562    #[test]
2563    fn test_replace_line() {
2564        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2565
2566  * First change
2567  * Second change
2568  * Third change
2569
2570 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2571"#
2572        .parse()
2573        .unwrap();
2574
2575        let changes = iter_changes_by_author(&changelog);
2576        assert_eq!(changes.len(), 1);
2577
2578        // Replace the second line
2579        changes[0]
2580            .replace_line(1, "* Updated second change")
2581            .unwrap();
2582
2583        // Re-read and verify
2584        let updated_changes = iter_changes_by_author(&changelog);
2585        assert_eq!(
2586            updated_changes[0].lines(),
2587            vec![
2588                "* First change",
2589                "* Updated second change",
2590                "* Third change"
2591            ]
2592        );
2593    }
2594
2595    #[test]
2596    fn test_replace_line_out_of_bounds() {
2597        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2598
2599  * First change
2600
2601 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2602"#
2603        .parse()
2604        .unwrap();
2605
2606        let changes = iter_changes_by_author(&changelog);
2607        assert_eq!(changes.len(), 1);
2608
2609        // Try to replace a line that doesn't exist
2610        let result = changes[0].replace_line(5, "* Updated");
2611        assert!(result.is_err());
2612    }
2613
2614    #[test]
2615    fn test_update_lines() {
2616        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2617
2618  * First change
2619  * Second change
2620  * Third change
2621
2622 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2623"#
2624        .parse()
2625        .unwrap();
2626
2627        let changes = iter_changes_by_author(&changelog);
2628
2629        // Update lines containing "First" or "Second"
2630        let count = changes[0].update_lines(
2631            |line| line.contains("First") || line.contains("Second"),
2632            |line| format!("{} (updated)", line),
2633        );
2634
2635        assert_eq!(count, 2);
2636
2637        // Verify the changes
2638        let updated_changes = iter_changes_by_author(&changelog);
2639        assert_eq!(
2640            updated_changes[0].lines(),
2641            vec![
2642                "* First change (updated)",
2643                "* Second change (updated)",
2644                "* Third change"
2645            ]
2646        );
2647    }
2648
2649    #[test]
2650    fn test_update_lines_no_matches() {
2651        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2652
2653  * First change
2654  * Second change
2655
2656 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2657"#
2658        .parse()
2659        .unwrap();
2660
2661        let changes = iter_changes_by_author(&changelog);
2662
2663        // Update lines that don't exist
2664        let count = changes[0].update_lines(
2665            |line| line.contains("NonExistent"),
2666            |line| format!("{} (updated)", line),
2667        );
2668
2669        assert_eq!(count, 0);
2670
2671        // Verify nothing changed
2672        let updated_changes = iter_changes_by_author(&changelog);
2673        assert_eq!(
2674            updated_changes[0].lines(),
2675            vec!["* First change", "* Second change"]
2676        );
2677    }
2678
2679    #[test]
2680    fn test_update_lines_with_continuation() {
2681        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2682
2683  * First change
2684    with continuation line
2685  * Second change
2686
2687 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2688"#
2689        .parse()
2690        .unwrap();
2691
2692        let changes = iter_changes_by_author(&changelog);
2693
2694        // Update the continuation line
2695        let count = changes[0].update_lines(
2696            |line| line.contains("continuation"),
2697            |line| line.replace("continuation", "updated"),
2698        );
2699
2700        assert_eq!(count, 1);
2701
2702        // Verify the changes
2703        let updated_changes = iter_changes_by_author(&changelog);
2704        assert_eq!(
2705            updated_changes[0].lines(),
2706            vec!["* First change", "  with updated line", "* Second change"]
2707        );
2708    }
2709
2710    #[test]
2711    fn test_add_bullet() {
2712        let mut changelog = ChangeLog::new();
2713        let entry = changelog
2714            .new_entry()
2715            .maintainer(("Test User".into(), "test@example.com".into()))
2716            .distribution("unstable".to_string())
2717            .version("1.0.0".parse().unwrap())
2718            .finish();
2719
2720        // Add bullets - always prepends "* " automatically
2721        entry.add_bullet("First change");
2722        entry.add_bullet("Second change");
2723        entry.add_bullet("Third change");
2724
2725        let lines: Vec<_> = entry.change_lines().collect();
2726        assert_eq!(lines.len(), 3);
2727        assert_eq!(lines[0], "* First change");
2728        assert_eq!(lines[1], "* Second change");
2729        assert_eq!(lines[2], "* Third change");
2730    }
2731
2732    #[test]
2733    fn test_add_bullet_empty_entry() {
2734        let mut changelog = ChangeLog::new();
2735        let entry = changelog
2736            .new_entry()
2737            .maintainer(("Test User".into(), "test@example.com".into()))
2738            .distribution("unstable".to_string())
2739            .version("1.0.0".parse().unwrap())
2740            .finish();
2741
2742        entry.add_bullet("Only bullet");
2743
2744        let lines: Vec<_> = entry.change_lines().collect();
2745        assert_eq!(lines.len(), 1);
2746        assert_eq!(lines[0], "* Only bullet");
2747    }
2748
2749    #[test]
2750    fn test_add_bullet_long_text() {
2751        let mut changelog = ChangeLog::new();
2752        let entry = changelog
2753            .new_entry()
2754            .maintainer(("Test User".into(), "test@example.com".into()))
2755            .distribution("unstable".to_string())
2756            .version("1.0.0".parse().unwrap())
2757            .finish();
2758
2759        // Add a bullet with text that's too long and should be wrapped
2760        entry.add_bullet("This is a very long line that exceeds the 78 column limit and should be automatically wrapped to multiple lines with proper indentation");
2761
2762        let lines: Vec<_> = entry.change_lines().collect();
2763        // Should be wrapped into multiple lines
2764        assert!(lines.len() > 1);
2765        // First line should start with "* "
2766        assert!(lines[0].starts_with("* "));
2767        // Continuation lines should start with "  " (two spaces)
2768        for line in &lines[1..] {
2769            assert!(line.starts_with("  "));
2770        }
2771        // No line should exceed 78 characters
2772        for line in &lines {
2773            assert!(line.len() <= 78, "Line exceeds 78 chars: {}", line);
2774        }
2775    }
2776
2777    #[test]
2778    fn test_add_bullet_preserves_closes() {
2779        let mut changelog = ChangeLog::new();
2780        let entry = changelog
2781            .new_entry()
2782            .maintainer(("Test User".into(), "test@example.com".into()))
2783            .distribution("unstable".to_string())
2784            .version("1.0.0".parse().unwrap())
2785            .finish();
2786
2787        // Add a bullet with "Closes: #" that should not be broken
2788        entry.add_bullet("Fix a very important bug that was causing problems (Closes: #123456)");
2789
2790        let lines: Vec<_> = entry.change_lines().collect();
2791        let text = lines.join(" ");
2792        // "Closes: #123456" should not be split across lines
2793        assert!(text.contains("Closes: #123456"));
2794    }
2795
2796    #[test]
2797    fn test_add_bullet_multiple_closes() {
2798        let mut changelog = ChangeLog::new();
2799        let entry = changelog
2800            .new_entry()
2801            .maintainer(("Test User".into(), "test@example.com".into()))
2802            .distribution("unstable".to_string())
2803            .version("1.0.0".parse().unwrap())
2804            .finish();
2805
2806        // Add bullet with multiple bug references
2807        entry.add_bullet("Fix several bugs (Closes: #123456, #789012)");
2808
2809        let lines: Vec<_> = entry.change_lines().collect();
2810        let text = lines.join(" ");
2811        assert!(text.contains("Closes: #123456"));
2812        assert!(text.contains("#789012"));
2813    }
2814
2815    #[test]
2816    fn test_add_bullet_preserves_lp() {
2817        let mut changelog = ChangeLog::new();
2818        let entry = changelog
2819            .new_entry()
2820            .maintainer(("Test User".into(), "test@example.com".into()))
2821            .distribution("unstable".to_string())
2822            .version("1.0.0".parse().unwrap())
2823            .finish();
2824
2825        // Add bullet with Launchpad bug reference
2826        entry.add_bullet("Fix bug (LP: #123456)");
2827
2828        let lines: Vec<_> = entry.change_lines().collect();
2829        let text = lines.join(" ");
2830        // "LP: #123456" should not be split
2831        assert!(text.contains("LP: #123456"));
2832    }
2833
2834    #[test]
2835    fn test_add_bullet_with_existing_bullets() {
2836        let mut changelog = ChangeLog::new();
2837        let entry = changelog
2838            .new_entry()
2839            .maintainer(("Test User".into(), "test@example.com".into()))
2840            .distribution("unstable".to_string())
2841            .version("1.0.0".parse().unwrap())
2842            .change_line("* Existing change".to_string())
2843            .finish();
2844
2845        // Add more bullets
2846        entry.add_bullet("New change");
2847
2848        let lines: Vec<_> = entry.change_lines().collect();
2849        assert_eq!(lines.len(), 2);
2850        assert_eq!(lines[0], "* Existing change");
2851        assert_eq!(lines[1], "* New change");
2852    }
2853
2854    #[test]
2855    fn test_add_bullet_special_characters() {
2856        let mut changelog = ChangeLog::new();
2857        let entry = changelog
2858            .new_entry()
2859            .maintainer(("Test User".into(), "test@example.com".into()))
2860            .distribution("unstable".to_string())
2861            .version("1.0.0".parse().unwrap())
2862            .finish();
2863
2864        entry.add_bullet("Fix issue with \"quotes\" and 'apostrophes'");
2865        entry.add_bullet("Handle paths like /usr/bin/foo");
2866        entry.add_bullet("Support $VARIABLES and ${EXPANSIONS}");
2867
2868        let lines: Vec<_> = entry.change_lines().collect();
2869        assert_eq!(lines.len(), 3);
2870        assert!(lines[0].contains("\"quotes\""));
2871        assert!(lines[1].contains("/usr/bin/foo"));
2872        assert!(lines[2].contains("$VARIABLES"));
2873    }
2874
2875    #[test]
2876    fn test_add_bullet_empty_string() {
2877        let mut changelog = ChangeLog::new();
2878        let entry = changelog
2879            .new_entry()
2880            .maintainer(("Test User".into(), "test@example.com".into()))
2881            .distribution("unstable".to_string())
2882            .version("1.0.0".parse().unwrap())
2883            .finish();
2884
2885        // Empty string gets filtered out by textwrap - this is expected behavior
2886        entry.add_bullet("");
2887
2888        let lines: Vec<_> = entry.change_lines().collect();
2889        // textwrap filters out empty strings, so no line is added
2890        assert_eq!(lines.len(), 0);
2891    }
2892
2893    #[test]
2894    fn test_add_bullet_url() {
2895        let mut changelog = ChangeLog::new();
2896        let entry = changelog
2897            .new_entry()
2898            .maintainer(("Test User".into(), "test@example.com".into()))
2899            .distribution("unstable".to_string())
2900            .version("1.0.0".parse().unwrap())
2901            .finish();
2902
2903        // Long URL should not be broken
2904        entry.add_bullet("Update documentation at https://www.example.com/very/long/path/to/documentation/page.html");
2905
2906        let lines: Vec<_> = entry.change_lines().collect();
2907        let text = lines.join(" ");
2908        assert!(text.contains("https://www.example.com"));
2909    }
2910
2911    #[test]
2912    fn test_add_bullet_mixed_with_manual_changes() {
2913        let mut changelog = ChangeLog::new();
2914        let entry = changelog
2915            .new_entry()
2916            .maintainer(("Test User".into(), "test@example.com".into()))
2917            .distribution("unstable".to_string())
2918            .version("1.0.0".parse().unwrap())
2919            .finish();
2920
2921        // Mix add_bullet with manual append_change_line
2922        entry.add_bullet("First bullet");
2923        entry.append_change_line("  Manual continuation line");
2924        entry.add_bullet("Second bullet");
2925
2926        let lines: Vec<_> = entry.change_lines().collect();
2927        assert_eq!(lines.len(), 3);
2928        assert_eq!(lines[0], "* First bullet");
2929        assert_eq!(lines[1], "  Manual continuation line");
2930        assert_eq!(lines[2], "* Second bullet");
2931    }
2932
2933    #[test]
2934    fn test_replace_line_with_continuation() {
2935        let changelog: ChangeLog = r#"breezy (3.3.4-1) unstable; urgency=low
2936
2937  * First change
2938    with continuation line
2939  * Second change
2940
2941 -- Jelmer Vernooij <jelmer@debian.org>  Mon, 04 Sep 2023 18:13:45 -0500
2942"#
2943        .parse()
2944        .unwrap();
2945
2946        let changes = iter_changes_by_author(&changelog);
2947
2948        // Replace the continuation line
2949        changes[0]
2950            .replace_line(1, "  with updated continuation")
2951            .unwrap();
2952
2953        let updated_changes = iter_changes_by_author(&changelog);
2954        assert_eq!(
2955            updated_changes[0].lines(),
2956            vec![
2957                "* First change",
2958                "  with updated continuation",
2959                "* Second change"
2960            ]
2961        );
2962    }
2963
2964    #[test]
2965    fn test_change_line_col() {
2966        let changelog: ChangeLog = r#"foo (1.0-1) unstable; urgency=low
2967
2968  * First change
2969  * Second change
2970
2971 -- Maintainer <email@example.com>  Mon, 01 Jan 2024 12:00:00 +0000
2972
2973bar (2.0-1) experimental; urgency=high
2974
2975  [ Alice ]
2976  * Alice's change
2977  * Alice's second change
2978
2979  [ Bob ]
2980  * Bob's change
2981
2982 -- Another <another@example.com>  Tue, 02 Jan 2024 13:00:00 +0000
2983"#
2984        .parse()
2985        .unwrap();
2986
2987        let changes = iter_changes_by_author(&changelog);
2988
2989        // Total: 1 unattributed (first entry) + Alice + Bob = 3 changes
2990        assert_eq!(changes.len(), 3);
2991
2992        // First change (unattributed) should be at line 2 (0-indexed)
2993        assert_eq!(changes[0].line(), Some(2));
2994        assert_eq!(changes[0].column(), Some(2)); // After "  "
2995        assert_eq!(changes[0].line_col(), Some((2, 2)));
2996        assert_eq!(changes[0].lines().len(), 2); // Two bullets in first entry
2997
2998        // Alice's changes - starts at line 10 (after "  [ Alice ]" on line 9)
2999        assert_eq!(changes[1].line(), Some(10));
3000        assert_eq!(changes[1].column(), Some(2)); // After "  "
3001        assert_eq!(changes[1].lines().len(), 2); // Two bullets
3002
3003        // Bob's changes - starts at line 14 (after blank line and "  [ Bob ]" on line 13)
3004        assert_eq!(changes[2].line(), Some(14));
3005        assert_eq!(changes[2].column(), Some(2)); // After "  "
3006        assert_eq!(changes[2].lines().len(), 1); // One bullet
3007    }
3008}