feat: CLI Configuration and maintainability

This commit is contained in:
quexeky
2026-01-20 17:44:33 +11:00
parent 85b2e65b5f
commit a3cc54f8a6
17 changed files with 672 additions and 136 deletions
+5 -7
View File
@@ -2,6 +2,8 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::config::config::ConfigOption;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
@@ -16,16 +18,12 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Configures a new Drop server
Configure {
/// Endpoint of the Drop server
url: String,
/// API token for non-interactive configuration.
#[arg(short, long)]
token: Option<String>,
},
#[command(subcommand)]
Configure(ConfigOption),
/// Uploads new game version to depot
Upload(UploadInfo),
}
#[derive(Args)]
pub struct UploadInfo {
/// Identifies the specific upload style that will be used for the set depot
+2 -5
View File
@@ -1,10 +1,7 @@
use std::path::Path;
use crate::{
cli::UploadInfo,
commands::upload::{s3::S3, uploadable::Uploadable},
config::Config,
manifest::generate_manifest,
cli::UploadInfo, commands::upload::{s3::S3, uploadable::Uploadable}, config::config::Config, manifest::generate_manifest
};
use log::info;
@@ -16,7 +13,7 @@ pub async fn upload(info: &UploadInfo, config: Config) -> anyhow::Result<()> {
let manifest = generate_manifest(&Path::new(path)).await?;
let mut uploader: Box<dyn Uploadable> = match info.upload_style {
crate::cli::UploadStyle::S3 => Box::new(S3::new(
config
&config
.get_active_s3()
.ok_or(anyhow::Error::msg("Could not get active S3 value"))?,
)?),
+1 -2
View File
@@ -1,5 +1,4 @@
pub mod interface;
pub mod s3;
pub mod speedtest;
pub mod uploadable;
pub mod void;
pub mod speedtest;
+2 -3
View File
@@ -2,12 +2,11 @@ use crate::{
commands::upload::{
speedtest::{SPEEDTEST_PATH, Speedtest},
uploadable::Uploadable,
},
config::S3Config,
}, config::s3::S3Config,
};
use async_trait::async_trait;
use droplet_rs::manifest::{ChunkData, Manifest};
use s3::{Bucket, creds::Credentials};
use s3::Bucket;
use serde_json::json;
use std::{ops::Deref, path::PathBuf};
-40
View File
@@ -1,40 +0,0 @@
use async_trait::async_trait;
use droplet_rs::manifest::{ChunkData, Manifest};
use log::warn;
use crate::commands::upload::uploadable::Uploadable;
pub struct VoidUploadable;
#[async_trait]
impl Uploadable for VoidUploadable {
async fn upload_chunk(
&mut self,
_id: &String,
_version: &String,
_chunk_id: &String,
_chunk: &ChunkData,
) -> anyhow::Result<()> {
warn!("Uploading chunk to VoidUploader");
Ok(())
}
async fn upload_speedtest(&mut self) -> anyhow::Result<()> {
warn!("Uploading speedtest to VoidUploader");
Ok(())
}
async fn upload_manifest(
&mut self,
_manifest: Manifest,
_game_id: &String,
_version_id: &String,
) -> anyhow::Result<()> {
warn!("Uploading manifest to VoidUploader");
Ok(())
}
}
impl VoidUploadable {
pub fn new() -> Self {
Self
}
}
-67
View File
@@ -1,67 +0,0 @@
use std::str::FromStr;
use s3::{Bucket, Region, creds::Credentials};
pub struct Config {
items: Vec<ConfigItem>,
active_s3: Option<String>,
}
pub struct ConfigItem {
name: String,
config_option: ConfigOption,
}
enum ConfigOption {
S3(S3Config),
}
pub struct S3Config {
secret_key: String,
key_id: String,
region: String,
bucket_name: String,
endpoint: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self {
items: Vec::new(),
active_s3: None,
}
}
pub fn get_active_s3(&self) -> Option<&S3Config> {
if let Some(active_s3) = &self.active_s3 {
self.items
.iter()
.filter_map(|item| {
if item.name == *active_s3 {
match &item.config_option {
ConfigOption::S3(s3_config) => Some(s3_config),
}
} else {
None
}
})
.next()
} else {
None
}
}
}
impl S3Config {
pub fn generate_bucket(&self) -> anyhow::Result<s3::Bucket> {
let credentials =
Credentials::new(Some(&self.key_id), Some(&self.secret_key), None, None, None)?;
let region = if let Some(endpoint) = &self.endpoint {
Region::Custom {
region: self.region.clone(),
endpoint: endpoint.clone(),
}
} else {
Region::from_str(&self.region)?
};
let bucket = Bucket::new(&self.bucket_name, region, credentials)?;
Ok(*bucket)
}
}
+90
View File
@@ -0,0 +1,90 @@
use std::{fs, str::FromStr};
use clap::Subcommand;
use dialoguer::{Input, theme::ColorfulTheme};
use log::warn;
use serde::{Deserialize, Serialize};
use crate::config::{
s3::{S3Config, S3ConfigCli},
server::ServerConfig,
};
const CONFIG_DIR: &str = "downpour/config.json";
#[derive(Serialize, Deserialize)]
pub struct Config {
items: Vec<ConfigItem>,
active_s3: Option<String>,
}
impl Config {
pub fn save(&self) -> anyhow::Result<()> {
let json = serde_json::to_string(self)?;
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
fs::write(save_path, json)?;
Ok(())
}
pub fn read() -> Self {
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
if fs::exists(&save_path).expect(&format!("Could not read save path {:#?}", &save_path)) {
serde_json::from_str(&fs::read_to_string(save_path).unwrap()).unwrap()
} else {
Config::new()
}
}
pub fn add_item(&mut self, item: ConfigItem) {
if matches!(item.config_option, ConfigOption::S3(..)) {
self.active_s3 = Some(item.name.clone())
}
self.items.push(item);
}
}
#[derive(Serialize, Deserialize)]
pub struct ConfigItem {
name: String,
config_option: ConfigOption,
}
#[derive(Subcommand, Serialize, Deserialize)]
pub enum ConfigOption {
Server(ServerConfig),
S3(S3ConfigCli),
}
impl Config {
pub fn new() -> Self {
Self {
items: Vec::new(),
active_s3: None,
}
}
pub fn get_active_s3(&self) -> Option<S3Config> {
if let Some(active_s3) = &self.active_s3 {
self.items
.iter()
.filter_map(|item| {
if item.name == *active_s3 {
match &item.config_option {
ConfigOption::S3(s3_config) => Some(s3_config),
_ => {
warn!("Name {} is not of type 'S3'", item.name);
None
}
}
} else {
None
}
})
.next()
.cloned()
.map(|c| c.into())
} else {
None
}
}
}
+7
View File
@@ -0,0 +1,7 @@
use crate::config::config::Config;
/// Trait which represents data which may be stored in `config_dir/downpour/config.json`
pub trait Configurable {
fn configure(&self, config: &mut Config);
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config;
pub mod configurable;
pub mod s3;
pub mod server;
+72
View File
@@ -0,0 +1,72 @@
use std::str::FromStr;
use clap::Args;
use dialoguer::{Input, theme::ColorfulTheme};
use s3::{Bucket, Region, creds::Credentials};
use serde::{Deserialize, Serialize};
use crate::{config::configurable::Configurable, interactive_optional_variable, interactive_variable};
#[derive(Serialize, Deserialize, Args, Clone)]
pub struct S3ConfigCli {
secret_key: Option<String>,
key_id: Option<String>,
region: Option<String>,
bucket_name: Option<String>,
endpoint: Option<String>,
}
impl From<S3ConfigCli> for S3Config {
fn from(value: S3ConfigCli) -> Self {
interactive_variable!(value, secret_key, "S3 Secret Key");
interactive_variable!(value, key_id, "S3 Key ID");
interactive_variable!(value, region, "S3 Region");
interactive_variable!(value, bucket_name, "S3 Bucket Name");
interactive_optional_variable!(value, endpoint, "S3 Endpoint (leave blank for none");
Self {
secret_key,
key_id,
region,
bucket_name,
endpoint,
}
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct S3Config {
secret_key: String,
key_id: String,
region: String,
bucket_name: String,
endpoint: Option<String>,
}
impl S3Config {
pub fn generate_bucket(&self) -> anyhow::Result<s3::Bucket> {
let credentials =
Credentials::new(Some(&self.key_id), Some(&self.secret_key), None, None, None)?;
let region = if let Some(endpoint) = &self.endpoint {
Region::Custom {
region: self.region.clone(),
endpoint: endpoint.clone(),
}
} else {
Region::from_str(&self.region)?
};
let bucket = Bucket::new(&self.bucket_name, region, credentials)?;
Ok(*bucket)
}
}
impl Configurable for S3Config {
fn configure(&self, config: &mut super::config::Config) {
println!("Configuring S3Config with {:?}", self);
}
}
+18
View File
@@ -0,0 +1,18 @@
use clap::Args;
use serde::{Deserialize, Serialize};
use crate::config::configurable::Configurable;
#[derive(Serialize, Deserialize, Args, Clone)]
pub struct ServerConfig {
/// Endpoint of the Drop server
url: String,
#[arg(short, long)]
token: String,
}
impl Configurable for ServerConfig {
fn configure(&self, config: &mut super::config::Config) {
println!("Configured ServerConfig")
}
}
+47
View File
@@ -0,0 +1,47 @@
use std::str::FromStr;
use dialoguer::{Input, theme::ColorfulTheme};
#[macro_export]
macro_rules! interactive_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
$var
} else {
crate::interactive::query_variable($prompt).unwrap()
};
};
}
#[macro_export]
macro_rules! interactive_optional_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
Some($var)
} else {
crate::interactive::query_optional_variable($prompt).unwrap()
};
};
}
pub fn query_variable<T: Clone + FromStr + ToString>(prompt: impl ToString) -> dialoguer::Result<T>
where
<T as FromStr>::Err: ToString,
{
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.interact_text()
}
pub fn query_optional_variable<T: Clone + FromStr + ToString>(
prompt: impl ToString,
) -> dialoguer::Result<Option<T>>
where
<T as FromStr>::Err: ToString,
{
let input: T = Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.allow_empty(true)
.interact_text()?;
if input.to_string().len() == 0 {
return Ok(None);
}
Ok(Some(input))
}
+22 -7
View File
@@ -1,6 +1,11 @@
use crate::{
cli::{Cli, Commands},
commands::{configure::interactive_configure, upload}, config::Config,
commands::{configure::interactive_configure, upload},
config::{
config::{Config, ConfigOption},
configurable::Configurable,
s3::S3Config,
},
};
use clap::Parser;
use fern::colors::{Color, ColoredLevelConfig};
@@ -13,17 +18,18 @@ mod commands;
mod config;
mod manifest;
#[macro_use]
pub mod interactive;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
configure_logging()?;
let cli = Cli::parse();
let config = Config::new();
let mut config = Config::read();
match &cli.command {
Commands::Configure { url, token } => {
if let Some(token) = token {
} else {
interactive_configure(url.to_string()).await?;
}
Commands::Configure(options) => {
configure_command(&mut config, options).await?;
}
Commands::Upload(info) => {
upload::interface::upload(info, config).await?;
@@ -33,6 +39,15 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
async fn configure_command(config: &mut Config, options: &ConfigOption) -> anyhow::Result<()> {
let configuration: Box<dyn Configurable> = match options {
ConfigOption::Server(options) => Box::new(options.clone()),
ConfigOption::S3(options) => Box::new(S3Config::from(options.clone())),
};
configuration.configure(config);
Ok(())
}
pub fn configure_logging() -> anyhow::Result<()> {
let log_level = env::var("RUST_LOG")
.unwrap_or_else(|_| "info".to_string())