oy-cli 0.10.1

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
//! JSON schema builders for model-visible tool arguments.
//!
//! Schemas are closed objects by default so invalid or misspelled arguments are
//! rejected near the tool boundary.

use serde::Serialize;
use serde_json::{Map, Value, json};

use super::DEFAULT_LIMIT;

// === Schema builder ===

/// A JSON Schema value under construction.
#[derive(Debug, Clone)]
pub(super) struct Schema(pub(super) Value);

impl Schema {
    /// Return a closed object builder.
    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"}))
    }

    /// AnyOf combinator for nullable or union types.
    pub fn any_of(schemas: Vec<Schema>) -> Self {
        let items: Vec<Value> = schemas.into_iter().map(|s| s.0).collect();
        Self(json!({"anyOf": items}))
    }

    /// Attach a default value.
    pub fn default(mut self, value: impl Serialize) -> Self {
        self.0["default"] = json!(value);
        self
    }

    /// Attach an enum constraint.
    pub fn enum_values(mut self, values: &[&str]) -> Self {
        self.0["enum"] = json!(values);
        self
    }

    /// Attach a description.
    pub fn describe(mut self, text: &str) -> Self {
        self.0["description"] = json!(text);
        self
    }
}

/// Builder for a JSON Schema object with properties.
#[derive(Default)]
pub(super) struct ObjectBuilder {
    properties: Map<String, Value>,
    required: Vec<String>,
}

impl ObjectBuilder {
    /// Add a property.
    pub fn property(mut self, name: &str, schema: Schema) -> Self {
        self.properties.insert(name.to_string(), schema.0);
        self
    }

    /// Mark previously added properties as required.
    pub fn required(mut self, names: &[&str]) -> Self {
        self.required.extend(names.iter().map(|s| s.to_string()));
        self
    }

    /// Build the closed object schema as a raw Value.
    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)
    }

    /// Build the closed object schema as a Schema value.
    pub fn build_schema(self) -> Schema {
        Schema(self.build())
    }
}

// === Shared sub-schemas ===

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()
}

// === Per-tool schema functions ===

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()
}