use crate::config::{Adapter, Config};
use crate::hashing::{sha256_hex, short};
use crate::paths::Paths;
use anyhow::Result;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Header {
pub schema: String,
pub adapter: String,
pub store: String,
pub body: String,
}
pub fn parse(content: &str) -> Option<(Header, &str)> {
let mut fields = None;
let mut offset = 0usize;
for line in content.split_inclusive('\n') {
let t = line.trim();
if t.starts_with("<!--") {
if fields.is_none() && t.starts_with("<!-- keel:generated") {
fields = Some(parse_fields(t));
}
offset += line.len();
continue;
}
break;
}
let fields = fields?;
let rest = &content[offset..];
let body = rest.strip_prefix('\n').unwrap_or(rest);
Some((fields, body))
}
fn parse_fields(line: &str) -> Header {
let mut h = Header {
schema: String::new(),
adapter: String::new(),
store: String::new(),
body: String::new(),
};
for tok in line.trim_start_matches("<!--").trim_end_matches("-->").split_whitespace() {
let Some((k, v)) = tok.split_once('=') else { continue };
match k {
"schema" => h.schema = v.to_string(),
"adapter" => h.adapter = v.to_string(),
"store" => h.store = v.to_string(),
"body" => h.body = v.to_string(),
_ => {}
}
}
h
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
Ok,
Missing,
Foreign,
Drift,
Stale,
}
impl State {
pub fn is_blocking(&self) -> bool {
matches!(self, State::Missing | State::Foreign | State::Drift | State::Stale)
}
pub fn glyph(&self) -> &'static str {
match self {
State::Ok => "ok",
State::Missing => "missing",
State::Foreign => "foreign",
State::Drift => "DRIFT",
State::Stale => "stale",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub adapter: String,
pub path: String,
pub state: State,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub lines: Option<usize>,
pub budget: usize,
pub over_budget: bool,
}
pub fn check_adapter(paths: &Paths, adapter: &Adapter, store_hash: &str) -> Result<Report> {
let path: PathBuf = paths.repo.join(&adapter.out);
let rel = adapter.out.clone();
let mk = |state: State, detail: String, lines: Option<usize>| Report {
adapter: adapter.id.clone(),
path: rel.clone(),
state,
detail,
lines,
budget: adapter.budget,
over_budget: lines.map(|l| l > adapter.budget).unwrap_or(false),
};
if !path.exists() {
return Ok(mk(State::Missing, "not rendered yet".into(), None));
}
let content = std::fs::read_to_string(&path)?;
let Some((header, body)) = parse(&content) else {
return Ok(mk(
State::Foreign,
"file exists but was not generated by keel — move its content into .keel/store/ or disable this adapter".into(),
Some(content.lines().count()),
));
};
let lines = Some(body.lines().count());
let body_hash = sha256_hex(body.as_bytes());
let actual_body = short(&body_hash);
if actual_body != header.body {
return Ok(mk(
State::Drift,
format!("hand-edited (header says body={}, file is {})", header.body, actual_body),
lines,
));
}
if header.store != short(store_hash) {
return Ok(mk(
State::Stale,
format!("store has moved on (rendered from store={}, now {})", header.store, short(store_hash)),
lines,
));
}
Ok(mk(State::Ok, "current".into(), lines))
}
pub fn check_all(paths: &Paths, cfg: &Config, store_hash: &str) -> Result<Vec<Report>> {
super::enabled_adapters(cfg)
.map(|a| check_adapter(paths, a, store_hash))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = "<!-- keel:generated schema=keel.projection/1 adapter=claude store=aaaa1111bbbb body=cccc2222dddd -->\n<!-- Source of truth: .keel/store/ -->\n\n# Body\nline\n";
#[test]
fn parses_header_and_body() {
let (h, body) = parse(FILE).expect("header");
assert_eq!(h.adapter, "claude");
assert_eq!(h.store, "aaaa1111bbbb");
assert_eq!(h.body, "cccc2222dddd");
assert_eq!(body, "# Body\nline\n");
}
#[test]
fn foreign_file_has_no_header() {
assert!(parse("# Hand written CLAUDE.md\n").is_none());
}
#[test]
fn round_trip_body_hash_matches() {
let body = "# Body\nline\n";
let full = sha256_hex(body.as_bytes());
let hash = short(&full);
let file = format!("<!-- keel:generated schema=x adapter=claude store=s body={hash} -->\n\n{body}");
let (h, parsed) = parse(&file).unwrap();
assert_eq!(parsed, body);
let reparsed = sha256_hex(parsed.as_bytes());
assert_eq!(h.body, short(&reparsed));
}
#[test]
fn drift_and_stale_are_distinguishable() {
let body = "# Body\n";
let full = sha256_hex(body.as_bytes());
let bh = short(&full);
let file = format!("<!-- keel:generated schema=x adapter=claude store=OLD body={bh} -->\n\n{body}");
let (h, parsed) = parse(&file).unwrap();
let reparsed = sha256_hex(parsed.as_bytes());
assert_eq!(short(&reparsed), h.body);
assert_ne!(h.store, "NEW");
}
}