use crate::config::Config;
use crate::model::{AdrEntry, ClauseKind, ClauseStatus, RfcIndex, WorkItemEntry};
use crate::signature::{
compute_adr_signature, compute_rfc_signature, compute_work_item_signature,
format_signature_header,
};
use crate::ui;
use regex::Regex;
use std::fmt::Write as FmtWrite;
use std::io::Write;
fn ref_link(ref_id: &str) -> String {
ref_link_with_base(ref_id, "..")
}
pub fn ref_link_from_root(ref_id: &str, docs_output: &str) -> String {
ref_link_with_base(ref_id, docs_output)
}
fn ref_link_with_base(ref_id: &str, base: &str) -> String {
if ref_id.starts_with("RFC-") {
if ref_id.contains(':') {
let rfc_id = ref_id.split(':').next().unwrap_or(ref_id);
let anchor = ref_id.to_lowercase().replace(':', "");
format!("[{}]({}/rfc/{}.md#{})", ref_id, base, rfc_id, anchor)
} else {
format!("[{}]({}/rfc/{}.md)", ref_id, base, ref_id)
}
} else if ref_id.starts_with("ADR-") {
format!("[{}]({}/adr/{}.md)", ref_id, base, ref_id)
} else if ref_id.starts_with("WI-") {
format!("[{}]({}/work/{}.md)", ref_id, base, ref_id)
} else {
ref_id.to_string()
}
}
fn render_refs(refs: &[String]) -> String {
refs.iter()
.map(|r| ref_link(r))
.collect::<Vec<_>>()
.join(", ")
}
pub fn expand_inline_refs(text: &str, pattern: &str) -> String {
expand_inline_refs_with_linker(text, pattern, ref_link)
}
pub fn expand_inline_refs_from_root(text: &str, pattern: &str, docs_output: &str) -> String {
expand_inline_refs_with_linker(text, pattern, |ref_id| {
ref_link_from_root(ref_id, docs_output)
})
}
fn expand_inline_refs_with_linker<F>(text: &str, pattern: &str, linker: F) -> String
where
F: Fn(&str) -> String,
{
let Ok(re) = Regex::new(pattern) else {
return text.to_string();
};
re.replace_all(text, |caps: ®ex::Captures| {
if let Some(artifact_id) = caps.get(1) {
linker(artifact_id.as_str())
} else {
caps.get(0).map_or("", |m| m.as_str()).to_string()
}
})
.to_string()
}
fn indent_continuation(text: &str) -> String {
let mut lines = text.lines();
let Some(first) = lines.next() else {
return String::new();
};
let mut result = first.to_string();
for line in lines {
result.push('\n');
result.push_str(" ");
result.push_str(line);
}
result
}
pub fn render_rfc(rfc: &RfcIndex) -> anyhow::Result<String> {
let mut out = String::new();
let signature = compute_rfc_signature(rfc)?;
out.push_str(&format_signature_header(&rfc.rfc.rfc_id, &signature));
let _ = writeln!(out);
let _ = writeln!(out, "# {}: {}", rfc.rfc.rfc_id, rfc.rfc.title);
let _ = writeln!(out);
let _ = writeln!(
out,
"> **Version:** {} | **Status:** {} | **Phase:** {}",
rfc.rfc.version,
rfc.rfc.status.as_ref(),
rfc.rfc.phase.as_ref()
);
let _ = writeln!(out);
if !rfc.rfc.refs.is_empty() {
let _ = writeln!(out, "**References:** {}", render_refs(&rfc.rfc.refs));
let _ = writeln!(out);
}
for (i, section) in rfc.rfc.sections.iter().enumerate() {
let _ = writeln!(out, "---");
let _ = writeln!(out);
let _ = writeln!(out, "## {}. {}", i + 1, section.title);
let _ = writeln!(out);
for clause_path in §ion.clauses {
if let Some(clause) = rfc.clauses.iter().find(|c| {
c.path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| clause_path.ends_with(n))
}) {
render_clause(&mut out, &rfc.rfc.rfc_id, clause);
}
}
}
if !rfc.rfc.changelog.is_empty() {
let _ = writeln!(out, "---");
let _ = writeln!(out);
let _ = writeln!(out, "## Changelog");
let _ = writeln!(out);
for entry in &rfc.rfc.changelog {
let _ = writeln!(out, "### v{} ({})", entry.version, entry.date);
let _ = writeln!(out);
if let Some(ref notes) = entry.notes {
let _ = writeln!(out, "{notes}");
let _ = writeln!(out);
}
render_changelog_section(&mut out, "Added", &entry.added);
render_changelog_section(&mut out, "Changed", &entry.changed);
render_changelog_section(&mut out, "Deprecated", &entry.deprecated);
render_changelog_section(&mut out, "Removed", &entry.removed);
render_changelog_section(&mut out, "Fixed", &entry.fixed);
render_changelog_section(&mut out, "Security", &entry.security);
}
}
Ok(out)
}
fn render_changelog_section(out: &mut String, heading: &str, items: &[String]) {
if items.is_empty() {
return;
}
let _ = writeln!(out, "#### {heading}");
let _ = writeln!(out);
for item in items {
let _ = writeln!(out, "- {item}");
}
let _ = writeln!(out);
}
fn clause_anchor(rfc_id: &str, clause_id: &str) -> String {
format!("{}:{}", rfc_id, clause_id)
.to_lowercase()
.replace(':', "")
}
pub fn render_clause(out: &mut String, rfc_id: &str, clause: &crate::model::ClauseEntry) {
let spec = &clause.spec;
let kind_marker = match spec.kind {
ClauseKind::Normative => "(Normative)",
ClauseKind::Informative => "(Informative)",
};
let anchor = clause_anchor(rfc_id, &spec.clause_id);
let title_part = format!("[{}:{}] {}", rfc_id, spec.clause_id, spec.title);
let formatted_title = match spec.status {
ClauseStatus::Active => title_part,
ClauseStatus::Deprecated | ClauseStatus::Superseded => format!("<del>{}</del>", title_part),
};
let _ = writeln!(
out,
"### {} {kind_marker} <a id=\"{anchor}\"></a>",
formatted_title
);
let _ = writeln!(out);
let _ = writeln!(out, "{}", spec.text);
let _ = writeln!(out);
if let Some(ref by) = spec.superseded_by {
let _ = writeln!(out, "> **Superseded by:** {by}");
let _ = writeln!(out);
}
if let Some(ref since) = spec.since {
let _ = writeln!(out, "*Since: v{since}*");
let _ = writeln!(out);
}
}
fn write_rendered_md(
config: &Config,
output_path: &std::path::Path,
content: &str,
dry_run: bool,
preview_lines: usize,
) -> anyhow::Result<()> {
let content = format!("{}\n", content.trim_end());
let display_path = config.display_path(output_path);
if dry_run {
ui::dry_run_preview(&display_path);
for line in content.lines().take(preview_lines) {
ui::preview_line(line);
}
ui::preview_truncated();
} else {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::File::create(output_path)?;
file.write_all(content.as_bytes())?;
ui::rendered(&display_path);
}
Ok(())
}
pub fn write_rfc(config: &Config, rfc: &RfcIndex, dry_run: bool) -> anyhow::Result<()> {
let output_path = config.rfc_output().join(format!("{}.md", rfc.rfc.rfc_id));
let raw = render_rfc(rfc)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
write_rendered_md(config, &output_path, &expanded, dry_run, 20)
}
pub fn render_adr(adr: &AdrEntry) -> anyhow::Result<String> {
let meta = adr.meta();
let content = &adr.spec.content;
let mut out = String::new();
let signature = compute_adr_signature(adr)?;
out.push_str(&format_signature_header(&meta.id, &signature));
let _ = writeln!(out);
let _ = writeln!(out, "# {}: {}", meta.id, meta.title);
let _ = writeln!(out);
let _ = writeln!(
out,
"> **Status:** {} | **Date:** {}",
meta.status.as_ref(),
meta.date
);
if let Some(ref by) = meta.superseded_by {
let _ = writeln!(out, "> **Superseded by:** {by}");
}
let _ = writeln!(out);
if !meta.refs.is_empty() {
let _ = writeln!(out, "**References:** {}", render_refs(&meta.refs));
let _ = writeln!(out);
}
let _ = writeln!(out, "## Context");
let _ = writeln!(out);
let _ = writeln!(out, "{}", content.context);
let _ = writeln!(out);
let _ = writeln!(out, "## Decision");
let _ = writeln!(out);
let _ = writeln!(out, "{}", content.decision);
let _ = writeln!(out);
let _ = writeln!(out, "## Consequences");
let _ = writeln!(out);
let _ = writeln!(out, "{}", content.consequences);
let _ = writeln!(out);
if !content.alternatives.is_empty() {
use crate::model::AlternativeStatus;
let _ = writeln!(out, "## Alternatives Considered");
let _ = writeln!(out);
for alt in &content.alternatives {
let status_suffix = match alt.status {
AlternativeStatus::Considered => "",
AlternativeStatus::Accepted => " (accepted)",
AlternativeStatus::Rejected => " (rejected)",
};
let _ = writeln!(out, "### {}{}", alt.text, status_suffix);
let _ = writeln!(out);
if !alt.pros.is_empty() {
let _ = writeln!(out, "- **Pros:** {}", alt.pros.join(", "));
}
if !alt.cons.is_empty() {
let _ = writeln!(out, "- **Cons:** {}", alt.cons.join(", "));
}
if let Some(ref reason) = alt.rejection_reason {
let _ = writeln!(out, "- **Rejected because:** {}", reason);
}
let _ = writeln!(out);
}
}
Ok(out)
}
pub fn write_adr_md(config: &Config, adr: &AdrEntry, dry_run: bool) -> anyhow::Result<()> {
let meta = adr.meta();
let output_path = config.adr_output().join(format!("{}.md", meta.id));
let raw = render_adr(adr)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
write_rendered_md(config, &output_path, &expanded, dry_run, 15)
}
pub fn render_work_item(item: &WorkItemEntry) -> anyhow::Result<String> {
let meta = item.meta();
let content = &item.spec.content;
let mut out = String::new();
let signature = compute_work_item_signature(item)?;
out.push_str(&format_signature_header(&meta.id, &signature));
let _ = writeln!(out);
let _ = writeln!(out, "# {}", meta.title);
let _ = writeln!(out);
let mut status_line = format!(
"> **ID:** {} | **Status:** {}",
meta.id,
meta.status.as_ref()
);
if let Some(ref start) = meta.started {
status_line.push_str(&format!(" | **Started:** {start}"));
}
if let Some(ref done) = meta.completed {
status_line.push_str(&format!(" | **Completed:** {done}"));
}
let _ = writeln!(out, "{status_line}");
let _ = writeln!(out);
if !meta.refs.is_empty() {
let _ = writeln!(out, "**References:** {}", render_refs(&meta.refs));
let _ = writeln!(out);
}
let _ = writeln!(out, "## Description");
let _ = writeln!(out);
let _ = writeln!(out, "{}", content.description);
let _ = writeln!(out);
if !content.journal.is_empty() {
let _ = writeln!(out, "## Journal");
let _ = writeln!(out);
for entry in &content.journal {
if let Some(ref scope) = entry.scope {
let _ = writeln!(out, "### {} · {}", entry.date, scope);
} else {
let _ = writeln!(out, "### {}", entry.date);
}
let _ = writeln!(out);
let _ = writeln!(out, "{}", entry.content);
let _ = writeln!(out);
}
}
if !content.acceptance_criteria.is_empty() {
use crate::model::ChecklistStatus;
let _ = writeln!(out, "## Acceptance Criteria");
let _ = writeln!(out);
for ac_item in &content.acceptance_criteria {
let indented_text = indent_continuation(&ac_item.text);
let line = match ac_item.status {
ChecklistStatus::Pending => format!("- [ ] {}", indented_text),
ChecklistStatus::Done => format!("- [x] {}", indented_text),
ChecklistStatus::Cancelled => format!("- ~~{}~~", indented_text),
};
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out);
}
if !content.notes.is_empty() {
let _ = writeln!(out, "## Notes");
let _ = writeln!(out);
for note in &content.notes {
let _ = writeln!(out, "- {}", note);
}
let _ = writeln!(out);
}
Ok(out)
}
pub fn write_work_item_md(
config: &Config,
item: &WorkItemEntry,
dry_run: bool,
) -> anyhow::Result<()> {
let meta = item.meta();
let output_path = config.work_output().join(format!("{}.md", meta.id));
let raw = render_work_item(item)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
write_rendered_md(config, &output_path, &expanded, dry_run, 15)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
AdrContent, AdrMeta, AdrSpec, AdrStatus, Alternative, AlternativeStatus, JournalEntry,
WorkItemContent, WorkItemMeta, WorkItemSpec, WorkItemStatus,
};
const DEFAULT_PATTERN: &str = r"\[\[(RFC-\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\d{4}|WI-\d{4}-\d{2}-\d{2}-(?:[a-f0-9]{4}(?:-\d{3})?|\d{3}))\]\]";
#[test]
fn test_expand_inline_refs_rfc() {
let text = "See [[RFC-0000]] for details.";
let result = expand_inline_refs(text, DEFAULT_PATTERN);
assert_eq!(result, "See [RFC-0000](../rfc/RFC-0000.md) for details.");
}
#[test]
fn test_expand_inline_refs_clause() {
let text = "Per [[RFC-0000:C-WORK-DEF]], work items must...";
let result = expand_inline_refs(text, DEFAULT_PATTERN);
assert_eq!(
result,
"Per [RFC-0000:C-WORK-DEF](../rfc/RFC-0000.md#rfc-0000c-work-def), work items must..."
);
}
#[test]
fn test_expand_inline_refs_adr() {
let text = "This follows [[ADR-0005]] guidelines.";
let result = expand_inline_refs(text, DEFAULT_PATTERN);
assert_eq!(
result,
"This follows [ADR-0005](../adr/ADR-0005.md) guidelines."
);
}
#[test]
fn test_expand_inline_refs_multiple() {
let text = "See [[RFC-0000]] and [[ADR-0001]] for context.";
let result = expand_inline_refs(text, DEFAULT_PATTERN);
assert_eq!(
result,
"See [RFC-0000](../rfc/RFC-0000.md) and [ADR-0001](../adr/ADR-0001.md) for context."
);
}
#[test]
fn test_expand_inline_refs_no_match() {
let text = "No references here.";
let result = expand_inline_refs(text, DEFAULT_PATTERN);
assert_eq!(result, "No references here.");
}
#[test]
fn test_expand_inline_refs_invalid_pattern() {
let text = "[[RFC-0000]] test";
let result = expand_inline_refs(text, "[invalid(regex");
assert_eq!(result, "[[RFC-0000]] test");
}
#[test]
fn test_ref_link_from_root_rfc() {
let result = ref_link_from_root("RFC-0000", "docs");
assert_eq!(result, "[RFC-0000](docs/rfc/RFC-0000.md)");
}
#[test]
fn test_ref_link_from_root_clause() {
let result = ref_link_from_root("RFC-0000:C-WORK-DEF", "docs");
assert_eq!(
result,
"[RFC-0000:C-WORK-DEF](docs/rfc/RFC-0000.md#rfc-0000c-work-def)"
);
}
#[test]
fn test_ref_link_from_root_adr() {
let result = ref_link_from_root("ADR-0005", "docs");
assert_eq!(result, "[ADR-0005](docs/adr/ADR-0005.md)");
}
#[test]
fn test_ref_link_from_root_custom_path() {
let result = ref_link_from_root("RFC-0001", "documentation");
assert_eq!(result, "[RFC-0001](documentation/rfc/RFC-0001.md)");
}
#[test]
fn test_expand_inline_refs_from_root() {
let text = "Per [[RFC-0002:C-RESOURCE-MODEL]], resources use verb pattern.";
let result = expand_inline_refs_from_root(text, DEFAULT_PATTERN, "docs");
assert_eq!(
result,
"Per [RFC-0002:C-RESOURCE-MODEL](docs/rfc/RFC-0002.md#rfc-0002c-resource-model), resources use verb pattern."
);
}
#[test]
fn test_expand_inline_refs_from_root_multiple() {
let text = "See [[RFC-0000]] and [[ADR-0018]] for details.";
let result = expand_inline_refs_from_root(text, DEFAULT_PATTERN, "docs");
assert_eq!(
result,
"See [RFC-0000](docs/rfc/RFC-0000.md) and [ADR-0018](docs/adr/ADR-0018.md) for details."
);
}
fn wi_ref(id: &str) -> String {
format!("[[{}]]", id)
}
#[test]
fn test_expand_inline_refs_work_item_sequential() {
let id = "WI-9999-01-26-001";
let text = format!("See {} for task details.", wi_ref(id));
let result = expand_inline_refs(&text, DEFAULT_PATTERN);
assert_eq!(
result,
format!("See [{}](../work/{}.md) for task details.", id, id)
);
}
#[test]
fn test_expand_inline_refs_work_item_author_hash() {
let id = "WI-9999-01-26-a7f3-001";
let text = format!("See {} for task details.", wi_ref(id));
let result = expand_inline_refs(&text, DEFAULT_PATTERN);
assert_eq!(
result,
format!("See [{}](../work/{}.md) for task details.", id, id)
);
}
#[test]
fn test_expand_inline_refs_work_item_random() {
let id = "WI-9999-01-26-b2c9";
let text = format!("See {} for task details.", wi_ref(id));
let result = expand_inline_refs(&text, DEFAULT_PATTERN);
assert_eq!(
result,
format!("See [{}](../work/{}.md) for task details.", id, id)
);
}
#[test]
fn test_expand_inline_refs_work_item_mixed() {
let wi_id = "WI-9999-01-26-001";
let text = format!("Per [[RFC-0000]], see {} and [[ADR-0020]].", wi_ref(wi_id));
let result = expand_inline_refs(&text, DEFAULT_PATTERN);
assert_eq!(
result,
format!(
"Per [RFC-0000](../rfc/RFC-0000.md), see [{}](../work/{}.md) and [ADR-0020](../adr/ADR-0020.md).",
wi_id, wi_id
)
);
}
#[test]
fn test_render_adr_alternatives_with_pros_cons() -> Result<(), Box<dyn std::error::Error>> {
let adr = AdrEntry {
spec: AdrSpec {
govctl: AdrMeta {
schema: 1,
id: "ADR-9999".to_string(),
title: "Test ADR".to_string(),
status: AdrStatus::Accepted,
date: "2026-02-22".to_string(),
superseded_by: None,
refs: vec![],
tags: vec![],
},
content: AdrContent {
context: "Test context".to_string(),
decision: "Test decision".to_string(),
consequences: "Test consequences".to_string(),
alternatives: vec![Alternative {
text: "Option A".to_string(),
status: AlternativeStatus::Considered,
pros: vec!["Fast".to_string(), "Cheap".to_string()],
cons: vec!["Less reliable".to_string()],
rejection_reason: None,
}],
},
},
path: std::path::PathBuf::new(),
};
let result = render_adr(&adr)?;
assert!(result.contains("### Option A"));
assert!(result.contains("- **Pros:** Fast, Cheap"));
assert!(result.contains("- **Cons:** Less reliable"));
Ok(())
}
#[test]
fn test_render_adr_alternatives_rejected_with_reason() -> Result<(), Box<dyn std::error::Error>>
{
let adr = AdrEntry {
spec: AdrSpec {
govctl: AdrMeta {
schema: 1,
id: "ADR-9998".to_string(),
title: "Test ADR Rejected".to_string(),
status: AdrStatus::Accepted,
date: "2026-02-22".to_string(),
superseded_by: None,
refs: vec![],
tags: vec![],
},
content: AdrContent {
context: "Test context".to_string(),
decision: "Test decision".to_string(),
consequences: "Test consequences".to_string(),
alternatives: vec![Alternative {
text: "Option B".to_string(),
status: AlternativeStatus::Rejected,
pros: vec![],
cons: vec!["Too expensive".to_string()],
rejection_reason: Some("Budget constraints".to_string()),
}],
},
},
path: std::path::PathBuf::new(),
};
let result = render_adr(&adr)?;
assert!(result.contains("### Option B (rejected)"));
assert!(result.contains("- **Rejected because:** Budget constraints"));
Ok(())
}
#[test]
fn test_render_work_item_journal() -> Result<(), Box<dyn std::error::Error>> {
let item = WorkItemEntry {
spec: WorkItemSpec {
govctl: WorkItemMeta {
schema: 1,
id: "WI-2026-02-22-001".to_string(),
title: "Test Work Item".to_string(),
status: WorkItemStatus::Active,
created: Some("2026-02-22".to_string()),
started: Some("2026-02-22".to_string()),
completed: None,
refs: vec![],
tags: vec![],
},
content: WorkItemContent {
description: "Test description".to_string(),
journal: vec![JournalEntry {
date: "2026-02-22".to_string(),
scope: None,
content: "Started implementation".to_string(),
}],
acceptance_criteria: vec![],
notes: vec![],
},
verification: crate::model::WorkItemVerification::default(),
},
path: std::path::PathBuf::new(),
};
let result = render_work_item(&item)?;
assert!(result.contains("## Journal"));
assert!(result.contains("### 2026-02-22"));
assert!(result.contains("Started implementation"));
Ok(())
}
#[test]
fn test_render_work_item_journal_with_scope() -> Result<(), Box<dyn std::error::Error>> {
let item = WorkItemEntry {
spec: WorkItemSpec {
govctl: WorkItemMeta {
schema: 1,
id: "WI-2026-02-22-002".to_string(),
title: "Test Work Item with Scope".to_string(),
status: WorkItemStatus::Active,
created: Some("2026-02-22".to_string()),
started: Some("2026-02-22".to_string()),
completed: None,
refs: vec![],
tags: vec![],
},
content: WorkItemContent {
description: "Test description".to_string(),
journal: vec![
JournalEntry {
date: "2026-02-22".to_string(),
scope: Some("API".to_string()),
content: "Created endpoint".to_string(),
},
JournalEntry {
date: "2026-02-23".to_string(),
scope: Some("Testing".to_string()),
content: "Added unit tests".to_string(),
},
],
acceptance_criteria: vec![],
notes: vec![],
},
verification: crate::model::WorkItemVerification::default(),
},
path: std::path::PathBuf::new(),
};
let result = render_work_item(&item)?;
assert!(result.contains("### 2026-02-22 · API"));
assert!(result.contains("Created endpoint"));
assert!(result.contains("### 2026-02-23 · Testing"));
assert!(result.contains("Added unit tests"));
Ok(())
}
}