use serde::Serialize;
use serde_json::{Map, Value, json};
use super::DEFAULT_LIMIT;
#[derive(Debug, Clone)]
pub(super) struct Schema(pub(super) Value);
impl Schema {
pub fn object() -> ObjectBuilder {
ObjectBuilder::default()
}
pub fn string() -> Self {
Self(json!({"type": "string"}))
}
pub fn integer() -> Self {
Self(json!({"type": ["integer", "string"]}))
}
pub fn boolean() -> Self {
Self(json!({"type": "boolean"}))
}
pub fn array(items: Schema) -> Self {
Self(json!({"type": "array", "items": items.0}))
}
pub fn null() -> Self {
Self(json!({"type": "null"}))
}
pub fn any_of(schemas: Vec<Schema>) -> Self {
let items: Vec<Value> = schemas.into_iter().map(|s| s.0).collect();
Self(json!({"anyOf": items}))
}
pub fn default(mut self, value: impl Serialize) -> Self {
self.0["default"] = json!(value);
self
}
pub fn enum_values(mut self, values: &[&str]) -> Self {
self.0["enum"] = json!(values);
self
}
pub fn describe(mut self, text: &str) -> Self {
self.0["description"] = json!(text);
self
}
}
#[derive(Default)]
pub(super) struct ObjectBuilder {
properties: Map<String, Value>,
required: Vec<String>,
}
impl ObjectBuilder {
pub fn property(mut self, name: &str, schema: Schema) -> Self {
self.properties.insert(name.to_string(), schema.0);
self
}
pub fn required(mut self, names: &[&str]) -> Self {
self.required.extend(names.iter().map(|s| s.to_string()));
self
}
pub fn build(self) -> Value {
let mut schema = Map::new();
schema.insert("type".to_string(), json!("object"));
schema.insert("properties".to_string(), Value::Object(self.properties));
schema.insert("additionalProperties".to_string(), json!(false));
if !self.required.is_empty() {
schema.insert("required".to_string(), json!(self.required));
}
Value::Object(schema)
}
pub fn build_schema(self) -> Schema {
Schema(self.build())
}
}
fn exclude_schema() -> Schema {
Schema::any_of(vec![
Schema::string(),
Schema::array(Schema::string()),
Schema::null(),
])
}
fn todo_item_schema() -> Schema {
Schema::object()
.property(
"id",
Schema::string().describe("Stable short id; optional, defaults to 1-based position."),
)
.property("task", Schema::string())
.property(
"status",
Schema::string()
.enum_values(&["pending", "in_progress", "done"])
.default("pending"),
)
.required(&["task"])
.build_schema()
}
pub(super) fn schema_list() -> Value {
Schema::object()
.property(
"path",
Schema::string().default("*").describe(
"Directory/file/glob to list, or a fuzzy file query when the non-glob path does not exist. Use `*` or `.` for workspace root discovery.",
),
)
.property(
"exclude",
exclude_schema().describe("Glob or array of globs to omit from returned workspace-relative paths."),
)
.property(
"limit",
Schema::integer().default(DEFAULT_LIMIT).describe(
"Maximum items to return; count still reports total matches before truncation.",
),
)
.build()
}
pub(super) fn schema_read() -> Value {
Schema::object()
.property(
"path",
Schema::string().describe(
"Exact workspace file path to read. Missing paths may return fuzzy suggestions, but read never resolves them implicitly.",
),
)
.property(
"offset",
Schema::integer()
.default(1)
.describe("1-based starting line number; use small slices instead of full-file reads."),
)
.property(
"limit",
Schema::integer().default(DEFAULT_LIMIT).describe(
"Maximum lines to return from offset; prefer the narrowest slice needed.",
),
)
.required(&["path"])
.build()
}
pub(super) fn schema_search() -> Value {
Schema::object()
.property(
"pattern",
Schema::string().describe(
"Text or Rust regex to search for. In auto mode, plain text is literal and regex-looking text is regex.",
),
)
.property(
"path",
Schema::string().default(".").describe(
"Exact file/dir to search, or whitespace-separated exact paths. Globs and fuzzy paths are not accepted here; use list first.",
),
)
.property(
"exclude",
exclude_schema().describe("Glob or array of globs to omit from fff-indexed search paths."),
)
.property(
"limit",
Schema::integer().default(DEFAULT_LIMIT).describe(
"Maximum matches to return; search stops once this limit is reached.",
),
)
.property(
"mode",
Schema::string()
.enum_values(&["auto", "regex", "literal"])
.default("auto")
.describe("Pattern mode: auto, regex, or literal. Prefer literal for exact strings."),
)
.required(&["pattern"])
.build()
}
pub(super) fn schema_sloc() -> Value {
Schema::object()
.property(
"path",
Schema::string()
.default(".")
.describe("Workspace path or whitespace-separated paths to count."),
)
.property("exclude", exclude_schema())
.build()
}
pub(super) fn schema_todo() -> Value {
let item = todo_item_schema();
Schema::object()
.property(
"todos",
Schema::array(item.clone()).describe(
"Complete replacement todo list. Alias: items. Omit to return current list.",
),
)
.property("items", Schema::array(item).describe("Alias for todos."))
.property(
"persist",
Schema::boolean()
.default(false)
.describe("Write to TODO.md; default false avoids git churn."),
)
.build()
}
pub(super) fn schema_ask() -> Value {
Schema::object()
.property("question", Schema::string())
.property(
"choices",
Schema::any_of(vec![Schema::array(Schema::string()), Schema::null()]),
)
.required(&["question"])
.build()
}
pub(super) fn schema_webfetch() -> Value {
Schema::object()
.property(
"url",
Schema::string().describe("The URL to scrape. Public http(s) targets only; localhost and private IP targets are denied. Bare hostnames are treated as https://host."),
)
.property(
"return_format",
Schema::string()
.enum_values(&["raw", "markdown", "text", "xml"])
.default("markdown")
.describe("Output format: raw, markdown, text, or xml (default: markdown)."),
)
.property(
"user_agent",
Schema::any_of(vec![Schema::string(), Schema::null()])
.describe("Custom User-Agent string."),
)
.property(
"cookie",
Schema::any_of(vec![Schema::string(), Schema::null()])
.describe("Cookie string (e.g. \"key=val; key2=val2\")."),
)
.required(&["url"])
.build()
}
pub(super) fn schema_replace() -> Value {
Schema::object()
.property(
"pattern",
Schema::string().describe(
"Rust regex by default, or exact text when mode=literal.",
),
)
.property(
"replacement",
Schema::string().describe(
"Replacement text. In regex mode, Rust regex captures like $1 are expanded; in literal mode dollars are plain text.",
),
)
.property(
"path",
Schema::string().default(".").describe(
"Exact file or directory whose fff-indexed files should be edited. Globs and fuzzy paths are not accepted.",
),
)
.property(
"exclude",
exclude_schema().describe("Glob or array of globs to omit from replacement paths."),
)
.property(
"limit",
Schema::integer().default(DEFAULT_LIMIT).describe(
"Maximum changed files to show in the result; replacement still applies to all matched files.",
),
)
.property(
"mode",
Schema::string()
.enum_values(&["regex", "literal"])
.default("regex")
.describe("Use regex for captures, literal for exact text replacement."),
)
.required(&["pattern", "replacement"])
.build()
}
pub(super) fn schema_patch() -> Value {
Schema::object()
.property(
"patch",
Schema::string().describe(
"Unified or git diff to apply. Existing UTF-8 files only; create/delete/rename/copy/binary patches are rejected.",
),
)
.property(
"strip",
Schema::integer().default(1).describe(
"Path components to strip, like patch -p. Git diffs usually use 1; with strip=1, raw unprefixed paths are retried automatically if the stripped path does not resolve.",
),
)
.property("limit", Schema::integer().default(DEFAULT_LIMIT))
.required(&["patch"])
.build()
}
pub(super) fn schema_bash() -> Value {
Schema::object()
.property(
"command",
Schema::string().describe(
"Shell command to run from the workspace. Inspect first; avoid credential, network, and destructive commands unless necessary.",
),
)
.property(
"timeout_seconds",
Schema::integer()
.default(120)
.describe("Command timeout in seconds; capped by the tool."),
)
.required(&["command"])
.build()
}