kova-docs-lint 0.8.3

Frontmatter linter, link checker, and write-boundary validator for docs/agent
//! Minimal YAML-subset parser for agent-doc frontmatter and plan fences.
//!
//! The doc schema is fixed, so this parser supports exactly the subset the
//! schema needs and rejects everything else:
//!
//! - top-level `key: value` scalars (plain, single- or double-quoted)
//! - block lists: `key:` followed by indented `- item` lines
//! - inline lists: `key: [a, b]`, `key: []`
//! - full-line comments and trailing ` # ...` comments on unquoted values
//!
//! This is deliberately not a YAML library: the linter's job is to reject
//! anything the schema does not pin down, not to guess at it.

use std::collections::BTreeMap;

#[derive(Debug)]
pub enum MetaValue {
    Scalar(String),
    List(Vec<String>),
}

impl MetaValue {
    pub fn as_list(&self) -> Option<&[String]> {
        match self {
            MetaValue::List(items) => Some(items),
            _ => None,
        }
    }

    pub fn as_scalar(&self) -> Option<&str> {
        match self {
            MetaValue::Scalar(s) => Some(s),
            _ => None,
        }
    }
}

#[derive(Debug)]
pub struct FmError {
    /// 1-based line number, when known.
    pub line: Option<usize>,
    pub msg: String,
}

impl std::fmt::Display for FmError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.line {
            Some(n) => write!(f, "line {n}: {}", self.msg),
            None => f.write_str(&self.msg),
        }
    }
}

pub type FmResult<T> = Result<T, FmError>;

fn is_key(key: &str) -> bool {
    !key.is_empty()
        && key
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
        && !key.chars().next().unwrap().is_ascii_digit()
}

/// Clean a scalar or list-item value: strip one layer of matching quotes,
/// then strip a trailing ` # comment` from unquoted values.
fn clean_scalar(value: &str, line: usize) -> FmResult<String> {
    let t = value.trim();
    let bytes = t.as_bytes();
    if t.len() >= 2
        && ((bytes[0] == b'"' && t.ends_with('"')) || (bytes[0] == b'\'' && t.ends_with('\'')))
    {
        return Ok(t[1..t.len() - 1].to_string());
    }
    // Trailing comment: first ` #` (hash preceded by whitespace).
    if let Some(idx) = t.find(" #") {
        return Ok(t[..idx].trim().to_string());
    }
    if t.starts_with('#') {
        return Err(FmError {
            line: Some(line),
            msg: "bare comment where a value is expected".into(),
        });
    }
    Ok(t.to_string())
}

