use std::collections::BTreeMap;
use std::fmt::Write as _;
use harn_parser::diagnostic_codes::{Category, Code, RegistryEntry};
use serde::Serialize;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Serialize)]
struct CatalogEnvelope<'a> {
#[serde(rename = "schemaVersion")]
schema_version: u32,
categories: Vec<CategoryEnvelope<'a>>,
codes: Vec<CodeEnvelope<'a>>,
}
#[derive(Debug, Serialize)]
struct CategoryEnvelope<'a> {
id: &'a str,
title: &'a str,
count: usize,
}
#[derive(Debug, Serialize)]
struct CodeEnvelope<'a> {
code: &'a str,
category: &'a str,
summary: &'a str,
repairs: Vec<RepairEnvelope<'a>>,
related: Vec<&'a str>,
#[serde(rename = "explanationPresent")]
explanation_present: bool,
#[serde(rename = "apiStability")]
api_stability: &'static str,
}
#[derive(Debug, Serialize)]
struct RepairEnvelope<'a> {
id: &'a str,
safety: &'a str,
summary: &'a str,
}
pub fn render_markdown() -> String {
Renderer::new().markdown()
}
pub fn render_json() -> String {
Renderer::new().json()
}
pub fn render_text() -> String {
Renderer::new().text()
}
struct Renderer {
by_category: BTreeMap<Category, Vec<&'static RegistryEntry>>,
}
impl Renderer {
fn new() -> Self {
let mut by_category: BTreeMap<Category, Vec<&'static RegistryEntry>> = BTreeMap::new();
for entry in Code::registry() {
by_category.entry(entry.category).or_default().push(entry);
}
for entries in by_category.values_mut() {
entries.sort_by_key(|entry| entry.identifier);
}
Self { by_category }
}
fn ordered_categories(
&self,
) -> impl Iterator<Item = (&Category, &Vec<&'static RegistryEntry>)> {
Category::ALL
.iter()
.filter_map(|category| self.by_category.get_key_value(category))
}
fn json(&self) -> String {
let categories = self
.ordered_categories()
.map(|(category, entries)| CategoryEnvelope {
id: category.as_str(),
title: category_title(*category),
count: entries.len(),
})
.collect();
let codes = self
.ordered_categories()
.flat_map(|(_, entries)| entries.iter().map(|entry| code_envelope(entry)))
.collect();
let envelope = CatalogEnvelope {
schema_version: SCHEMA_VERSION,
categories,
codes,
};
let mut out = serde_json::to_string_pretty(&envelope)
.expect("catalog envelope serialises with serde_json");
out.push('\n');
out
}
fn text(&self) -> String {
let mut out = String::new();
for (category, entries) in self.ordered_categories() {
for entry in entries {
let repair = entry
.code
.repair_template()
.map(|template| format!(" [repair: {} ({})]", template.id, template.safety))
.unwrap_or_default();
writeln!(
out,
"{} ({}) — {}{}",
entry.identifier,
category.as_str(),
entry.summary,
repair,
)
.expect("writing to string never fails");
}
}
out
}
fn markdown(&self) -> String {
let mut out = String::new();
out.push_str("# Diagnostic codes\n\n");
out.push_str("<!-- GENERATED by `harn explain --catalog --format markdown` -- do not edit by hand. -->\n");
out.push_str("<!-- Source of truth: crates/harn-parser/src/diagnostic_codes.rs. Run `make sync-diagnostics-catalog` to regenerate. -->\n\n");
out.push_str("<!-- markdownlint-disable MD013 MD024 -->\n\n");
out.push_str(
"Every diagnostic emitted by `harn check`, `harn lint`, and `harn fmt` carries a \
stable `HARN-<CAT>-<NNN>` code. Codes are dispatchable: agents, IDEs, and the \
hosted error pages all read the same `apiStability: stable` contract, so cross-\
tooling integrations never have to regex on prose.\n\n",
);
out.push_str("Look up a single code interactively:\n\n");
out.push_str("```sh\n");
out.push_str("harn explain HARN-TYP-014\n");
out.push_str("harn explain HARN-TYP-014 --json\n");
out.push_str("```\n\n");
out.push_str(
"The structured JSON sidecar that drives this page is committed at \
[`docs/diagnostics-catalog.json`](https://github.com/burin-labs/harn/blob/main/docs/diagnostics-catalog.json) — its \
`schemaVersion: 1` shape is the contract consumed by downstream tooling \
(burin-code's IDE diagnostic panel, harn-cloud's hosted error pages). \
Regenerate locally with `make sync-diagnostics-catalog`.\n\n",
);
out.push_str(
"Prose-style tour of common shape and nilable diagnostics: \
[Reading shape diagnostics](./reading-shape-diagnostics.md).\n\n",
);
out.push_str("## Repair safety classes\n\n");
out.push_str("Repairs are tagged with a six-level safety class so `harn fix --apply --safety <ceiling>` and IDE auto-apply policies can dispatch without inspecting individual edits:\n\n");
out.push_str("| Class | Meaning |\n");
out.push_str("|---|---|\n");
out.push_str("| `format-only` | Whitespace, trivia, or canonical layout only. Always safe to auto-apply. |\n");
out.push_str(
"| `behavior-preserving` | Intended not to change observable runtime behavior. |\n",
);
out.push_str("| `scope-local` | Confined to the current local scope or file; blast radius does not cross a public surface. |\n");
out.push_str("| `surface-changing` | Touches a signature, export, or call-site surface other files can observe. |\n");
out.push_str(
"| `capability-changing` | Required capabilities or sandbox profile may change. |\n",
);
out.push_str("| `needs-human` | Planning hint only — propose, never auto-apply. |\n\n");
out.push_str("## Categories\n\n");
out.push_str("| Category | Title | Codes |\n");
out.push_str("|---|---|---:|\n");
for (category, entries) in self.ordered_categories() {
writeln!(
out,
"| [`{}`](#{}) | {} | {} |",
category.as_str(),
category_anchor(*category),
category_title(*category),
entries.len(),
)
.expect("writing to string never fails");
}
out.push('\n');
for (category, entries) in self.ordered_categories() {
writeln!(
out,
"## {} — {}",
category.as_str(),
category_title(*category)
)
.expect("write");
out.push('\n');
out.push_str("| Code | Summary | Repair | Safety |\n");
out.push_str("|---|---|---|---|\n");
for entry in entries {
let template = entry.code.repair_template();
let (repair_cell, safety_cell) = match template {
Some(template) => (
format!("`{}`", template.id),
format!("`{}`", template.safety),
),
None => ("—".to_string(), "—".to_string()),
};
writeln!(
out,
"| [`{}`](#{}) | {} | {} | {} |",
entry.identifier,
code_anchor(entry.identifier),
escape_pipe(entry.summary),
repair_cell,
safety_cell,
)
.expect("write");
}
out.push('\n');
}
out.push_str("## Code reference\n\n");
for (_, entries) in self.ordered_categories() {
for entry in entries {
writeln!(out, "### `{}`", entry.identifier).expect("write");
out.push('\n');
writeln!(
out,
"**Category:** `{}` ({}) · **API stability:** `stable`",
entry.category,
category_title(entry.category),
)
.expect("write");
out.push('\n');
writeln!(out, "{}", entry.summary).expect("write");
out.push('\n');
if let Some(template) = entry.code.repair_template() {
writeln!(
out,
"- **Repair:** `{}` · **Safety:** `{}`",
template.id, template.safety
)
.expect("write");
writeln!(out, "- {}", template.summary).expect("write");
}
let related = entry.code.related();
if !related.is_empty() {
let mut links = String::new();
for (index, other) in related.iter().enumerate() {
if index > 0 {
links.push_str(", ");
}
write!(
links,
"[`{}`](#{})",
other.as_str(),
code_anchor(other.as_str())
)
.expect("write");
}
writeln!(out, "- **See also:** {links}").expect("write");
}
out.push('\n');
let body = strip_leading_heading(entry.code.explanation());
out.push_str(&demote_markdown_headings(body, 2));
if !out.ends_with("\n\n") {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
}
collapse_blank_runs(&out)
}
}
fn collapse_blank_runs(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut newline_run = 0usize;
for ch in source.chars() {
if ch == '\n' {
newline_run += 1;
if newline_run <= 2 {
out.push('\n');
}
} else {
newline_run = 0;
out.push(ch);
}
}
while out.ends_with("\n\n") {
out.pop();
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn code_envelope(entry: &RegistryEntry) -> CodeEnvelope<'static> {
let repairs = entry
.code
.repair_template()
.map(|template| {
vec![RepairEnvelope {
id: template.id,
safety: template.safety.as_str(),
summary: template.summary,
}]
})
.unwrap_or_default();
CodeEnvelope {
code: entry.identifier,
category: entry.category.as_str(),
summary: entry.summary,
repairs,
related: entry.code.related().iter().map(|c| c.as_str()).collect(),
explanation_present: !entry.code.explanation().trim().is_empty(),
api_stability: "stable",
}
}
const fn category_title(category: Category) -> &'static str {
match category {
Category::Typ => "Type checker",
Category::Par => "Parser / lexer",
Category::Nam => "Naming and resolution",
Category::Cap => "Capabilities",
Category::Llm => "LLM calls",
Category::Orc => "Orchestration constructs",
Category::Std => "Stdlib usage",
Category::Prm => "Prompt templates",
Category::Mod => "Modules and exports",
Category::Rmd => "Reminder lifecycle",
Category::Sus => "Suspend / resume lifecycle",
Category::Lnt => "Lint rules",
Category::Fmt => "Formatter",
Category::Imp => "Import resolution",
Category::Own => "Ownership and mutability",
Category::Rcv => "Error recovery",
Category::Mat => "Match exhaustiveness",
Category::Pol => "Runtime policies",
Category::Met => "Compile-time meta restrictions",
Category::Cst => "Const-eval sandbox",
}
}
fn category_anchor(category: Category) -> String {
format!(
"{}--{}",
category.as_str().to_lowercase(),
category_title(category)
.to_lowercase()
.replace(' ', "-")
.replace('/', "")
)
}
fn code_anchor(identifier: &str) -> String {
identifier.to_lowercase()
}
fn escape_pipe(value: &str) -> String {
value.replace('|', "\\|")
}
fn strip_leading_heading(source: &str) -> &str {
for (idx, ch) in source.char_indices() {
if ch == '\n' || ch == '\r' {
continue;
}
if ch != '#' {
return source;
}
let mut end = idx;
for (next_idx, next_ch) in source[idx..].char_indices() {
end = idx + next_idx + next_ch.len_utf8();
if next_ch == '\n' {
break;
}
}
if source[end..].starts_with('\n') {
end += 1;
}
return &source[end..];
}
source
}
fn demote_markdown_headings(source: &str, levels: usize) -> String {
let mut out = String::with_capacity(source.len() + 16);
let mut in_fence = false;
for line in source.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
out.push_str(line);
out.push('\n');
continue;
}
if !in_fence && trimmed.starts_with('#') {
let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
if (1..=6).contains(&hashes) {
let prefix_len = line.len() - trimmed.len();
out.push_str(&line[..prefix_len]);
for _ in 0..(hashes + levels).min(6) {
out.push('#');
}
out.push_str(&trimmed[hashes..]);
out.push('\n');
continue;
}
}
out.push_str(line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_envelope_round_trips_schema_version() {
let json = render_json();
let value: serde_json::Value = serde_json::from_str(&json).expect("parse catalog json");
assert_eq!(value["schemaVersion"], serde_json::json!(1));
assert!(
value["codes"]
.as_array()
.expect("codes array")
.iter()
.all(|entry| entry["apiStability"] == serde_json::json!("stable")),
"every code envelope must declare apiStability=stable"
);
}
#[test]
fn json_envelope_includes_every_registered_code() {
let json = render_json();
let value: serde_json::Value = serde_json::from_str(&json).expect("parse catalog json");
let codes = value["codes"].as_array().expect("codes array");
assert_eq!(codes.len(), Code::ALL.len());
let identifiers: std::collections::HashSet<&str> = codes
.iter()
.map(|entry| entry["code"].as_str().unwrap())
.collect();
for entry in Code::registry() {
assert!(
identifiers.contains(entry.identifier),
"catalog json missing {}",
entry.identifier
);
}
}
#[test]
fn json_envelope_lists_every_category_with_count() {
let json = render_json();
let value: serde_json::Value = serde_json::from_str(&json).expect("parse catalog json");
let categories = value["categories"].as_array().expect("categories array");
assert_eq!(categories.len(), Category::ALL.len());
for envelope in categories {
let id = envelope["id"].as_str().unwrap();
let count = envelope["count"].as_u64().unwrap();
let expected = Code::registry()
.iter()
.filter(|entry| entry.category.as_str() == id)
.count() as u64;
assert_eq!(count, expected, "category {id} count drifted");
}
}
#[test]
fn json_envelope_surfaces_repair_safety() {
let json = render_json();
let value: serde_json::Value = serde_json::from_str(&json).expect("parse catalog json");
let entry = value["codes"]
.as_array()
.expect("codes array")
.iter()
.find(|entry| entry["code"] == serde_json::json!("HARN-OWN-001"))
.expect("HARN-OWN-001 present");
let repairs = entry["repairs"].as_array().expect("repairs array");
assert_eq!(repairs.len(), 1);
assert_eq!(repairs[0]["id"], serde_json::json!("bindings/make-mutable"));
assert_eq!(repairs[0]["safety"], serde_json::json!("scope-local"));
}
#[test]
fn markdown_renders_every_code_anchor() {
let markdown = render_markdown();
for entry in Code::registry() {
let anchor = format!("### `{}`", entry.identifier);
assert!(
markdown.contains(&anchor),
"markdown missing per-code section for {}",
entry.identifier
);
}
}
#[test]
fn markdown_starts_with_generated_banner() {
let markdown = render_markdown();
assert!(markdown.starts_with("# Diagnostic codes\n"));
assert!(markdown.contains("<!-- GENERATED by"));
}
#[test]
fn text_renders_one_line_per_code() {
let text = render_text();
let registry_count = Code::registry().len();
assert_eq!(text.lines().count(), registry_count);
for entry in Code::registry() {
assert!(
text.contains(entry.identifier),
"text catalog missing {}",
entry.identifier
);
}
}
#[test]
fn strip_leading_heading_removes_first_heading_and_blank() {
let body = "# HARN-TYP-014 — title\n\nFirst paragraph.\n";
assert_eq!(strip_leading_heading(body), "First paragraph.\n");
}
#[test]
fn strip_leading_heading_is_noop_when_first_line_is_prose() {
let body = "Just some prose.\n# heading later\n";
assert_eq!(strip_leading_heading(body), body);
}
#[test]
fn heading_demotion_skips_code_fences() {
let input = "# top\n\n```sh\n# not a heading\n```\n\n## inner\n";
let demoted = demote_markdown_headings(input, 2);
assert!(demoted.contains("### top"));
assert!(demoted.contains("# not a heading"));
assert!(demoted.contains("#### inner"));
}
}