use clap::{Arg, ArgAction, Command, Parser};
use serde::{Deserialize, Serialize};
use snafu::Snafu;
use crate::{config::Config, ServiceInfo};
const GENERATE_CONFIG_OPT_ID: &str = "generate";
const USE_CONFIG_OPT_ID: &str = "config";
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("Failed to load configuration: {source}"))]
ConfigLoad {
source: crate::Error,
},
#[snafu(display("Failed to parse command-line arguments: {message}"))]
ArgParse {
message: String,
},
#[snafu(display("Failed to generate configuration file: {source}"))]
ConfigGenerateFailed {
source: crate::Error,
},
}
#[derive(clap::Parser, Serialize, Deserialize)]
pub struct NoArguments {}
#[must_use]
pub struct Cli<C, A = NoArguments> {
pub args: A,
pub config: C,
}
impl<'a, C, A> Cli<C, A>
where
A: Parser + Serialize + Deserialize<'a>,
C: Deserialize<'a> + doku::Document,
{
pub fn try_new(
service_info: &ServiceInfo,
env_prefix: impl AsRef<str>,
) -> Result<Option<Self>, Error> {
Self::try_new_from(std::env::args_os(), service_info, env_prefix)
}
pub fn try_new_from<I, T>(
args: I,
service_info: &ServiceInfo,
env_prefix: impl AsRef<str>,
) -> Result<Option<Self>, Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let arg_command = A::command();
let cmd = Command::new(service_info.name)
.version(service_info.version)
.author(service_info.author)
.about(
arg_command
.get_about()
.map_or_else(|| service_info.description.to_owned(), ToString::to_string),
)
.args(arg_command.get_arguments())
.arg(
Arg::new("config")
.required_unless_present(GENERATE_CONFIG_OPT_ID)
.action(ArgAction::Set)
.long(USE_CONFIG_OPT_ID)
.short('c')
.help("Specifies the toml config file to run the service with"),
)
.arg(
Arg::new(GENERATE_CONFIG_OPT_ID)
.action(ArgAction::Set)
.long(GENERATE_CONFIG_OPT_ID)
.short('g')
.help("Generates a new default toml config file for the service"),
);
let mut arg_matches = cmd
.try_get_matches_from(args)
.map_err(|e| Error::ArgParse {
message: e.to_string(),
})?;
if let Some(config_file_path_str) = arg_matches.remove_one::<String>(GENERATE_CONFIG_OPT_ID)
{
crate::config::create_config_file::<C>(config_file_path_str)
.map_err(|source| Error::ConfigGenerateFailed { source })?;
return Ok(None);
}
let Some(config_path_str) = arg_matches.remove_one::<String>(USE_CONFIG_OPT_ID) else {
unreachable!("config is required unless generate is present")
};
let args = A::from_arg_matches_mut(&mut arg_matches).map_err(|e| Error::ArgParse {
message: e.to_string(),
})?;
let env_prefix = env_prefix.as_ref();
let config_result = Config::new(Some(config_path_str), Some(env_prefix));
let config = config_result
.map(|c| c.config)
.map_err(|source| Error::ConfigLoad { source })?;
Ok(Some(Self { args, config }))
}
pub fn new(service_info: &ServiceInfo, env_prefix: impl AsRef<str>) -> Self {
match Self::try_new(service_info, env_prefix) {
Ok(Some(cli)) => cli,
Ok(None) => {
std::process::exit(0);
}
Err(Error::ConfigGenerateFailed { source }) => {
eprintln!("Failed to generate config file: {source}");
std::process::exit(1);
}
Err(Error::ArgParse { message }) => {
eprintln!("{message}");
std::process::exit(1);
}
Err(Error::ConfigLoad { source }) => {
eprintln!("{source}");
std::process::exit(1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use doku::Document;
use serde::Deserialize;
use std::io::Write;
use tempfile::NamedTempFile;
#[derive(Deserialize, Document, Default)]
struct TestConfig {
#[doku(example = "test_value")]
pub setting: Option<String>,
}
#[derive(Parser, Serialize, Deserialize)]
struct TestArgs {
#[arg(long)]
verbose: bool,
}
fn test_service_info() -> crate::ServiceInfo {
crate::ServiceInfo {
name: "test-service",
name_in_metrics: "test_service".to_string(),
version: "1.0.0",
author: "Test Author",
description: "Test service description",
}
}
#[test]
fn test_try_new_from_with_config_returns_some() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, "setting = \"hello\"").unwrap();
let config_path = config_file.path().to_str().unwrap();
let args = vec!["test-program", "--config", config_path, "--verbose"];
let result = Cli::<TestConfig, TestArgs>::try_new_from(args, &test_service_info(), "TEST");
assert!(result.is_ok(), "try_new_from should succeed");
let cli_option = result.unwrap();
assert!(
cli_option.is_some(),
"should return Some(Cli) when --config is provided"
);
let cli = cli_option.unwrap();
assert_eq!(cli.config.setting, Some("hello".to_string()));
assert!(cli.args.verbose);
}
#[test]
fn test_try_new_from_generate_returns_none() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("generated.toml");
let output_path_str = output_path.to_str().unwrap();
let args = vec!["test-program", "--generate", output_path_str];
let result = Cli::<TestConfig, TestArgs>::try_new_from(args, &test_service_info(), "TEST");
assert!(result.is_ok(), "try_new_from should succeed for generate");
let cli_option = result.unwrap();
assert!(
cli_option.is_none(),
"should return None when --generate is provided"
);
assert!(output_path.exists(), "config file should be created");
let contents = std::fs::read_to_string(&output_path).unwrap();
assert!(
contents.contains("setting"),
"generated config should contain setting field"
);
}
#[test]
fn test_try_new_from_missing_config_fails() {
let args = vec!["test-program"];
let result = Cli::<TestConfig, TestArgs>::try_new_from(args, &test_service_info(), "TEST");
assert!(
result.is_err(),
"should fail when neither config nor generate is provided"
);
let err = result.err().unwrap();
assert!(
matches!(err, Error::ArgParse { .. }),
"expected ArgParse error"
);
}
#[test]
fn test_try_new_from_with_malformed_config_fails() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, "this is not valid toml {{{{").unwrap();
let config_path = config_file.path().to_str().unwrap();
let args = vec!["test-program", "--config", config_path];
let result = Cli::<TestConfig, TestArgs>::try_new_from(args, &test_service_info(), "TEST");
assert!(result.is_err(), "should fail with malformed config");
let err = result.err().unwrap();
assert!(
matches!(err, Error::ConfigLoad { .. }),
"expected ConfigLoad error"
);
}
}