Add cli parsing logic and logging to rotom_discord_bot

This commit is contained in:
2025-01-29 20:03:19 +00:00
parent 42108ea8e7
commit d2de03fde1
8 changed files with 616 additions and 13 deletions

View File

@@ -4,8 +4,15 @@ version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.27", features = ["derive", "env"] }
dotenvy = { version = "0.15.7", features = ["clap"] }
env_logger = "0.11.6"
log = "0.4.25"
rotom_core = { path = "../rotom_core" }
rotom_database = { path = "../rotom_database", default-features = false }
secrecy = "0.10.3"
thiserror = "2.0.11"
tokio = { version = "1.43.0", features = ["full"] }
[features]
default = ["mysql", "postgres", "sqlite"]

View File

@@ -0,0 +1,17 @@
use rotom_core::repository::RepositoryProvider;
use crate::cli::DiscordCredentials;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
}
pub async fn start<R>(_credentials: DiscordCredentials, _repository_provider: R) -> Result<(), AppError>
where
R: RepositoryProvider + Send + Sync + 'static,
R::BackendError: Send + Sync,
for<'a> R::Repository<'a>: Send + Sync,
{
todo!()
}

View File

@@ -0,0 +1,29 @@
use clap::Parser;
/// Subcommand of the CLI application.
#[derive(Debug, Clone, Parser)]
pub enum Command {
/// Start the main discord bot application.
#[command(
name = "start",
about,
long_about = None,
)]
Start(super::start::Start),
}
#[derive(Debug, thiserror::Error)]
pub enum CommandError {
#[error(transparent)]
StartError(#[from] super::start::StartError),
}
impl Command {
pub async fn execute(self) -> Result<(), CommandError> {
match self {
Command::Start(start) => start.execute().await?,
}
Ok(())
}
}

View File

@@ -0,0 +1,148 @@
use std::fmt::Debug;
use std::path::PathBuf;
use clap::Parser;
use clap::ValueEnum;
use command::Command;
use secrecy::SecretString;
pub mod command;
pub mod start;
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error(transparent)]
Dotenvy(#[from] dotenvy::Error),
}
/// Parses command line arguments.
pub fn parse() -> Result<Cli, CliError> {
// First phase: If a dotenv file is specified, load values from it.
// This allows values in the dotenv file to be used when parsing other arguments.
let dotenv = Dotenv::parse();
if let Some(dotenv_path) = &dotenv.path {
dotenvy::from_path_override(dotenv_path)?;
}
// Second phase: Parse CLI options using the `Cli` parser.
let mut cli = Cli::parse();
// The `Cli` parser contains the `Dotenv` parser and it may parse different results in different phases.
// It is replaced here to ensure the `Cli` instance reflects the original dotenv configuration.
cli.dotenv = dotenv;
return Ok(cli);
}
/// Main command line interface for the librarian application.
///
/// This struct combines the dotenv configuration with the subcommands
/// that the CLI application supports.
///
/// The help and version flags and subcommands are explicitly enabled
/// because `Dotenv` disables them and `Cli` requires them to be enabled.
#[derive(Debug, Parser)]
#[command(
name = "rotom",
about,
version,
long_about = None,
ignore_errors = false,
disable_help_flag = false,
disable_help_subcommand = false,
disable_version_flag = false,
)]
pub struct Cli {
/// Configuration for loading environment variables from a dotenv file.
#[command(flatten)]
pub dotenv: Dotenv,
/// The command to be executed as part of the CLI application.
#[command(subcommand)]
pub command: Command,
}
/// Configuration for loading environment variables from a dotenv file.
///
/// This struct is used to specify the path to a dotenv file from which
/// environment variables can be loaded. The default value is `.env`.
///
/// The help and version flags and subcommands are disabled because
/// they are handled by `Cli` which is parsed after `Dotenv`.
#[derive(Debug, Clone, Parser)]
#[command(
ignore_errors = true,
disable_help_flag = true,
disable_help_subcommand = true,
disable_version_flag = true
)]
pub struct Dotenv {
/// The path to the dotenv file.
#[arg(
name = "path",
short = None,
long = "dotenv",
env = "DOTENV",
)]
pub path: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum DatabaseDialect {
#[cfg(feature = "mysql")]
Mysql,
#[cfg(feature = "postgres")]
Postgres,
#[cfg(feature = "sqlite")]
Sqlite,
}
impl From<DatabaseDialect> for rotom_database::DatabaseDialect {
fn from(value: DatabaseDialect) -> Self {
use rotom_database::DatabaseDialect as Dialect;
match value {
#[cfg(feature = "mysql")]
DatabaseDialect::Mysql => Dialect::Mysql,
#[cfg(feature = "postgres")]
DatabaseDialect::Postgres => Dialect::Postgres,
#[cfg(feature = "sqlite")]
DatabaseDialect::Sqlite => Dialect::Sqlite,
}
}
}
/// Credentials required to establish a database connection.
#[derive(Clone, Debug, Parser)]
pub struct DatabaseCredentials {
/// The dialect of the database to connect to.
#[arg(
short = None,
long = "database-dialect",
env = "DATABASE_DIALECT",
)]
pub dialect: DatabaseDialect,
/// The URL of the database to connect to. This should include the
/// necessary credentials (username and password) and the database
/// name, following the format: `dialect://username:password@host:port/database`.
#[arg(
short = None,
long = "database-url",
env = "DATABASE_URL",
hide_env_values(true),
)]
pub url: SecretString,
}
/// Credentials required to authenticate a bot with Discord.
#[derive(Clone, Debug, Parser)]
pub struct DiscordCredentials {
/// The token used to authenticate the bot with Discord.
#[arg(
short = None,
long = "bot-token",
env = "BOT_TOKEN",
hide_env_values(true),
)]
pub bot_token: SecretString,
}

