procyon 0.1.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use std::path::{Path, PathBuf};

use crate::account::{get_accounts_path, AccountStore};
use crate::project::Project;

const IDENTITY: &str = "\
You are Procyon, a terminal development harness for Stellar and Soroban smart contracts. You \
work inside the user's workspace through the tools you are given.";

const WORKING_RULES: &str = "\
## Working rules

- Locate code with `glob` and `grep` before reading it. Do not guess file paths.
- When the answer depends on how Stellar or Soroban actually behaves — storage and TTL, auth, \
SEPs, CLI flags, fees, XDR — or on explaining a contract or host error, look it up with a \
connected MCP tool and cite what you found. Prefer looking it up over recalling from memory, \
and say so plainly when no source is available rather than guessing.
- Every file tool is confined to the workspace; paths outside it are rejected, so do not try \
absolute paths elsewhere.
- `write_file` and `edit_file` change the user's files immediately and are not undoable by you. \
Read a file before editing it, and prefer `edit_file` over rewriting a whole file.
- In a project that has `caatinga.config.ts`, use the `caatinga_*` tools for build, deploy and \
invoke rather than raw `stellar` CLI commands. They take contract *names* from that config, never \
a wasm path or a contract id: Caatinga resolves those from `caatinga.artifacts.json`, deploys in \
dependency order, and regenerates bindings afterwards. Do not copy a contract id out of a deploy \
log — read it from the artifacts, or let the tool report it. In a project without that config the \
`caatinga_*` tools will refuse, and the `stellar` CLI is the right path.
- Signing is always by Stellar CLI identity alias, such as `alice`. Never pass a secret key, seed \
phrase or raw address as `source`: it would reach the process list and the session log.
- Any tool that signs and submits requires the network as an explicit argument; there is no \
default. State the network you are about to act on before calling it.
- Mainnet is refused unless the operator enabled it in the config or the environment. You cannot \
enable it — if a mainnet operation is wanted, say what the user has to set and stop. Prefer \
`caatinga_read` over `caatinga_invoke` whenever you only need to read a value: it simulates, so it \
signs nothing and costs nothing.
- When a tool fails, read its error before retrying. Do not repeat an identical failing call.";

// Rebuilt every turn so the model sees the workspace as it is now, not as it was at boot.
pub struct WorkspaceContext {
    pub cwd: PathBuf,
    pub project: Option<Project>,
    pub accounts: Vec<String>,
    pub stellar_cli: Option<String>,
    pub npx: bool,
    pub mcp_servers: Vec<String>,
}

impl WorkspaceContext {
    pub async fn gather(cwd: &Path, mcp_servers: &[String]) -> Self {
        let project_dir = Project::find_project_dir(cwd).await.ok();

        let project = match &project_dir {
            Some(dir) => Project::load(&dir.join(".procyon").join("project.toml"))
                .await
                .ok(),
            None => None,
        };

        let accounts = match &project_dir {
            Some(dir) => AccountStore::load(&get_accounts_path(dir))
                .await
                .map(|store| {
                    store
                        .list()
                        .iter()
                        .map(|a| format!("{} ({}) [{}]", a.name, a.address, a.network))
                        .collect()
                })
                .unwrap_or_default(),
            None => Vec::new(),
        };

        Self {
            cwd: cwd.to_path_buf(),
            project,
            accounts,
            stellar_cli: stellar_cli_version().await,
            npx: which::which("npx").is_ok(),
            mcp_servers: mcp_servers.to_vec(),
        }
    }

    pub fn system_prompt(&self) -> String {
        let mut out = String::from(IDENTITY);
        out.push_str("\n\n## Environment\n\n");
        out.push_str(&format!("- Workspace: {}\n", self.cwd.display()));

        match &self.project {
            Some(project) => {
                out.push_str(&format!(
                    "- Project: {} v{}\n- Default network: {}\n",
                    project.name, project.version, project.default_network
                ));
                if project.contracts.is_empty() {
                    out.push_str("- Contracts: none registered yet\n");
                } else {
                    out.push_str("- Contracts:\n");
                    for contract in &project.contracts {
                        out.push_str(&format!(
                            "  - {} ({})\n",
                            contract.name,
                            contract.address.as_deref().unwrap_or("not deployed")
                        ));
                    }
                }
            }
            None => out.push_str(
                "- Project: no .procyon/project.toml found. Use `project_init` before tools that \
                 need project state.\n",
            ),
        }

        if self.accounts.is_empty() {
            out.push_str("- Accounts: none configured\n");
        } else {
            out.push_str("- Accounts:\n");
            for account in &self.accounts {
                out.push_str(&format!("  - {}\n", account));
            }
        }

        // Stated explicitly so the model does not propose a toolchain that is not installed.
        out.push_str(&format!(
            "- stellar CLI: {}\n- npx: {}\n",
            self.stellar_cli.as_deref().unwrap_or("not installed"),
            if self.npx {
                "available"
            } else {
                "not installed"
            }
        ));
        if self.mcp_servers.is_empty() {
            out.push_str(
                "- MCP servers: none connected, so you have no way to look up Stellar facts\n",
            );
        } else {
            out.push_str("- MCP servers:\n");
            for server in &self.mcp_servers {
                out.push_str(&format!("  - {}\n", server));
            }
        }

        out.push('\n');
        out.push_str(WORKING_RULES);
        out
    }
}

