procyon 0.1.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use async_trait::async_trait;
use serde_json::{json, Value};

use super::caatinga::{require_caatinga_project, run_caatinga};
use super::Tool;

pub struct GenerateBindingsTool;

#[async_trait]
impl Tool for GenerateBindingsTool {
    fn name(&self) -> &str {
        "generate_bindings"
    }

    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::Write
    }

    fn description(&self) -> &str {
        "Generate TypeScript bindings for contracts Caatinga has deployed, reading the contract \
         ids from its artifacts. Covers every deployed contract unless one is named. Caatinga \
         already does this after a deploy, so this is for regenerating them on demand. Only works \
         in a Caatinga project."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "contract": {
                    "type": "string",
                    "description": "Name of a contract from caatinga.config.ts. Omit for every deployed contract."
                },
                "network": {
                    "type": "string",
                    "description": "Network name as configured in caatinga.config.ts (e.g. testnet)"
                },
                "strict_network": {
                    "type": "boolean",
                    "description": "Fail instead of doing nothing when the network has no deployment artifacts"
                }
            },
            "required": []
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        require_caatinga_project().await?;

        // No output directory and no language: both come from caatinga.config.ts, and the ids come
        // from the artifacts. Passing them here would be the hand-plumbing Caatinga exists to
        // remove — and bindings written somewhere the config does not expect are bindings the app
        // never imports.
        let mut args = vec!["generate".to_string()];

        if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
            args.push(contract.to_string());
        }

        if let Some(network) = input.get("network").and_then(|v| v.as_str()) {
            args.push("--network".to_string());
            args.push(network.to_string());
        }

        if input.get("strict_network").and_then(|v| v.as_bool()) == Some(true) {
            args.push("--strict-network".to_string());
        }

        let stdout = run_caatinga(&args).await?;
        Ok(format!("Bindings generated.\n\n{}", stdout))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Bindings land where the config says; a caller choosing a directory produces bindings the app
    // does not import. The schema must not offer that choice.
    #[test]
    fn the_schema_takes_a_contract_name_not_an_id_or_a_path() {
        let schema = GenerateBindingsTool.input_schema();
        let props = schema["properties"].as_object().unwrap();

        assert!(props.contains_key("contract"));
        for absent in ["contract_id", "output_dir", "language"] {
            assert!(
                !props.contains_key(absent),
                "{} must not be a parameter: it comes from the config or the artifacts",
                absent
            );
        }
    }

    #[tokio::test]
    async fn it_refuses_a_project_without_a_caatinga_config() {
        let err = GenerateBindingsTool
            .execute(json!({}))
            .await
            .expect_err("generate must refuse a non-Caatinga project");
        assert!(err.contains("not a Caatinga project"), "{}", err);
    }
}