keel-harness 0.2.2

A gated harness for AI-assisted delivery: auditable stopping conditions and durable memory across coding agents.
//! Parsing the provenance header, and classifying what happened to a file.

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,
}

/// Split a projection file into its header and body. `None` means the file was
/// not written by keel.
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..];
    // Exactly one blank separator line belongs to the header.
    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 {
    /// Body intact, store hash current.
    Ok,
    /// keel has never written this file.
    Missing,
    /// The file exists but has no keel header — something else owns it.
    Foreign,
    /// The body was edited after keel wrote it.
    Drift,
    /// The body is keel's, but the store has changed since.
    Stale,
}

impl State {
    /// Whether this state should fail a gate / pre-commit hook.
    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);
        // intact body, old store hash => stale, not drift
        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");
    }
}