use color_eyre::{eyre::bail, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[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>,
}
#[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<()> {
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());
}
}