use clap::builder::{PossibleValue, TypedValueParser};
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
use tackler_api::txn_ts;
use tackler_core::config;
use tackler_core::config::overlaps::{
AuditOverlap, FileInputOverlap, FsInputOverlap, GitInputOverlap, InputOverlap, OverlapConfig,
PriceOverlap, ReportOverlap, StorageOverlap, StrictOverlap, TargetOverlap,
};
use tackler_core::config::{PriceLookupType, StorageType};
use tackler_core::kernel::settings::GitInputSelector;
use tackler_core::config::FormatType;
pub(crate) const PRICE_BEFORE: &str = "price.before";
#[derive(Parser)]
#[command(author, version=env!("VERSION"), about, long_about = None,
after_help = "See tackler documentation for more information and examples:\nhttps://tackler.fi/docs/",
)]
#[command(args_conflicts_with_subcommands = true)]
pub(crate) struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[clap(flatten)]
pub args: DefaultModeArgs,
}
impl Cli {
pub(crate) fn cmd(&self) -> Commands {
let cmd = if let Some(cmd) = &self.command {
cmd.clone()
} else {
Commands::Report(self.args.clone())
};
if let Commands::Report(report_cmd) = &cmd
&& report_cmd.conf_path.is_none()
{
let mut cmd = Cli::command();
let msg = format!(
"config file is not provided, use: \n\n{} --config <path/to/config-file>",
cmd.get_name()
);
cmd.error(ErrorKind::MissingRequiredArgument, msg.as_str())
.exit();
}
cmd
}
}
#[derive(Debug, Clone, clap::Args)]
#[group(multiple = false)]
pub(crate) struct GitInputGroup {
#[arg(
long = "input.git.ref",
value_name = "reference",
group = "git_input_group"
)]
pub(crate) input_git_ref: Option<String>,
#[arg(
long = "input.git.commit",
value_name = "commit-id",
group = "git_input_group"
)]
pub(crate) input_git_commit: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct StorageTypeParser;
impl TypedValueParser for StorageTypeParser {
type Value = StorageType;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let val = value
.to_str()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidUtf8))?;
if let Ok(v) = StorageType::try_from(val) {
Ok(v)
} else {
let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
if let Some(arg) = arg {
err.insert(
ContextKind::InvalidArg,
ContextValue::String(arg.to_string()),
);
}
err.insert(
ContextKind::InvalidValue,
ContextValue::String(val.to_string()),
);
Err(err)
}
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
Some(Box::new(
[StorageType::FS, StorageType::GIT]
.into_iter()
.map(clap::builder::PossibleValue::new),
))
}
}
#[derive(Debug, Clone, Copy)]
struct PriceLookupParser;
impl TypedValueParser for PriceLookupParser {
type Value = PriceLookupType;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
let val = value
.to_str()
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidUtf8))?;
if let Ok(v) = PriceLookupType::try_from(val) {
Ok(v)
} else {
let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
if let Some(arg) = arg {
err.insert(
ContextKind::InvalidArg,
ContextValue::String(arg.to_string()),
);
}
err.insert(
ContextKind::InvalidValue,
ContextValue::String(val.to_string()),
);
Err(err)
}
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
Some(Box::new(
[
config::NONE_VALUE,
PriceLookupType::TXN_TIME,
PriceLookupType::LAST_PRICE,
PriceLookupType::GIVEN_TIME,
]
.into_iter()
.map(clap::builder::PossibleValue::new),
))
}
}
#[derive(Clone, Subcommand)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Commands {
New { name: String },
Init {},
Report(DefaultModeArgs),
}
#[rustfmt::skip]
#[derive(Debug, Clone, clap::Args)]
pub(crate) struct DefaultModeArgs {
#[arg(long = "config", value_name = "filename")]
pub(crate) conf_path: Option<PathBuf>,
#[arg(
long = "strict.mode",
value_name = "true|false",
verbatim_doc_comment
)]
pub(crate) strict_mode: Option<bool>,
#[arg(
long = "audit.mode",
value_name = "true|false",
verbatim_doc_comment
)]
pub(crate) audit_mode: Option<bool>,
#[arg(
long = "output.dir",
value_name = "directory",
requires("output_name")
)]
pub(crate) output_directory: Option<PathBuf>,
#[arg(
long = "output.prefix",
value_name = "filename",
requires("output_directory")
)]
pub(crate) output_name: Option<String>,
#[arg(long="input.file",
value_name = "filename",
conflicts_with_all([
"input_storage",
"input_fs_path",
"input_fs_dir",
"input_fs_ext",
"input_git_repo",
"input_git_ref",
"input_git_commit",
"input_git_dir",
"input_git_ext",
])
)]
pub(crate) input_filename: Option<PathBuf>,
#[arg(long="input.storage",
value_name = "fs|git",
conflicts_with_all([
"input_filename",
]),
value_parser = StorageTypeParser
)]
pub(crate) input_storage: Option<StorageType>,
#[arg(long="input.fs.path",
value_name = "path",
requires("input_fs_dir"),
requires("input_fs_ext"),
conflicts_with_all([
"input_git_repo",
"input_git_ref",
"input_git_commit",
"input_git_dir"
])
)]
pub(crate) input_fs_path: Option<String>,
#[arg(long="input.fs.dir",
value_name = "txns-directory",
conflicts_with_all([
"input_git_repo",
"input_git_ref",
"input_git_commit",
"input_git_dir"
]),
verbatim_doc_comment
)]
pub(crate) input_fs_dir: Option<String>,
#[arg(
long = "input.fs.ext",
value_name = "extension",
requires("input_fs_dir"),
conflicts_with_all([
"input_git_repo",
"input_git_ref",
"input_git_commit",
"input_git_dir"
])
)]
pub(crate) input_fs_ext: Option<String>,
#[arg(
long = "input.git.repository",
alias = "input.git.repo",
value_name = "path",
requires("input_git_dir"),
requires("input_git_ext"),
requires("git_input_group")
)]
pub(crate) input_git_repo: Option<String>,
#[clap(flatten)]
git_input_selector: GitInputGroup,
#[arg(long = "input.git.dir", value_name = "txns-directory")]
pub(crate) input_git_dir: Option<String>,
#[arg(
long = "input.git.ext",
value_name = "extension",
requires("input_git_repo"),
requires("input_git_dir")
)]
pub(crate) input_git_ext: Option<String>,
#[arg(
long = "accounts",
value_name = "regex",
num_args(1..),
verbatim_doc_comment
)]
pub(crate) accounts: Option<Vec<String>>,
#[arg(long = "reports", value_name = "type", num_args(1..),
value_parser([
PossibleValue::new(""),
PossibleValue::new("register"),
PossibleValue::new("balance"),
PossibleValue::new("balance-group"),
])
)]
pub(crate) reports: Option<Vec<String>>,
#[arg(long = "group-by", value_name = "group-by", num_args(1),
value_parser([
PossibleValue::new(txn_ts::GroupBy::YEAR),
PossibleValue::new(txn_ts::GroupBy::MONTH),
PossibleValue::new(txn_ts::GroupBy::DATE),
PossibleValue::new(txn_ts::GroupBy::ISO_WEEK),
PossibleValue::new(txn_ts::GroupBy::ISO_WEEK_DATE),
])
)]
pub(crate) group_by: Option<String>,
#[arg(long = "formats", value_name = "type", num_args(1..),
value_parser([
PossibleValue::new(FormatType::TXT),
PossibleValue::new(FormatType::JSON),
]),
requires("output_directory"),
requires("output_name"),
)]
pub(crate) formats: Option<Vec<String>>,
#[arg(long = "exports", value_name = "type", num_args(1..),
value_parser([
PossibleValue::new(""),
PossibleValue::new("identity"),
PossibleValue::new("equity"),
PossibleValue::new("accounts"),
]),
requires("output_directory"),
requires("output_name"),
)]
pub(crate) exports: Option<Vec<String>>,
#[arg(long = "invert")]
pub(crate) invert: bool,
#[arg(
long = "pricedb",
value_name = "pricedb-file",
)]
pub(crate) pricedb_filename: Option<PathBuf>,
#[arg(
long = "report.commodity",
value_name = "commodity",
verbatim_doc_comment
)]
pub(crate) report_commodity: Option<String>,
#[arg(
long = "price.lookup-type",
value_name = "lookup-type",
value_parser = PriceLookupParser
)]
pub(crate) price_lookup_type: Option<PriceLookupType>,
#[arg(long = PRICE_BEFORE, value_name = "price-before")]
pub(crate) price_before_ts: Option<String>,
#[arg(long = "api-filter-def", value_name = "txn_filter")]
pub(crate) api_filter_def: Option<String>,
}
impl DefaultModeArgs {
fn verify_storage_mode(&self, allowed_type: StorageType) -> Result<(), clap::Error> {
match self.input_storage {
Some(st) => {
if st == allowed_type {
Ok(())
} else {
let mut err = clap::Error::new(ErrorKind::ArgumentConflict);
err.insert(
ContextKind::InvalidArg,
ContextValue::String(format!("--input.storage {st}")),
);
err.insert(
ContextKind::PriorArg,
ContextValue::String(format!("--input.{allowed_type}.*")),
);
Err(err)
}
}
None => Ok(()),
}
}
fn input_overlap(&self) -> Result<Option<InputOverlap>, clap::Error> {
let git_selector = self.git_selector();
if let Some(filename) = &self.input_filename {
let i = FileInputOverlap {
path: filename.clone(),
};
Ok(Some(InputOverlap::File(i)))
} else if self.input_fs_path.is_some()
|| self.input_fs_dir.is_some()
|| self.input_fs_ext.is_some()
{
self.verify_storage_mode(StorageType::Fs)?;
let i = FsInputOverlap {
path: self.input_fs_path.clone(),
dir: self.input_fs_dir.clone(),
ext: self.input_fs_ext.clone(),
};
Ok(Some(InputOverlap::Fs(i)))
} else if self.input_git_repo.is_some()
|| self.input_git_dir.is_some()
|| self.input_git_ext.is_some()
|| git_selector.is_some()
{
self.verify_storage_mode(StorageType::Git)?;
let i = GitInputOverlap {
repo: self.input_git_repo.clone(),
git_ref: git_selector,
dir: self.input_git_dir.clone(),
ext: self.input_git_ext.clone(),
};
Ok(Some(InputOverlap::Git(i)))
} else {
Ok(None)
}
}
pub(crate) fn overlaps(&self) -> Result<OverlapConfig, clap::Error> {
Ok(OverlapConfig {
audit: AuditOverlap {
mode: self.audit_mode,
},
strict: StrictOverlap {
mode: self.strict_mode,
},
storage: StorageOverlap {
storage_type: self.input_storage,
input: self.input_overlap()?,
},
price: PriceOverlap {
db_path: self.pricedb_filename.clone(),
lookup_type: self.price_lookup_type,
before_time: self.price_before_ts.clone(),
},
report: ReportOverlap {
commodity: self.report_commodity.clone(),
account_overlap: self.accounts.clone(),
group_by: self.group_by.clone(),
inverted: self.invert,
},
target: TargetOverlap {
reports: self.reports.clone(),
exports: self.exports.clone(),
formats: self.formats.clone(),
},
})
}
fn git_selector(&self) -> Option<GitInputSelector> {
match (
&self.git_input_selector.input_git_commit,
&self.git_input_selector.input_git_ref,
) {
(Some(commit), None) => Some(GitInputSelector::CommitId(commit.clone())),
(None, Some(git_ref)) => Some(GitInputSelector::Reference(git_ref.clone())),
(None, None) => None,
(Some(_), Some(_)) => {
panic!("IE: invalid combination of git input selectors (Clap)")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_cli() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
}