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 std::path::Path;
use tera::Tera;

use super::paths::resolve_in_workspace;
use super::Tool;
use crate::account::{get_accounts_path, AccountStore};
use crate::project::Project;

pub struct ProjectInitTool;

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

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

    fn description(&self) -> &str {
        "Initialize a new Soroban project with scaffolded contracts"
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "Project name"
                },
                "template": {
                    "type": "string",
                    "enum": ["token", "empty"],
                    "description": "Project template (default: empty)"
                },
                "path": {
                    "type": "string",
                    "description": "Directory to create project in (default: current directory)"
                }
            },
            "required": ["name"]
        })
    }

    async fn execute(&self, input: Value) -> Result<String, String> {
        let name = input
            .get("name")
            .and_then(|v| v.as_str())
            .ok_or("Missing 'name' parameter")?;

        let template = input
            .get("template")
            .and_then(|v| v.as_str())
            .unwrap_or("empty");

        if name.is_empty() || name.contains('/') || name.contains('\\') || name.starts_with('.') {
            return Err(format!(
                "Invalid project name '{}': must not be empty, start with '.', or contain path separators",
                name
            ));
        }

        let base_path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");

        let project_dir = resolve_in_workspace(base_path)?.join(name);
        let procyon_dir = project_dir.join(".procyon");
        let contracts_dir = project_dir.join("contracts");

        tokio::fs::create_dir_all(&procyon_dir)
            .await
            .map_err(|e| format!("Failed to create .procyon dir: {}", e))?;
        tokio::fs::create_dir_all(&contracts_dir)
            .await
            .map_err(|e| format!("Failed to create contracts dir: {}", e))?;

        let project = Project::new(name);
        project
            .save(&procyon_dir.join("project.toml"))
            .await
            .map_err(|e| format!("Failed to save project config: {}", e))?;

        if template == "token" {
            let contract_name = format!("{}_token", name.replace('-', "_"));
            scaffold_token_contract(&contracts_dir, &contract_name).await?;
        }

        Ok(format!(
            "Project '{}' initialized at {}\nTemplate: {}\n\nCreated:\n- .procyon/project.toml\n- contracts/",
            name,
            project_dir.display(),
            template
        ))
    }
}

fn to_upper_camel(name: &str) -> String {
    name.split(['_', '-'])
        .filter(|word| !word.is_empty())
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect()
}

async fn scaffold_token_contract(contracts_dir: &Path, contract_name: &str) -> Result<(), String> {
    let contract_dir = contracts_dir.join(contract_name);
    let src_dir = contract_dir.join("src");

    tokio::fs::create_dir_all(&src_dir)
        .await
        .map_err(|e| format!("Failed to create contract src dir: {}", e))?;

    let mut tera = Tera::default();
    tera.add_raw_template(
        "contract.rs",
        include_str!("../../templates/token_contract.rs"),
    )
    .map_err(|e| format!("Failed to add template: {}", e))?;
    tera.add_raw_template(
        "Cargo.toml",
        include_str!("../../templates/contract_cargo.toml"),
    )
    .map_err(|e| format!("Failed to add template: {}", e))?;

    let mut context = tera::Context::new();
    context.insert("contract_name", contract_name);
    // Computed here because Tera has no upper-camel filter; the template used to reference a
    // nonexistent `upper_camel`, which failed rendering before anything was written.
    context.insert("contract_struct", &to_upper_camel(contract_name));

    let contract_code = tera
        .render("contract.rs", &context)
        .map_err(|e| format!("Failed to render contract template: {}", e))?;
    let cargo_toml = tera
        .render("Cargo.toml", &context)
        .map_err(|e| format!("Failed to render Cargo.toml template: {}", e))?;

    tokio::fs::write(src_dir.join("lib.rs"), contract_code)
        .await
        .map_err(|e| format!("Failed to write lib.rs: {}", e))?;
    tokio::fs::write(contract_dir.join("Cargo.toml"), cargo_toml)
        .await
        .map_err(|e| format!("Failed to write Cargo.toml: {}", e))?;

    Ok(())
}