/// Parse YAML-subset lines (no `---` delimiters) into key/value pairs.
///
/// Duplicate keys, unexpected indentation, and malformed lists are errors.
pub fn parse_lines(lines: &[&str]) -> FmResult<BTreeMap<String, MetaValue>> {
    let mut out: BTreeMap<String, MetaValue> = BTreeMap::new();
    let mut i = 0;
    while i < lines.len() {
        let line_no = i + 1;
        let raw = lines[i];
        let trimmed = raw.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            i += 1;
            continue;
        }
        if raw.starts_with(' ') || raw.starts_with('\t') {
            return Err(FmError {
                line: Some(line_no),
                msg: format!("unexpected indented line not inside a list: {trimmed:?}"),
            });
        }
        let Some((key, rest)) = trimmed.split_once(':') else {
            return Err(FmError {
                line: Some(line_no),
                msg: format!("expected `key: value`, got: {trimmed:?}"),
            });
        };
        let key = key.trim();
        if !is_key(key) {
            return Err(FmError {
                line: Some(line_no),
                msg: format!("invalid key: {key:?}"),
            });
        }
        if out.contains_key(key) {
            return Err(FmError {
                line: Some(line_no),
                msg: format!("duplicate key: {key:?}"),
            });
        }
        let rest = rest.trim();
        if rest.is_empty() {
            // Block list: consume subsequent indented `- item` lines.
            let mut items: Vec<String> = Vec::new();
            let mut j = i + 1;
            while j < lines.len() {
                let l2 = lines[j];
                let t2 = l2.trim();
                if t2.is_empty() || t2.starts_with('#') {
                    j += 1;
                    continue;
                }
                if !(l2.starts_with(' ') || l2.starts_with('\t')) {
                    break; // next top-level key
                }
                let Some(after) = t2.strip_prefix('-') else {
                    break;
                };
                if !after.is_empty() && !after.starts_with(' ') {
                    break; // e.g. `-item` is not a list item in this subset
                }
                let item = after.trim();
                if item.is_empty() {
                    return Err(FmError {
                        line: Some(j + 1),
                        msg: "empty list item".into(),
                    });
                }
                items.push(clean_scalar(item, j + 1)?);
                j += 1;
            }
            out.insert(key.to_string(), MetaValue::List(items));
            i = j;
        } else if rest == "[]" {
            out.insert(key.to_string(), MetaValue::List(Vec::new()));
            i += 1;
        } else if rest.starts_with('[') {
            let Some(close) = rest.rfind(']') else {
                return Err(FmError {
                    line: Some(line_no),
                    msg: format!("unclosed inline list: {rest:?}"),
                });
            };
            let inner = &rest[1..close];
            let items: Vec<String> = if inner.trim().is_empty() {
                Vec::new()
            } else {
                inner
                    .split(',')
                    .map(|s| {
                        let s = s.trim();
                        if s.is_empty() {
                            Err(FmError {
                                line: Some(line_no),
                                msg: "empty item in inline list".into(),
                            })
                        } else {
                            clean_scalar(s, line_no)
                        }
                    })
                    .collect::<FmResult<Vec<String>>>()?
            };
            out.insert(key.to_string(), MetaValue::List(items));
            i += 1;
        } else {
            let scalar = clean_scalar(rest, line_no)?;
            out.insert(key.to_string(), MetaValue::Scalar(scalar));
            i += 1;
        }
    }
    Ok(out)
}

/// Split a document into `(frontmatter, body)`.
///
/// The first line must be exactly `---` (trailing whitespace allowed) and the
/// frontmatter must close with a line that is exactly `---`.
pub fn split_frontmatter(text: &str) -> FmResult<(BTreeMap<String, MetaValue>, String)> {
    let all: Vec<&str> = text.lines().collect();
    let Some(first) = all.first() else {
        return Err(FmError {
            line: Some(1),
            msg: "empty file: missing frontmatter".into(),
        });
    };
    if first.trim_end() != "---" {
        return Err(FmError {
            line: Some(1),
            msg: "frontmatter must start on line 1 with `---`".into(),
        });
    }
    let close = all[1..]
        .iter()
        .position(|l| l.trim_end() == "---")
        .map(|idx| idx + 1);
    let Some(idx) = close else {
        return Err(FmError {
            line: None,
            msg: "unterminated frontmatter: no closing `---`".into(),
        });
    };
    let meta = parse_lines(&all[1..idx])?;
    let body = all[idx + 1..].join("\n");
    Ok((meta, body))
}

/// Return the contents of every fenced code block whose info string is `yaml`
/// (or `yml`), with the 1-based line number where the fence opens.
pub fn yaml_fences(text: &str) -> Vec<(usize, Vec<String>)> {
    let lines: Vec<&str> = text.lines().collect();
    let mut out = Vec::new();
    let mut i = 0;
    while i < lines.len() {
        let trimmed = lines[i].trim_start();
        if let Some(info) = trimmed.strip_prefix("```") {
            let info = info.trim();
            if info.eq_ignore_ascii_case("yaml") || info.eq_ignore_ascii_case("yml") {
                let mut content: Vec<String> = Vec::new();
                let mut j = i + 1;
                while j < lines.len() && !lines[j].trim_start().starts_with("```") {
                    content.push(lines[j].to_string());
                    j += 1;
                }
                out.push((i + 1, content));
                i = j;
            }
        }
        i += 1;
    }
    out
}

