Skip to content

Commit 1d87131

Browse files
committed
fix: fall back to body H1 when frontmatter title missing, implement pad filter
When an ADR has YAML frontmatter but omits the `title` field, the parser now extracts the title from the first H1 heading in the body instead of silently dropping the ADR. Also implements the `pad` template filter documented in templates.md and corrects the `number` variable description. Closes #185, closes #186
1 parent 6aaac8b commit 1d87131

File tree

4 files changed

+146
-10
lines changed

4 files changed

+146
-10
lines changed

book/src/templates.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Create custom templates using [Jinja2](https://jinja.palletsprojects.com/) synta
103103

104104
| Variable | Description | Example |
105105
|----------|-------------|---------|
106-
| `number` | ADR number (padded) | `0001` |
106+
| `number` | ADR number | `1` |
107107
| `title` | ADR title | `Use PostgreSQL` |
108108
| `date` | Current date | `2024-01-15` |
109109
| `status` | Initial status | `Proposed` |
@@ -169,7 +169,7 @@ Use Jinja2 conditionals for optional content:
169169
## Related Decisions
170170

171171
{% for link in links %}
172-
* {{ link.kind }} [ADR {{ link.target }}]({{ link.target | pad(4) }}-*.md)
172+
* {{ link.kind }} [ADR {{ link.target }}]({{ link.target | pad(width=4) }}-*.md)
173173
{% endfor %}
174174
{% endif %}
175175
```

crates/adrs-core/src/parse.rs

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ impl Parser {
6767
// Parse frontmatter
6868
let mut adr: Adr = serde_yaml::from_str(yaml)?;
6969

70+
// If title is missing from frontmatter, try to extract from body H1
71+
if adr.title.is_empty()
72+
&& let Some((num, title)) = extract_h1_title(body)
73+
{
74+
adr.title = title;
75+
if adr.number == 0 {
76+
adr.number = num;
77+
}
78+
}
79+
7080
// Parse body sections
7181
let sections = self.parse_sections(body);
7282
if let Some(context) = sections.get("context") {
@@ -90,14 +100,9 @@ impl Parser {
90100
let sections = self.extract_sections_raw(content);
91101

92102
// Parse H1 title
93-
if let Some(title_line) = content.lines().find(|l| l.starts_with("# ")) {
94-
let title_str = title_line.trim_start_matches("# ").trim();
95-
if let Some((num, title)) = parse_numbered_title(title_str) {
96-
adr.number = num;
97-
adr.title = title;
98-
} else {
99-
adr.title = title_str.to_string();
100-
}
103+
if let Some((num, title)) = extract_h1_title(content) {
104+
adr.number = num;
105+
adr.title = title;
101106
}
102107

103108
// Apply sections
@@ -252,6 +257,23 @@ impl Parser {
252257
}
253258
}
254259

260+
/// Extract a title from the first H1 heading in markdown content.
261+
///
262+
/// Returns `(number, title)` where number is extracted from patterns like `# 1. Title`,
263+
/// or `0` if the H1 has no number prefix.
264+
fn extract_h1_title(content: &str) -> Option<(u32, String)> {
265+
let title_line = content.lines().find(|l| l.starts_with("# "))?;
266+
let title_str = title_line.trim_start_matches("# ").trim();
267+
if title_str.is_empty() {
268+
return None;
269+
}
270+
if let Some((num, title)) = parse_numbered_title(title_str) {
271+
Some((num, title))
272+
} else {
273+
Some((0, title_str.to_string()))
274+
}
275+
}
276+
255277
/// Parse a numbered title like "1. Use Rust" into (1, "Use Rust").
256278
fn parse_numbered_title(title: &str) -> Option<(u32, String)> {
257279
let parts: Vec<&str> = title.splitn(2, ". ").collect();
@@ -1313,4 +1335,81 @@ Context.
13131335
assert_eq!(adr.links[0].kind, LinkKind::Supersedes);
13141336
assert_eq!(adr.links[1].kind, LinkKind::Amends);
13151337
}
1338+
1339+
// ========== Frontmatter Title Fallback (#186) ==========
1340+
1341+
#[test]
1342+
fn test_parse_frontmatter_title_from_body_h1() {
1343+
let content = r#"---
1344+
number: 2
1345+
date: 2024-01-15
1346+
status: proposed
1347+
---
1348+
1349+
# My Decision Title
1350+
1351+
## Context
1352+
1353+
Context.
1354+
1355+
## Decision
1356+
1357+
Decision.
1358+
1359+
## Consequences
1360+
1361+
Consequences.
1362+
"#;
1363+
1364+
let parser = Parser::new();
1365+
let adr = parser.parse(content).unwrap();
1366+
1367+
assert_eq!(adr.number, 2);
1368+
assert_eq!(adr.title, "My Decision Title");
1369+
assert_eq!(adr.status, AdrStatus::Proposed);
1370+
}
1371+
1372+
#[test]
1373+
fn test_parse_frontmatter_title_from_body_h1_numbered() {
1374+
let content = r#"---
1375+
number: 2
1376+
date: 2024-01-15
1377+
status: proposed
1378+
---
1379+
1380+
# 2. My Numbered Title
1381+
1382+
## Context
1383+
1384+
Context.
1385+
"#;
1386+
1387+
let parser = Parser::new();
1388+
let adr = parser.parse(content).unwrap();
1389+
1390+
assert_eq!(adr.number, 2);
1391+
assert_eq!(adr.title, "My Numbered Title");
1392+
}
1393+
1394+
#[test]
1395+
fn test_parse_frontmatter_title_prefers_frontmatter() {
1396+
let content = r#"---
1397+
number: 2
1398+
title: Frontmatter Title
1399+
date: 2024-01-15
1400+
status: proposed
1401+
---
1402+
1403+
# Body Title
1404+
1405+
## Context
1406+
1407+
Context.
1408+
"#;
1409+
1410+
let parser = Parser::new();
1411+
let adr = parser.parse(content).unwrap();
1412+
1413+
assert_eq!(adr.title, "Frontmatter Title");
1414+
}
13161415
}

