use std::{
path::{Path, PathBuf},
process::Command as ProcessCommand,
};
use anyhow::{Context, Result, anyhow};
use serde::Serialize;
use serde_json::Value;
use super::schemas::{REGISTERED_VERBS, schema_for_verb};
use crate::cli::{Cli, should_output_json};
#[derive(Debug, Clone, Serialize)]
pub struct SchemaIssue {
pub verb: String,
pub line: usize,
pub unknown_key: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SchemaReport {
pub registered_verbs: Vec<String>,
pub unmatched_verbs: Vec<String>,
pub passing_verbs: Vec<String>,
pub issues: Vec<SchemaIssue>,
pub doc_path: String,
}
pub fn cmd_doctor_schemas(cli: &Cli) -> Result<()> {
let json = should_output_json(cli, None);
let repo_root = cli.repo.clone().map(Ok).unwrap_or_else(|| {
std::env::current_dir().map(|cwd| find_repo_root(&cwd).unwrap_or(cwd))
})?;
let doc_path = repo_root.join("docs").join("json-schemas.md");
let doc = std::fs::read_to_string(&doc_path)
.with_context(|| format!("read {}", doc_path.display()))?;
let samples = extract_samples(&doc);
let mut issues = Vec::new();
let mut passing_verbs = Vec::new();
let mut unmatched_verbs = Vec::new();
for verb in REGISTERED_VERBS {
let schema = match schema_for_verb(verb) {
Some(s) => s,
None => {
continue;
}
};
let property_keys = schema_property_keys(&schema);
let verb_samples: Vec<&DocSample> = samples
.iter()
.filter(|s| sample_matches_verb_with_hints(s, verb))
.collect();
if verb_samples.is_empty() {
unmatched_verbs.push((*verb).to_string());
continue;
}
let mut verb_clean = true;
for sample in verb_samples {
let sample_keys = match top_level_keys(&sample.json) {
Some(keys) => keys,
None => {
continue;
}
};
for key in sample_keys {
if !property_keys.contains(&key) {
verb_clean = false;
issues.push(SchemaIssue {
verb: (*verb).to_string(),
line: sample.start_line,
unknown_key: key.clone(),
detail: format!("sample has field '{key}', but schema does not declare it"),
});
}
}
}
if verb_clean {
passing_verbs.push((*verb).to_string());
}
}
let report = SchemaReport {
registered_verbs: REGISTERED_VERBS.iter().map(|s| s.to_string()).collect(),
unmatched_verbs,
passing_verbs,
issues,
doc_path: doc_path.display().to_string(),
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
render_human(&report);
}
if !report.issues.is_empty() {
return Err(anyhow!(
"{} schema drift issue(s) found",
report.issues.len()
));
}
Ok(())
}
fn render_human(report: &SchemaReport) {
println!(
"heddle doctor schemas — {} verb(s) registered, doc: {}",
report.registered_verbs.len(),
report.doc_path
);
println!();
for verb in &report.passing_verbs {
println!(" ok {verb}: sample matches generated schema");
}
if !report.unmatched_verbs.is_empty() {
println!();
println!(
" -- {} verb(s) without a documented sample (allowed):",
report.unmatched_verbs.len()
);
for verb in &report.unmatched_verbs {
println!(" {verb}");
}
}
if !report.issues.is_empty() {
println!();
for issue in &report.issues {
println!(
" drift {}: {} (doc line {})",
issue.verb, issue.detail, issue.line
);
}
println!();
println!("Found {} drift issue(s).", report.issues.len());
} else {
println!();
println!("No drift detected.");
}
}
fn schema_property_keys(schema: &Value) -> std::collections::BTreeSet<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default()
}
#[derive(Debug)]
struct DocSample {
heading: String,
inline_verb: Option<String>,
start_line: usize,
json: Value,
}
fn extract_samples(doc: &str) -> Vec<DocSample> {
let mut samples = Vec::new();
let mut current_heading = String::new();
let mut last_inline_verb: Option<String> = None;
let mut in_fence = false;
let mut fence_start = 0usize;
let mut buffer = String::new();
for (idx, line) in doc.lines().enumerate() {
let lineno = idx + 1;
if !in_fence && line.starts_with("## ") {
current_heading = line.trim_start_matches("## ").trim().to_string();
last_inline_verb = None;
continue;
}
if !in_fence {
if let Some(verb) = parse_inline_verb(line) {
last_inline_verb = Some(verb);
}
}
if !in_fence && line.trim() == "```json" {
in_fence = true;
fence_start = lineno;
buffer.clear();
continue;
}
if in_fence && line.trim() == "```" {
in_fence = false;
if let Ok(json) = serde_json::from_str::<Value>(&buffer) {
samples.push(DocSample {
heading: current_heading.clone(),
inline_verb: last_inline_verb.clone(),
start_line: fence_start,
json,
});
last_inline_verb = None;
}
buffer.clear();
continue;
}
if in_fence {
buffer.push_str(line);
buffer.push('\n');
}
}
samples
}
fn sample_matches_verb_with_hints(sample: &DocSample, verb: &str) -> bool {
if let Some(inline) = &sample.inline_verb {
if inline_verb_matches(inline, verb) {
return true;
}
return false;
}
sample_matches_verb(&sample.heading, verb)
}
fn inline_verb_matches(inline: &str, verb: &str) -> bool {
let trimmed = inline.trim();
if trimmed == verb {
return true;
}
let mut parts: Vec<&str> = trimmed.split_whitespace().collect();
if let Some(last) = parts.pop()
&& last.contains('|')
{
let prefix = parts.join(" ");
for variant in last.split('|') {
let combined = if prefix.is_empty() {
variant.to_string()
} else {
format!("{prefix} {variant}")
};
if combined == verb {
return true;
}
}
}
false
}
fn parse_inline_verb(line: &str) -> Option<String> {
let bytes = line.as_bytes();
let backtick_start = line.find('`')?;
let after_first = &line[backtick_start + 1..];
let backtick_end_rel = after_first.find('`')?;
let inner = &after_first[..backtick_end_rel];
let inner = inner.trim();
let inner_verb = inner.strip_prefix("heddle ").unwrap_or(inner).trim();
let inner_verb = inner_verb.trim_end_matches("--json").trim();
if !is_plausible_verb_phrase(inner_verb) {
return None;
}
let after_close = &line[backtick_start + 1 + backtick_end_rel + 1..];
let after_close_lower = after_close.to_ascii_lowercase();
let _ = bytes; if after_close_lower.contains("emits") || after_close_lower.contains("emit") {
Some(inner_verb.to_string())
} else {
None
}
}
fn is_plausible_verb_phrase(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, ' ' | '-' | '|' | '<' | '>' | '_')
})
}
fn sample_matches_verb(heading: &str, verb: &str) -> bool {
let stripped = heading.trim_start_matches('`').trim_end_matches('`').trim();
let stripped = stripped.trim_start_matches("heddle ").trim();
let mut tokens: Vec<&str> = stripped
.split_whitespace()
.filter(|tok| !tok.starts_with('<') && *tok != "--json")
.collect();
if tokens.is_empty() {
return false;
}
let last = tokens.pop().unwrap();
let prefix = tokens.join(" ");
if last.contains('|') {
for variant in last.split('|') {
let combined = if prefix.is_empty() {
variant.to_string()
} else {
format!("{prefix} {variant}")
};
if combined == verb {
return true;
}
}
false
} else {
let combined = if prefix.is_empty() {
last.to_string()
} else {
format!("{prefix} {last}")
};
combined == verb
}
}
fn top_level_keys(value: &Value) -> Option<Vec<String>> {
let object = value.as_object()?;
Some(object.keys().cloned().collect())
}
fn find_repo_root(start: &Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
if ancestor.join(".heddle").exists() || ancestor.join(".git").exists() {
return Some(ancestor.to_path_buf());
}
}
let output = ProcessCommand::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(start)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8(output.stdout).ok()?;
Some(PathBuf::from(path.trim()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_samples_from_simple_doc() {
let doc = "\
## `heddle foo --json`
Some prose.
```json
{\"a\": 1, \"b\": 2}
```
## `heddle bar --json`
```json
{\"x\": true}
```
";
let samples = extract_samples(doc);
assert_eq!(samples.len(), 2);
assert_eq!(samples[0].heading, "`heddle foo --json`");
assert_eq!(samples[0].json.get("a").and_then(|v| v.as_u64()), Some(1));
assert_eq!(samples[1].heading, "`heddle bar --json`");
}
#[test]
fn skips_fences_with_nonparseable_placeholder_samples() {
let doc = "\
## `heddle baz --json`
```json
{\"placeholder\": ...}
```
";
let samples = extract_samples(doc);
assert!(samples.is_empty());
}
#[test]
fn sample_matches_verb_strips_heddle_prefix_and_args() {
assert!(sample_matches_verb("`heddle status --json`", "status"));
assert!(sample_matches_verb(
"`heddle bridge git status --json`",
"bridge git status"
));
assert!(sample_matches_verb("`heddle show <state> --json`", "show"));
assert!(!sample_matches_verb("`heddle status --json`", "log"));
}
#[test]
fn top_level_keys_returns_none_for_null() {
assert!(top_level_keys(&Value::Null).is_none());
assert!(top_level_keys(&Value::Bool(true)).is_none());
}
#[test]
fn top_level_keys_returns_keys_for_object() {
let v: Value = serde_json::from_str(r#"{"a": 1, "b": 2}"#).unwrap();
let mut keys = top_level_keys(&v).unwrap();
keys.sort();
assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
}
}