use async_trait::async_trait;
use serde_json::{json, Value};
use super::paths::resolve_in_workspace;
use super::{is_contract_id, Tool};
pub struct GenerateDocsTool;
#[async_trait]
impl Tool for GenerateDocsTool {
fn name(&self) -> &str {
"generate_docs"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Write
}
fn description(&self) -> &str {
"Generate documentation for a deployed contract from its on-chain interface spec"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"contract_id": {
"type": "string",
"description": "The contract ID to generate docs for"
},
"output_dir": {
"type": "string",
"description": "Output directory (default: ./docs)"
},
"format": {
"type": "string",
"enum": ["markdown", "json"],
"description": "Output format (default: markdown)"
},
"network": {
"type": "string",
"enum": ["local", "testnet", "mainnet"],
"description": "Network the contract is deployed on (default: testnet)"
}
},
"required": ["contract_id"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let contract_id = input
.get("contract_id")
.and_then(|v| v.as_str())
.ok_or("Missing 'contract_id' parameter")?;
if !is_contract_id(contract_id) {
return Err(format!("Not a valid contract id: {}", contract_id));
}
let output_dir = input
.get("output_dir")
.and_then(|v| v.as_str())
.unwrap_or("./docs");
let format = input
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("markdown");
let network = input
.get("network")
.and_then(|v| v.as_str())
.unwrap_or("testnet");
let spec = fetch_contract_spec(contract_id, network).await?;
let content = match format {
"markdown" => generate_markdown_docs(contract_id, network, &spec)?,
"json" => serde_json::to_string_pretty(&spec)
.map_err(|e| format!("Failed to serialize spec: {}", e))?,
_ => return Err(format!("Unsupported format: {}", format)),
};
let output_path = resolve_in_workspace(output_dir)?;
tokio::fs::create_dir_all(&output_path)
.await
.map_err(|e| format!("Failed to create output directory: {}", e))?;
let extension = if format == "markdown" { "md" } else { "json" };
let file_path = output_path.join(format!("{}.{}", contract_id, extension));
tokio::fs::write(&file_path, &content)
.await
.map_err(|e| format!("Failed to write docs: {}", e))?;
Ok(format!(
"Documentation generated!\n\nContract: {}\nNetwork: {}\nFunctions: {}\nOutput: {}",
contract_id,
network,
functions(&spec).count(),
file_path.display()
))
}
}
async fn fetch_contract_spec(contract_id: &str, network: &str) -> Result<Value, String> {
let output = tokio::process::Command::new("stellar")
.args([
"contract",
"info",
"interface",
"--id",
contract_id,
"--network",
network,
"--output",
"json",
])
.output()
.await
.map_err(|e| format!("Failed to run stellar CLI: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to fetch contract interface: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse contract interface: {}", e))
}
fn functions(spec: &Value) -> impl Iterator<Item = &Value> {
spec.as_array()
.map(|entries| entries.as_slice())
.unwrap_or_default()
.iter()
.filter_map(|entry| entry.get("function_v0"))
}
fn type_name(value: &Value) -> String {
value
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| value.to_string())
}
fn signature(function: &Value) -> String {
let inputs = function["inputs"]
.as_array()
.map(|args| {
args.iter()
.map(|arg| {
format!(
"{}: {}",
arg["name"].as_str().unwrap_or("_"),
type_name(&arg["type_"])
)
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let outputs = function["outputs"]
.as_array()
.map(|outs| outs.iter().map(type_name).collect::<Vec<_>>().join(", "))
.unwrap_or_default();
let name = function["name"].as_str().unwrap_or("<unnamed>");
if outputs.is_empty() {
format!("{}({})", name, inputs)
} else {
format!("{}({}) -> {}", name, inputs, outputs)
}
}
fn generate_markdown_docs(
contract_id: &str,
network: &str,
spec: &Value,
) -> Result<String, String> {
let mut docs = format!("# Contract `{}`\n\n", contract_id);
docs.push_str(&format!("- Network: `{}`\n\n", network));
docs.push_str("## Functions\n\n");
let mut any = false;
for function in functions(spec) {
any = true;
let name = function["name"].as_str().unwrap_or("<unnamed>");
docs.push_str(&format!("### `{}`\n\n", name));
let doc = function["doc"].as_str().unwrap_or("").trim();
if !doc.is_empty() {
for line in doc.lines() {
docs.push_str(&format!("{}\n", line));
}
docs.push('\n');
}
docs.push_str(&format!("```\n{}\n```\n\n", signature(function)));
if let Some(args) = function["inputs"].as_array().filter(|a| !a.is_empty()) {
docs.push_str("| Parameter | Type | Description |\n|---|---|---|\n");
for arg in args {
docs.push_str(&format!(
"| `{}` | `{}` | {} |\n",
arg["name"].as_str().unwrap_or("_"),
type_name(&arg["type_"]),
arg["doc"].as_str().unwrap_or("").replace('\n', " ").trim()
));
}
docs.push('\n');
}
docs.push_str("Invoke:\n\n```bash\n");
docs.push_str(&format!(
"stellar contract invoke \\\n --id {} \\\n --source <account> \\\n --network {} \\\n -- \\\n {}\n",
contract_id, network, name
));
docs.push_str("```\n\n");
}
if !any {
docs.push_str("This contract exposes no callable functions.\n");
}
Ok(docs)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec() -> Value {
json!([
{"function_v0": {
"doc": "Returns the allowance for `spender`.",
"name": "allowance",
"inputs": [
{"doc": "owner", "name": "from", "type_": "address"},
{"doc": "", "name": "spender", "type_": "address"}
],
"outputs": ["i128"]
}},
{"function_v0": {
"doc": "", "name": "burn",
"inputs": [{"doc": "", "name": "amount", "type_": "i128"}],
"outputs": []
}},
{"event_v0": {"name": "transfer"}}
])
}
#[test]
fn counts_only_functions() {
assert_eq!(functions(&sample_spec()).count(), 2);
}
#[test]
fn renders_signature_with_and_without_return() {
let spec = sample_spec();
let fns: Vec<_> = functions(&spec).collect();
assert_eq!(
signature(fns[0]),
"allowance(from: address, spender: address) -> i128"
);
assert_eq!(signature(fns[1]), "burn(amount: i128)");
}
#[test]
fn markdown_includes_docs_signature_and_params() {
let docs = generate_markdown_docs("CID", "testnet", &sample_spec()).unwrap();
assert!(docs.contains("### `allowance`"));
assert!(docs.contains("Returns the allowance for `spender`."));
assert!(docs.contains("allowance(from: address, spender: address) -> i128"));
assert!(docs.contains("| `from` | `address` | owner |"));
assert!(docs.contains("--network testnet"));
}
#[test]
fn handles_a_spec_with_no_functions() {
let docs = generate_markdown_docs("CID", "testnet", &json!([])).unwrap();
assert!(docs.contains("no callable functions"));
}
#[tokio::test]
#[ignore]
async fn fetches_a_real_contract_spec_from_testnet() {
const ID: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
let spec = fetch_contract_spec(ID, "testnet").await.unwrap();
let count = functions(&spec).count();
assert!(count > 0, "expected functions, got spec: {}", spec);
let docs = generate_markdown_docs(ID, "testnet", &spec).unwrap();
assert!(docs.contains("### `"), "no function sections rendered");
}
}