use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{AnalysisError, AnalysisResult};
use crate::markdown;
const TEMPLATES: &[(&str, &[&str])] = &[
(
"adr",
&[
"Context and Problem Statement",
"Decision Drivers",
"Considered Options",
"Decision Outcome",
"Consequences",
],
),
(
"handoff",
&[
"Where things stand",
"Decisions made",
"What's next",
"Landmines",
],
),
(
"design-doc",
&[
"Overview",
"Context",
"Approach",
"Alternatives considered",
"Consequences",
],
),
];
const PLACEHOLDER_PATTERNS: &[&str] = &["tbd", "todo", "n/a", "...", "\u{2014}", "placeholder"];
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CompletenessReport {
pub template: String,
pub sections: Vec<SectionResult>,
pub pass: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SectionResult {
pub name: String,
pub status: SectionStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SectionStatus {
Present,
Empty,
Missing,
}
#[tracing::instrument(skip(text, custom_templates), fields(text_len = text.len(), template))]
pub fn check_completeness(
text: &str,
template: &str,
custom_templates: Option<&HashMap<String, Vec<String>>>,
) -> AnalysisResult<CompletenessReport> {
let required = find_template(template, custom_templates)?;
let headings = markdown::extract_headings(text);
let sections: Vec<SectionResult> = required
.iter()
.map(|section_name| {
let status = check_section(text, section_name, &headings);
SectionResult {
name: section_name.to_string(),
status,
}
})
.collect();
let pass = sections.iter().all(|s| s.status == SectionStatus::Present);
Ok(CompletenessReport {
template: template.to_string(),
sections,
pass,
})
}
pub fn available_templates(custom_templates: Option<&HashMap<String, Vec<String>>>) -> Vec<String> {
let mut names: Vec<String> = TEMPLATES
.iter()
.map(|(name, _)| (*name).to_string())
.collect();
if let Some(custom) = custom_templates {
for key in custom.keys() {
if !names.iter().any(|n| n == key) {
names.push(key.clone());
}
}
}
names
}
fn find_template<'a>(
name: &str,
custom_templates: Option<&'a HashMap<String, Vec<String>>>,
) -> AnalysisResult<Vec<&'a str>>
where
'static: 'a,
{
if let Some(custom) = custom_templates
&& let Some(sections) = custom.get(name)
{
return Ok(sections.iter().map(String::as_str).collect());
}
TEMPLATES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, sections)| sections.iter().map(|s| *s as &str).collect())
.ok_or_else(|| {
let available = available_templates(custom_templates).join(", ");
AnalysisError::UnknownTemplate {
name: name.to_string(),
available,
}
})
}
fn check_section(text: &str, section_name: &str, headings: &[(u8, String)]) -> SectionStatus {
let section_lower = section_name.to_lowercase();
let matching_heading = headings.iter().find(|(level, heading_text)| {
(*level == 2 || *level == 3) && heading_text.to_lowercase().contains(§ion_lower)
});
let Some((level, matched_text)) = matching_heading else {
return SectionStatus::Missing;
};
let content = extract_section_content(text, matched_text, *level);
if content.trim().is_empty() {
return SectionStatus::Empty;
}
let normalized = content.trim().to_lowercase();
if PLACEHOLDER_PATTERNS.iter().any(|p| normalized == *p) {
return SectionStatus::Empty;
}
SectionStatus::Present
}
fn extract_section_content(text: &str, heading_text: &str, heading_level: u8) -> String {
let heading_lower = heading_text.to_lowercase();
let lines: Vec<&str> = text.lines().collect();
let heading_idx = lines.iter().position(|line| {
let trimmed = line.trim().to_lowercase();
let stripped = trimmed.trim_start_matches('#').trim();
stripped.contains(&heading_lower) && trimmed.starts_with('#')
});
let Some(idx) = heading_idx else {
return String::new();
};
let mut content = String::new();
for line in &lines[idx + 1..] {
let trimmed = line.trim();
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|c| *c == '#').count() as u8;
if level <= heading_level {
break;
}
}
content.push_str(trimmed);
content.push('\n');
}
content
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complete_handoff_passes() {
let content = r#"# Handoff: Test
**Date:** 2026-02-07
## Where things stand
Everything works fine.
## Decisions made
- Chose X over Y because Z.
## What's next
1. Do the thing.
## Landmines
- Watch out for the thing.
"#;
let report = check_completeness(content, "handoff", None).unwrap();
assert!(report.pass);
assert!(
report
.sections
.iter()
.all(|s| s.status == SectionStatus::Present)
);
}
#[test]
fn missing_section_detected() {
let content = r#"# Handoff: Test
## Where things stand
Everything works fine.
## Decisions made
- Chose X.
"#;
let report = check_completeness(content, "handoff", None).unwrap();
assert!(!report.pass);
let landmines = report
.sections
.iter()
.find(|s| s.name == "Landmines")
.unwrap();
assert_eq!(landmines.status, SectionStatus::Missing);
}
#[test]
fn empty_section_detected() {
let content = r#"# Handoff: Test
## Where things stand
Everything works fine.
## Decisions made
- Chose X.
## What's next
Do stuff.
## Landmines
TBD
"#;
let report = check_completeness(content, "handoff", None).unwrap();
assert!(!report.pass);
let landmines = report
.sections
.iter()
.find(|s| s.name == "Landmines")
.unwrap();
assert_eq!(landmines.status, SectionStatus::Empty);
}
#[test]
fn unknown_template_errors() {
let result = check_completeness("# Test", "nonexistent", None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("unknown template"));
}
#[test]
fn adr_template_sections() {
let templates = available_templates(None);
assert!(templates.iter().any(|t| t == "adr"));
assert!(templates.iter().any(|t| t == "handoff"));
assert!(templates.iter().any(|t| t == "design-doc"));
}
#[test]
fn custom_template_works() {
let mut custom = HashMap::new();
custom.insert(
"release-notes".to_string(),
vec!["Summary".to_string(), "Changes".to_string()],
);
let content = "## Summary\n\nStuff happened.\n\n## Changes\n\n- Fixed bug.";
let report = check_completeness(content, "release-notes", Some(&custom)).unwrap();
assert!(report.pass);
}
#[test]
fn custom_template_overrides_builtin() {
let mut custom = HashMap::new();
custom.insert(
"handoff".to_string(),
vec!["Status".to_string(), "Next".to_string()],
);
let content = "## Status\n\nDone.\n\n## Next\n\nShip it.";
let report = check_completeness(content, "handoff", Some(&custom)).unwrap();
assert!(report.pass);
}
#[test]
fn available_templates_includes_custom() {
let mut custom = HashMap::new();
custom.insert("release-notes".to_string(), vec!["Summary".to_string()]);
let templates = available_templates(Some(&custom));
assert!(templates.iter().any(|t| t == "release-notes"));
assert!(templates.iter().any(|t| t == "adr"));
}
#[test]
fn complete_adr_passes() {
let content = r#"# ADR-0001: Test Decision
## Context and Problem Statement
We need to decide something.
## Decision Drivers
- Speed
- Simplicity
## Considered Options
1. Option A
2. Option B
## Decision Outcome
Chose option A because it's faster.
## Consequences
- Good: faster delivery.
- Bad: more complexity.
"#;
let report = check_completeness(content, "adr", None).unwrap();
assert!(report.pass);
}
}