use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HopperToml {
pub project: ProjectSection,
#[serde(default)]
pub toolchain: ToolchainSection,
#[serde(default)]
pub testing: TestingSection,
#[serde(default)]
pub backend: BackendSection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSection {
pub name: String,
#[serde(default = "default_template")]
pub template: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolchainSection {
#[serde(default = "default_toolchain_kind")]
pub kind: String,
}
impl Default for ToolchainSection {
fn default() -> Self {
Self {
kind: default_toolchain_kind(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestingSection {
#[serde(default = "default_testing_framework")]
pub framework: String,
}
impl Default for TestingSection {
fn default() -> Self {
Self {
framework: default_testing_framework(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendSection {
#[serde(default = "default_backend")]
pub default: String,
}
impl Default for BackendSection {
fn default() -> Self {
Self {
default: default_backend(),
}
}
}
fn default_template() -> String {
"minimal".to_string()
}
fn default_toolchain_kind() -> String {
"solana".to_string()
}
fn default_testing_framework() -> String {
"mollusk".to_string()
}
fn default_backend() -> String {
"hopper-native".to_string()
}
impl HopperToml {
pub fn new(project_name: impl Into<String>, template: impl Into<String>) -> Self {
Self {
project: ProjectSection {
name: project_name.into(),
template: template.into(),
},
toolchain: ToolchainSection::default(),
testing: TestingSection::default(),
backend: BackendSection::default(),
}
}
#[allow(dead_code)]
pub fn load(project_dir: &Path) -> Result<Option<Self>, String> {
let path = project_dir.join("Hopper.toml");
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path)
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
let parsed: HopperToml =
toml::from_str(&raw).map_err(|err| format!("malformed {}: {err}", path.display()))?;
Ok(Some(parsed))
}
pub fn save(&self, project_dir: &Path) -> Result<(), String> {
if !project_dir.exists() {
fs::create_dir_all(project_dir).map_err(|err| {
format!(
"failed to create project directory {}: {err}",
project_dir.display()
)
})?;
}
let path = project_dir.join("Hopper.toml");
let serialised = toml::to_string_pretty(self)
.map_err(|err| format!("failed to serialise Hopper.toml: {err}"))?;
let body = format!(
"# Hopper project configuration. Generated by `hopper init`.\n# Edit by hand or via `hopper config set <key> <value>`.\n# Schema: https://hopperzero.dev/docs/hopper-toml\n\n{serialised}"
);
fs::write(&path, body).map_err(|err| format!("failed to write {}: {err}", path.display()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalConfig {
#[serde(default)]
pub defaults: GlobalDefaults,
#[serde(default)]
pub ui: GlobalUi,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalDefaults {
pub toolchain: String,
pub testing: String,
pub backend: String,
pub template: String,
pub git: String,
}
impl Default for GlobalDefaults {
fn default() -> Self {
Self {
toolchain: default_toolchain_kind(),
testing: default_testing_framework(),
backend: default_backend(),
template: default_template(),
git: "commit".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalUi {
pub color: bool,
pub animation: bool,
}
impl Default for GlobalUi {
fn default() -> Self {
Self {
color: true,
animation: true,
}
}
}
impl GlobalConfig {
pub fn path() -> PathBuf {
if let Some(home) = dirs::home_dir() {
return home.join(".hopper").join("wizard.toml");
}
if let Some(dir) = dirs::config_dir() {
return dir.join("hopper").join("wizard.toml");
}
PathBuf::from(".hopper-wizard.toml")
}
pub fn load() -> Self {
let path = Self::path();
match fs::read_to_string(&path) {
Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create global config dir {}: {err}",
parent.display()
)
})?;
}
let serialised = toml::to_string_pretty(self)
.map_err(|err| format!("failed to serialise global config: {err}"))?;
let body = format!(
"# Hopper global config. Edit via `hopper config set <key> <value>`.\n# Stored at {}\n\n{serialised}",
path.display()
);
fs::write(&path, body).map_err(|err| format!("failed to write {}: {err}", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hopper_toml_round_trips() {
let cfg = HopperToml::new("my-program", "nft-mint");
let s = toml::to_string(&cfg).unwrap();
let parsed: HopperToml = toml::from_str(&s).unwrap();
assert_eq!(parsed.project.name, "my-program");
assert_eq!(parsed.project.template, "nft-mint");
assert_eq!(parsed.toolchain.kind, "solana");
assert_eq!(parsed.testing.framework, "mollusk");
assert_eq!(parsed.backend.default, "hopper-native");
}
#[test]
fn missing_sections_default() {
let s = r#"
[project]
name = "skinny"
"#;
let parsed: HopperToml = toml::from_str(s).unwrap();
assert_eq!(parsed.project.template, "minimal");
assert_eq!(parsed.toolchain.kind, "solana");
}
#[test]
fn global_config_defaults_are_sane() {
let cfg = GlobalConfig::default();
assert_eq!(cfg.defaults.git, "commit");
assert!(cfg.ui.color);
}
}