~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/asyncai

0
0
main
855 lines · 29.5 KB Raw History
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::service::{CredentialReport, DiscoveryReport, KcpService};

/// Explicit object schema for `serde_json::Value` fields. Schemars otherwise
/// emits the boolean schema `true`, which some OpenAI-compatible backends
/// reject during tool conversion.
fn freeform_object_schema(_: &mut SchemaGenerator) -> Schema {
    json_schema!({"type": "object"})
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct ListKcpsRequest {
    /// Rediscover kcps instead of using the 30 second cache.
    pub refresh: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct KcpWorkspaceRequest {
    /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    /// Workspace path, for example "root" or "root:app". Defaults to the kcp's current workspace.
    pub workspace: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ListWorkspacesRequest {
    /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    /// Workspace path to start from. Defaults to the kcp's current workspace.
    pub workspace: Option<String>,
    /// Walk child workspaces. Defaults to true.
    pub recursive: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ListObjectsRequest {
    /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    /// Workspace path, for example "root:app". Defaults to the kcp's current workspace.
    pub workspace: Option<String>,
    /// Resource name as kubectl accepts it, for example "computevms" or "workspaces.tenancy.kcp.io".
    pub resource: String,
    /// Namespace. Use "*" or set all_namespaces for every namespace.
    pub namespace: Option<String>,
    pub all_namespaces: Option<bool>,
    pub label_selector: Option<String>,
    pub field_selector: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct GetObjectRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub resource: String,
    pub name: String,
    pub namespace: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct GetLogsRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub pod: String,
    pub namespace: Option<String>,
    pub container: Option<String>,
    /// Number of trailing lines. Defaults to 200.
    pub tail_lines: Option<u32>,
    pub previous: Option<bool>,
    /// Only return logs newer than this duration, for example "5m".
    pub since: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct GetEventsRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub namespace: Option<String>,
    pub field_selector: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ReadonlyCredentialsRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    /// Mint a fresh token instead of using the cached one.
    pub refresh: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ApplyObjectRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    /// One Kubernetes object with apiVersion, kind, and metadata.name.
    #[schemars(schema_with = "freeform_object_schema")]
    pub manifest: Value,
    pub namespace: Option<String>,
    /// Server-side apply field manager. Defaults to "socialweb-computer-kcp-readwrite".
    pub field_manager: Option<String>,
    /// Take ownership of fields another field manager owns.
    pub force: Option<bool>,
    /// Run admission and validation without storing the object.
    pub dry_run: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct DeleteObjectRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub resource: String,
    pub name: String,
    pub namespace: Option<String>,
    /// Wait for deletion to complete. Defaults to true.
    pub wait: Option<bool>,
    pub ignore_not_found: Option<bool>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct PatchObjectRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub resource: String,
    pub name: String,
    pub namespace: Option<String>,
    #[schemars(schema_with = "freeform_object_schema")]
    pub patch: Value,
    /// merge, strategic, or json. Defaults to merge.
    pub patch_type: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ScaleObjectRequest {
    /// Kcp id from list_kcps. Omit when only one kcp is running.
    pub kcp: Option<String>,
    pub workspace: Option<String>,
    pub resource: String,
    pub name: String,
    pub namespace: Option<String>,
    pub replicas: i64,
}

pub async fn list_kcps(service: &KcpService, request: ListKcpsRequest) -> ToolResult {
    let (kcps, errors) = service.discover(request.refresh.unwrap_or(false)).await;
    json_result(Ok(DiscoveryReport {
        kcps: &kcps,
        errors: &errors,
    }))
}

pub async fn list_workspaces(service: &KcpService, request: ListWorkspacesRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        service
            .list_workspaces(
                &kcp,
                request.workspace.as_deref(),
                request.recursive.unwrap_or(true),
            )
            .await
            .map(|workspaces| serde_json::json!({ "workspaces": workspaces }))
    }
    .await;
    json_result(result)
}

pub async fn get_api_resources(service: &KcpService, request: KcpWorkspaceRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        api_resources(service, &kcp, &workspace).await
    }
    .await;
    json_result(result)
}

/// Lists the api-resources of a workspace as JSON (`{"resources": [...]}`).
pub async fn api_resources(
    service: &KcpService,
    kcp: &super::discovery::KcpInfo,
    workspace: &str,
) -> Result<Value, String> {
    let command = vec![
        "api-resources".to_string(),
        "--verbs=list,get".to_string(),
        "-o".to_string(),
        "wide".to_string(),
    ];
    let table = service.read(kcp, workspace, &command).await?;
    api_resources_json(&table)
}

/// kubectl's unknown-resource error, extended with the closest known names.
pub(crate) async fn with_resource_hint(
    service: &KcpService,
    kcp: &super::discovery::KcpInfo,
    workspace: &str,
    error: String,
) -> String {
    let Some(unknown) = unknown_resource(&error) else {
        return error;
    };
    let Ok(listed) = api_resources(service, kcp, workspace).await else {
        return error;
    };
    let mut candidates = Vec::new();
    for resource in listed["resources"].as_array().into_iter().flatten() {
        let name = resource["name"].as_str().unwrap_or_default();
        let group = resource["api_version"]
            .as_str()
            .and_then(|version| version.rsplit_once('/'))
            .map(|(group, _)| group);
        candidates.push(name.to_string());
        if let Some(group) = group {
            candidates.push(format!("{name}.{group}"));
        }
        for short in resource["short_names"].as_array().into_iter().flatten() {
            candidates.extend(short.as_str().map(str::to_string));
        }
        if let Some(kind) = resource["kind"].as_str() {
            candidates.push(kind.to_lowercase());
        }
    }
    let suggestions = suggest(&unknown, &candidates);
    if suggestions.is_empty() {
        format!("{error}; list kinds with get_api_resources()")
    } else {
        format!("{error}; did you mean: {}", suggestions.join(", "))
    }
}

/// `the server doesn't have a resource type "X"` → `X`.
fn unknown_resource(error: &str) -> Option<String> {
    let rest = error.split_once("doesn't have a resource type \"")?.1;
    Some(rest.split_once('"')?.0.to_string())
}

/// Up to three closest names: exact after normalizing `-`/`_` to `.` in
/// the group, then substring matches, then edit distance ≤ 3.
pub fn suggest(unknown: &str, candidates: &[String]) -> Vec<String> {
    let wanted = unknown.to_lowercase();
    let normalized = wanted.replace(['-', '_'], ".");
    let mut scored: Vec<(usize, &String)> = candidates
        .iter()
        .filter_map(|candidate| {
            let lower = candidate.to_lowercase();
            if lower == normalized && lower != wanted {
                return Some((0, candidate));
            }
            if lower == wanted {
                return None;
            }
            // Short names need a tight bound: "bids" must not suggest "ns".
            let distance = edit_distance(&wanted, &lower);
            if distance <= (wanted.chars().count() / 4).clamp(1, 3) {
                return Some((1 + distance, candidate));
            }
            if wanted.len() >= 4 && (lower.contains(&wanted) || wanted.contains(&lower)) {
                return Some((5, candidate));
            }
            None
        })
        .collect();
    scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.len().cmp(&b.1.len())));
    let mut out: Vec<String> = Vec::new();
    for (_, candidate) in scored {
        if !out.contains(candidate) {
            out.push(candidate.clone());
        }
        if out.len() == 3 {
            break;
        }
    }
    out
}

fn edit_distance(a: &str, b: &str) -> usize {
    let b: Vec<char> = b.chars().collect();
    let mut previous: Vec<usize> = (0..=b.len()).collect();
    for (i, ca) in a.chars().enumerate() {
        let mut current = vec![i + 1];
        for (j, cb) in b.iter().enumerate() {
            let cost = usize::from(ca != *cb);
            current.push(
                (previous[j] + cost)
                    .min(previous[j + 1] + 1)
                    .min(current[j] + 1),
            );
        }
        previous = current;
    }
    previous[b.len()]
}

pub async fn list_objects(service: &KcpService, request: ListObjectsRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "get".to_string(),
            request.resource.clone(),
            "-o".to_string(),
            "json".to_string(),
        ];
        append_namespace(
            &mut command,
            request.namespace.as_deref(),
            request.all_namespaces.unwrap_or(false),
        );
        if let Some(selector) = &request.label_selector {
            command.push("-l".to_string());
            command.push(selector.clone());
        }
        if let Some(selector) = &request.field_selector {
            command.push("--field-selector".to_string());
            command.push(selector.clone());
        }
        match service.read(&kcp, &workspace, &command).await {
            Ok(text) => Ok(text),
            Err(error) => Err(with_resource_hint(service, &kcp, &workspace, error).await),
        }
    }
    .await;
    kubectl_json_result(result)
}