async fn stellar_cli_version() -> Option<String> {
    if which::which("stellar").is_err() {
        return None;
    }
    let output = tokio::process::Command::new("stellar")
        .arg("--version")
        .output()
        .await
        .ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8_lossy(&output.stdout)
        .lines()
        .next()
        .map(|line| line.trim().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::project::{Contract, Network};

    fn context() -> WorkspaceContext {
        WorkspaceContext {
            cwd: PathBuf::from("/w/demo"),
            project: None,
            accounts: Vec::new(),
            stellar_cli: None,
            npx: false,
            mcp_servers: Vec::new(),
        }
    }

    #[test]
    fn states_the_identity_and_the_workspace() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("Stellar and Soroban"));
        assert!(prompt.contains("/w/demo"));
    }

    #[test]
    fn says_plainly_when_there_is_no_project() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("no .procyon/project.toml found"));
        assert!(prompt.contains("Accounts: none configured"));
    }

    #[test]
    fn reports_missing_tooling_instead_of_staying_silent() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("stellar CLI: not installed"));
        assert!(prompt.contains("npx: not installed"));
    }

    #[test]
    fn includes_project_network_and_contract_addresses() {
        let mut ctx = context();
        ctx.project = Some(Project {
            name: "demo".to_string(),
            version: "0.2.0".to_string(),
            default_network: Network::Testnet,
            contracts: vec![
                Contract {
                    name: "token".to_string(),
                    address: Some("CDLZ".to_string()),
                    wasm_path: None,
                },
                Contract {
                    name: "pending".to_string(),
                    address: None,
                    wasm_path: None,
                },
            ],
        });
        ctx.accounts = vec!["alice (GALICE) [testnet]".to_string()];

        let prompt = ctx.system_prompt();
        assert!(prompt.contains("demo v0.2.0"));
        assert!(prompt.contains("Default network: testnet"));
        assert!(prompt.contains("token (CDLZ)"));
        assert!(prompt.contains("pending (not deployed)"));
        assert!(prompt.contains("alice (GALICE) [testnet]"));
    }

    #[test]
    fn says_plainly_when_no_mcp_server_is_connected() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("none connected"), "got {}", prompt);
    }

    #[test]
    fn lists_connected_mcp_servers_and_their_tools() {
        let mut ctx = context();
        ctx.mcp_servers = vec!["raven (https://raven.stellar.org/mcp): raven__search".to_string()];
        let prompt = ctx.system_prompt();
        assert!(prompt.contains("raven__search"), "got {}", prompt);
        assert!(prompt.contains("https://raven.stellar.org/mcp"));
    }

    #[test]
    fn directs_the_model_to_look_up_rather_than_recall() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("Prefer looking it up over recalling from memory"));
        assert!(prompt.contains("host error"));
    }

    #[test]
    fn tells_the_model_to_search_before_reading() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("`glob`"));
        assert!(prompt.contains("`grep`"));
        assert!(prompt.contains("Do not guess file paths"));
    }

    #[test]
    fn warns_about_mainnet_and_unrecoverable_writes() {
        let prompt = context().system_prompt();
        assert!(prompt.contains("mainnet"));
        assert!(prompt.contains("not undoable"));
    }

    #[tokio::test]
    async fn gathering_in_a_directory_without_a_project_does_not_fail() {
        let temp = tempfile::tempdir().unwrap();
        let ctx = WorkspaceContext::gather(temp.path(), &[]).await;
        assert!(ctx.project.is_none());
        assert!(ctx.accounts.is_empty());
        assert!(ctx.system_prompt().contains("no .procyon/project.toml"));
    }
}

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

    // Exercises the real gather() path against the actual working directory, including the MCP
    // servers from the user's config, so this is the prompt the model actually receives.
    // cargo test dump_real_prompt -- --ignored --nocapture
    #[tokio::test]
    #[ignore]
    async fn dump_real_prompt() {
        let cwd = std::env::current_dir().unwrap();

        // Boot-time input in the real app, so a prompt printed without it would be misleading.
        let servers = match crate::config::AppConfig::load() {
            Ok(cfg) => crate::mcp::load_servers(&cfg.mcp_servers).await.1,
            Err(_) => Vec::new(),
        };

        let prompt = WorkspaceContext::gather(&cwd, &servers)
            .await
            .system_prompt();
        println!("{}", prompt);

        assert!(
            prompt.contains(&cwd.display().to_string()),
            "the prompt must name the real workspace"
        );
        assert!(prompt.contains("## Environment"));
        assert!(prompt.contains("## Working rules"));
    }
}