use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::process;
pub fn cmd_config(args: &[String]) {
if args.is_empty() || matches!(args[0].as_str(), "--help" | "-h" | "help") {
print_usage();
return;
}
match args[0].as_str() {
"get" => cmd_get(&args[1..]),
"set" => cmd_set(&args[1..]),
"list" | "ls" => cmd_list(),
"reset" => cmd_reset(),
"path" => cmd_path(),
other => {
eprintln!("Unknown config subcommand: {other}");
print_usage();
process::exit(1);
}
}
}
fn print_usage() {
eprintln!("Usage: hopper config <subcommand>");
eprintln!();
eprintln!("Subcommands:");
eprintln!(" get <key> Print a single config value");
eprintln!(" set <key> <value> Store a config value");
eprintln!(" list Print every config key + value");
eprintln!(" reset Remove the config file");
eprintln!(" path Print the config file path");
eprintln!();
eprintln!("Known keys:");
eprintln!(" cluster_url `mainnet` / `devnet` / `localnet` or a full URL");
eprintln!(" payer Path to fee-payer keypair.json");
eprintln!(" default_program_id Program id fallback for commands that need one");
eprintln!(" default_keypair Upgrade-authority keypair fallback");
eprintln!(" default_manifest Manifest-json fallback for commands that need one");
}
fn cmd_get(args: &[String]) {
let Some(key) = args.first() else {
eprintln!("Usage: hopper config get <key>");
process::exit(1);
};
validate_key(key);
let config = load().unwrap_or_default();
match config.get(key) {
Some(v) => println!("{v}"),
None => {
eprintln!("(unset)");
process::exit(1);
}
}
}
fn cmd_set(args: &[String]) {
if args.len() < 2 {
eprintln!("Usage: hopper config set <key> <value>");
process::exit(1);
}
let key = &args[0];
let value = &args[1];
validate_key(key);
let mut config = load().unwrap_or_default();
config.insert(key.clone(), value.clone());
save(&config).unwrap_or_else(|e| {
eprintln!("failed to write config: {e}");
process::exit(1);
});
println!("{key} = {value}");
}
fn cmd_list() {
let config = load().unwrap_or_default();
if config.is_empty() {
println!("(empty; use `hopper config set <key> <value>`)");
return;
}
let width = config.keys().map(String::len).max().unwrap_or(0);
for (k, v) in &config {
println!("{k:<width$} {v}", width = width);
}
}
fn cmd_reset() {
let path = config_path();
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
eprintln!("failed to remove {}: {e}", path.display());
process::exit(1);
}
}
println!("config cleared");
}
fn cmd_path() {
println!("{}", config_path().display());
}
const KNOWN_KEYS: &[&str] = &[
"cluster_url",
"payer",
"default_program_id",
"default_keypair",
"default_manifest",
];
fn validate_key(key: &str) {
if !KNOWN_KEYS.contains(&key) {
eprintln!(
"warning: unknown config key `{key}`. known keys: {}",
KNOWN_KEYS.join(", ")
);
}
}
fn config_path() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".hopper").join("config.toml");
}
if let Ok(profile) = std::env::var("USERPROFILE") {
return PathBuf::from(profile).join(".hopper").join("config.toml");
}
PathBuf::from(".hopper").join("config.toml")
}
fn load() -> Option<BTreeMap<String, String>> {
let path = config_path();
let text = fs::read_to_string(&path).ok()?;
Some(parse_flat_toml(&text))
}
fn save(config: &BTreeMap<String, String>) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = String::new();
for (k, v) in config {
let escaped = v.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!("{k} = \"{escaped}\"\n"));
}
fs::write(&path, out)
}
fn parse_flat_toml(text: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim().to_string();
let value = value.trim();
let unquoted = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
value[1..value.len() - 1]
.replace("\\\"", "\"")
.replace("\\\\", "\\")
} else {
value.to_string()
};
out.insert(key, unquoted);
}
out
}
#[allow(dead_code)]
pub fn resolve_cluster(value: &str) -> String {
match value {
"mainnet" | "mainnet-beta" => "https://api.mainnet-beta.solana.com".into(),
"devnet" => "https://api.devnet.solana.com".into(),
"testnet" => "https://api.testnet.solana.com".into(),
"localnet" | "local" => "http://127.0.0.1:8899".into(),
other => other.into(),
}
}