pub async fn get_object(service: &KcpService, request: GetObjectRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "get".to_string(),
            request.resource,
            request.name,
            "-o".to_string(),
            "json".to_string(),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), false);
        match service.read(&kcp, &workspace, &command).await {
            Ok(text) => Ok(text),
            Err(error) => Err(with_resource_hint(service, &kcp, &workspace, error).await),
        }
    }
    .await;
    kubectl_json_result(result)
}

pub async fn get_logs(service: &KcpService, request: GetLogsRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "logs".to_string(),
            request.pod,
            format!("--tail={}", request.tail_lines.unwrap_or(200)),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), false);
        if let Some(container) = request.container {
            command.push("-c".to_string());
            command.push(container);
        }
        if request.previous.unwrap_or(false) {
            command.push("--previous".to_string());
        }
        if let Some(since) = request.since {
            command.push(format!("--since={since}"));
        }
        service.read(&kcp, &workspace, &command).await
    }
    .await;
    text_result(result)
}

pub async fn get_events(service: &KcpService, request: GetEventsRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "get".to_string(),
            "events".to_string(),
            "-o".to_string(),
            "json".to_string(),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), true);
        if let Some(selector) = request.field_selector {
            command.push("--field-selector".to_string());
            command.push(selector);
        }
        service.read(&kcp, &workspace, &command).await
    }
    .await;
    kubectl_json_result(result)
}

