procyon 0.3.0

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

// The interface is a stream of SCSpecEntry values. The CLI already resolves the contract's wasm
// and decodes them, which avoids pulling an XDR decoder in here.
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"));
    }

    // Hits testnet and shells out to the stellar CLI; run with `cargo test -- --ignored`.
    #[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");
    }
}