use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct ContractRoot {
tools: Vec<ToolEntry>,
}
#[derive(Debug, Deserialize)]
struct ToolEntry {
name: String,
description: String,
#[serde(default)]
args: Vec<ArgEntry>,
#[serde(default)]
required: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ArgEntry {
name: String,
#[serde(rename = "type")]
arg_type: String,
description: String,
#[serde(default)]
#[allow(dead_code)]
required: bool,
}
fn main() {
let contract_path = locate_contract();
println!("cargo:rerun-if-changed={}", contract_path.display());
println!("cargo:rerun-if-changed=build.rs");
let yaml = std::fs::read_to_string(&contract_path).unwrap_or_else(|e| {
panic!(
"FALSIFY-MCP-008: failed to read {}: {e}",
contract_path.display()
)
});
let parsed: ContractRoot = serde_yaml::from_str(&yaml).unwrap_or_else(|e| {
panic!(
"FALSIFY-MCP-008: failed to parse {} as YAML: {e}",
contract_path.display()
)
});
let out_dir = std::env::var_os("OUT_DIR")
.expect("FALSIFY-MCP-008: OUT_DIR not set by cargo; cannot emit generated schemas");
let out_path = PathBuf::from(&out_dir).join("schemas.rs");
let generated = render_module(&parsed.tools);
std::fs::write(&out_path, generated).unwrap_or_else(|e| {
panic!(
"FALSIFY-MCP-008: failed to write {}: {e}",
out_path.display()
)
});
}
fn locate_contract() -> PathBuf {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("FALSIFY-MCP-008: CARGO_MANIFEST_DIR unset in build.rs");
Path::new(&manifest_dir)
.join("contracts")
.join("apr-mcp-tool-schemas-v1.yaml")
}
fn render_module(tools: &[ToolEntry]) -> String {
let mut out = String::new();
out.push_str("// @generated by crates/aprender-mcp/build.rs from\n");
out.push_str("// contracts/apr-mcp-tool-schemas-v1.yaml. DO NOT EDIT BY HAND.\n");
out.push_str("//\n");
out.push_str("// Each constant is the JSON Schema body (as a JSON string) for the\n");
out.push_str(
"// corresponding tool's MCP `inputSchema`. Consume via serde_json::from_str.\n\n",
);
out.push_str("/// All tool names in the contract, in declaration order.\n");
out.push_str("pub const TOOL_NAMES: &[&str] = &[\n");
for t in tools {
out.push_str(" \"");
out.push_str(&escape_rust_str(&t.name));
out.push_str("\",\n");
}
out.push_str("];\n\n");
for tool in tools {
let schema_json = render_schema_json(tool);
let escaped_schema = escape_rust_str(&schema_json);
let schema_const = tool_schema_const_name(&tool.name);
out.push_str(&format!("/// JSON Schema body for `{}`.\n", tool.name));
out.push_str(&format!(
"pub const {schema_const}: &str = \"{escaped_schema}\";\n\n"
));
let escaped_desc = escape_rust_str(&tool.description);
let desc_const = tool_description_const_name(&tool.name);
out.push_str(&format!(
"/// Tool-level description for `{}` (PMAT-514).\n",
tool.name
));
out.push_str(&format!(
"pub const {desc_const}: &str = \"{escaped_desc}\";\n\n"
));
}
out
}
fn render_schema_json(tool: &ToolEntry) -> String {
use serde_json::{Map, Value};
let mut schema = Map::new();
schema.insert("type".to_string(), Value::String("object".to_string()));
if !tool.args.is_empty() {
let mut props_sorted: BTreeMap<&str, &ArgEntry> = BTreeMap::new();
for a in &tool.args {
props_sorted.insert(a.name.as_str(), a);
}
let mut props = Map::new();
for (name, arg) in props_sorted {
let mut prop = Map::new();
prop.insert("type".to_string(), Value::String(arg.arg_type.clone()));
prop.insert(
"description".to_string(),
Value::String(arg.description.clone()),
);
props.insert(name.to_string(), Value::Object(prop));
}
schema.insert("properties".to_string(), Value::Object(props));
}
if !tool.required.is_empty() {
let req: Vec<Value> = tool
.required
.iter()
.map(|s| Value::String(s.clone()))
.collect();
schema.insert("required".to_string(), Value::Array(req));
}
serde_json::to_string(&Value::Object(schema))
.expect("FALSIFY-MCP-008: failed to serialize schema Map to JSON")
}
fn tool_schema_const_name(tool_name: &str) -> String {
let mut out = tool_name.replace(['.', '-'], "_").to_uppercase();
out.push_str("_SCHEMA");
out
}
fn tool_description_const_name(tool_name: &str) -> String {
let mut out = tool_name.replace(['.', '-'], "_").to_uppercase();
out.push_str("_DESCRIPTION");
out
}
fn escape_rust_str(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}