pub async fn readonly_credentials(
    service: &KcpService,
    request: ReadonlyCredentialsRequest,
) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let credential = service
            .readonly_credential_status(&kcp, &workspace, request.refresh.unwrap_or(false))
            .await?;
        Ok(CredentialReport {
            kcp: kcp.id.clone(),
            host: kcp.host.clone(),
            workspace: workspace.clone(),
            token_kubeconfig: credential.kubeconfig.clone(),
            token_expires_in_seconds: credential.token_expires_in_seconds,
            note: "read-only service account token; the token itself is never returned",
        })
    }
    .await;
    json_result(result)
}

pub async fn apply_object(service: &KcpService, request: ApplyObjectRequest) -> ToolResult {
    let result = async {
        validate_manifest(&request.manifest)?;
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "apply".to_string(),
            "--server-side".to_string(),
            "-f".to_string(),
            "-".to_string(),
            "-o".to_string(),
            "json".to_string(),
            format!(
                "--field-manager={}",
                request
                    .field_manager
                    .unwrap_or_else(|| "socialweb-computer-kcp-readwrite".to_string())
            ),
        ];
        if request.force.unwrap_or(false) {
            command.push("--force-conflicts".to_string());
        }
        if request.dry_run.unwrap_or(false) {
            command.push("--dry-run=server".to_string());
        }
        if let Some(namespace) = request.namespace {
            command.push("-n".to_string());
            command.push(namespace);
        }
        service
            .admin(
                &kcp,
                &workspace,
                &command,
                Some(&request.manifest.to_string()),
            )
            .await
    }
    .await;
    kubectl_json_result(result)
}