pub struct ProjectInfoTool;

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

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

    fn description(&self) -> &str {
        "Get information about the current project"
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {},
            "required": []
        })
    }

    async fn execute(&self, _input: Value) -> Result<String, String> {
        let current_dir = std::env::current_dir()
            .map_err(|e| format!("Failed to get current directory: {}", e))?;

        let project_dir = Project::find_project_dir(&current_dir)
            .await
            .map_err(|e| e.to_string())?;

        let project_file = project_dir.join(".procyon").join("project.toml");
        let project = Project::load(&project_file)
            .await
            .map_err(|e| format!("Failed to load project: {}", e))?;

        let mut output = format!(
            "Project: {} v{}\nNetwork: {}\n",
            project.name, project.version, project.default_network
        );

        if !project.contracts.is_empty() {
            output.push_str("\nContracts:\n");
            for contract in &project.contracts {
                let addr = contract.address.as_deref().unwrap_or("not deployed");
                output.push_str(&format!("  - {} ({})\n", contract.name, addr));
            }
        }

        // Read from the account store rather than the project file: that is where account_create
        // writes, so anything else would report an empty list.
        let store = AccountStore::load(&get_accounts_path(&project_dir))
            .await
            .map_err(|e| format!("Failed to load accounts: {}", e))?;

        if !store.list().is_empty() {
            output.push_str("\nAccounts:\n");
            for account in store.list() {
                output.push_str(&format!(
                    "  - {} ({}) [{}]\n",
                    account.name, account.address, account.network
                ));
            }
        }

        Ok(output)
    }
}

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

    #[test]
    fn both_templates_render() {
        let mut tera = Tera::default();
        tera.add_raw_template(
            "contract.rs",
            include_str!("../../templates/token_contract.rs"),
        )
        .unwrap();
        tera.add_raw_template(
            "Cargo.toml",
            include_str!("../../templates/contract_cargo.toml"),
        )
        .unwrap();

        let mut context = tera::Context::new();
        context.insert("contract_name", "my_token");
        context.insert("contract_struct", "MyToken");

        let contract = tera
            .render("contract.rs", &context)
            .expect("contract template must render");
        let cargo = tera
            .render("Cargo.toml", &context)
            .expect("Cargo.toml template must render");

        assert!(contract.contains("MyToken"), "struct name not substituted");
        assert!(
            !contract.contains("{{"),
            "unrendered placeholder left behind"
        );
        assert!(cargo.contains("my_token"));
        assert!(!cargo.contains("{{"));
    }

    #[test]
    fn upper_camel_handles_separators_and_casing() {
        assert_eq!(to_upper_camel("my_token"), "MyToken");
        assert_eq!(to_upper_camel("my-cool-token"), "MyCoolToken");
        assert_eq!(to_upper_camel("token"), "Token");
        assert_eq!(to_upper_camel("my__token"), "MyToken");
        assert_eq!(to_upper_camel("MY_token"), "MYToken");
    }

    #[test]
    fn rendered_struct_name_is_a_valid_rust_identifier() {
        let mut tera = Tera::default();
        tera.add_raw_template(
            "contract.rs",
            include_str!("../../templates/token_contract.rs"),
        )
        .unwrap();

        let mut context = tera::Context::new();
        context.insert("contract_name", "my_cool_token");
        context.insert("contract_struct", &to_upper_camel("my_cool_token"));

        let contract = tera.render("contract.rs", &context).unwrap();
        assert!(contract.contains("pub struct MyCoolTokenContract;"));
        assert!(contract.contains("MyCoolTokenContractClient"));
    }

    // Drives the tool itself, not just the scaffold helper: name validation, workspace
    // confinement, project.toml, and the token contract, then builds the result for wasm.
    // Uses target/ as the base because it is inside the workspace and gitignored.
    // cargo test project_init_tool_end_to_end -- --ignored --nocapture
    #[tokio::test]
    #[ignore]
    async fn project_init_tool_end_to_end() {
        let base = "target/procyon-demo";
        let name = "token_demo";
        let root = std::path::Path::new(base).join(name);
        let _ = tokio::fs::remove_dir_all(&root).await;

        let report = ProjectInitTool
            .execute(json!({"name": name, "template": "token", "path": base}))
            .await
            .expect("project_init should succeed");
        println!("--- project_init ---\n{}", report);

        // Project state
        let project_file = root.join(".procyon/project.toml");
        assert!(project_file.exists(), "missing {}", project_file.display());
        let project = Project::load(&project_file)
            .await
            .expect("project.toml parses");
        assert_eq!(project.name, name);
        assert_eq!(project.default_network.to_string(), "testnet");

        // Scaffolded contract, with the struct name derived in Rust rather than by a Tera filter
        let crate_dir = root.join("contracts").join(format!("{}_token", name));
        let lib = tokio::fs::read_to_string(crate_dir.join("src/lib.rs"))
            .await
            .expect("lib.rs");
        assert!(
            lib.contains("pub struct TokenDemoTokenContract;"),
            "got a bad struct name"
        );
        assert!(!lib.contains("{{"), "unrendered placeholder survived");
        assert!(lib.contains("#[contracterror]"));

        // It has to actually build for the target Soroban accepts.
        let wasm = std::process::Command::new("cargo")
            .args(["build", "--release", "--target", "wasm32v1-none"])
            .current_dir(&crate_dir)
            .output()
            .expect("cargo runs");
        assert!(
            wasm.status.success(),
            "generated contract failed to build:\n{}",
            String::from_utf8_lossy(&wasm.stderr)
        );

        let artifact = crate_dir.join(format!("target/wasm32v1-none/release/{}_token.wasm", name));
        let size = tokio::fs::metadata(&artifact).await.expect("wasm").len();
        println!("wasm: {} ({} bytes)", artifact.display(), size);
        assert!(size > 0);
    }

    // Compiles the scaffolded contract for real: downloads soroban-sdk and builds wasm, so it is
    // a CI gate rather than part of the fast local suite.
    // Run with: cargo test scaffolded_contract_builds -- --ignored
    #[tokio::test]
    #[ignore]
    async fn scaffolded_contract_builds_and_passes_its_own_tests() {
        let temp = tempfile::tempdir().unwrap();
        let contracts_dir = temp.path().join("contracts");

        scaffold_token_contract(&contracts_dir, "my_token")
            .await
            .expect("scaffolding must succeed");

        let crate_dir = contracts_dir.join("my_token");
        assert!(crate_dir.join("src/lib.rs").exists());
        assert!(crate_dir.join("Cargo.toml").exists());

        let tests = std::process::Command::new("cargo")
            .args(["test"])
            .current_dir(&crate_dir)
            .output()
            .expect("cargo must be runnable");
        assert!(
            tests.status.success(),
            "generated contract failed its own tests:\n{}",
            String::from_utf8_lossy(&tests.stderr)
        );

        let wasm = std::process::Command::new("cargo")
            .args(["build", "--release", "--target", "wasm32v1-none"])
            .current_dir(&crate_dir)
            .output()
            .expect("cargo must be runnable");
        assert!(
            wasm.status.success(),
            "generated contract failed to build for wasm32v1-none:\n{}",
            String::from_utf8_lossy(&wasm.stderr)
        );

        assert!(
            crate_dir
                .join("target/wasm32v1-none/release/my_token.wasm")
                .exists(),
            "no wasm artifact produced"
        );
    }
}