/// Find a non-empty `writes:` list in any yaml fence.
///
/// Returns `(fence_line, items)` for the first fence that declares one, or
/// `None` when the plan declares no writes.
pub fn plan_writes(text: &str) -> FmResult<Option<(usize, Vec<String>)>> {
    for (line_no, content) in yaml_fences(text) {
        let meta = parse_lines(&content.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
        if let Some(items) = meta.get("writes").and_then(|v| v.as_list()) {
            if !items.is_empty() {
                return Ok(Some((line_no, items.to_vec())));
            }
        }
    }
    Ok(None)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_scalars_lists_and_quotes() {
        let lines = vec![
            "doc_tier: \"1\"",
            "module: kova-core",
            "structs:",
            "  - KovaEngine",
            "  - \"RunningGuard\"",
            "reads: []",
            "writes: [PipelineState, state:pipeline-state]",
        ];
        let meta = parse_lines(&lines).unwrap();
        assert_eq!(
            meta.get("module").and_then(|v| v.as_scalar()),
            Some("kova-core")
        );
        assert_eq!(
            meta.get("structs").and_then(|v| v.as_list()),
            Some(&["KovaEngine".to_string(), "RunningGuard".to_string()][..])
        );
        assert_eq!(meta.get("reads").and_then(|v| v.as_list()), Some(&[][..]));
        assert_eq!(
            meta.get("writes").and_then(|v| v.as_list()),
            Some(
                &[
                    "PipelineState".to_string(),
                    "state:pipeline-state".to_string()
                ][..]
            )
        );
    }

    #[test]
    fn rejects_duplicate_keys_garbage_and_bad_indent() {
        assert!(parse_lines(&["a: 1", "a: 2"]).is_err());
        assert!(parse_lines(&["no-colon-here"]).is_err());
        assert!(parse_lines(&["  indented: x"]).is_err());
        assert!(parse_lines(&["a: [1, 2"]).is_err());
        assert!(parse_lines(&["a:", "  -"]).is_err());
    }

    #[test]
    fn splits_frontmatter_and_body() {
        let text =
            "---\nmodule: kova-core\nwrites: []\n---\n# Body\n\n[link](../tier2/engine_ops.md)\n";
        let (meta, body) = split_frontmatter(text).unwrap();
        assert_eq!(
            meta.get("module").and_then(|v| v.as_scalar()),
            Some("kova-core")
        );
        assert!(body.contains("# Body"));
        assert!(body.contains("../tier2/engine_ops.md"));
    }

    #[test]
    fn rejects_missing_and_unterminated_frontmatter() {
        assert!(split_frontmatter("# no frontmatter").is_err());
        assert!(split_frontmatter("---\nmodule: kova-core\n").is_err());
    }

    #[test]
    fn strips_comments() {
        let lines = vec!["# full line", "a: 1 # trailing", "b: [x, y]"];
        let meta = parse_lines(&lines).unwrap();
        assert_eq!(meta.get("a").and_then(|v| v.as_scalar()), Some("1"));
    }

    #[test]
    fn finds_yaml_fences_and_writes() {
        let plan = "intro text\n\n```yaml\nmodule: mcp\nwrites:\n  - research/plans/plan.md\n  - state:event-log\n```\n\nmore text\n\n```text\nnot a yaml fence\nwrites:\n  - evil\n```\n";
        let found = plan_writes(plan).unwrap().unwrap();
        assert_eq!(found.0, 3);
        assert_eq!(
            found.1,
            vec![
                "research/plans/plan.md".to_string(),
                "state:event-log".to_string()
            ]
        );
    }

    #[test]
    fn plan_without_writes_fence_is_none() {
        assert!(plan_writes("no fences here").unwrap().is_none());
        let plan = "```yaml\nmodule: mcp\nwrites: []\n```\n";
        assert!(plan_writes(plan).unwrap().is_none());
    }
}