use scrybe_core::{ContentAddressable, Document};
use serde_json::{json, Value};
use crate::lint::lint_document;
use crate::{Ctx, DataSchema, EngineFault, Facet, ToolOutcome, ToolSpec};
const DATA_VERSION: u32 = 1;
pub(crate) fn spec() -> ToolSpec {
ToolSpec {
name: "lint",
description: "Analyze a Markdown document and return structured statistics \
(word/heading/code counts, code-block languages, math & Mermaid \
presence, broken links) plus the source's content id. Input: `source` \
(Markdown string). Read the `data` payload — `clean` is true when \
there are no broken links.",
input_schema,
data_schema: DataSchema {
version: DATA_VERSION,
schema: data_schema,
},
mutates: false,
facet: Facet::Core,
handler,
}
}
fn input_schema() -> Value {
json!({
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Markdown source to lint."
}
},
"required": ["source"]
})
}
fn data_schema() -> Value {
crate::schema::envelope(
"lint",
DATA_VERSION,
json!({
"content_id": { "type": "string", "description": "BLAKE3 content digest of the source (64 lowercase hex chars)." },
"word_count": { "type": "integer" },
"heading_count": { "type": "integer" },
"max_heading_depth": { "type": "integer" },
"code_block_count": { "type": "integer" },
"code_block_langs": { "type": "array", "items": { "type": "string" } },
"has_math": { "type": "boolean" },
"has_mermaid": { "type": "boolean" },
"broken_links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": { "type": "string" },
"url": { "type": "string" }
}
}
},
"clean": { "type": "boolean" }
}),
&[
"content_id",
"word_count",
"heading_count",
"max_heading_depth",
"code_block_count",
"code_block_langs",
"has_math",
"has_mermaid",
"broken_links",
"clean",
],
)
}
fn handler(_ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
let source = args
.get("source")
.and_then(Value::as_str)
.unwrap_or_default();
let doc = Document::new(source);
let report = lint_document(&doc);
let broken: Vec<Value> = report
.broken_links
.iter()
.map(|b| json!({ "text": b.text, "url": b.url }))
.collect();
Ok(ToolOutcome::ok(json!({
"v": DATA_VERSION,
"kind": "lint",
"content_id": doc.content_digest().to_string(),
"word_count": report.word_count,
"heading_count": report.heading_count,
"max_heading_depth": report.max_heading_depth,
"code_block_count": report.code_block_count,
"code_block_langs": report.code_block_langs,
"has_math": report.has_math,
"has_mermaid": report.has_mermaid,
"broken_links": broken,
"clean": report.is_clean(),
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Ctx, Registry};
fn lint(src: &str) -> Value {
Registry::default()
.call("lint", &Ctx::headless(), &json!({ "source": src }))
.expect("lint dispatches")
.data
}
#[test]
fn reports_counts_flags_and_content_id() {
let d = lint("# H1\n\n## H2\n\ntext $x$\n\n```rust\nfn a(){}\n```\n");
assert_eq!(d["v"], DATA_VERSION);
assert_eq!(d["kind"], "lint");
assert_eq!(d["heading_count"], 2);
assert_eq!(d["max_heading_depth"], 2);
assert_eq!(d["code_block_count"], 1);
assert_eq!(d["has_math"], true);
assert_eq!(d["clean"], true);
assert!(d["content_id"].as_str().unwrap().len() >= 32);
}
#[test]
fn detects_broken_links_and_reports_unclean() {
let d = lint("[ok](https://x) and [bad]() and [frag](#)");
assert_eq!(d["broken_links"].as_array().unwrap().len(), 2);
assert_eq!(d["clean"], false);
}
#[test]
fn mermaid_flagged_but_excluded_from_langs() {
let d = lint("```mermaid\ngraph TD; A-->B\n```\n");
assert_eq!(d["has_mermaid"], true);
let langs = d["code_block_langs"].as_array().unwrap();
assert!(!langs.iter().any(|l| l == "mermaid"));
}
}