cargo_smart_release/changelog/
merge.rs

1use std::{collections::VecDeque, iter::FromIterator};
2
3use anyhow::bail;
4use gix::hash::ObjectId;
5
6use crate::{
7    changelog::{
8        section,
9        section::{segment::conventional, Segment},
10        Section, Version,
11    },
12    ChangeLog,
13};
14
15impl ChangeLog {
16    /// Bring `generated` into `self` in such a way that `self` preserves everything while enriching itself from `generated`.
17    /// Thus we clearly assume that `self` is parsed and `generated` is generated.
18    pub fn merge_generated(mut self, rhs: Self) -> anyhow::Result<Self> {
19        if self.sections.is_empty() {
20            return Ok(rhs);
21        }
22
23        let mut sections_to_merge = VecDeque::from_iter(rhs.sections);
24        let sections = &mut self.sections;
25
26        merge_generated_verbatim_section_if_there_is_only_releases_on_lhs(&mut sections_to_merge, sections);
27
28        let (first_release_pos, first_release_indentation, first_version_prefix) =
29            match sections.iter().enumerate().find_map(|(idx, s)| match s {
30                Section::Release {
31                    heading_level,
32                    version_prefix,
33                    ..
34                } => Some((idx, heading_level, version_prefix)),
35                _ => None,
36            }) {
37                Some((idx, level, prefix)) => (idx, *level, prefix.to_owned()),
38                None => {
39                    sections.extend(sections_to_merge);
40                    return Ok(self);
41                }
42            };
43
44        for mut section_to_merge in sections_to_merge {
45            match section_to_merge {
46                Section::Verbatim { .. } => {
47                    bail!("BUG: generated logs may only have verbatim sections at the beginning")
48                }
49                Section::Release { ref name, .. } => match find_target_section(name, sections, first_release_pos) {
50                    Insertion::MergeWith(pos) => sections[pos].merge(section_to_merge)?,
51                    Insertion::At(pos) => {
52                        if let Section::Release {
53                            heading_level,
54                            version_prefix,
55                            ..
56                        } = &mut section_to_merge
57                        {
58                            *heading_level = first_release_indentation;
59                            version_prefix.clone_from(&first_version_prefix);
60                        }
61                        sections.insert(pos, section_to_merge);
62                    }
63                },
64            }
65        }
66
67        Ok(self)
68    }
69}
70
71impl Section {
72    pub fn merge(&mut self, src: Section) -> anyhow::Result<()> {
73        let dest = self;
74        match (dest, src) {
75            (Section::Verbatim { .. }, _) | (_, Section::Verbatim { .. }) => {
76                bail!("BUG: we should never try to merge into or from a verbatim section")
77            }
78            (
79                Section::Release {
80                    date: dest_date,
81                    segments: dest_segments,
82                    removed_messages,
83                    ..
84                },
85                Section::Release {
86                    date: src_date,
87                    segments: src_segments,
88                    unknown: src_unknown,
89                    ..
90                },
91            ) => {
92                assert!(src_unknown.is_empty(), "shouldn't ever generate 'unknown' portions");
93                let has_no_read_only_segments = !dest_segments.iter().any(Segment::is_read_only);
94                let mode = if has_no_read_only_segments {
95                    ReplaceMode::ReplaceAllOrAppend
96                } else {
97                    ReplaceMode::ReplaceAllOrAppendIfPresentInLhs
98                };
99                for rhs_segment in src_segments {
100                    match rhs_segment {
101                        Segment::User { markdown } => {
102                            bail!("BUG: User segments are never auto-generated: {markdown}")
103                        }
104                        Segment::Details(section::Data::Parsed)
105                        | Segment::Statistics(section::Data::Parsed)
106                        | Segment::Clippy(section::Data::Parsed) => {
107                            bail!("BUG: Clippy, statistics, and details are set if generated, or not present")
108                        }
109                        Segment::Conventional(conventional) => {
110                            merge_conventional(removed_messages, dest_segments, conventional)?
111                        }
112                        clippy @ Segment::Clippy(_) => {
113                            merge_read_only_segment(dest_segments, |s| matches!(s, Segment::Clippy(_)), clippy, mode)
114                        }
115                        stats @ Segment::Statistics(_) => {
116                            merge_read_only_segment(dest_segments, |s| matches!(s, Segment::Statistics(_)), stats, mode)
117                        }
118                        details @ Segment::Details(_) => {
119                            merge_read_only_segment(dest_segments, |s| matches!(s, Segment::Details(_)), details, mode)
120                        }
121                    }
122                }
123                *dest_date = src_date;
124            }
125        }
126        Ok(())
127    }
128}
129
130#[derive(Clone, Copy)]
131enum ReplaceMode {
132    ReplaceAllOrAppend,
133    ReplaceAllOrAppendIfPresentInLhs,
134}
135
136fn merge_read_only_segment(
137    dest: &mut Vec<Segment>,
138    mut filter: impl FnMut(&section::Segment) -> bool,
139    insert: Segment,
140    mode: ReplaceMode,
141) {
142    let mut found_one = false;
143    for dest_segment in dest.iter_mut().filter(|s| filter(s)) {
144        *dest_segment = insert.clone();
145        found_one = true;
146    }
147    if !found_one && matches!(mode, ReplaceMode::ReplaceAllOrAppend) {
148        dest.push(insert);
149    }
150}
151
152fn merge_conventional(
153    removed_in_release: &[gix::hash::ObjectId],
154    dest_segments: &mut Vec<Segment>,
155    mut src: section::segment::Conventional,
156) -> anyhow::Result<()> {
157    assert!(
158        src.removed.is_empty(),
159        "generated sections never contains removed items"
160    );
161    let mut found_one = false;
162    for dest_segment in dest_segments.iter_mut().filter(
163        |s| matches!(s, Segment::Conventional(rhs) if rhs.kind == src.kind && rhs.is_breaking == src.is_breaking),
164    ) {
165        match dest_segment {
166            Segment::Conventional(section::segment::Conventional {
167                removed,
168                messages,
169                kind: _,
170                is_breaking: _,
171            }) => {
172                for src_message in src.messages.clone() {
173                    match src_message {
174                        conventional::Message::Generated { id, title, body } => {
175                            if removed.contains(&id)
176                                || removed_in_release.contains(&id)
177                                || messages.iter().any(
178                                    |m| matches!(m, conventional::Message::Generated {id: lhs_id, ..} if *lhs_id == id),
179                                )
180                            {
181                                continue;
182                            }
183                            let pos = messages
184                                .iter()
185                                .take_while(|m| matches!(m, conventional::Message::User { .. }))
186                                .enumerate()
187                                .map(|(pos, _)| pos + 1)
188                                .last()
189                                .unwrap_or(messages.len());
190                            messages.insert(pos, conventional::Message::Generated { id, title, body });
191                        }
192                        conventional::Message::User { .. } => bail!("User messages are never generated"),
193                    }
194                }
195            }
196            _ => bail!("assured correct type in filter"),
197        }
198        found_one = true;
199    }
200
201    if !found_one
202        && (has_user_messages(&src.messages)
203            || at_least_one_generated_message_visible(removed_in_release, &src.messages))
204    {
205        dest_segments.insert(
206            dest_segments
207                .iter()
208                .enumerate()
209                .find_map(|(pos, item)| {
210                    if matches!(item, Segment::User { .. }) {
211                        // we know that the segment that follows (if one) is generated, so this won't be between two user segments
212                        Some(pos + 1)
213                    } else {
214                        None
215                    }
216                })
217                .unwrap_or(0),
218            {
219                src.messages.retain(|m| match m {
220                    conventional::Message::User { .. } => true,
221                    conventional::Message::Generated { id, .. } => !removed_in_release.contains(id),
222                });
223                Segment::Conventional(src)
224            },
225        );
226    }
227    Ok(())
228}
229
230fn at_least_one_generated_message_visible(removed_in_release: &[ObjectId], messages: &[conventional::Message]) -> bool {
231    messages
232        .iter()
233        .any(|m| matches!(m, conventional::Message::Generated {id,..} if !removed_in_release.contains(id)))
234}
235
236fn has_user_messages(messages: &[conventional::Message]) -> bool {
237    messages.iter().any(|m| matches!(m, conventional::Message::User { .. }))
238}
239
240enum Insertion {
241    MergeWith(usize),
242    At(usize),
243}
244
245fn find_target_section(wanted: &Version, sections: &[Section], first_release_index: usize) -> Insertion {
246    if sections.is_empty() {
247        return Insertion::At(0);
248    }
249
250    match sections.iter().enumerate().find_map(|(idx, s)| match s {
251        Section::Release { name, .. } if name == wanted => Some(Insertion::MergeWith(idx)),
252        _ => None,
253    }) {
254        Some(res) => res,
255        None => match wanted {
256            Version::Unreleased => Insertion::At(first_release_index),
257            Version::Semantic(version) => {
258                let (mut pos, min_distance) = sections
259                    .iter()
260                    .enumerate()
261                    .map(|(idx, section)| {
262                        (
263                            idx,
264                            match section {
265                                Section::Verbatim { .. } => MAX_DISTANCE,
266                                Section::Release { name, .. } => version_distance(name, version),
267                            },
268                        )
269                    })
270                    .fold(
271                        (usize::MAX, MAX_DISTANCE),
272                        |(mut pos, mut dist), (cur_pos, cur_dist)| {
273                            if abs_distance(cur_dist) < abs_distance(dist) {
274                                dist = cur_dist;
275                                pos = cur_pos;
276                            }
277                            (pos, dist)
278                        },
279                    );
280                if pos == usize::MAX {
281                    // We had nothing to compare against, append to the end
282                    pos = sections.len();
283                }
284                if min_distance < (0, 0, 0) {
285                    Insertion::At(pos + 1)
286                } else {
287                    Insertion::At(pos)
288                }
289            }
290        },
291    }
292}
293
294type Distance = (i64, i64, i64);
295
296const MAX_DISTANCE: Distance = (i64::MAX, i64::MAX, i64::MAX);
297
298fn abs_distance((x, y, z): Distance) -> Distance {
299    (x.abs(), y.abs(), z.abs())
300}
301
302fn version_distance(from: &Version, to: &semver::Version) -> Distance {
303    match from {
304        Version::Unreleased => MAX_DISTANCE,
305        Version::Semantic(from) => (
306            to.major as i64 - from.major as i64,
307            to.minor as i64 - from.minor as i64,
308            to.patch as i64 - from.patch as i64,
309        ),
310    }
311}
312
313fn merge_generated_verbatim_section_if_there_is_only_releases_on_lhs(
314    sections_to_merge: &mut VecDeque<Section>,
315    sections: &mut Vec<Section>,
316) {
317    while let Some(section_to_merge) = sections_to_merge.pop_front() {
318        match section_to_merge {
319            Section::Verbatim { generated, .. } => {
320                assert!(generated, "BUG: rhs must always be generated");
321                let first_section = &sections[0];
322                if matches!(first_section, Section::Release { .. })
323                    || matches!(first_section, Section::Verbatim {generated, ..} if *generated )
324                {
325                    sections.insert(0, section_to_merge)
326                }
327            }
328            Section::Release { .. } => {
329                sections_to_merge.push_front(section_to_merge);
330                break;
331            }
332        }
333    }
334}