1use crate::{Adr, Level, validated_pending_in};
15use std::fmt;
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Version {
23 pub major: u64,
24 pub minor: u64,
25 pub patch: u64,
26}
27
28impl Version {
29 pub fn next(self, level: &Level) -> Version {
32 match level {
33 Level::Minor => Version {
34 minor: self.minor + 1,
35 patch: 0,
36 ..self
37 },
38 Level::Patch => Version {
39 patch: self.patch + 1,
40 ..self
41 },
42 }
43 }
44
45 pub fn parse(s: &str) -> Option<Version> {
47 let mut it = s.trim().split('.');
48 let major = it.next()?.parse().ok()?;
49 let minor = it.next()?.parse().ok()?;
50 let patch = it.next()?.parse().ok()?;
51 if it.next().is_some() {
52 return None;
53 }
54 Some(Version {
55 major,
56 minor,
57 patch,
58 })
59 }
60}
61
62impl fmt::Display for Version {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
65 }
66}
67
68impl Version {
69 pub fn short(self) -> String {
74 if self.patch == 0 {
75 format!("{}.{}", self.major, self.minor)
76 } else {
77 self.to_string()
78 }
79 }
80}
81
82#[derive(Debug)]
84pub struct Stamped {
85 pub file: String,
87 pub version: Version,
88 pub changelog: String,
89 pub adrs: Vec<Adr>,
90 pub closes_rule: Vec<String>,
93}
94
95#[derive(Debug)]
97pub struct Plan {
98 pub base_version: Version,
99 pub increments: Vec<Stamped>,
100 pub first_adr_number: u32,
102}
103
104impl Plan {
105 pub fn final_version(&self) -> Version {
108 self.increments
109 .last()
110 .map(|s| s.version)
111 .unwrap_or(self.base_version)
112 }
113
114 pub fn is_empty(&self) -> bool {
115 self.increments.is_empty()
116 }
117}
118
119pub fn plan(root: &Path) -> Result<Plan, Vec<String>> {
123 let cargo = root.join("Cargo.toml");
124 let cargo_src = fs::read_to_string(&cargo)
125 .map_err(|e| vec![format!("cannot read {}: {e}", cargo.display())])?;
126 let base_version = parse_workspace_version(&cargo_src)
127 .ok_or_else(|| vec![format!("no `version = \"X.Y.Z\"` in {}", cargo.display())])?;
128
129 let pending = validated_pending_in(root)?;
130 let first_adr_number = next_adr_number(root).map_err(|e| {
131 vec![format!(
132 "cannot scan {}: {e}",
133 root.join("design/decisions").display()
134 )]
135 })?;
136
137 let mut version = base_version;
138 let mut increments = Vec::new();
139 for (file, p) in pending {
140 version = version.next(&p.level);
141 increments.push(Stamped {
142 file,
143 version,
144 changelog: p.changelog,
145 adrs: p.adrs,
146 closes_rule: p.closes_rule,
147 });
148 }
149
150 Ok(Plan {
151 base_version,
152 increments,
153 first_adr_number,
154 })
155}
156
157pub fn apply(
189 root: &Path,
190 plan: &Plan,
191 pr_number: Option<u32>,
192 bump: impl Fn(Version) -> io::Result<()>,
193) -> io::Result<()> {
194 if plan.is_empty() {
195 return Ok(());
196 }
197
198 let decisions = root.join("design/decisions");
199 let changelog_path = root.join("site/src/content/docs/book/reference/changelog.md");
200 let readme_path = decisions.join("README.md");
201 let rule_ledger_path = root.join("design/greenfield-status-rules.md");
202
203 let ledger_pr_number = if plan.increments.len() == 1 {
213 pr_number
214 } else {
215 None
216 };
217
218 let mut number = plan.first_adr_number;
222 let mut adr_files: Vec<(PathBuf, String)> = Vec::new();
223 let mut changelog_rows = Vec::new();
224 let mut index_rows = Vec::new();
225 let mut rule_ledger_rows_all = Vec::new();
226 for inc in &plan.increments {
227 for adr in &inc.adrs {
228 let path = decisions.join(format!("{number:04}-{}.md", adr.slug));
229 if path.exists() {
233 return Err(io::Error::new(
234 io::ErrorKind::AlreadyExists,
235 format!(
236 "ADR {} already exists — refusing to overwrite",
237 path.display()
238 ),
239 ));
240 }
241 adr_files.push((path, adr_file_contents(number, adr, inc.version)));
242 index_rows.push(index_row(number, adr, inc.version));
243 number += 1;
244 }
245 changelog_rows.push(changelog_row(inc.version, &inc.changelog));
246 rule_ledger_rows_all.extend(rule_ledger_rows(inc, ledger_pr_number));
247 }
248
249 changelog_rows.reverse();
252 index_rows.reverse();
253 rule_ledger_rows_all.reverse();
254
255 let changelog_before = fs::read_to_string(&changelog_path)?;
256 let changelog_after =
257 insert_after_table_separator(&changelog_before, "| Version |", &changelog_rows)
258 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
259
260 let readme_edit = if index_rows.is_empty() {
262 None
263 } else {
264 let before = fs::read_to_string(&readme_path)?;
265 let after = insert_after_table_separator(&before, "| # | Decision |", &index_rows)
266 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
267 Some((before, after))
268 };
269
270 let ledger_existed = rule_ledger_path.exists();
276 let ledger_edit = if rule_ledger_rows_all.is_empty() {
277 None
278 } else {
279 let ledger_before = if ledger_existed {
280 Some(fs::read_to_string(&rule_ledger_path)?)
281 } else {
282 None
283 };
284 let base = ledger_before
285 .clone()
286 .unwrap_or_else(|| RULE_LEDGER_HEADER.to_string());
287 let after = insert_after_table_separator(&base, "| Rule |", &rule_ledger_rows_all)
288 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
289 Some((ledger_before, after))
290 };
291
292 let mut written_adrs: Vec<PathBuf> = Vec::new();
295 let commit = (|| -> io::Result<()> {
296 for (path, contents) in &adr_files {
297 fs::write(path, contents)?;
298 written_adrs.push(path.clone());
299 }
300 fs::write(&changelog_path, &changelog_after)?;
301 if let Some((_, after)) = &readme_edit {
302 fs::write(&readme_path, after)?;
303 }
304 if let Some((_, after)) = &ledger_edit {
305 fs::write(&rule_ledger_path, after)?;
306 }
307 bump(plan.final_version())
311 })();
312
313 if let Err(e) = commit {
314 let _ = fs::write(&changelog_path, &changelog_before);
318 if let Some((before, _)) = &readme_edit {
319 let _ = fs::write(&readme_path, before);
320 }
321 if let Some((before, _)) = &ledger_edit {
322 match before {
323 Some(orig) => {
324 let _ = fs::write(&rule_ledger_path, orig);
325 }
326 None => {
327 let _ = fs::remove_file(&rule_ledger_path);
328 }
329 }
330 }
331 for path in &written_adrs {
332 let _ = fs::remove_file(path);
333 }
334 return Err(e);
335 }
336
337 for inc in &plan.increments {
340 fs::remove_file(root.join("design/pending").join(&inc.file))?;
341 }
342 Ok(())
343}
344
345pub fn parse_workspace_version(cargo_toml: &str) -> Option<Version> {
353 let mut in_section = false;
354 for line in cargo_toml.lines() {
355 let trimmed = line.trim();
356 if trimmed.starts_with('[') && trimmed.ends_with(']') {
358 in_section = trimmed == "[workspace.package]";
359 continue;
360 }
361 if in_section
362 && let Some(value) = line
363 .strip_prefix("version = \"")
364 .and_then(|r| r.strip_suffix('"'))
365 {
366 return Version::parse(value);
367 }
368 }
369 None
370}
371
372pub fn next_adr_number(root: &Path) -> io::Result<u32> {
376 let dir = root.join("design/decisions");
377 let mut max = 0;
378 for entry in fs::read_dir(&dir)? {
379 let name = entry?.file_name().to_string_lossy().into_owned();
380 let bytes = name.as_bytes();
381 let is_adr = name.ends_with(".md")
382 && bytes.len() > 5
383 && bytes[4] == b'-'
384 && bytes[..4].iter().all(u8::is_ascii_digit);
385 if is_adr && let Ok(n) = name[..4].parse::<u32>() {
386 max = max.max(n);
387 }
388 }
389 Ok(max + 1)
390}
391
392pub fn changelog_row(version: Version, blurb: &str) -> String {
394 format!("| **v{version}** | {blurb} |")
395}
396
397pub fn adr_file_contents(number: u32, adr: &Adr, version: Version) -> String {
400 format!(
401 "# {number:04} — {title}\n\n- **Status:** {status} (v{ver})\n\n{body}\n",
402 title = adr.title,
403 status = adr.status(),
404 ver = version.short(),
405 body = adr.body,
406 )
407}
408
409pub fn index_row(number: u32, adr: &Adr, version: Version) -> String {
411 format!(
412 "| [{number:04}]({number:04}-{slug}.md) | **{title}** (v{ver}) — {summary} | {status} (v{ver}) |",
413 slug = adr.slug,
414 title = adr.title,
415 ver = version.short(),
416 summary = adr.summary(),
417 status = adr.status(),
418 )
419}
420
421const RULE_LEDGER_HEADER: &str = "<!-- GENERATED, APPEND-ONLY — written by `cargo xtask stamp \
426--apply` (xtask/src/stamp.rs) at merge, one row per rule id an increment cites in its \
427`closes_rule` frontmatter (#1001).\n Read by `cargo xtask greenfield-status` to populate \
428the reference's rule-citation surface. Do not hand-edit. -->\n\n# Rules closed\n\n\
429| Rule | Version | PR | Changelog |\n|---|---|---|---|\n";
430
431pub fn rule_ledger_rows(inc: &Stamped, pr: Option<u32>) -> Vec<String> {
436 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_default();
437 inc.closes_rule
438 .iter()
439 .map(|rule| {
440 format!(
441 "| {rule} | v{} | {pr_cell} | {} |",
442 inc.version, inc.changelog
443 )
444 })
445 .collect()
446}
447
448pub fn insert_after_table_separator(
453 md: &str,
454 header_anchor: &str,
455 rows: &[String],
456) -> Result<String, String> {
457 if rows.is_empty() {
458 return Ok(md.to_string());
459 }
460 let mut out = Vec::new();
461 let mut seen_header = false;
462 let mut inserted = false;
463 for line in md.lines() {
464 out.push(line.to_string());
465 if inserted {
466 continue;
467 }
468 if !seen_header {
469 seen_header = line.contains(header_anchor);
470 } else if is_table_separator(line) {
471 out.extend(rows.iter().cloned());
472 inserted = true;
473 }
474 }
475 if !inserted {
476 return Err(format!(
477 "no table with header {header_anchor:?} (and a `|---|` separator) to insert after"
478 ));
479 }
480 let mut joined = out.join("\n");
482 if md.ends_with('\n') {
483 joined.push('\n');
484 }
485 Ok(joined)
486}
487
488fn is_table_separator(line: &str) -> bool {
491 let t = line.trim();
492 t.starts_with('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 fn adr(slug: &str, title: &str) -> Adr {
500 Adr {
501 slug: slug.into(),
502 title: title.into(),
503 summary: None,
504 status: None,
505 body: "**Decision.** Do the thing.".into(),
506 }
507 }
508
509 #[test]
510 fn version_next() {
511 let v = Version {
512 major: 0,
513 minor: 185,
514 patch: 0,
515 };
516 assert_eq!(v.next(&Level::Minor).to_string(), "0.186.0");
517 assert_eq!(v.next(&Level::Patch).to_string(), "0.185.1");
518 let p = Version {
519 major: 0,
520 minor: 185,
521 patch: 3,
522 };
523 assert_eq!(p.next(&Level::Minor).to_string(), "0.186.0");
524 }
525
526 #[test]
527 fn parse_workspace_version_ignores_dep_specs() {
528 let cargo = "[workspace.package]\nversion = \"0.185.0\"\n\n[workspace.dependencies]\nbynk = { path = \"bynk\", version = \"0.185.0\" }\n";
529 assert_eq!(
530 parse_workspace_version(cargo).unwrap().to_string(),
531 "0.185.0"
532 );
533 }
534
535 #[test]
536 fn parse_workspace_version_is_scoped_to_the_workspace_package_table() {
537 let cargo = "[package]\nname = \"root\"\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.185.0\"\n";
540 assert_eq!(
541 parse_workspace_version(cargo).unwrap().to_string(),
542 "0.185.0"
543 );
544 }
545
546 #[test]
547 fn parse_workspace_version_none_without_the_table() {
548 assert!(parse_workspace_version("version = \"1.2.3\"\n").is_none());
551 }
552
553 #[test]
554 fn changelog_row_format() {
555 let v = Version {
556 major: 0,
557 minor: 186,
558 patch: 0,
559 };
560 assert_eq!(
561 changelog_row(v, "Add a thing"),
562 "| **v0.186.0** | Add a thing |"
563 );
564 }
565
566 #[test]
567 fn version_short_follows_the_corpus_convention() {
568 assert_eq!(
570 Version {
571 major: 0,
572 minor: 186,
573 patch: 0
574 }
575 .short(),
576 "0.186"
577 );
578 assert_eq!(
579 Version {
580 major: 0,
581 minor: 185,
582 patch: 1
583 }
584 .short(),
585 "0.185.1"
586 );
587 }
588
589 #[test]
590 fn adr_file_and_index_row_use_the_short_version() {
591 let v = Version {
592 major: 0,
593 minor: 186,
594 patch: 0,
595 };
596 let a = adr("a-slug", "The title");
597 let file = adr_file_contents(206, &a, v);
598 assert!(file.starts_with("# 0206 — The title\n"));
599 assert!(file.contains("- **Status:** Accepted (v0.186)"));
601 assert!(file.trim_end().ends_with("Do the thing."));
602
603 let row = index_row(206, &a, v);
604 assert_eq!(
605 row,
606 "| [0206](0206-a-slug.md) | **The title** (v0.186) — The title | Accepted (v0.186) |"
607 );
608 }
609
610 #[test]
611 fn patch_increment_index_row_keeps_the_patch() {
612 let v = Version {
613 major: 0,
614 minor: 185,
615 patch: 1,
616 };
617 let a = adr("s", "T");
618 assert!(index_row(9, &a, v).contains("(v0.185.1)"));
619 }
620
621 #[test]
622 fn index_row_uses_explicit_summary_and_status() {
623 let v = Version {
624 major: 0,
625 minor: 1,
626 patch: 0,
627 };
628 let a = Adr {
629 slug: "s".into(),
630 title: "T".into(),
631 summary: Some("the distillation".into()),
632 status: Some("Proposed".into()),
633 body: "b".into(),
634 };
635 assert_eq!(
636 index_row(7, &a, v),
637 "| [0007](0007-s.md) | **T** (v0.1) — the distillation | Proposed (v0.1) |"
638 );
639 }
640
641 #[test]
642 fn insert_prepends_after_the_anchored_table_newest_first() {
643 let md =
644 "## Recent increments\n\n| Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
645 let out = insert_after_table_separator(
646 md,
647 "| Version |",
648 &["| NEW1 |".into(), "| NEW2 |".into()],
649 )
650 .unwrap();
651 let lines: Vec<&str> = out.lines().collect();
652 let sep = lines.iter().position(|l| *l == "|---|---|").unwrap();
653 assert_eq!(lines[sep + 1], "| NEW1 |");
654 assert_eq!(lines[sep + 2], "| NEW2 |");
655 assert_eq!(lines[sep + 3], "| **v0.185.0** | Old |");
656 assert!(out.ends_with('\n'));
657 }
658
659 #[test]
660 fn insert_skips_an_earlier_unrelated_table() {
661 let md = "| Legend | Meaning |\n|---|---|\n| x | y |\n\n\
663 | Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
664 let out = insert_after_table_separator(md, "| Version |", &["| NEW |".into()]).unwrap();
665 let lines: Vec<&str> = out.lines().collect();
666 let target = lines
668 .iter()
669 .position(|l| *l == "| **v0.185.0** | Old |")
670 .unwrap();
671 assert_eq!(lines[target - 1], "| NEW |");
672 assert!(
673 !out.contains("| x | y |\n| NEW |"),
674 "must not touch the legend table"
675 );
676 }
677
678 #[test]
679 fn insert_without_the_anchored_table_errors() {
680 assert!(
681 insert_after_table_separator("no table here\n", "| Version |", &["| r |".into()])
682 .is_err()
683 );
684 }
685}