pub mod app;
pub mod config;
pub mod defaults;
pub mod utils;
use std::path::PathBuf;
use std::result::Result;
use std::str::FromStr;
use clap::{Parser, Subcommand};
use once_cell::sync::Lazy;
use regex::Regex;
static RE_FILTER_QUERY: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?P<operator>[?*=]?)(?P<field>\w*):(?P<query>.*)$").unwrap()
});
#[derive(Debug, Parser)]
#[command(author, version, about)]
pub struct Cli {
#[clap(flatten)]
pub options: Options,
#[clap(subcommand)]
pub command: Command,
}
#[derive(Debug, Clone, Parser)]
pub struct Options {
#[arg(short, long, value_name = "PATH", value_parser(validate_path_exists))]
pub output_directory: Option<PathBuf>,
#[arg(short, long, value_name = "PATH", value_parser(validate_path_exists))]
pub databases_directory: Option<PathBuf>,
#[arg(short, long)]
pub force: bool,
#[arg(short = 'q', long = "quiet")]
pub is_quiet: bool,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Render {
#[clap(flatten)]
filter_options: FilterOptions,
#[clap(flatten)]
template_options: RenderOptions,
#[clap(flatten)]
preprocess_options: PreProcessOptions,
#[clap(flatten)]
postprocess_options: PostProcessOptions,
},
Export {
#[clap(flatten)]
filter_options: FilterOptions,
#[clap(flatten)]
export_options: ExportOptions,
#[clap(flatten)]
preprocess_options: PreProcessOptions,
},
Backup {
#[clap(flatten)]
backup_options: BackupOptions,
},
}
#[derive(Debug, Clone, Default, Parser)]
pub struct RenderOptions {
#[arg(
short = 'd',
long,
value_name = "PATH",
value_parser(validate_path_exists)
)]
pub templates_directory: Option<PathBuf>,
#[arg(short = 'g', long = "template-group", value_name = "GROUP")]
pub template_groups: Vec<String>,
#[clap(short = 'o', long)]
pub overwrite_existing: bool,
}
#[derive(Debug, Clone, Default, Parser)]
pub struct ExportOptions {
#[clap(short = 'd', long, value_name = "TEMPLATE")]
pub directory_template: Option<String>,
#[clap(short = 'o', long)]
pub overwrite_existing: bool,
}
#[derive(Debug, Clone, Default, Parser)]
pub struct BackupOptions {
#[clap(short = 'd', long, value_name = "TEMPLATE")]
pub directory_template: Option<String>,
}
#[derive(Debug, Clone, Default, Parser)]
pub struct FilterOptions {
#[clap(short, long = "filter", value_name = "[OP]{FIELD}:{QUERY}")]
filter_types: Vec<FilterType>,
#[clap(short = 'A', long = "auto-confirm-filter", requires = "filter_types")]
auto_confirm: bool,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum FilterType {
Title {
query: Vec<String>,
operator: FilterOperator,
},
Author {
query: Vec<String>,
operator: FilterOperator,
},
Tags {
query: Vec<String>,
operator: FilterOperator,
},
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum FilterOperator {
#[default]
Any,
All,
Exact,
}
#[derive(Debug, Clone, Copy, Default, Parser)]
#[allow(clippy::struct_excessive_bools)]
pub struct PreProcessOptions {
#[arg(short = 'e', long)]
pub extract_tags: bool,
#[arg(short = 'n', long)]
pub normalize_whitespace: bool,
#[arg(
short = 'a',
long = "ascii-all",
conflicts_with = "convert_symbols_to_ascii"
)]
pub convert_all_to_ascii: bool,
#[arg(
short = 's',
long = "ascii-symbols",
conflicts_with = "convert_all_to_ascii"
)]
pub convert_symbols_to_ascii: bool,
}
#[derive(Debug, Clone, Copy, Default, Parser)]
pub struct PostProcessOptions {
#[arg(short = 't', long)]
pub trim_blocks: bool,
#[arg(short = 'w', long, value_name = "WIDTH")]
pub wrap_text: Option<usize>,
}
pub fn validate_path_exists(value: &str) -> Result<PathBuf, String> {
std::fs::canonicalize(value).map_err(|_| "path does not exist".into())
}
impl From<RenderOptions> for lib::render::templates::RenderOptions {
fn from(options: RenderOptions) -> Self {
Self {
templates_directory: options.templates_directory,
template_groups: options.template_groups,
overwrite_existing: options.overwrite_existing,
}
}
}
impl From<ExportOptions> for lib::export::ExportOptions {
fn from(options: ExportOptions) -> Self {
Self {
directory_template: options.directory_template,
overwrite_existing: options.overwrite_existing,
}
}
}
impl From<BackupOptions> for lib::backup::BackupOptions {
fn from(options: BackupOptions) -> Self {
Self {
directory_template: options.directory_template,
}
}
}
impl From<FilterOperator> for lib::filter::FilterOperator {
fn from(filter_operator: FilterOperator) -> Self {
match filter_operator {
FilterOperator::Any => Self::Any,
FilterOperator::All => Self::All,
FilterOperator::Exact => Self::Exact,
}
}
}
impl From<FilterType> for lib::filter::FilterType {
fn from(filter_type: FilterType) -> Self {
match filter_type {
FilterType::Title { query, operator } => Self::Title {
query,
operator: operator.into(),
},
FilterType::Author { query, operator } => Self::Author {
query,
operator: operator.into(),
},
FilterType::Tags { query, operator } => Self::Tags {
query,
operator: operator.into(),
},
}
}
}
impl From<PreProcessOptions> for lib::process::PreProcessOptions {
fn from(options: PreProcessOptions) -> Self {
Self {
extract_tags: options.extract_tags,
normalize_whitespace: options.normalize_whitespace,
convert_all_to_ascii: options.convert_all_to_ascii,
convert_symbols_to_ascii: options.convert_symbols_to_ascii,
}
}
}
impl From<PostProcessOptions> for lib::process::PostProcessOptions {
fn from(options: PostProcessOptions) -> Self {
Self {
trim_blocks: options.trim_blocks,
wrap_text: options.wrap_text,
}
}
}
impl FromStr for FilterType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let captures = RE_FILTER_QUERY.captures(s);
let Some(captures) = captures else {
return Err("filters must follow the format '[op]{field}:{query}'".into());
};
let operator = captures.name("operator").unwrap().as_str();
let field = captures.name("field").unwrap().as_str().to_lowercase();
let query = captures.name("query").unwrap();
let operator = if operator.is_empty() {
FilterOperator::default()
} else {
operator.parse()?
};
let query = query
.as_str()
.split(' ')
.map(std::string::ToString::to_string)
.collect();
let filter_by = match field.as_str() {
"title" => Self::Title { query, operator },
"author" => Self::Author { query, operator },
"tags" | "tag" => Self::Tags { query, operator },
_ => return Err(format!("invalid field: '{field}'")),
};
Ok(filter_by)
}
}
impl FromStr for FilterOperator {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let filter_type = match s {
"?" => Self::Any,
"*" => Self::All,
"=" => Self::Exact,
_ => return Err(format!("invalid operator: '{s}'")),
};
Ok(filter_type)
}
}
#[cfg(test)]
mod test_cli {
use super::*;
mod parse_filter {
use super::*;
#[test]
fn test_title_any() {
assert_eq!(
FilterType::from_str("?title:art think").unwrap(),
FilterType::Title {
query: vec!["art".to_string(), "think".to_string()],
operator: FilterOperator::Any,
}
);
}
#[test]
fn test_title_all() {
assert_eq!(
FilterType::from_str("*title:joking feynman").unwrap(),
FilterType::Title {
query: vec!["joking".to_string(), "feynman".to_string()],
operator: FilterOperator::All,
}
);
}
#[test]
fn test_title_exact() {
assert_eq!(
FilterType::from_str("=title:the art spirit").unwrap(),
FilterType::Title {
query: vec!["the".to_string(), "art".to_string(), "spirit".to_string()],
operator: FilterOperator::Exact,
}
);
}
#[test]
fn test_author_any() {
assert_eq!(
FilterType::from_str("?author:robert richard").unwrap(),
FilterType::Author {
query: vec!["robert".to_string(), "richard".to_string()],
operator: FilterOperator::Any,
}
);
}
#[test]
fn test_author_all() {
assert_eq!(
FilterType::from_str("*author:richard feynman").unwrap(),
FilterType::Author {
query: vec!["richard".to_string(), "feynman".to_string()],
operator: FilterOperator::All,
}
);
}
#[test]
fn test_author_exact() {
assert_eq!(
FilterType::from_str("=author:richard p. feynman").unwrap(),
FilterType::Author {
query: vec![
"richard".to_string(),
"p.".to_string(),
"feynman".to_string(),
],
operator: FilterOperator::Exact,
}
);
}
#[test]
fn test_tags_any() {
assert_eq!(
FilterType::from_str("?tags:#artist #death").unwrap(),
FilterType::Tags {
query: vec!["#artist".to_string(), "#death".to_string()],
operator: FilterOperator::Any,
}
);
}
#[test]
fn test_tags_all() {
assert_eq!(
FilterType::from_str("*tags:#death #impermanence").unwrap(),
FilterType::Tags {
query: vec!["#death".to_string(), "#impermanence".to_string()],
operator: FilterOperator::All,
}
);
}
#[test]
fn test_tags_exact() {
assert_eq!(
FilterType::from_str("=tags:#artist #being").unwrap(),
FilterType::Tags {
query: vec!["#artist".to_string(), "#being".to_string()],
operator: FilterOperator::Exact,
}
);
}
}
}