crates/adrs-core/src/template.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ impl std::str::FromStr for TemplateVariant {
8484
}
8585
}
8686

87+
/// Zero-pad a number to a given width (default 4).
88+
///
89+
/// Used in templates as `{{ number | pad }}` or `{{ number | pad(width=6) }}`.
90+
fn pad_filter(
91+
value: u32,
92+
kwargs: minijinja::value::Kwargs,
93+
) -> std::result::Result<String, minijinja::Error> {
94+
let width: Option<u32> = kwargs.get("width")?;
95+
kwargs.assert_all_used()?;
96+
let w = width.unwrap_or(4) as usize;
97+
Ok(format!("{value:0>w$}"))
98+
}
99+
87100
/// A template for generating ADRs.
88101
#[derive(Debug, Clone)]
89102
pub struct Template {
@@ -170,6 +183,7 @@ impl Template {
170183
use crate::LinkKind;
171184

172185
let mut env = Environment::new();
186+
env.add_filter("pad", pad_filter);
173187
env.add_template(&self.name, &self.content)
174188
.map_err(|e| Error::TemplateError(e.to_string()))?;
175189

@@ -1496,4 +1510,26 @@ Links: {% for link in links %}{{ link.kind }} {{ link.target }}{% endfor %}"#,
14961510
// No tags section when tags are empty
14971511
assert!(!output.contains("tags:"));
14981512
}
1513+
1514+
// ========== Pad Filter Tests (#185) ==========
1515+
1516+
#[test]
1517+
fn test_pad_filter_default_width() {
1518+
let template = Template::from_string("test", "{{ number | pad }}");
1519+
let adr = Adr::new(1, "Test");
1520+
let config = Config::default();
1521+
let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1522+
1523+
assert_eq!(output, "0001");
1524+
}
1525+
1526+
#[test]
1527+
fn test_pad_filter_custom_width() {
1528+
let template = Template::from_string("test", "{{ number | pad(width=6) }}");
1529+
let adr = Adr::new(1, "Test");
1530+
let config = Config::default();
1531+
let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1532+
1533+
assert_eq!(output, "000001");
1534+
}
14991535
}

crates/adrs-core/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct Adr {
1111
pub number: u32,
1212

1313
/// The title of the decision.
14+
#[serde(default)]
1415
pub title: String,
1516

1617
/// The date the decision was made.

0 commit comments

Comments
 (0)