procyon 0.1.0

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};

// Must stay lowercase to match `Display`/`FromStr`, which is the spelling `project.toml` and the
// `/network` command use.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Network {
    Local,
    Testnet,
    Mainnet,
}

impl std::fmt::Display for Network {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Network::Local => write!(f, "local"),
            Network::Testnet => write!(f, "testnet"),
            Network::Mainnet => write!(f, "mainnet"),
        }
    }
}

impl std::str::FromStr for Network {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "local" => Ok(Network::Local),
            "testnet" => Ok(Network::Testnet),
            "mainnet" => Ok(Network::Mainnet),
            _ => Err(format!("Unknown network: {}", s)),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract {
    pub name: String,
    pub address: Option<String>,
    pub wasm_path: Option<String>,
}

// Accounts deliberately do not live here. They are owned by `AccountStore` in
// `.procyon/accounts.toml`, which is the only thing that writes them; a second list on Project
// was never written and so silently reported "no accounts" while accounts existed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    pub name: String,
    pub version: String,
    pub default_network: Network,
    pub contracts: Vec<Contract>,
}

impl Project {
    pub fn new(name: &str) -> Self {
        Self {
            name: name.to_string(),
            version: "0.1.0".to_string(),
            default_network: Network::Testnet,
            contracts: Vec::new(),
        }
    }

    pub async fn save(&self, path: &Path) -> Result<()> {
        // Unlike AccountStore::save this used to assume the parent existed, which only held for
        // the one caller that had just created it.
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let toml = toml::to_string_pretty(self)?;
        tokio::fs::write(path, toml).await?;
        Ok(())
    }

    pub async fn load(path: &Path) -> Result<Self> {
        let toml = tokio::fs::read_to_string(path).await?;
        let project: Self = toml::from_str(&toml)?;
        Ok(project)
    }

    pub async fn find_project_dir(start: &Path) -> Result<PathBuf> {
        let mut current = start.to_path_buf();
        loop {
            let project_file = current.join(".procyon").join("project.toml");
            if tokio::fs::try_exists(&project_file).await.unwrap_or(false) {
                return Ok(current);
            }
            if !current.pop() {
                bail!("No .procyon/project.toml found in parent directories");
            }
        }
    }
}

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

    #[test]
    fn deserializes_lowercase_network_from_project_file() {
        let toml_str = r#"
            name = "demo"
            version = "0.1.0"
            default_network = "testnet"
            contracts = []
            accounts = []
        "#;
        let project: Project = toml::from_str(toml_str).unwrap();
        assert_eq!(project.default_network.to_string(), "testnet");
    }

    #[test]
    fn a_project_file_written_before_accounts_moved_out_still_loads() {
        let legacy = r#"
            name = "demo"
            version = "0.1.0"
            default_network = "testnet"
            contracts = []

            [[accounts]]
            name = "alice"
            address = "GALICE"
            network = "testnet"
        "#;
        let project: Project = toml::from_str(legacy).expect("legacy file must still parse");
        assert_eq!(project.name, "demo");
    }

    #[test]
    fn accounts_are_not_serialized_into_the_project_file() {
        let written = toml::to_string(&Project::new("demo")).unwrap();
        assert!(
            !written.contains("accounts"),
            "accounts belong to AccountStore, not project.toml: {}",
            written
        );
    }

    #[test]
    fn serde_agrees_with_display() {
        for network in [Network::Local, Network::Testnet, Network::Mainnet] {
            let project = Project {
                default_network: network.clone(),
                ..Project::new("demo")
            };
            let written = toml::to_string(&project).unwrap();
            assert!(
                written.contains(&format!("default_network = \"{}\"", network)),
                "Display spelling of {:?} is not what serde wrote: {}",
                network,
                written
            );
        }
    }

    #[test]
    fn from_str_matches_serde() {
        for spelling in ["local", "testnet", "mainnet"] {
            let toml_str = format!(
                "name = \"d\"\nversion = \"0.1.0\"\ndefault_network = \"{}\"\ncontracts = []\naccounts = []\n",
                spelling
            );
            let project: Project = toml::from_str(&toml_str).unwrap();
            assert_eq!(
                Network::from_str(spelling).unwrap().to_string(),
                project.default_network.to_string()
            );
        }
    }

    #[test]
    fn rejects_unknown_network() {
        let toml_str = "name = \"d\"\nversion = \"0.1.0\"\ndefault_network = \"futurenet\"\ncontracts = []\naccounts = []\n";
        assert!(toml::from_str::<Project>(toml_str).is_err());
    }
}