use std::time::Duration;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::process::Command;
use super::paths::resolve_in_workspace;
use super::Tool;
const CAATINGA_VERSION: &str = "@caatinga/cli@3.9.2";
const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
pub fn check_npx_available() -> Result<(), String> {
which::which("npx")
.map(|_| ())
.map_err(|_| "npx was not found on PATH. Is Node.js installed?".to_string())
}
pub async fn require_caatinga_project() -> Result<String, String> {
for candidate in ["caatinga.config.ts", "caatinga.config.js"] {
let path = resolve_in_workspace(candidate)?;
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok(candidate.to_string());
}
}
Err(
"This is not a Caatinga project: no caatinga.config.ts in the workspace. \
The caatinga_* tools deploy contracts declared in that config, so there is nothing \
for them to act on. Run `npx @caatinga/cli init` to set one up, or use the stellar \
CLI directly for a one-off contract."
.to_string(),
)
}
pub fn validate_source(source: &str) -> Result<(), String> {
let source = source.trim();
if source.is_empty() {
return Err("'source' is empty; give a Stellar CLI identity alias, e.g. alice".to_string());
}
if source.split_whitespace().count() > 1 {
return Err(
"'source' looks like a seed phrase. Pass a Stellar CLI identity alias instead \
(e.g. alice); Procyon never handles key material."
.to_string(),
);
}
let looks_like_strkey = source.len() >= 56
&& source
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit());
if looks_like_strkey {
return Err(match source.as_bytes().first() {
Some(b'S') => "'source' is a secret key. Pass a Stellar CLI identity alias instead \
(e.g. alice) — a secret on a command line reaches the process list, \
the error text and the session log."
.to_string(),
_ => "'source' is a raw address. Caatinga signs through a Stellar CLI identity \
alias (e.g. alice), which is also what keeps the key out of Procyon."
.to_string(),
});
}
Ok(())
}
pub fn validate_target(target: &str) -> Result<(), String> {
let Some((contract, method)) = target.split_once('.') else {
return Err(format!(
"'{}' is not a valid target. Use <contract>.<method>, where the contract is a name \
from caatinga.config.ts — e.g. token.transfer. Caatinga resolves the contract id \
from its artifacts.",
target
));
};
if contract.is_empty() || method.is_empty() {
return Err(format!(
"'{}' is missing the contract or the method. Use <contract>.<method>, e.g. \
token.transfer.",
target
));
}
if super::is_contract_id(contract) {
return Err(format!(
"'{}' is a contract id, not a contract name. Use the name from \
caatinga.config.ts — Caatinga looks the id up in its artifacts, which is what keeps \
the two from drifting after a redeploy.",
contract
));
}
Ok(())
}
pub async fn run_caatinga(args: &[String]) -> Result<String, String> {
check_npx_available()?;
let mut argv = vec!["-y".to_string(), CAATINGA_VERSION.to_string()];
argv.extend_from_slice(args);
let subcommand = args.first().cloned().unwrap_or_default();
let child = Command::new("npx").args(&argv).output();
let output = match tokio::time::timeout(COMMAND_TIMEOUT, child).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(format!("Failed to execute caatinga {}: {}", subcommand, e)),
Err(_) => {
return Err(format!(
"caatinga {} did not finish within {}s and was abandoned. It may still be \
running; check `npx {} status` before retrying.",
subcommand,
COMMAND_TIMEOUT.as_secs(),
CAATINGA_VERSION
))
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok(stdout)
} else {
Err(format!(
"caatinga {} failed (exit code: {})\n\nstdout:\n{}\n\nstderr:\n{}",
subcommand,
output.status.code().unwrap_or(-1),
stdout,
stderr
))
}
}
async fn deployed_contracts(network: &str) -> Vec<(String, String)> {
let Ok(path) = resolve_in_workspace("caatinga.artifacts.json") else {
return Vec::new();
};
let Ok(raw) = tokio::fs::read_to_string(&path).await else {
return Vec::new();
};
let Ok(artifacts) = serde_json::from_str::<Value>(&raw) else {
crate::diag::warn("caatinga.artifacts.json is not valid JSON; contract ids not reported");
return Vec::new();
};
let contracts = artifacts
.get("networks")
.and_then(|n| n.get(network))
.and_then(|n| n.get("contracts"))
.and_then(|c| c.as_object());
let Some(contracts) = contracts else {
return Vec::new();
};
contracts
.iter()
.filter_map(|(name, entry)| {
let id = entry.get("contractId").and_then(|v| v.as_str())?;
Some((name.clone(), id.to_string()))
})
.collect()
}
pub struct CaatingaBuildTool;
#[async_trait]
impl Tool for CaatingaBuildTool {
fn name(&self) -> &str {
"caatinga_build"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Build
}
fn description(&self) -> &str {
"Build the Soroban contracts declared in caatinga.config.ts. Builds every contract unless \
one is named. 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 to build all."
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let mut args = vec!["build".to_string()];
if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
args.push(contract.to_string());
}
let stdout = run_caatinga(&args).await?;
Ok(format!("Build successful.\n\n{}", stdout))
}
}
pub struct CaatingaDeployTool;
#[async_trait]
impl Tool for CaatingaDeployTool {
fn name(&self) -> &str {
"caatinga_deploy"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Signing
}
fn description(&self) -> &str {
"Deploy Soroban contracts declared in caatinga.config.ts. Deploys every contract in \
dependency order unless one is named, and afterwards Caatinga regenerates bindings, runs \
wiring hooks and syncs frontend env by itself. Use dry_run to estimate cost without \
submitting. 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 to deploy all, in dependency order."
},
"network": {
"type": "string",
"description": "Network name as configured in caatinga.config.ts (e.g. testnet). Required: this signs and submits, so it must not rely on a default. Mainnet is refused unless the operator enabled it."
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias that signs, e.g. 'alice'. Never a secret key, seed phrase or raw address."
},
"dry_run": {
"type": "boolean",
"description": "Estimate the deploy cost without submitting anything"
},
"if_changed": {
"type": "boolean",
"description": "Skip contracts whose local WASM already matches the artifacts"
}
},
"required": ["network"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
crate::verify::session().guard_deploy()?;
let mut args = vec!["deploy".to_string()];
if let Some(contract) = input.get("contract").and_then(|v| v.as_str()) {
args.push(contract.to_string());
}
let network =
super::mainnet::resolve_signing_network(input.get("network").and_then(|v| v.as_str()))?;
args.push("--network".to_string());
args.push(network.clone());
if let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if input.get("dry_run").and_then(|v| v.as_bool()) == Some(true) {
args.push("--dry-run".to_string());
}
if input.get("if_changed").and_then(|v| v.as_bool()) == Some(true) {
args.push("--if-changed".to_string());
}
let stdout = run_caatinga(&args).await?;
let mut result = "Deploy successful.\n".to_string();
let deployed = deployed_contracts(&network).await;
if !deployed.is_empty() {
result.push_str(&format!(
"\nRecorded in caatinga.artifacts.json for {}:\n",
network
));
for (name, id) in deployed {
result.push_str(&format!(" {} = {}\n", name, id));
}
}
result.push_str(&format!("\n{}", stdout));
Ok(result)
}
}
pub struct CaatingaDoctorTool;
#[async_trait]
impl Tool for CaatingaDoctorTool {
fn name(&self) -> &str {
"caatinga_doctor"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Check the local Caatinga setup: CLI, Stellar CLI, Rust, config, network reachability and \
signing identity. Run this first when a build, deploy or invoke fails for a reason that \
is not in the contract — most such failures are environment drift, and this names them \
instead of guessing. Only works in a Caatinga project."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"network": {
"type": "string",
"description": "Network name from caatinga.config.ts to validate"
},
"source": {
"type": "string",
"description": "Stellar CLI identity alias to validate, e.g. 'alice'. Never a secret key, seed phrase or raw address."
},
"all_networks": {
"type": "boolean",
"description": "Report deploy and bindings coverage for every configured network"
},
"strict": {
"type": "boolean",
"description": "Also fail when the frontend env file drifts from the artifacts, or bindings are stale"
}
},
"required": []
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
require_caatinga_project().await?;
let mut args = vec!["doctor".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 let Some(source) = input.get("source").and_then(|v| v.as_str()) {
validate_source(source)?;
args.push("--source".to_string());
args.push(source.to_string());
}
if input.get("all_networks").and_then(|v| v.as_bool()) == Some(true) {
args.push("--all-networks".to_string());
}
if input.get("strict").and_then(|v| v.as_bool()) == Some(true) {
args.push("--strict".to_string());
}
match run_caatinga(&args).await {
Ok(stdout) => Ok(format!("Setup looks healthy.\n\n{}", stdout)),
Err(report) => Ok(format!(
"Doctor reported problems. This is the diagnosis, not a tool failure:\n\n{}",
report
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
const ADDRESS: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEXAMPLE";
const ID: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
#[test]
fn an_identity_alias_is_accepted() {
for alias in ["alice", "bob", "deployer-ci", "s", "Steve"] {
assert!(validate_source(alias).is_ok(), "rejected {}", alias);
}
}
#[test]
fn a_secret_key_is_refused_and_the_message_says_why() {
let err = validate_source(SECRET).expect_err("a secret key must be refused");
assert!(err.contains("secret key"), "{}", err);
assert!(err.contains("alias"), "{}", err);
assert!(
!err.contains(SECRET),
"the error must not echo the secret: {}",
err
);
}
#[test]
fn a_raw_address_is_refused() {
let err = validate_source(ADDRESS).expect_err("an address is not an alias");
assert!(err.contains("alias"), "{}", err);
}
#[test]
fn a_seed_phrase_is_refused() {
let err = validate_source("abandon abandon abandon about")
.expect_err("a seed phrase must be refused");
assert!(err.contains("seed phrase"), "{}", err);
}
#[test]
fn an_empty_source_is_refused() {
assert!(validate_source(" ").is_err());
}
#[tokio::test]
async fn the_tools_refuse_a_project_without_a_caatinga_config() {
let err = CaatingaDeployTool
.execute(json!({"network": "testnet"}))
.await
.expect_err("deploy must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
let err = CaatingaBuildTool
.execute(json!({}))
.await
.expect_err("build must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
}
#[tokio::test]
async fn deploy_refuses_a_secret_before_spawning_anything() {
let err = CaatingaDeployTool
.execute(json!({"source": SECRET}))
.await
.expect_err("deploy must refuse a secret key");
assert!(
err.contains("secret key") || err.contains("not a Caatinga project"),
"got {}",
err
);
}
#[tokio::test]
async fn no_contract_ids_are_reported_when_there_are_no_artifacts() {
assert!(deployed_contracts("testnet").await.is_empty());
}
#[test]
fn a_contract_and_method_is_a_valid_target() {
for target in ["token.transfer", "my-contract.balance_of", "a.b"] {
assert!(validate_target(target).is_ok(), "rejected {}", target);
}
}
#[test]
fn a_contract_id_as_the_target_is_refused_by_name() {
let target = format!("{}.transfer", ID);
let err = validate_target(&target).expect_err("an id is not a contract name");
assert!(err.contains("contract id"), "{}", err);
assert!(err.contains("artifacts"), "{}", err);
}
#[test]
fn a_target_without_a_method_is_refused() {
for target in ["token", "token.", ".transfer", ""] {
assert!(
validate_target(target).is_err(),
"accepted an incomplete target: {:?}",
target
);
}
}
#[tokio::test]
async fn doctor_refuses_a_project_without_a_caatinga_config() {
let err = CaatingaDoctorTool
.execute(json!({}))
.await
.expect_err("doctor must refuse a non-Caatinga project");
assert!(err.contains("not a Caatinga project"), "{}", err);
}
#[test]
fn deploy_requires_its_network() {
let schema = CaatingaDeployTool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(
required.contains(&json!("network")),
"a deploy must state its network: {}",
schema
);
}
#[tokio::test]
async fn doctor_refuses_a_secret_source() {
let err = CaatingaDoctorTool
.execute(json!({"source": SECRET}))
.await
.expect_err("doctor must refuse a secret key");
assert!(
err.contains("secret key") || err.contains("not a Caatinga project"),
"got {}",
err
);
}
}