use anyhow::Context;
use serde::{Deserialize, Serialize};
static OPERATION_FULL_SCHEMA: std::sync::LazyLock<serde_json::Value> =
std::sync::LazyLock::new(|| {
let generator = schemars::generate::SchemaSettings::default().into_generator();
let root = generator.into_root_schema_for::<crate::plan::Operation>();
serde_json::to_value(root).expect("Operation schema serialization")
});
pub fn operation_variant_schema(op_name: &str) -> anyhow::Result<serde_json::Value> {
let schema = &*OPERATION_FULL_SCHEMA;
let one_of = schema
.get("oneOf")
.and_then(|v| v.as_array())
.context("Operation schema missing oneOf array")?;
for variant in one_of {
let op_const = variant
.pointer("/properties/op/const")
.and_then(|v| v.as_str());
if op_const == Some(op_name) {
let mut v = variant.clone();
if let Some(obj) = v.as_object_mut() {
if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
props.remove("op");
}
if let Some(req) = obj.get_mut("required").and_then(|r| r.as_array_mut()) {
req.retain(|r| r.as_str() != Some("op"));
}
obj.remove("$schema");
obj.remove("title");
if let Some(defs) = schema.get("$defs") {
obj.insert("$defs".to_string(), defs.clone());
}
}
return Ok(v);
}
}
anyhow::bail!("Operation variant '{op_name}' not found in oneOf schema");
}
pub const INTENT_FORMAT_VERSION: &str = "1";
pub fn is_compatible_version(version: &str) -> bool {
version == INTENT_FORMAT_VERSION
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum Tier {
Weak,
Medium,
Strong,
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Tier::Weak => write!(f, "weak"),
Tier::Medium => write!(f, "medium"),
Tier::Strong => write!(f, "strong"),
}
}
}
impl std::str::FromStr for Tier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"weak" => Ok(Tier::Weak),
"medium" => Ok(Tier::Medium),
"strong" => Ok(Tier::Strong),
_ => Err(format!(
"unknown tier: {s} (expected weak, medium, or strong)"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationExample {
pub description: String,
pub args: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationSchema {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
pub min_tier: Tier,
pub examples: Vec<OperationExample>,
}
struct OpMeta {
name: &'static str,
description: &'static str,
tier: Tier,
examples: &'static [(&'static str, &'static str)],
}
const OPERATION_REGISTRY: &[OpMeta] = &[
OpMeta {
name: "replace",
description: "Replace text in a file using literal string matching.",
tier: Tier::Weak,
examples: &[(
"Replace a function name",
r###"{"op":"replace","path":"src/main.rs","old":"old_name","new":"new_name"}"###,
)],
},
OpMeta {
name: "file.append",
description: "Append content to an existing file.",
tier: Tier::Weak,
examples: &[(
"Append a test function to a test file",
r###"{"op":"file.append","path":"tests/test.rs","content":"#[test]\nfn new_test() {}\n"}"###,
)],
},
OpMeta {
name: "file.prepend",
description: "Prepend content to an existing file.",
tier: Tier::Weak,
examples: &[(
"Prepend a license header",
r###"{"op":"file.prepend","path":"src/main.rs","content":"// Copyright 2026\n"}"###,
)],
},
OpMeta {
name: "file.create",
description: "Create a new file with specified content.",
tier: Tier::Weak,
examples: &[(
"Create a new config file",
r###"{"op":"file.create","path":"config.json","content":"{\"version\": \"1.0\"}"}"###,
)],
},
OpMeta {
name: "file.delete",
description: "Delete a file.",
tier: Tier::Weak,
examples: &[(
"Delete a temporary file",
r###"{"op":"file.delete","path":"tmp/scratch.txt"}"###,
)],
},
OpMeta {
name: "file.rename",
description: "Rename (move) a file.",
tier: Tier::Weak,
examples: &[(
"Rename a file",
r###"{"op":"file.rename","from":"old.txt","to":"new.txt"}"###,
)],
},
OpMeta {
name: "tidy.fix",
description: "Normalize whitespace, line endings, and final newline in a file.",
tier: Tier::Weak,
examples: &[(
"Fix whitespace in a file",
r###"{"op":"tidy.fix","path":"src/main.rs","ensure_final_newline":true,"trim_trailing_whitespace":true}"###,
)],
},
OpMeta {
name: "doc.set",
description: "Set a value at a selector path in a JSON, YAML, or TOML file. Parser-backed; output is always valid.",
tier: Tier::Medium,
examples: &[(
"Update a version in package.json",
r###"{"op":"doc.set","path":"package.json","selector":"version","value":"2.0.0"}"###,
)],
},
OpMeta {
name: "doc.delete",
description: "Delete a value at a selector path in a JSON, YAML, or TOML file. CLI --json and MCP/tx success include changed and removed (0 on missing key; exit 0 / ok is idempotent).",
tier: Tier::Medium,
examples: &[(
"Remove a deprecated config entry",
r###"{"op":"doc.delete","path":"config.json","selector":"deprecated_key"}"###,
)],
},
OpMeta {
name: "doc.merge",
description: "Deep-merge a JSON object into the root of a document.",
tier: Tier::Medium,
examples: &[(
"Add database config",
r###"{"op":"doc.merge","path":"config.yaml","value":{"database":{"host":"localhost","port":5432}}}"###,
)],
},
OpMeta {
name: "doc.append",
description: "Append a value to an array at a selector path.",
tier: Tier::Medium,
examples: &[(
"Add an item to a list",
r###"{"op":"doc.append","path":"data.json","selector":"items","value":"new_item"}"###,
)],
},
OpMeta {
name: "doc.move",
description: "Move a value from one selector path to another within the same file.",
tier: Tier::Medium,
examples: &[(
"Rename a config path",
r###"{"op":"doc.move","path":"config.json","from":"old_key","to":"new_key"}"###,
)],
},
OpMeta {
name: "doc.ensure",
description: "Set a value only if the selector path does not already exist.",
tier: Tier::Medium,
examples: &[(
"Ensure a default config value exists",
r###"{"op":"doc.ensure","path":"config.json","selector":"timeout","value":30}"###,
)],
},
OpMeta {
name: "md.replace_section",
description: "Replace the body of a markdown section identified by heading.",
tier: Tier::Medium,
examples: &[(
"Update the install section of README",
r###"{"op":"md.replace_section","path":"README.md","heading":"## Installation","content":"Run `npm install`.\n"}"###,
)],
},
OpMeta {
name: "md.upsert_bullet",
description: "Insert or update a bullet point under a markdown heading.",
tier: Tier::Medium,
examples: &[(
"Add a changelog entry",
r###"{"op":"md.upsert_bullet","path":"CHANGELOG.md","heading":"## Changes","bullet":"- Added new feature"}"###,
)],
},
OpMeta {
name: "md.table_append",
description: "Append a row to a markdown table under a heading.",
tier: Tier::Medium,
examples: &[(
"Add a row to an API table",
r###"{"op":"md.table_append","path":"README.md","heading":"## API","row":"| GET | /health | Health check |"}"###,
)],
},
OpMeta {
name: "md.insert_after_heading",
description: "Insert content immediately after a markdown heading.",
tier: Tier::Medium,
examples: &[],
},
OpMeta {
name: "md.insert_before_heading",
description: "Insert content immediately before a markdown heading.",
tier: Tier::Medium,
examples: &[],
},
OpMeta {
name: "md.move_section",
description: "Move a heading section to a new position (same-file reorder or cross-file move). Exactly one of before or after is required.",
tier: Tier::Medium,
examples: &[
(
"Reorder within same file",
r###"{"op":"md.move_section","path":"README.md","heading":"## FAQ","before":"## License"}"###,
),
(
"Move section to another file",
r###"{"op":"md.move_section","path":"spec.md","heading":"## Appendix E","to":"investigation.md","after":"## Layer 4"}"###,
),
],
},
OpMeta {
name: "patch.apply",
description: "Apply a unified diff patch to one or more files. Supports three-way merge on stale context.",
tier: Tier::Medium,
examples: &[],
},
OpMeta {
name: "doc.prepend",
description: "Prepend a value to the beginning of an array at a selector path.",
tier: Tier::Strong,
examples: &[],
},
OpMeta {
name: "doc.update",
description: "Set a new value at every location matching a selector. Use wildcards (items[*].enabled) or selector predicates (items[name=foo].v). Not a separate --where flag; the filter is part of the selector string.",
tier: Tier::Strong,
examples: &[
(
"Update one field on matching array objects via a selector predicate",
r###"{"op":"doc.update","path":"config.toml","selector":"items[name=a].v","value":7}"###,
),
(
"Set a field on every array element with a wildcard",
r###"{"op":"doc.update","path":"config.json","selector":"items[*].enabled","value":true}"###,
),
],
},
OpMeta {
name: "doc.delete_where",
description: "Delete array elements matching a key=value predicate via --predicate (CLI) or the predicate field (plans). For scalar arrays use .=x, _=x, or value=x. Different from doc.update, which filters inside the selector path. CLI --json and MCP/tx success include changed and removed (0 when no elements match; exit 0 / ok is idempotent).",
tier: Tier::Strong,
examples: &[(
"Remove scalar array elements equal to a (agent-friendly value= form)",
r###"{"op":"doc.delete_where","path":"config.toml","selector":"tags","predicate":"value=a"}"###,
)],
},
OpMeta {
name: "search",
description: "Search for text across files with optional regex, context, and count assertion. Supports advanced layered ignores: literal (vs regex), globs (include), exclude_patterns, custom_ignore_filenames, max_results, before_context/after_context.",
tier: Tier::Strong,
examples: &[],
},
OpMeta {
name: "read",
description: "Read file contents with optional line range.",
tier: Tier::Strong,
examples: &[],
},
OpMeta {
name: "md.dedupe_headings",
description: "Remove duplicate markdown headings in a file.",
tier: Tier::Strong,
examples: &[],
},
OpMeta {
name: "md.lint_agents",
description: "Lint an AGENTS.md file for common issues.",
tier: Tier::Strong,
examples: &[],
},
];
#[cfg(feature = "ast")]
const AST_OPERATION_REGISTRY: &[OpMeta] = &[
OpMeta {
name: "ast.rename",
description: "AST-aware rename: rename identifiers skipping strings and comments.",
tier: Tier::Medium,
examples: &[(
"Rename a struct",
r###"{"op":"ast.rename","path":"src/lib.rs","old":"OldStruct","new":"NewStruct"}"###,
)],
},
OpMeta {
name: "ast.replace",
description: "Replace text within a specific symbol's body (AST-scoped).",
tier: Tier::Medium,
examples: &[(
"Replace a constant inside a function",
r###"{"op":"ast.replace","path":"src/config.rs","symbol":"default_timeout","old":"30","new":"60"}"###,
)],
},
OpMeta {
name: "ast.rewrite_signature",
description: "Rewrite a function signature with structured fields (visibility, parameters, return_type) or a full new_signature string. Multi-language via tree-sitter.",
tier: Tier::Medium,
examples: &[(
"Change parameters and return type",
r###"{"op":"ast.rewrite_signature","path":"src/lib.rs","old":"process","parameters":"(x: i32)","return_type":"-> String"}"###,
)],
},
OpMeta {
name: "ast.insert",
description: "Insert code at a structurally-aware position: inside a container, or after/before a named symbol.",
tier: Tier::Medium,
examples: &[(
"Insert a function after an existing one",
r###"{"op":"ast.insert","path":"src/lib.rs","content":"fn new_fn() {}","after":"existing_fn"}"###,
)],
},
OpMeta {
name: "ast.wrap",
description: "Wrap existing code in a structural block (module, impl, cfg, etc.).",
tier: Tier::Medium,
examples: &[(
"Wrap helpers in a module",
r###"{"op":"ast.wrap","path":"src/lib.rs","symbols":["helper_a","helper_b"],"wrapper":"mod helpers"}"###,
)],
},
OpMeta {
name: "ast.imports",
description: "Manage import/use statements: add (idempotent), remove, deduplicate.",
tier: Tier::Medium,
examples: &[(
"Add an import statement",
r###"{"op":"ast.imports","path":"src/main.rs","add":["use std::collections::HashMap;"]}"###,
)],
},
OpMeta {
name: "ast.reorder",
description: "Reorder symbols within a file or scope by name, kind, or custom order.",
tier: Tier::Medium,
examples: &[(
"Alphabetize functions",
r###"{"op":"ast.reorder","path":"src/lib.rs","order":"alphabetical"}"###,
)],
},
OpMeta {
name: "ast.group",
description: "Group symbols into a named module within a file.",
tier: Tier::Medium,
examples: &[(
"Group test functions into a module",
r###"{"op":"ast.group","path":"src/tests.rs","module":"line_endings","symbols":["test_crlf","test_lf"],"preamble":"use super::*;"}"###,
)],
},
OpMeta {
name: "ast.move",
description: "Move symbols between files with optional target creation.",
tier: Tier::Medium,
examples: &[(
"Move helpers to a utility file",
r###"{"op":"ast.move","path":"src/lib.rs","target":"src/helpers.rs","symbols":["helper_fn"]}"###,
)],
},
OpMeta {
name: "ast.extract_to_file",
description: "Extract a symbol to a separate file with optional module unwrapping.",
tier: Tier::Medium,
examples: &[(
"Extract test module to companion file",
r###"{"op":"ast.extract_to_file","source":"src/lib.rs","symbol":"tests","target":"src/lib_tests.rs","replacement":"mod tests;","prepend":"use super::*;"}"###,
)],
},
OpMeta {
name: "ast.split",
description: "Split a file into multiple target files by distributing symbols.",
tier: Tier::Strong,
examples: &[(
"Split a large module into sub-modules",
r###"{"op":"ast.split","source":"src/big.rs","targets":[{"path":"src/types.rs","symbols":["Config"],"prepend":"use super::*;"}],"keep_in_source":["main"],"source_suffix":"mod types;"}"###,
)],
},
];
pub fn operation_schemas() -> anyhow::Result<Vec<OperationSchema>> {
#[allow(unused_mut)]
let mut all: Vec<&OpMeta> = OPERATION_REGISTRY.iter().collect();
#[cfg(feature = "ast")]
all.extend(AST_OPERATION_REGISTRY.iter());
all.into_iter()
.map(|meta| {
Ok(OperationSchema {
name: meta.name.into(),
description: meta.description.into(),
parameters: operation_variant_schema(meta.name)?,
min_tier: meta.tier,
examples: meta
.examples
.iter()
.map(|(desc, json)| {
Ok(OperationExample {
description: (*desc).into(),
args: serde_json::from_str(json)
.with_context(|| format!("bad example JSON for {}", meta.name))?,
})
})
.collect::<anyhow::Result<Vec<_>>>()?,
})
})
.collect()
}
pub fn plan_write_policy_schema() -> serde_json::Value {
let generator = schemars::generate::SchemaSettings::default().into_generator();
let root = generator.into_root_schema_for::<crate::write::WritePolicyOverride>();
let mut v = serde_json::to_value(root).expect("schema serialization");
if let Some(obj) = v.as_object_mut() {
obj.remove("$schema");
obj.remove("title");
}
v
}
pub fn operations_for_tier(tier: Tier) -> anyhow::Result<Vec<OperationSchema>> {
Ok(operation_schemas()?
.into_iter()
.filter(|op| op.min_tier <= tier)
.collect())
}
fn extract_type_str(schema: &serde_json::Value) -> String {
match schema.get("type") {
Some(t) if t.is_string() => t.as_str().expect("is_string guard").to_string(),
Some(t) if t.is_array() => t
.as_array()
.expect("is_array guard")
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" | "),
_ => "any".to_string(),
}
}
pub fn system_prompt_for_tier(tier: Tier) -> anyhow::Result<String> {
let ops = operations_for_tier(tier)?;
let mut out = String::new();
out.push_str(&format!(
"# Patchloom Operations (tier: {tier}, format version: {INTENT_FORMAT_VERSION})\n\n"
));
out.push_str("The following operations are available. Each operation is expressed as a JSON object with an `\"op\"` field.\n\n");
for op in &ops {
out.push_str(&format!("## `{}`\n\n", op.name));
out.push_str(&format!("{}\n\n", op.description));
if let Some(props) = op.parameters.get("properties")
&& let Some(obj) = props.as_object()
{
out.push_str("**Parameters:**\n\n");
let required: Vec<&str> = op
.parameters
.get("required")
.and_then(|r| r.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for (key, schema) in obj {
let type_str = extract_type_str(schema);
let desc = schema
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
let req = if required.contains(&key.as_str()) {
" (required)"
} else {
""
};
out.push_str(&format!("- `{key}`: {type_str}{req}"));
if !desc.is_empty() {
out.push_str(&format!(" -- {desc}"));
}
out.push('\n');
}
out.push('\n');
}
if !op.examples.is_empty() {
out.push_str("**Examples:**\n\n");
for ex in &op.examples {
out.push_str(&format!("- {}: `{}`\n", ex.description, ex.args));
}
out.push('\n');
}
}
out.push_str("## Plan Envelope: `write_policy`\n\n");
out.push_str("Set at the plan level (not per-operation) to apply write transformations to all operations.\n\n");
let wp_schema = plan_write_policy_schema();
if let Some(props) = wp_schema.get("properties")
&& let Some(obj) = props.as_object()
{
out.push_str("**Fields:**\n\n");
for (key, schema) in obj {
let type_str = extract_type_str(schema);
let desc = schema
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
out.push_str(&format!("- `{key}`: {type_str}"));
if !desc.is_empty() {
out.push_str(&format!(" -- {desc}"));
}
out.push('\n');
}
out.push('\n');
}
Ok(out)
}
#[path = "schema_tests.rs"]
#[cfg(test)]
mod tests;
pub fn registered_operation_names() -> Vec<&'static str> {
#[cfg(feature = "ast")]
{
let mut names: Vec<&'static str> = OPERATION_REGISTRY.iter().map(|o| o.name).collect();
names.extend(AST_OPERATION_REGISTRY.iter().map(|o| o.name));
names
}
#[cfg(not(feature = "ast"))]
{
OPERATION_REGISTRY.iter().map(|o| o.name).collect()
}
}
pub fn operation_description(op_name: &str) -> Option<&'static str> {
OPERATION_REGISTRY
.iter()
.chain(ast_registry_iter())
.find(|o| o.name == op_name)
.map(|o| o.description)
}
pub fn operation_example_json(op_name: &str) -> Option<&'static str> {
OPERATION_REGISTRY
.iter()
.chain(ast_registry_iter())
.find(|o| o.name == op_name)
.and_then(|o| o.examples.first().map(|(_, json)| *json))
}
fn ast_registry_iter() -> impl Iterator<Item = &'static OpMeta> {
#[cfg(feature = "ast")]
{
AST_OPERATION_REGISTRY.iter()
}
#[cfg(not(feature = "ast"))]
{
std::iter::empty()
}
}
pub fn mcp_tool_description(op_name: &str, extra: Option<&str>) -> String {
let base = operation_description(op_name).unwrap_or("Patchloom operation.");
let mut out = base.to_string();
if let Some(extra) = extra.filter(|s| !s.is_empty()) {
if !out.ends_with('.') && !out.ends_with(' ') {
out.push('.');
}
out.push(' ');
out.push_str(extra.trim());
}
if let Some(example) = operation_example_json(op_name) {
out.push_str(" Example: ");
out.push_str(example);
}
out
}
pub fn agent_operations_catalogue() -> String {
let mut out = String::from("## Operations (from schema registry)\n\n");
for meta in OPERATION_REGISTRY.iter().chain(ast_registry_iter()) {
out.push_str(&format!("- `{}`: {}\n", meta.name, meta.description));
}
out
}