use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use tokio::task;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalRpcConfig {
pub svm: String,
pub network: String,
pub port: u16,
pub faucet_port: Option<u16>,
pub ledger_path: String,
pub reset: bool,
pub background: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalRpcInfo {
pub svm: String,
pub port: u16,
pub faucet_port: Option<u16>,
pub ledger_path: String,
pub pid: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalRpcStatus {
pub running: bool,
pub pid: Option<u32>,
pub port: Option<u16>,
pub network: Option<String>,
pub uptime: Option<String>,
}
pub async fn start_local_rpc(config: LocalRpcConfig) -> Result<LocalRpcInfo> {
if config.svm != "solana" {
anyhow::bail!(
"Currently only Solana local RPC is supported. {} support coming soon!",
config.svm
);
}
fs::create_dir_all(&config.ledger_path).context("Failed to create ledger directory")?;
let mut cmd = Command::new("solana-test-validator");
if config.reset {
cmd.arg("--reset");
}
cmd.arg("--ledger").arg(&config.ledger_path);
cmd.arg("--rpc-port").arg(config.port.to_string());
if let Some(faucet_port) = config.faucet_port {
cmd.arg("--faucet-port").arg(faucet_port.to_string());
}
if config.background {
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to start solana-test-validator")?;
} else {
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to start solana-test-validator")?;
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
task::spawn_blocking(move || {
for line in reader.lines() {
if let Ok(line) = line {
println!("{}", line);
}
}
});
}
child
.wait()
.context("Failed to wait for solana-test-validator")?;
}
Ok(LocalRpcInfo {
svm: config.svm,
port: config.port,
faucet_port: config.faucet_port,
ledger_path: config.ledger_path,
pid: None, })
}
pub async fn stop_local_rpc() -> Result<()> {
let check_output = Command::new("pgrep")
.arg("-f")
.arg("solana-test-validator")
.output()
.context("Failed to execute pgrep")?;
if !check_output.status.success() || check_output.stdout.is_empty() {
return Ok(());
}
let output = Command::new("pkill")
.arg("-f")
.arg("solana-test-validator")
.output()
.context("Failed to execute pkill")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("Failed to stop solana-test-validator process")
}
}
pub async fn check_local_rpc_status() -> Result<LocalRpcStatus> {
let output = Command::new("pgrep")
.arg("-f")
.arg("solana-test-validator")
.output()
.context("Failed to execute pgrep")?;
if output.status.success() && !output.stdout.is_empty() {
let pid_str = String::from_utf8_lossy(&output.stdout);
let pid = pid_str.trim().parse::<u32>().ok();
let health_check = Command::new("curl")
.arg("-s")
.arg("-X")
.arg("POST")
.arg("-H")
.arg("Content-Type: application/json")
.arg("-d")
.arg(r#"{"jsonrpc":"2.0","id":1,"method":"getHealth"}"#)
.arg("http://127.0.0.1:8899")
.output();
let running = health_check
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("ok"))
.unwrap_or(false);
Ok(LocalRpcStatus {
running,
pid,
port: if running { Some(8899) } else { None },
network: if running {
Some("localnet".to_string())
} else {
None
},
uptime: None, })
} else {
Ok(LocalRpcStatus {
running: false,
pid: None,
port: None,
network: None,
uptime: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_local_rpc_config() {
let config = LocalRpcConfig {
svm: "solana".to_string(),
network: "localnet".to_string(),
port: 8899,
faucet_port: Some(9900),
ledger_path: "/tmp/test-ledger".to_string(),
reset: true,
background: false,
};
assert_eq!(config.svm, "solana");
assert_eq!(config.port, 8899);
assert_eq!(config.faucet_port, Some(9900));
}
}