1use std::ops::Range;
5
6const RELEASES_SECTION: &str =
7 "## Releases\n\nSee [releases.md](releases.md) for the release history.";
8const RELEASES_START: &str = "<!-- bake-readme:releases:start -->";
9const RELEASES_END: &str = "<!-- bake-readme:releases:end -->";
10const RECENT_RELEASE_COUNT: usize = 3;
11
12struct Heading<'document> {
13 title: &'document str,
14 level: usize,
15 start: usize,
16 body_start: usize,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct PackageMetadata {
22 pub name: String,
24 pub description: Option<String>,
26 pub repository: Option<String>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct Release {
33 pub name: String,
35 pub notes: String,
37}
38
39fn headings(document: &str) -> Vec<Heading<'_>> {
40 let mut headings = Vec::new();
41 let mut offset = 0;
42 let mut fence: Option<(u8, usize)> = None;
43
44 for line in document.split_inclusive('\n') {
45 let text = line.trim_end_matches(['\r', '\n']);
46 let trimmed = text.trim_start_matches(' ');
47 let indentation = text.len() - trimmed.len();
48 let marker = trimmed.as_bytes().first().copied().unwrap_or_default();
49 let count = trimmed.bytes().take_while(|byte| *byte == marker).count();
50
51 if let Some((open_marker, open_count)) = fence {
52 if indentation <= 3
53 && marker == open_marker
54 && count >= open_count
55 && trimmed[count..].trim().is_empty()
56 {
57 fence = None;
58 }
59 } else if indentation <= 3
60 && matches!(marker, b'`' | b'~')
61 && count >= 3
62 && (marker != b'`' || !trimmed[count..].contains('`'))
63 {
64 fence = Some((marker, count));
65 } else if indentation == 0
66 && marker == b'#'
67 && (1..=6).contains(&count)
68 && (text.len() == count || text.as_bytes()[count].is_ascii_whitespace())
69 {
70 let title = trimmed[count..].trim();
71 let title = trim_closing_hashes(title);
72 headings.push(Heading {
73 title,
74 level: count,
75 start: offset,
76 body_start: offset + line.len(),
77 });
78 }
79
80 offset += line.len();
81 }
82
83 headings
84}
85
86fn trim_closing_hashes(title: &str) -> &str {
87 let without_hashes = title.trim_end_matches('#');
88 if without_hashes.len() < title.len() && without_hashes.ends_with([' ', '\t']) {
89 without_hashes.trim_end()
90 } else {
91 title
92 }
93}
94
95fn trailing_blank_lines_start(document: &str, position: usize) -> Range<usize> {
96 let prefix = &document[..position];
97 let mut start = position;
98 for line in prefix.split_inclusive('\n').rev() {
99 if line.trim().is_empty() {
100 start -= line.len();
101 } else {
102 break;
103 }
104 }
105 start..position
106}
107
108fn newline(document: &str) -> &'static str {
109 if document.contains("\r\n") {
110 "\r\n"
111 } else {
112 "\n"
113 }
114}
115
116fn rendered_package_entry(package: &PackageMetadata) -> String {
117 let name = package.name.trim();
118 let name_link = match package
119 .repository
120 .as_deref()
121 .filter(|value| !value.trim().is_empty())
122 {
123 Some(repository) => format!("[{name}]({repository})"),
124 None => format!("`{name}`"),
125 };
126 let description = package
127 .description
128 .as_deref()
129 .map(str::split_whitespace)
130 .map(|words| words.collect::<Vec<_>>())
131 .filter(|words| !words.is_empty())
132 .map(|words| format!(" — {}", words.join(" ")))
133 .unwrap_or_default();
134
135 format!("- {name_link}{description} <!-- bake-readme:package -->")
136}
137
138fn section_end(document: &str, headings: &[Heading<'_>], index: usize) -> usize {
139 let heading = &headings[index];
140 headings
141 .iter()
142 .skip(index + 1)
143 .find(|next| next.level <= heading.level)
144 .map_or(document.len(), |next| next.start)
145}
146
147pub fn recent_releases(document: &str) -> Vec<Release> {
149 let headings = headings(document);
150 let Some((releases_index, releases_heading)) = headings
151 .iter()
152 .enumerate()
153 .find(|(_, heading)| heading.level == 1 && heading.title == "Releases")
154 else {
155 return Vec::new();
156 };
157 let releases_end = section_end(document, &headings, releases_index);
158
159 headings
160 .iter()
161 .enumerate()
162 .filter(|(_, heading)| {
163 heading.level == 2
164 && heading.start >= releases_heading.body_start
165 && heading.start < releases_end
166 && !heading.title.eq_ignore_ascii_case("Unreleased")
167 })
168 .take(RECENT_RELEASE_COUNT)
169 .map(|(index, heading)| Release {
170 name: heading.title.to_owned(),
171 notes: document[heading.body_start..section_end(document, &headings, index)]
172 .trim()
173 .to_owned(),
174 })
175 .collect()
176}
177
178fn rendered_recent_releases(releases: &[Release], line_ending: &str) -> String {
179 let mut content = String::from("See [releases.md](releases.md) for the full release history.");
180
181 for release in releases {
182 content.push_str(line_ending);
183 content.push_str(line_ending);
184 content.push_str("### ");
185 content.push_str(&release.name);
186 if !release.notes.is_empty() {
187 content.push_str(line_ending);
188 content.push_str(line_ending);
189 content.push_str(
190 &release
191 .notes
192 .replace("\r\n", "\n")
193 .replace('\n', line_ending),
194 );
195 }
196 }
197
198 content
199}
200
201fn marker_line(document: &str, range: Range<usize>, marker: &str) -> Option<(usize, usize)> {
202 let mut offset = range.start;
203 for line in document[range.clone()].split_inclusive('\n') {
204 let content = line.trim_end_matches(['\r', '\n']).trim();
205 if content == marker {
206 return Some((offset, offset + line.len()));
207 }
208 offset += line.len();
209 }
210 None
211}
212
213fn replace_generated_releases(
214 document: &str,
215 body: Range<usize>,
216 releases: &[Release],
217) -> Option<String> {
218 let start = marker_line(document, body.clone(), RELEASES_START);
219 let end = marker_line(document, body.clone(), RELEASES_END);
220 let line_ending = newline(document);
221 let content = rendered_recent_releases(releases, line_ending);
222
223 if let (Some((_, start_end)), Some((end_start, _))) = (start, end) {
224 if start_end <= end_start {
225 let mut updated = String::with_capacity(document.len() + content.len());
226 updated.push_str(&document[..start_end]);
227 updated.push_str(&content);
228 updated.push_str(line_ending);
229 updated.push_str(&document[end_start..]);
230 return Some(updated);
231 }
232 return None;
233 }
234
235 if document[body.clone()].trim() == "See [releases.md](releases.md) for the release history." {
237 let replacement = format!(
238 "{line_ending}{RELEASES_START}{line_ending}{content}{line_ending}{RELEASES_END}{line_ending}{line_ending}"
239 );
240 let mut updated = String::with_capacity(document.len() + replacement.len());
241 updated.push_str(&document[..body.start]);
242 updated.push_str(&replacement);
243 updated.push_str(&document[body.end..]);
244 return Some(updated);
245 }
246
247 None
248}
249
250pub fn update_releases_section(document: &str, releases: &[Release]) -> String {
255 let document_headings = headings(document);
256 if let Some((index, heading)) = document_headings
257 .iter()
258 .enumerate()
259 .find(|(_, heading)| heading.title == "Releases")
260 {
261 let body = heading.body_start..section_end(document, &document_headings, index);
262 return replace_generated_releases(document, body, releases)
263 .unwrap_or_else(|| document.to_owned());
264 }
265
266 let document = ensure_releases_section(document);
267 let generated_headings = headings(&document);
268 let Some((index, heading)) = generated_headings
269 .iter()
270 .enumerate()
271 .find(|(_, heading)| heading.title == "Releases")
272 else {
273 return document;
274 };
275 let body = heading.body_start..section_end(&document, &generated_headings, index);
276 replace_generated_releases(&document, body, releases).unwrap_or(document)
277}
278
279fn update_see_also_section(document: &str, package: Option<&PackageMetadata>) -> String {
280 let headings = headings(document);
281 let Some(package) = package else {
282 return document.to_owned();
283 };
284 let entry = rendered_package_entry(package);
285 let newline = newline(document);
286
287 if let Some((index, heading)) = headings
288 .iter()
289 .enumerate()
290 .find(|(_, heading)| heading.title == "See Also")
291 {
292 let end = section_end(document, &headings, index);
293 let body = &document[heading.body_start..end];
294 if let Some((line_start, line_end, line_ending)) = body
295 .split_inclusive('\n')
296 .scan(heading.body_start, |offset, line| {
297 let start = *offset;
298 *offset += line.len();
299 Some((start, *offset, line))
300 })
301 .find_map(|(start, end, line)| {
302 line.contains("<!-- bake-readme:package -->").then(|| {
303 let line_ending = if line.ends_with("\r\n") {
304 "\r\n"
305 } else if line.ends_with('\n') {
306 "\n"
307 } else {
308 ""
309 };
310 (start, end, line_ending)
311 })
312 })
313 {
314 let mut updated = String::with_capacity(document.len() + entry.len());
315 updated.push_str(&document[..line_start]);
316 updated.push_str(&entry);
317 updated.push_str(line_ending);
318 updated.push_str(&document[line_end..]);
319 return updated;
320 }
321
322 if let Some(repository) = package.repository.as_deref()
323 && body.contains(repository)
324 {
325 return document.to_owned();
326 }
327
328 let mut insertion = heading.body_start;
329 while insertion < end {
330 let remaining = &document[insertion..end];
331 let Some(line_end) = remaining.find('\n').map(|index| insertion + index + 1) else {
332 break;
333 };
334 if document[insertion..line_end].trim().is_empty() {
335 insertion = line_end;
336 } else {
337 break;
338 }
339 }
340 let prefix = &document[..insertion];
341 let leading = if insertion == heading.body_start
342 && (!prefix.ends_with('\n') || !body.starts_with('\n'))
343 {
344 newline
345 } else {
346 ""
347 };
348 let mut updated = String::with_capacity(document.len() + entry.len() + 4);
349 updated.push_str(prefix);
350 updated.push_str(leading);
351 updated.push_str(&entry);
352 updated.push_str(newline);
353 updated.push_str(newline);
354 updated.push_str(&document[insertion..]);
355 return updated;
356 }
357
358 let target = headings
359 .iter()
360 .find(|heading| heading.title == "Contributing");
361 let section = format!("## See Also{newline}{newline}{entry}");
362
363 if let Some(target) = target {
364 let insertion = trailing_blank_lines_start(document, target.start).start;
365 let prefix = &document[..insertion];
366 let preceding_newlines = prefix
367 .chars()
368 .rev()
369 .take_while(|character| *character == '\n')
370 .count();
371 let leading = if insertion == 0 || preceding_newlines >= 2 {
372 ""
373 } else if preceding_newlines == 1 {
374 newline
375 } else if newline == "\r\n" {
376 "\r\n\r\n"
377 } else {
378 "\n\n"
379 };
380 let mut updated = String::with_capacity(document.len() + section.len() + 8);
381 updated.push_str(prefix);
382 updated.push_str(leading);
383 updated.push_str(§ion);
384 updated.push_str(newline);
385 updated.push_str(newline);
386 updated.push_str(&document[target.start..]);
387 updated
388 } else {
389 let separator = if document.is_empty() || document.ends_with("\n\n") {
390 ""
391 } else if document.ends_with('\n') {
392 newline
393 } else if newline == "\r\n" {
394 "\r\n\r\n"
395 } else {
396 "\n\n"
397 };
398 let mut updated = String::with_capacity(document.len() + section.len() + 4);
399 updated.push_str(document);
400 updated.push_str(separator);
401 updated.push_str(§ion);
402 updated.push_str(newline);
403 updated
404 }
405}
406
407pub fn ensure_releases_section(document: &str) -> String {
414 let headings = headings(document);
415 if headings.iter().any(|heading| heading.title == "Releases") {
416 return document.to_owned();
417 }
418
419 let target = headings
420 .iter()
421 .find(|heading| matches!(heading.title, "See Also" | "Contributing"));
422
423 let newline = newline(document);
424 let section = RELEASES_SECTION.replace('\n', newline);
425
426 if let Some(target) = target {
427 let insertion = trailing_blank_lines_start(document, target.start).start;
428 let prefix = &document[..insertion];
429 let preceding_newlines = prefix
430 .chars()
431 .rev()
432 .take_while(|character| *character == '\n')
433 .count();
434 let leading = if insertion == 0 || preceding_newlines >= 2 {
435 ""
436 } else if preceding_newlines == 1 {
437 newline
438 } else {
439 if newline == "\r\n" {
440 "\r\n\r\n"
441 } else {
442 "\n\n"
443 }
444 };
445 let mut updated = String::with_capacity(document.len() + section.len() + 8);
446 updated.push_str(prefix);
447 updated.push_str(leading);
448 updated.push_str(§ion);
449 updated.push_str(newline);
450 updated.push_str(newline);
451 updated.push_str(&document[target.start..]);
452 updated
453 } else {
454 let separator = if document.is_empty() || document.ends_with("\n\n") {
455 ""
456 } else if document.ends_with('\n') {
457 newline
458 } else if newline == "\r\n" {
459 "\r\n\r\n"
460 } else {
461 "\n\n"
462 };
463 let mut updated =
464 String::with_capacity(document.len() + section.len() + separator.len() + 1);
465 updated.push_str(document);
466 updated.push_str(separator);
467 updated.push_str(§ion);
468 updated.push_str(newline);
469 updated
470 }
471}
472
473pub fn update_document(document: &str, package: Option<&PackageMetadata>) -> String {
478 let updated = update_see_also_section(document, package);
479 ensure_releases_section(&updated)
480}
481
482pub fn update_document_with_releases(
484 document: &str,
485 package: Option<&PackageMetadata>,
486 releases: &[Release],
487) -> String {
488 let updated = update_see_also_section(document, package);
489 update_releases_section(&updated, releases)
490}