pub async fn delete_object(service: &KcpService, request: DeleteObjectRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "delete".to_string(),
            request.resource,
            request.name,
            "-o".to_string(),
            "name".to_string(),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), false);
        command.push(format!("--wait={}", request.wait.unwrap_or(true)));
        if request.ignore_not_found.unwrap_or(false) {
            command.push("--ignore-not-found".to_string());
        }
        service.admin(&kcp, &workspace, &command, None).await
    }
    .await;
    text_result(result)
}

pub async fn patch_object(service: &KcpService, request: PatchObjectRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "patch".to_string(),
            request.resource,
            request.name,
            "--type".to_string(),
            request.patch_type.unwrap_or_else(|| "merge".to_string()),
            "-p".to_string(),
            request.patch.to_string(),
            "-o".to_string(),
            "json".to_string(),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), false);
        service.admin(&kcp, &workspace, &command, None).await
    }
    .await;
    kubectl_json_result(result)
}

pub async fn scale_object(service: &KcpService, request: ScaleObjectRequest) -> ToolResult {
    let result = async {
        let kcp = service.resolve_kcp(request.kcp.as_deref()).await?;
        let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref());
        let mut command = vec![
            "scale".to_string(),
            request.resource,
            request.name,
            format!("--replicas={}", request.replicas),
            "-o".to_string(),
            "json".to_string(),
        ];
        append_namespace(&mut command, request.namespace.as_deref(), false);
        service.admin(&kcp, &workspace, &command, None).await
    }
    .await;
    kubectl_json_result(result)
}

pub(crate) fn append_namespace(
    command: &mut Vec<String>,
    namespace: Option<&str>,
    all_namespaces: bool,
) {
    if all_namespaces || namespace == Some("*") {
        command.push("--all-namespaces".to_string());
        return;
    }
    if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) {
        command.push("-n".to_string());
        command.push(namespace.to_string());
    }
}

fn validate_manifest(manifest: &Value) -> Result<(), String> {
    let object = manifest
        .as_object()
        .ok_or_else(|| "manifest must be one JSON object".to_string())?;
    for field in ["apiVersion", "kind"] {
        if object.get(field).and_then(Value::as_str).is_none() {
            return Err(format!("manifest is missing {field}"));
        }
    }
    if object
        .get("metadata")
        .and_then(|metadata| metadata.get("name"))
        .and_then(Value::as_str)
        .is_none()
    {
        return Err("manifest is missing metadata.name".to_string());
    }
    if object.get("kind").and_then(Value::as_str) == Some("List") {
        return Err("manifest must be one object, not a List".to_string());
    }
    if object
        .get("metadata")
        .and_then(|metadata| metadata.get("generateName"))
        .is_some()
    {
        return Err("manifest must use metadata.name, not metadata.generateName".to_string());
    }
    Ok(())
}

/// Tool output. JSON results carry `structuredContent` (plus the same JSON as
/// text); failures set `is_error`, which code mode raises as `RuntimeError`.
pub type ToolResult = CallToolResult;

fn error_result(error: String) -> ToolResult {
    CallToolResult::error(vec![ContentBlock::text(format!("error: {error}"))])
}

/// `structuredContent` must be an object; anything else is wrapped.
fn structured(value: Value) -> ToolResult {
    if value.is_object() {
        CallToolResult::structured(value)
    } else {
        CallToolResult::structured(serde_json::json!({ "value": value }))
    }
}

fn json_result<T: Serialize>(result: Result<T, String>) -> ToolResult {
    match result.and_then(|value| {
        serde_json::to_value(value).map_err(|error| format!("failed to encode JSON: {error}"))
    }) {
        Ok(value) => structured(value),
        Err(error) => error_result(error),
    }
}

