use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode};
use crate::load::load_rfcs;
use crate::model::{ChangelogCategory, ChecklistStatus, WorkItemStatus};
use crate::parse::{load_adrs, load_releases, load_work_items};
use crate::render::{expand_inline_refs_from_root, write_adr_md, write_rfc, write_work_item_md};
use crate::ui;
use std::collections::{HashMap, HashSet};
use std::path::Path;
fn display_path_string(config: &Config, path: impl AsRef<Path>) -> String {
config.display_path(path.as_ref()).display().to_string()
}
pub fn render(
config: &Config,
rfc_id: Option<&str>,
dry_run: bool,
) -> anyhow::Result<Vec<Diagnostic>> {
let rfcs = load_rfcs(config)
.map_err(Diagnostic::from)
.map_err(anyhow::Error::from)?;
if rfcs.is_empty() {
ui::not_found("RFC", &config.rfc_dir());
return Ok(vec![]);
}
let rfcs_to_render: Vec<_> = if let Some(id) = rfc_id {
rfcs.into_iter().filter(|r| r.rfc.rfc_id == id).collect()
} else {
rfcs
};
if rfcs_to_render.is_empty()
&& let Some(id) = rfc_id
{
let scope = display_path_string(config, config.rfc_dir());
return Err(Diagnostic::new(
DiagnosticCode::E0102RfcNotFound,
format!("RFC not found: {id}"),
scope,
)
.into());
}
for rfc in &rfcs_to_render {
write_rfc(config, rfc, dry_run)?;
}
if !dry_run {
ui::render_summary(rfcs_to_render.len(), "RFC");
}
Ok(vec![])
}
pub fn render_adrs(
config: &Config,
adr_id: Option<&str>,
dry_run: bool,
) -> anyhow::Result<Vec<Diagnostic>> {
let adrs = load_adrs(config)?;
if adrs.is_empty() {
ui::info("No ADRs found");
return Ok(vec![]);
}
let adrs_to_render: Vec<_> = if let Some(id) = adr_id {
adrs.into_iter()
.filter(|a| a.spec.govctl.id == id)
.collect()
} else {
adrs
};
if adrs_to_render.is_empty()
&& let Some(id) = adr_id
{
let scope = display_path_string(config, config.adr_dir());
return Err(Diagnostic::new(
DiagnosticCode::E0302AdrNotFound,
format!("ADR not found: {id}"),
scope,
)
.into());
}
for adr in &adrs_to_render {
write_adr_md(config, adr, dry_run)?;
}
if !dry_run {
ui::render_summary(adrs_to_render.len(), "ADR");
}
Ok(vec![])
}
pub fn render_work_items(
config: &Config,
work_id: Option<&str>,
dry_run: bool,
) -> anyhow::Result<Vec<Diagnostic>> {
let items = load_work_items(config)?;
if items.is_empty() {
ui::info("No work items found");
return Ok(vec![]);
}
let items_to_render: Vec<_> = if let Some(id) = work_id {
items
.into_iter()
.filter(|w| w.spec.govctl.id == id)
.collect()
} else {
items
};
if items_to_render.is_empty()
&& let Some(id) = work_id
{
let scope = config
.display_path(&config.work_dir())
.display()
.to_string();
return Err(Diagnostic::new(
DiagnosticCode::E0402WorkNotFound,
format!("Work item not found: {id}"),
scope,
)
.into());
}
for item in &items_to_render {
write_work_item_md(config, item, dry_run)?;
}
if !dry_run {
ui::render_summary(items_to_render.len(), "work item");
}
Ok(vec![])
}
pub fn render_changelog(
config: &Config,
dry_run: bool,
force: bool,
) -> anyhow::Result<Vec<Diagnostic>> {
let releases_file = load_releases(config).map_err(anyhow::Error::from)?;
let work_items = load_work_items(config).map_err(anyhow::Error::from)?;
let released_ids: HashSet<_> = releases_file
.releases
.iter()
.flat_map(|r| r.refs.iter().cloned())
.collect();
let unreleased: Vec<_> = work_items
.iter()
.filter(|w| w.spec.govctl.status == WorkItemStatus::Done)
.filter(|w| !released_ids.contains(&w.spec.govctl.id))
.collect();
let changelog_path = std::path::PathBuf::from("CHANGELOG.md");
let output = if force {
render_changelog_full(config, &releases_file, &work_items, &unreleased)?
} else {
render_changelog_incremental(
config,
&changelog_path,
&releases_file,
&work_items,
&unreleased,
)?
};
let unreleased_count = unreleased.len();
if dry_run {
ui::dry_run_file_preview(&changelog_path, &output);
} else {
std::fs::write(&changelog_path, &output)?;
ui::changelog_rendered(
&changelog_path,
releases_file.releases.len(),
unreleased_count,
);
}
Ok(vec![])
}
fn render_changelog_full(
config: &Config,
releases_file: &crate::model::ReleasesFile,
work_items: &[crate::model::WorkItemEntry],
unreleased: &[&crate::model::WorkItemEntry],
) -> anyhow::Result<String> {
let work_item_map: HashMap<_, _> = work_items
.iter()
.map(|w| (w.spec.govctl.id.clone(), w))
.collect();
let mut output = String::new();
output.push_str("# Changelog\n\n");
output.push_str("All notable changes to this project will be documented in this file.\n\n");
output.push_str(
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n",
);
output.push_str("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n");
let mut unreleased_content = String::new();
unreleased_content.push_str("## [Unreleased]\n\n");
if !unreleased.is_empty() {
render_changelog_section(&mut unreleased_content, unreleased);
}
let unreleased_expanded =
expand_inline_refs_from_root(&unreleased_content, &config.source_scan.pattern, "docs");
output.push_str(unreleased_expanded.trim_end());
output.push('\n');
for release in &releases_file.releases {
let mut release_content = String::new();
release_content.push_str(&format!("## [{}] - {}\n\n", release.version, release.date));
let items: Vec<_> = release
.refs
.iter()
.filter_map(|id| work_item_map.get(id).copied())
.collect();
if items.is_empty() {
release_content.push_str("*No changes recorded.*\n");
} else {
render_changelog_section(&mut release_content, &items);
}
let release_expanded =
expand_inline_refs_from_root(&release_content, &config.source_scan.pattern, "docs");
output.push('\n');
output.push_str(release_expanded.trim_end());
output.push('\n');
}
Ok(format!("{}\n", output.trim_end()))
}
fn render_changelog_incremental(
config: &Config,
changelog_path: &std::path::Path,
releases_file: &crate::model::ReleasesFile,
work_items: &[crate::model::WorkItemEntry],
unreleased: &[&crate::model::WorkItemEntry],
) -> anyhow::Result<String> {
let existing = if changelog_path.exists() {
std::fs::read_to_string(changelog_path)?
} else {
String::new()
};
let work_item_map: HashMap<_, _> = work_items
.iter()
.map(|w| (w.spec.govctl.id.clone(), w))
.collect();
let unreleased_header = "## [Unreleased]";
let release_pattern = "\n## [";
let (header, existing_released) = if existing.is_empty() {
let header = "# Changelog\n\n\
All notable changes to this project will be documented in this file.\n\n\
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n\
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n";
(header.to_string(), String::new())
} else if let Some(unreleased_pos) = existing.find(unreleased_header) {
let header = existing[..unreleased_pos].to_string();
let after_unreleased = &existing[unreleased_pos + unreleased_header.len()..];
let released = if let Some(pos) = after_unreleased.find(release_pattern) {
after_unreleased[pos + 1..].to_string() } else {
String::new()
};
(header, released)
} else if let Some(first_release_pos) = existing.find(release_pattern) {
let header = existing[..first_release_pos + 1].to_string();
let released = existing[first_release_pos + 1..].to_string();
(header, released)
} else {
(existing.clone(), String::new())
};
let mut existing_releases: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
if !existing_released.is_empty() {
let mut current_pos = 0;
let content = &existing_released;
while current_pos < content.len() {
if !content[current_pos..].starts_with("## [") {
break;
}
let rest = &content[current_pos..];
let section_end = rest[4..] .find("\n## [")
.map(|p| p + 4 + 1) .unwrap_or(rest.len());
let section = &rest[..section_end];
let version = if let Some(bracket_end) = section.find(']') {
section[4..bracket_end].to_string() } else {
current_pos += section.len();
continue;
};
existing_releases.insert(version, section.to_string());
current_pos += section.len();
}
};
let mut unreleased_content = String::new();
unreleased_content.push_str("## [Unreleased]\n\n");
if !unreleased.is_empty() {
render_changelog_section(&mut unreleased_content, unreleased);
}
let unreleased_expanded =
expand_inline_refs_from_root(&unreleased_content, &config.source_scan.pattern, "docs");
let mut output = header;
output.push_str(unreleased_expanded.trim_end());
output.push('\n');
for release in &releases_file.releases {
let version_variants = if release.version.starts_with('v') {
vec![release.version.clone(), release.version[1..].to_string()]
} else {
vec![release.version.clone(), format!("v{}", release.version)]
};
let exists = version_variants
.iter()
.any(|v| existing_releases.contains_key(v));
if !exists {
let mut release_content = String::new();
release_content.push_str(&format!("## [{}] - {}\n\n", release.version, release.date));
let items: Vec<_> = release
.refs
.iter()
.filter_map(|id| work_item_map.get(id).copied())
.collect();
if items.is_empty() {
release_content.push_str("*No changes recorded.*\n");
} else {
render_changelog_section(&mut release_content, &items);
}
let release_expanded =
expand_inline_refs_from_root(&release_content, &config.source_scan.pattern, "docs");
existing_releases.insert(
release.version.clone(),
release_expanded.trim_end().to_string(),
);
}
}
let mut versions: Vec<String> = existing_releases.keys().cloned().collect();
versions.sort_by(|a, b| {
let a_stripped = a.strip_prefix('v').unwrap_or(a);
let b_stripped = b.strip_prefix('v').unwrap_or(b);
let a_parts: Vec<u32> = a_stripped
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
let b_parts: Vec<u32> = b_stripped
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
b_parts.cmp(&a_parts)
});
for version in versions {
if let Some(section) = existing_releases.get(&version) {
output.push('\n');
output.push_str(section.trim_end());
output.push('\n');
}
}
Ok(format!("{}\n", output.trim_end()))
}
fn render_changelog_section(output: &mut String, items: &[&crate::model::WorkItemEntry]) {
let mut by_category: HashMap<ChangelogCategory, Vec<(String, String)>> = HashMap::new();
for item in items {
for criterion in &item.spec.content.acceptance_criteria {
if criterion.status == ChecklistStatus::Done {
by_category
.entry(criterion.category)
.or_default()
.push((criterion.text.clone(), item.spec.govctl.id.clone()));
}
}
}
let categories = [
(ChangelogCategory::Added, "Added"),
(ChangelogCategory::Changed, "Changed"),
(ChangelogCategory::Deprecated, "Deprecated"),
(ChangelogCategory::Removed, "Removed"),
(ChangelogCategory::Fixed, "Fixed"),
(ChangelogCategory::Security, "Security"),
];
for (cat, label) in categories {
if let Some(entries) = by_category.get(&cat) {
output.push_str(&format!("### {}\n\n", label));
for (text, work_id) in entries {
output.push_str(&format!("- {} ({})\n", text, work_id));
}
output.push('\n');
}
}
}
use crate::OutputFormat;
use crate::render::{expand_inline_refs, render_adr, render_clause, render_rfc, render_work_item};
use crate::terminal_md::render_terminal_md;
pub fn show_rfc(
config: &Config,
id: &str,
output: OutputFormat,
) -> anyhow::Result<Vec<Diagnostic>> {
let rfcs = load_rfcs(config)
.map_err(Diagnostic::from)
.map_err(anyhow::Error::from)?;
let rfc = rfcs
.into_iter()
.find(|r| r.rfc.rfc_id == id)
.ok_or_else(|| {
let scope = display_path_string(config, config.rfc_dir());
Diagnostic::new(
DiagnosticCode::E0102RfcNotFound,
format!("RFC not found: {id}"),
scope,
)
})?;
match output {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&rfc.rfc)?;
println!("{json}");
}
OutputFormat::Table | OutputFormat::Plain => {
let raw = render_rfc(&rfc)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
print!("{}", render_terminal_md(&expanded));
}
}
Ok(vec![])
}
pub fn show_adr(
config: &Config,
id: &str,
output: OutputFormat,
) -> anyhow::Result<Vec<Diagnostic>> {
let adrs = load_adrs(config)?;
let adr = adrs
.into_iter()
.find(|a| a.spec.govctl.id == id)
.ok_or_else(|| {
let scope = display_path_string(config, config.adr_dir());
Diagnostic::new(
DiagnosticCode::E0302AdrNotFound,
format!("ADR not found: {id}"),
scope,
)
})?;
match output {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&adr.spec)?;
println!("{json}");
}
OutputFormat::Table | OutputFormat::Plain => {
let raw = render_adr(&adr)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
print!("{}", render_terminal_md(&expanded));
}
}
Ok(vec![])
}
pub fn show_work(
config: &Config,
id: &str,
output: OutputFormat,
) -> anyhow::Result<Vec<Diagnostic>> {
let items = load_work_items(config)?;
let item = items
.into_iter()
.find(|w| w.spec.govctl.id == id)
.ok_or_else(|| {
let scope = config
.display_path(&config.work_dir())
.display()
.to_string();
Diagnostic::new(
DiagnosticCode::E0402WorkNotFound,
format!("Work item not found: {id}"),
scope,
)
})?;
match output {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&item.spec)?;
println!("{json}");
}
OutputFormat::Table | OutputFormat::Plain => {
let raw = render_work_item(&item)?;
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
print!("{}", render_terminal_md(&expanded));
}
}
Ok(vec![])
}
pub fn show_clause(
config: &Config,
id: &str,
output: OutputFormat,
) -> anyhow::Result<Vec<Diagnostic>> {
let parts: Vec<&str> = id.split(':').collect();
if parts.len() != 2 {
return Err(Diagnostic::new(
DiagnosticCode::E0202ClauseNotFound,
format!("Invalid clause ID format: {id} (expected RFC-NNNN:C-NAME)"),
id,
)
.into());
}
let rfc_id = parts[0];
let clause_name = parts[1];
let rfcs = load_rfcs(config)
.map_err(Diagnostic::from)
.map_err(anyhow::Error::from)?;
let rfc = rfcs
.into_iter()
.find(|r| r.rfc.rfc_id == rfc_id)
.ok_or_else(|| {
let scope = display_path_string(config, config.rfc_dir());
Diagnostic::new(
DiagnosticCode::E0102RfcNotFound,
format!("RFC not found: {rfc_id}"),
scope,
)
})?;
let clause = rfc
.clauses
.into_iter()
.find(|c| c.spec.clause_id == clause_name)
.ok_or_else(|| {
let scope = config
.display_path(&config.rfc_dir().join(rfc_id).join("clauses"))
.display()
.to_string();
Diagnostic::new(
DiagnosticCode::E0202ClauseNotFound,
format!("Clause not found: {id}"),
scope,
)
})?;
match output {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&clause.spec)?;
println!("{json}");
}
OutputFormat::Table | OutputFormat::Plain => {
let mut raw = String::new();
render_clause(&mut raw, rfc_id, &clause);
let expanded = expand_inline_refs(&raw, &config.source_scan.pattern);
print!("{}", render_terminal_md(&expanded));
}
}
Ok(vec![])
}