View File

@@ -0,0 +1,62 @@
use clap::Parser;
use secrecy::ExposeSecret;
use super::DatabaseCredentials;
use super::DiscordCredentials;
/// Start the main discord bot application.
#[derive(Debug, Clone, Parser)]
pub struct Start {
/// Credentials required to establish a database connection.
#[command(flatten)]
pub database: DatabaseCredentials,
/// Credentials required to authenticate a bot with Discord.
#[command(flatten)]
pub discord: DiscordCredentials,
}
#[derive(Debug, thiserror::Error)]
pub enum StartError {
#[error(transparent)]
RepositoryBackendError(#[from] rotom_database::BackendError),
#[error(transparent)]
AppError(#[from] crate::app::AppError),
}
impl Start {
pub async fn execute(self) -> Result<(), StartError> {
log::debug!("{:#?}", self);
let database_url = self.database.url.expose_secret();
match self.database.dialect {
#[cfg(feature = "mysql")]
crate::cli::DatabaseDialect::Mysql => {
log::info!("Running any pending database migrations.");
rotom_database::mysql::run_pending_migrations(database_url)?;
let repository_provider = rotom_database::mysql::repository_provider(database_url).await?;
log::info!("Starting discord application.");
crate::app::start(self.discord, repository_provider).await?;
},
#[cfg(feature = "postgres")]
crate::cli::DatabaseDialect::Postgres => {
log::info!("Running any pending database migrations.");
rotom_database::postgres::run_pending_migrations(database_url)?;
let repository_provider = rotom_database::postgres::repository_provider(database_url).await?;
log::info!("Starting discord application.");
crate::app::start(self.discord, repository_provider).await?;
},
#[cfg(feature = "sqlite")]
crate::cli::DatabaseDialect::Sqlite => {
log::info!("Running any pending database migrations.");
rotom_database::sqlite::run_pending_migrations(database_url)?;
let repository_provider = rotom_database::sqlite::repository_provider(database_url).await?;
log::info!("Starting discord application.");
crate::app::start(self.discord, repository_provider).await?;
},
}
Ok(())
}
}

View File

@@ -1 +1,20 @@
fn main() {}
mod app;
mod cli;
#[derive(Debug, thiserror::Error)]
enum MainError {
#[error(transparent)]
CliError(#[from] cli::CliError),
#[error(transparent)]
CommandError(#[from] cli::command::CommandError)
}
#[tokio::main]
async fn main() -> Result<(), MainError> {
let c = cli::parse()?;
env_logger::init();
c.command.execute().await?;
Ok(())
}