/// kubectl `-o json` output.
fn kubectl_json_result(result: Result<String, String>) -> ToolResult {
    match result {
        Ok(text) => match serde_json::from_str::<Value>(&text) {
            Ok(value) => structured(value),
            Err(_) => CallToolResult::success(vec![ContentBlock::text(text)]),
        },
        Err(error) => error_result(error),
    }
}

fn text_result(result: Result<String, String>) -> ToolResult {
    match result {
        Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]),
        Err(error) => error_result(error),
    }
}

#[derive(Debug, Serialize)]
struct ApiResource {
    name: String,
    short_names: Vec<String>,
    api_version: String,
    namespaced: bool,
    kind: String,
    verbs: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    categories: Vec<String>,
}

struct ApiColumns {
    name: usize,
    short_names: usize,
    api_version: usize,
    namespaced: usize,
    kind: usize,
    verbs: usize,
    categories: Option<usize>,
}

impl ApiColumns {
    fn parse(header: &str) -> Result<Self, String> {
        let names = header.split_whitespace().collect::<Vec<_>>();
        let starts = column_starts(header);
        if names.len() != starts.len() {
            return Err(format!(
                "unexpected api-resources header: {}",
                header.trim()
            ));
        }
        let index = |name: &str| {
            names
                .iter()
                .position(|column| column.eq_ignore_ascii_case(name))
        };
        Ok(Self {
            name: index("NAME")
                .ok_or_else(|| "api-resources header has no NAME column".to_string())?,
            short_names: index("SHORTNAMES")
                .ok_or_else(|| "api-resources header has no SHORTNAMES column".to_string())?,
            api_version: index("APIVERSION")
                .ok_or_else(|| "api-resources header has no APIVERSION column".to_string())?,
            namespaced: index("NAMESPACED")
                .ok_or_else(|| "api-resources header has no NAMESPACED column".to_string())?,
            kind: index("KIND")
                .ok_or_else(|| "api-resources header has no KIND column".to_string())?,
            verbs: index("VERBS")
                .ok_or_else(|| "api-resources header has no VERBS column".to_string())?,
            categories: index("CATEGORIES"),
        })
    }
}

/// `kubectl api-resources` only supports `-o wide` or `-o name`. Convert the
/// wide table into a JSON array so it parses like every other kcp result.
fn api_resources_json(table: &str) -> Result<Value, String> {
    let mut lines = table.lines();
    let header = lines
        .next()
        .ok_or_else(|| "api-resources returned no header".to_string())?;
    let starts = column_starts(header);
    let columns = ApiColumns::parse(header)?;

    let mut resources = Vec::new();
    for row in lines.filter(|line| !line.trim().is_empty()) {
        let field = |index: usize| field(row, &starts, index);
        let name = field(columns.name);
        if name.is_empty() {
            continue;
        }
        resources.push(ApiResource {
            name: name.to_string(),
            short_names: split_list(field(columns.short_names)),
            api_version: field(columns.api_version).to_string(),
            namespaced: field(columns.namespaced) == "true",
            kind: field(columns.kind).to_string(),
            verbs: split_list(field(columns.verbs)),
            categories: columns
                .categories
                .map(|index| split_list(field(index)))
                .unwrap_or_default(),
        });
    }

    Ok(serde_json::json!({ "resources": resources }))
}

fn column_starts(header: &str) -> Vec<usize> {
    let mut starts = Vec::new();
    let mut after_space = true;
    for (index, character) in header.char_indices() {
        if character.is_whitespace() {
            after_space = true;
        } else {
            if after_space {
                starts.push(index);
            }
            after_space = false;
        }
    }
    starts
}

fn field<'a>(row: &'a str, starts: &[usize], index: usize) -> &'a str {
    let start = starts[index];
    let end = starts
        .get(index + 1)
        .copied()
        .unwrap_or(row.len())
        .min(row.len());
    row.get(start..end).unwrap_or("").trim()
}

