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 pub probes: Vec<String>,
96}
97
98#[derive(Debug)]
100pub struct Plan {
101 pub base_version: Version,
102 pub increments: Vec<Stamped>,
103 pub first_adr_number: u32,
105}
106
107impl Plan {
108 pub fn final_version(&self) -> Version {
111 self.increments
112 .last()
113 .map(|s| s.version)
114 .unwrap_or(self.base_version)
115 }
116
117 pub fn is_empty(&self) -> bool {
118 self.increments.is_empty()
119 }
120}
121
122pub fn plan(root: &Path) -> Result<Plan, Vec<String>> {
126 let cargo = root.join("Cargo.toml");
127 let cargo_src = fs::read_to_string(&cargo)
128 .map_err(|e| vec![format!("cannot read {}: {e}", cargo.display())])?;
129 let base_version = parse_workspace_version(&cargo_src)
130 .ok_or_else(|| vec![format!("no `version = \"X.Y.Z\"` in {}", cargo.display())])?;
131
132 let pending = validated_pending_in(root)?;
133 let first_adr_number = next_adr_number(root).map_err(|e| {
134 vec![format!(
135 "cannot scan {}: {e}",
136 root.join("design/decisions").display()
137 )]
138 })?;
139
140 let mut version = base_version;
141 let mut increments = Vec::new();
142 for (file, p) in pending {
143 version = version.next(&p.level);
144 increments.push(Stamped {
145 file,
146 version,
147 changelog: p.changelog,
148 adrs: p.adrs,
149 closes_rule: p.closes_rule,
150 probes: p.probes,
151 });
152 }
153
154 Ok(Plan {
155 base_version,
156 increments,
157 first_adr_number,
158 })
159}
160
161pub fn apply(
193 root: &Path,
194 plan: &Plan,
195 pr_number: Option<u32>,
196 bump: impl Fn(Version) -> io::Result<()>,
197) -> io::Result<()> {
198 if plan.is_empty() {
199 return Ok(());
200 }
201
202 let decisions = root.join("design/decisions");
203 let changelog_path = root.join("site/src/content/docs/book/reference/changelog.md");
204 let readme_path = decisions.join("README.md");
205 let rule_ledger_path = root.join("design/greenfield-status-rules.md");
206
207 let ledger_pr_number = if plan.increments.len() == 1 {
217 pr_number
218 } else {
219 None
220 };
221
222 let mut number = plan.first_adr_number;
226 let mut adr_files: Vec<(PathBuf, String)> = Vec::new();
227 let mut changelog_rows = Vec::new();
228 let mut index_rows = Vec::new();
229 let mut rule_ledger_rows_all = Vec::new();
230 for inc in &plan.increments {
231 for adr in &inc.adrs {
232 let path = decisions.join(format!("{number:04}-{}.md", adr.slug));
233 if path.exists() {
237 return Err(io::Error::new(
238 io::ErrorKind::AlreadyExists,
239 format!(
240 "ADR {} already exists — refusing to overwrite",
241 path.display()
242 ),
243 ));
244 }
245 adr_files.push((path, adr_file_contents(number, adr, inc.version)));
246 index_rows.push(index_row(number, adr, inc.version));
247 number += 1;
248 }
249 changelog_rows.push(changelog_row(inc.version, &inc.changelog));
250 rule_ledger_rows_all.extend(rule_ledger_rows(inc, ledger_pr_number));
251 }
252
253 changelog_rows.reverse();
256 index_rows.reverse();
257 rule_ledger_rows_all.reverse();
258
259 let changelog_before = fs::read_to_string(&changelog_path)?;
260 let changelog_after =
261 insert_after_table_separator(&changelog_before, "| Version |", &changelog_rows)
262 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
263
264 let readme_edit = if index_rows.is_empty() {
266 None
267 } else {
268 let before = fs::read_to_string(&readme_path)?;
269 let after = insert_after_table_separator(&before, "| # | Decision |", &index_rows)
270 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
271 Some((before, after))
272 };
273
274 let ledger_existed = rule_ledger_path.exists();
280 let ledger_edit = if rule_ledger_rows_all.is_empty() {
281 None
282 } else {
283 let ledger_before = if ledger_existed {
284 Some(fs::read_to_string(&rule_ledger_path)?)
285 } else {
286 None
287 };
288 let base = ledger_before
289 .clone()
290 .unwrap_or_else(|| RULE_LEDGER_HEADER.to_string());
291 let after = insert_after_table_separator(&base, "| Rule |", &rule_ledger_rows_all)
292 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
293 Some((ledger_before, after))
294 };
295
296 let mut written_adrs: Vec<PathBuf> = Vec::new();
299 let commit = (|| -> io::Result<()> {
300 for (path, contents) in &adr_files {
301 fs::write(path, contents)?;
302 written_adrs.push(path.clone());
303 }
304 fs::write(&changelog_path, &changelog_after)?;
305 if let Some((_, after)) = &readme_edit {
306 fs::write(&readme_path, after)?;
307 }
308 if let Some((_, after)) = &ledger_edit {
309 fs::write(&rule_ledger_path, after)?;
310 }
311 bump(plan.final_version())
315 })();
316
317 if let Err(e) = commit {
318 let _ = fs::write(&changelog_path, &changelog_before);
322 if let Some((before, _)) = &readme_edit {
323 let _ = fs::write(&readme_path, before);
324 }
325 if let Some((before, _)) = &ledger_edit {
326 match before {
327 Some(orig) => {
328 let _ = fs::write(&rule_ledger_path, orig);
329 }
330 None => {
331 let _ = fs::remove_file(&rule_ledger_path);
332 }
333 }
334 }
335 for path in &written_adrs {
336 let _ = fs::remove_file(path);
337 }
338 return Err(e);
339 }
340
341 for inc in &plan.increments {
344 fs::remove_file(root.join("design/pending").join(&inc.file))?;
345 }
346 Ok(())
347}
348
349pub fn parse_workspace_version(cargo_toml: &str) -> Option<Version> {
357 let mut in_section = false;
358 for line in cargo_toml.lines() {
359 let trimmed = line.trim();
360 if trimmed.starts_with('[') && trimmed.ends_with(']') {
362 in_section = trimmed == "[workspace.package]";
363 continue;
364 }
365 if in_section
366 && let Some(value) = line
367 .strip_prefix("version = \"")
368 .and_then(|r| r.strip_suffix('"'))
369 {
370 return Version::parse(value);
371 }
372 }
373 None
374}
375
376pub fn next_adr_number(root: &Path) -> io::Result<u32> {
380 let dir = root.join("design/decisions");
381 let mut max = 0;
382 for entry in fs::read_dir(&dir)? {
383 let name = entry?.file_name().to_string_lossy().into_owned();
384 let bytes = name.as_bytes();
385 let is_adr = name.ends_with(".md")
386 && bytes.len() > 5
387 && bytes[4] == b'-'
388 && bytes[..4].iter().all(u8::is_ascii_digit);
389 if is_adr && let Ok(n) = name[..4].parse::<u32>() {
390 max = max.max(n);
391 }
392 }
393 Ok(max + 1)
394}
395
396pub fn changelog_row(version: Version, blurb: &str) -> String {
398 format!("| **v{version}** | {blurb} |")
399}
400
401pub fn adr_file_contents(number: u32, adr: &Adr, version: Version) -> String {
404 format!(
405 "# {number:04} — {title}\n\n- **Status:** {status} (v{ver})\n\n{body}\n",
406 title = adr.title,
407 status = adr.status(),
408 ver = version.short(),
409 body = adr.body,
410 )
411}
412
413pub fn index_row(number: u32, adr: &Adr, version: Version) -> String {
415 format!(
416 "| [{number:04}]({number:04}-{slug}.md) | **{title}** (v{ver}) — {summary} | {status} (v{ver}) |",
417 slug = adr.slug,
418 title = adr.title,
419 ver = version.short(),
420 summary = adr.summary(),
421 status = adr.status(),
422 )
423}
424
425const RULE_LEDGER_HEADER: &str = "<!-- GENERATED, APPEND-ONLY — written by `cargo xtask stamp \
430--apply` (xtask/src/stamp.rs) at merge, one row per rule id an increment cites in its \
431`closes_rule` frontmatter (#1001).\n Read by `cargo xtask greenfield-status` to populate \
432the reference's rule-citation surface. Do not hand-edit. -->\n\n# Rules closed\n\n\
433| Rule | Version | PR | Changelog |\n|---|---|---|---|\n";
434
435pub fn rule_ledger_rows(inc: &Stamped, pr: Option<u32>) -> Vec<String> {
440 let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_default();
441 inc.closes_rule
442 .iter()
443 .map(|rule| {
444 format!(
445 "| {rule} | v{} | {pr_cell} | {} |",
446 inc.version, inc.changelog
447 )
448 })
449 .collect()
450}
451
452pub fn insert_after_table_separator(
457 md: &str,
458 header_anchor: &str,
459 rows: &[String],
460) -> Result<String, String> {
461 if rows.is_empty() {
462 return Ok(md.to_string());
463 }
464 let mut out = Vec::new();
465 let mut seen_header = false;
466 let mut inserted = false;
467 for line in md.lines() {
468 out.push(line.to_string());
469 if inserted {
470 continue;
471 }
472 if !seen_header {
473 seen_header = line.contains(header_anchor);
474 } else if is_table_separator(line) {
475 out.extend(rows.iter().cloned());
476 inserted = true;
477 }
478 }
479 if !inserted {
480 return Err(format!(
481 "no table with header {header_anchor:?} (and a `|---|` separator) to insert after"
482 ));
483 }
484 let mut joined = out.join("\n");
486 if md.ends_with('\n') {
487 joined.push('\n');
488 }
489 Ok(joined)
490}
491
492fn is_table_separator(line: &str) -> bool {
495 let t = line.trim();
496 t.starts_with('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn adr(slug: &str, title: &str) -> Adr {
504 Adr {
505 slug: slug.into(),
506 title: title.into(),
507 summary: None,
508 status: None,
509 body: "**Decision.** Do the thing.".into(),
510 }
511 }
512
513 #[test]
514 fn version_next() {
515 let v = Version {
516 major: 0,
517 minor: 185,
518 patch: 0,
519 };
520 assert_eq!(v.next(&Level::Minor).to_string(), "0.186.0");
521 assert_eq!(v.next(&Level::Patch).to_string(), "0.185.1");
522 let p = Version {
523 major: 0,
524 minor: 185,
525 patch: 3,
526 };
527 assert_eq!(p.next(&Level::Minor).to_string(), "0.186.0");
528 }
529
530 #[test]
531 fn parse_workspace_version_ignores_dep_specs() {
532 let cargo = "[workspace.package]\nversion = \"0.185.0\"\n\n[workspace.dependencies]\nbynk = { path = \"bynk\", version = \"0.185.0\" }\n";
533 assert_eq!(
534 parse_workspace_version(cargo).unwrap().to_string(),
535 "0.185.0"
536 );
537 }
538
539 #[test]
540 fn parse_workspace_version_is_scoped_to_the_workspace_package_table() {
541 let cargo = "[package]\nname = \"root\"\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.185.0\"\n";
544 assert_eq!(
545 parse_workspace_version(cargo).unwrap().to_string(),
546 "0.185.0"
547 );
548 }
549
550 #[test]
551 fn parse_workspace_version_none_without_the_table() {
552 assert!(parse_workspace_version("version = \"1.2.3\"\n").is_none());
555 }
556
557 #[test]
558 fn changelog_row_format() {
559 let v = Version {
560 major: 0,
561 minor: 186,
562 patch: 0,
563 };
564 assert_eq!(
565 changelog_row(v, "Add a thing"),
566 "| **v0.186.0** | Add a thing |"
567 );
568 }
569
570 #[test]
571 fn version_short_follows_the_corpus_convention() {
572 assert_eq!(
574 Version {
575 major: 0,
576 minor: 186,
577 patch: 0
578 }
579 .short(),
580 "0.186"
581 );
582 assert_eq!(
583 Version {
584 major: 0,
585 minor: 185,
586 patch: 1
587 }
588 .short(),
589 "0.185.1"
590 );
591 }
592
593 #[test]
594 fn adr_file_and_index_row_use_the_short_version() {
595 let v = Version {
596 major: 0,
597 minor: 186,
598 patch: 0,
599 };
600 let a = adr("a-slug", "The title");
601 let file = adr_file_contents(206, &a, v);
602 assert!(file.starts_with("# 0206 — The title\n"));
603 assert!(file.contains("- **Status:** Accepted (v0.186)"));
605 assert!(file.trim_end().ends_with("Do the thing."));
606
607 let row = index_row(206, &a, v);
608 assert_eq!(
609 row,
610 "| [0206](0206-a-slug.md) | **The title** (v0.186) — The title | Accepted (v0.186) |"
611 );
612 }
613
614 #[test]
615 fn patch_increment_index_row_keeps_the_patch() {
616 let v = Version {
617 major: 0,
618 minor: 185,
619 patch: 1,
620 };
621 let a = adr("s", "T");
622 assert!(index_row(9, &a, v).contains("(v0.185.1)"));
623 }
624
625 #[test]
626 fn index_row_uses_explicit_summary_and_status() {
627 let v = Version {
628 major: 0,
629 minor: 1,
630 patch: 0,
631 };
632 let a = Adr {
633 slug: "s".into(),
634 title: "T".into(),
635 summary: Some("the distillation".into()),
636 status: Some("Proposed".into()),
637 body: "b".into(),
638 };
639 assert_eq!(
640 index_row(7, &a, v),
641 "| [0007](0007-s.md) | **T** (v0.1) — the distillation | Proposed (v0.1) |"
642 );
643 }
644
645 #[test]
646 fn insert_prepends_after_the_anchored_table_newest_first() {
647 let md =
648 "## Recent increments\n\n| Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
649 let out = insert_after_table_separator(
650 md,
651 "| Version |",
652 &["| NEW1 |".into(), "| NEW2 |".into()],
653 )
654 .unwrap();
655 let lines: Vec<&str> = out.lines().collect();
656 let sep = lines.iter().position(|l| *l == "|---|---|").unwrap();
657 assert_eq!(lines[sep + 1], "| NEW1 |");
658 assert_eq!(lines[sep + 2], "| NEW2 |");
659 assert_eq!(lines[sep + 3], "| **v0.185.0** | Old |");
660 assert!(out.ends_with('\n'));
661 }
662
663 #[test]
664 fn insert_skips_an_earlier_unrelated_table() {
665 let md = "| Legend | Meaning |\n|---|---|\n| x | y |\n\n\
667 | Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
668 let out = insert_after_table_separator(md, "| Version |", &["| NEW |".into()]).unwrap();
669 let lines: Vec<&str> = out.lines().collect();
670 let target = lines
672 .iter()
673 .position(|l| *l == "| **v0.185.0** | Old |")
674 .unwrap();
675 assert_eq!(lines[target - 1], "| NEW |");
676 assert!(
677 !out.contains("| x | y |\n| NEW |"),
678 "must not touch the legend table"
679 );
680 }
681
682 #[test]
683 fn insert_without_the_anchored_table_errors() {
684 assert!(
685 insert_after_table_separator("no table here\n", "| Version |", &["| r |".into()])
686 .is_err()
687 );
688 }
689}