procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use color_eyre::{eyre::bail, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::project::Network;

// Only public data lives here. Secret keys stay in the stellar CLI keystore, addressed by
// `name` — procyon never reads or persists them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
    pub name: String,
    pub address: String,
    pub network: Network,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountStore {
    pub accounts: Vec<Account>,
}

impl AccountStore {
    pub fn new() -> Self {
        Self {
            accounts: Vec::new(),
        }
    }

    pub async fn load(path: &Path) -> Result<Self> {
        if !tokio::fs::try_exists(path).await.unwrap_or(false) {
            return Ok(Self::new());
        }
        let content = tokio::fs::read_to_string(path).await?;
        let store: Self = toml::from_str(&content)?;
        Ok(store)
    }

    pub async fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let content = toml::to_string_pretty(self)?;
        tokio::fs::write(path, content).await?;
        Ok(())
    }

    pub fn add(&mut self, account: Account) {
        self.accounts.push(account);
    }

    pub fn get(&self, name: &str) -> Option<&Account> {
        self.accounts.iter().find(|a| a.name == name)
    }

    pub fn list(&self) -> &[Account] {
        &self.accounts
    }

    // Reachable once an account_remove tool exists (Sprint 2.4 follow-up).
    #[allow(dead_code)]
    pub fn remove(&mut self, name: &str) -> bool {
        let len = self.accounts.len();
        self.accounts.retain(|a| a.name != name);
        self.accounts.len() < len
    }
}

pub fn get_accounts_path(project_dir: &Path) -> PathBuf {
    project_dir.join(".procyon").join("accounts.toml")
}

pub fn get_network_rpc_url(network: &Network) -> Result<&'static str> {
    match network {
        Network::Local => Ok("http://localhost:8000/rpc"),
        Network::Testnet => Ok("https://soroban-testnet.stellar.org"),
        // The SDF runs no public mainnet RPC, so failing loudly beats a DNS error from a
        // hostname that never existed.
        Network::Mainnet => bail!(
            "No public mainnet RPC endpoint exists. Configure a provider endpoint to use mainnet."
        ),
    }
}

pub fn get_network_horizon_url(network: &Network) -> &'static str {
    match network {
        Network::Local => "http://localhost:8000",
        Network::Testnet => "https://horizon-testnet.stellar.org",
        Network::Mainnet => "https://horizon.stellar.org",
    }
}

fn is_stellar_address(candidate: &str) -> bool {
    candidate.len() == 56
        && candidate.starts_with('G')
        && candidate
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}

// Balances live in the classic ledger, which Horizon exposes directly. Stellar RPC has no
// equivalent account method — reaching them there would mean an XDR-encoded getLedgerEntries key.
pub async fn query_balance(address: &str, network: &Network) -> Result<String> {
    if !is_stellar_address(address) {
        bail!("Not a valid Stellar account address: {}", address);
    }

    let url = format!("{}/accounts/{}", get_network_horizon_url(network), address);

    let response = reqwest::Client::new().get(&url).send().await?;

    if response.status() == reqwest::StatusCode::NOT_FOUND {
        bail!("Account does not exist on {}: {}", network, address);
    }
    if !response.status().is_success() {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        bail!("Horizon error {}: {}", status, body);
    }

    let json: serde_json::Value = response.json().await?;
    let balances = json["balances"].as_array().cloned().unwrap_or_default();

    let mut result = String::new();
    for balance in &balances {
        let amount = balance["balance"].as_str().unwrap_or("0");
        if balance["asset_type"].as_str() == Some("native") {
            result.push_str(&format!("XLM: {}\n", amount));
        } else {
            let code = balance["asset_code"].as_str().unwrap_or("???");
            result.push_str(&format!("{}: {}\n", code, amount));
        }
    }

    if result.is_empty() {
        result = format!("Account {} holds no balances", address);
    }

    Ok(result)
}