fn split_list(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(str::to_string)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{
        api_resources_json, suggest, unknown_resource, ApplyObjectRequest, PatchObjectRequest,
    };
    use rmcp::schemars::{schema_for, Schema};
    use serde_json::Value;

    #[test]
    fn unknown_resources_get_close_suggestions() {
        let candidates: Vec<String> = [
            "atprotorecords",
            "atprotorecords.socialweb.computer",
            "atprotorepos",
            "policyengines",
            "policyengines.policy.socialweb.computer",
            "policyworkflows",
            "roles",
            "configmaps",
            "cm",
        ]
        .iter()
        .map(|name| name.to_string())
        .collect();
        assert_eq!(
            unknown_resource(r#"error: the server doesn't have a resource type "atproprecord""#)
                .as_deref(),
            Some("atproprecord")
        );
        assert_eq!(suggest("atproprecord", &candidates)[0], "atprotorecords");
        assert_eq!(
            suggest("atprotorecords.socialweb-computer", &candidates)[0],
            "atprotorecords.socialweb.computer"
        );
        assert!(suggest("policy", &candidates).contains(&"policyengines".to_string()));
        assert!(suggest("zzzzzzzzzz", &candidates).is_empty());
        let short: Vec<String> = ["crds", "ns", "ws", "bidders"]
            .iter()
            .map(|name| name.to_string())
            .collect();
        assert!(
            suggest("bids", &short).is_empty(),
            "no junk for short names"
        );
    }

    fn assert_no_boolean_schemas(value: &Value) {
        match value {
            Value::Bool(boolean) => panic!("boolean schema {boolean} emitted: {value}"),
            Value::Array(items) => items.iter().for_each(assert_no_boolean_schemas),
            Value::Object(object) => object.values().for_each(assert_no_boolean_schemas),
            _ => {}
        }
    }

    fn rendered_schema(schema: Schema) -> Value {
        serde_json::to_value(schema).expect("schema serializes")
    }

    #[test]
    fn freeform_fields_emit_object_schemas_not_booleans() {
        let apply = rendered_schema(schema_for!(ApplyObjectRequest));
        let patch = rendered_schema(schema_for!(PatchObjectRequest));
        for schema in [&apply, &patch] {
            assert_no_boolean_schemas(schema);
        }
        assert_eq!(apply["properties"]["manifest"]["type"], "object");
        assert_eq!(patch["properties"]["patch"]["type"], "object");
    }

    #[test]
    fn api_resources_table_becomes_a_json_array() {
        let table = format!(
            "{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n",
            "NAME",
            "SHORTNAMES",
            "APIVERSION",
            "NAMESPACED",
            "KIND",
            "VERBS",
            "CATEGORIES",
            "configmaps",
            "cm",
            "v1",
            "true",
            "ConfigMap",
            "get,list",
            "all",
            "secrets",
            "",
            "v1",
            "true",
            "Secret",
            "get",
            "",
        );
        let rendered = api_resources_json(&table).expect("table parses");
        let value: Value = rendered["resources"].clone();
        assert!(value.is_array());
        assert_eq!(value[0]["name"], "configmaps");
        assert_eq!(value[0]["short_names"], serde_json::json!(["cm"]));
        assert_eq!(value[0]["api_version"], "v1");
        assert_eq!(value[0]["namespaced"], true);
        assert_eq!(value[0]["kind"], "ConfigMap");
        assert_eq!(value[0]["verbs"], serde_json::json!(["get", "list"]));
        assert_eq!(value[0]["categories"], serde_json::json!(["all"]));
        assert_eq!(value[1]["name"], "secrets");
        assert_eq!(value[1]["short_names"], serde_json::json!([]));
        assert!(value[1].get("categories").is_none());
    }
}