use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;
use serde::Deserialize;
use crate::cli::{Cli, ProtoblockOptions, RollblockOptions, ZeldNetworkArg};
use super::overlay::Overlay;
use super::protoblock::ProtoblockSettings;
use super::rollblock::RollblockSettings;
const CONFIG_FILE_NAME: &str = "zeldhash-parser.toml";
const LEGACY_CONFIG_FILES: [&str; 2] = ["zeldhash-parser.toml", "config/zeldhash-parser.toml"];
const PROJECT_QUALIFIER: &str = "org";
const PROJECT_ORGANIZATION: &str = "zeldhash";
const PROJECT_APPLICATION: &str = "zeldhash-parser";
#[derive(Debug)]
pub struct AppConfig {
pub config_file: Option<PathBuf>,
pub data_dir: PathBuf,
pub network: zeldhash_protocol::ZeldNetwork,
pub protoblock: ProtoblockSettings,
pub rollblock: RollblockSettings,
pub runtime: RuntimePaths,
}
impl AppConfig {
pub fn load(cli: Cli) -> Result<Self> {
let Cli {
config,
network: cli_network,
protoblock: cli_protoblock,
data_dir: cli_data_dir,
rollblock: cli_rollblock,
..
} = cli;
let (file_config, config_path) = load_file_config(config.as_ref())?;
let FileConfig {
data_dir: file_data_dir,
network: file_network,
protoblock: file_protoblock,
rollblock: file_rollblock,
} = file_config;
let data_dir = cli_data_dir
.or(file_data_dir)
.or_else(default_data_dir_path)
.ok_or_else(|| {
anyhow!(
"data_dir is required (set --data-dir, ZELDHASH_PARSER_DATA_DIR, or ensure the OS user data directory is available)"
)
})?;
let rollblock_settings = file_rollblock
.unwrap_or_default()
.overlay(cli_rollblock)
.build(&data_dir)?;
let protoblock_start_height = rollblock_settings.determine_start_height()?;
let protoblock_settings = file_protoblock
.unwrap_or_default()
.overlay(cli_protoblock)
.build(protoblock_start_height)?;
let runtime = RuntimePaths::prepare(&data_dir)?;
let network = cli_network
.or(file_network)
.map(Into::into)
.unwrap_or(zeldhash_protocol::ZeldNetwork::Mainnet);
Ok(Self {
config_file: config_path,
data_dir,
network,
protoblock: protoblock_settings,
rollblock: rollblock_settings,
runtime,
})
}
}
pub fn load_runtime_paths(cli: Cli) -> Result<RuntimePaths> {
let Cli {
config,
data_dir: cli_data_dir,
..
} = cli;
let (file_config, _config_path) = load_file_config(config.as_ref())?;
let FileConfig {
data_dir: file_data_dir,
..
} = file_config;
let data_dir = cli_data_dir
.or(file_data_dir)
.or_else(default_data_dir_path)
.ok_or_else(|| {
anyhow!(
"data_dir is required (set --data-dir, ZELDHASH_PARSER_DATA_DIR, or ensure the OS user data directory is available)"
)
})?;
RuntimePaths::prepare(&data_dir)
}
#[derive(Debug, Default, Deserialize)]
struct FileConfig {
#[serde(default)]
data_dir: Option<PathBuf>,
#[serde(default)]
network: Option<ZeldNetworkArg>,
#[serde(default)]
protoblock: Option<ProtoblockOptions>,
#[serde(default)]
rollblock: Option<RollblockOptions>,
}
fn load_file_config(path: Option<&PathBuf>) -> Result<(FileConfig, Option<PathBuf>)> {
if let Some(provided) = path {
let config = read_toml(provided)?;
return Ok((config, Some(provided.clone())));
}
if let Some(default_path) = default_config_file_path().filter(|path| path.exists()) {
let config = read_toml(&default_path)?;
return Ok((config, Some(default_path)));
}
for candidate in LEGACY_CONFIG_FILES {
let candidate_path = Path::new(candidate);
if candidate_path.exists() {
let config = read_toml(candidate_path)?;
return Ok((config, Some(candidate_path.to_path_buf())));
}
}
Ok((FileConfig::default(), None))
}
fn read_toml(path: &Path) -> Result<FileConfig> {
let contents = fs::read_to_string(path)
.with_context(|| format!("failed to read config file {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("failed to parse config file {}", path.display()))
}
impl From<ZeldNetworkArg> for zeldhash_protocol::ZeldNetwork {
fn from(value: ZeldNetworkArg) -> Self {
match value {
ZeldNetworkArg::Mainnet => zeldhash_protocol::ZeldNetwork::Mainnet,
ZeldNetworkArg::Testnet4 => zeldhash_protocol::ZeldNetwork::Testnet4,
ZeldNetworkArg::Signet => zeldhash_protocol::ZeldNetwork::Signet,
ZeldNetworkArg::Regtest => zeldhash_protocol::ZeldNetwork::Regtest,
}
}
}
fn default_project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from(PROJECT_QUALIFIER, PROJECT_ORGANIZATION, PROJECT_APPLICATION)
}
fn default_config_file_path() -> Option<PathBuf> {
default_project_dirs().map(|dirs| dirs.config_dir().join(CONFIG_FILE_NAME))
}
fn default_data_dir_path() -> Option<PathBuf> {
default_project_dirs().map(|dirs| dirs.data_dir().to_path_buf())
}
#[derive(Debug, Clone)]
pub struct RuntimePaths {
pid_file: PathBuf,
log_file: PathBuf,
}
impl RuntimePaths {
pub fn prepare(data_dir: &Path) -> Result<Self> {
let run_dir = data_dir.join("run");
let logs_dir = data_dir.join("logs");
fs::create_dir_all(&run_dir)
.with_context(|| format!("failed to create runtime dir {}", run_dir.display()))?;
fs::create_dir_all(&logs_dir)
.with_context(|| format!("failed to create logs dir {}", logs_dir.display()))?;
Ok(Self {
pid_file: run_dir.join("zeldhash-parser.pid"),
log_file: logs_dir.join("zeldhash-parser.log"),
})
}
pub fn pid_file(&self) -> &Path {
&self.pid_file
}
pub fn log_file(&self) -> &Path {
&self.log_file
}
}
#[cfg(test)]
mod tests {
use super::{load_file_config, AppConfig, RuntimePaths};
use crate::cli::{Cli, ProtoblockOptions, RollblockOptions, ZeldNetworkArg};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn runtime_paths_create_directories() {
let temp = TempDir::new().expect("temp dir");
let data_dir = temp.path();
let runtime = RuntimePaths::prepare(data_dir).expect("runtime paths");
let pid_dir = runtime.pid_file().parent().expect("pid dir");
let log_dir = runtime.log_file().parent().expect("log dir");
assert!(pid_dir.exists());
assert!(log_dir.exists());
}
#[test]
fn load_file_config_reads_provided_path() {
let temp = TempDir::new().expect("temp dir");
let config_path = temp.path().join("zeldhash-parser.toml");
fs::write(&config_path, r#"data_dir = "/tmp/zeldhash-parser-tests""#)
.expect("write config");
let (config, path) = load_file_config(Some(&config_path)).expect("load config");
assert_eq!(path.as_deref(), Some(config_path.as_path()));
assert_eq!(
config.data_dir.as_deref(),
Some(PathBuf::from("/tmp/zeldhash-parser-tests").as_path())
);
}
#[test]
fn read_toml_returns_error_on_invalid_file() {
let temp = TempDir::new().expect("temp dir");
let config_path = temp.path().join("invalid.toml");
fs::write(&config_path, "not = [toml").expect("write invalid config");
let err = load_file_config(Some(&config_path))
.expect_err("invalid config should fail")
.to_string();
assert!(
err.contains("parse"),
"error should mention parse failure, got: {err}"
);
}
#[test]
fn app_config_prefers_cli_over_file_values() {
let temp = TempDir::new().expect("temp dir");
let file_data_dir = temp.path().join("file_data_dir");
let cli_data_dir = temp.path().join("cli_data_dir");
fs::create_dir_all(&file_data_dir).expect("file data dir");
fs::create_dir_all(&cli_data_dir).expect("cli data dir");
let config_path = temp.path().join("zeldhash-parser.toml");
fs::write(
&config_path,
format!(r#"data_dir = "{}""#, file_data_dir.display()),
)
.expect("write config");
let cli = Cli {
config: Some(config_path.clone()),
network: Some(ZeldNetworkArg::Signet),
data_dir: Some(cli_data_dir.clone()),
protoblock: ProtoblockOptions::default(),
rollblock: RollblockOptions::default(),
daemon: false,
daemon_child: false,
command: None,
};
let app = AppConfig::load(cli).expect("load app config");
assert_eq!(app.config_file.as_deref(), Some(config_path.as_path()));
assert_eq!(app.data_dir, cli_data_dir);
assert!(matches!(
app.network,
zeldhash_protocol::ZeldNetwork::Signet
));
}
#[test]
fn app_config_uses_file_when_cli_missing_values() {
let temp = TempDir::new().expect("temp dir");
let file_data_dir = temp.path().join("file_only");
fs::create_dir_all(&file_data_dir).expect("file data dir");
let config_path = temp.path().join("zeldhash-parser.toml");
fs::write(
&config_path,
format!(
r#"
data_dir = "{}"
network = "Regtest"
"#,
file_data_dir.display()
),
)
.expect("write config");
let cli = Cli {
config: Some(config_path.clone()),
network: None,
data_dir: None,
protoblock: ProtoblockOptions::default(),
rollblock: RollblockOptions::default(),
daemon: false,
daemon_child: false,
command: None,
};
let app = AppConfig::load(cli).expect("load app config from file");
assert_eq!(app.data_dir, file_data_dir);
assert!(matches!(
app.network,
zeldhash_protocol::ZeldNetwork::Regtest
));
}
#[test]
fn zeldhash_network_from_mainnet() {
let network: zeldhash_protocol::ZeldNetwork = ZeldNetworkArg::Mainnet.into();
assert!(matches!(network, zeldhash_protocol::ZeldNetwork::Mainnet));
}
#[test]
fn zeldhash_network_from_testnet4() {
let network: zeldhash_protocol::ZeldNetwork = ZeldNetworkArg::Testnet4.into();
assert!(matches!(network, zeldhash_protocol::ZeldNetwork::Testnet4));
}
#[test]
fn zeldhash_network_from_signet() {
let network: zeldhash_protocol::ZeldNetwork = ZeldNetworkArg::Signet.into();
assert!(matches!(network, zeldhash_protocol::ZeldNetwork::Signet));
}
#[test]
fn zeldhash_network_from_regtest() {
let network: zeldhash_protocol::ZeldNetwork = ZeldNetworkArg::Regtest.into();
assert!(matches!(network, zeldhash_protocol::ZeldNetwork::Regtest));
}
#[test]
fn load_file_config_returns_default_when_no_config_exists() {
let (config, path) = load_file_config(None).expect("default config");
assert!(config.data_dir.is_none() || config.data_dir.is_some());
let _ = path; }
#[test]
fn runtime_paths_accessors_return_expected_files() {
let temp = TempDir::new().expect("temp dir");
let runtime = RuntimePaths::prepare(temp.path()).expect("runtime paths");
let pid_file = runtime.pid_file();
let log_file = runtime.log_file();
assert!(pid_file.ends_with("zeldhash-parser.pid"));
assert!(log_file.ends_with("zeldhash-parser.log"));
assert!(pid_file.starts_with(temp.path()));
assert!(log_file.starts_with(temp.path()));
}
}