1use crate::error::Res;
2use crate::model::items::{Item, Items};
3use crate::model::Amount;
4use crate::utils;
5use anyhow::{bail, Context};
6use clap::Parser;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::str::FromStr;
11
12pub type AutoCats = Items<AutoCat>;
14
15#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub struct AutoCat {
19 pub(crate) category: String,
22
23 pub(crate) description: String,
26
27 pub(crate) description_contains: String,
30
31 pub(crate) account_contains: String,
33
34 pub(crate) institution_contains: String,
37
38 pub(crate) amount_min: Option<Amount>,
41
42 pub(crate) amount_max: Option<Amount>,
45
46 pub(crate) amount_equals: Option<Amount>,
48
49 pub(crate) description_equals: String,
51
52 pub(crate) description_full: String,
54
55 pub(crate) full_description_contains: String,
57
58 pub(crate) amount_contains: String,
60
61 pub(crate) other_fields: BTreeMap<String, String>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub(crate) original_order: Option<u64>,
67}
68
69impl AutoCat {
70 pub fn merge_updates(&mut self, update: AutoCatUpdates) {
72 if let Some(x) = update.category {
73 self.category = x;
74 }
75 if let Some(x) = update.description {
76 self.description = x;
77 }
78 if let Some(x) = update.description_contains {
79 self.description_contains = x;
80 }
81 if let Some(x) = update.account_contains {
82 self.account_contains = x;
83 }
84 if let Some(x) = update.institution_contains {
85 self.institution_contains = x;
86 }
87 if let Some(x) = update.amount_min {
88 self.amount_min = Some(x);
89 }
90 if let Some(x) = update.amount_max {
91 self.amount_max = Some(x);
92 }
93 if let Some(x) = update.amount_equals {
94 self.amount_equals = Some(x);
95 }
96 if let Some(x) = update.description_equals {
97 self.description_equals = x;
98 }
99 if let Some(x) = update.description_full {
100 self.description_full = x;
101 }
102 if let Some(x) = update.full_description_contains {
103 self.full_description_contains = x;
104 }
105 if let Some(x) = update.amount_contains {
106 self.amount_contains = x;
107 }
108
109 for (key, val) in update.other_fields {
110 self.other_fields.insert(key, val);
111 }
112 }
113}
114
115impl Item for AutoCat {
116 fn set_with_header<S1, S2>(&mut self, header: S1, value: S2) -> Res<()>
117 where
118 S1: AsRef<str>,
119 S2: Into<String>,
120 {
121 let header = header.as_ref();
122 let value = value.into();
123
124 match AutoCatColumn::from_header(header) {
125 Ok(col) => match col {
126 AutoCatColumn::Category => self.category = value,
127 AutoCatColumn::Description => self.description = value,
128 AutoCatColumn::DescriptionContains => self.description_contains = value,
129 AutoCatColumn::AccountContains => self.account_contains = value,
130 AutoCatColumn::InstitutionContains => self.institution_contains = value,
131 AutoCatColumn::AmountMin => self.amount_min = parse_optional_amount(&value)?,
132 AutoCatColumn::AmountMax => self.amount_max = parse_optional_amount(&value)?,
133 AutoCatColumn::AmountEquals => self.amount_equals = parse_optional_amount(&value)?,
134 AutoCatColumn::DescriptionEquals => self.description_equals = value,
135 AutoCatColumn::DescriptionFull => self.description_full = value,
136 AutoCatColumn::FullDescriptionContains => self.full_description_contains = value,
137 AutoCatColumn::AmountContains => self.amount_contains = value,
138 },
139 Err(_) => {
140 let _ = self.other_fields.insert(header.to_string(), value);
141 }
142 }
143
144 Ok(())
145 }
146
147 fn get_by_header(&self, header: &str) -> String {
148 match AutoCatColumn::from_header(header) {
149 Ok(col) => match col {
150 AutoCatColumn::Category => self.category.clone(),
151 AutoCatColumn::Description => self.description.clone(),
152 AutoCatColumn::DescriptionContains => self.description_contains.clone(),
153 AutoCatColumn::AccountContains => self.account_contains.clone(),
154 AutoCatColumn::InstitutionContains => self.institution_contains.clone(),
155 AutoCatColumn::AmountMin => optional_amount_to_string(&self.amount_min),
156 AutoCatColumn::AmountMax => optional_amount_to_string(&self.amount_max),
157 AutoCatColumn::AmountEquals => optional_amount_to_string(&self.amount_equals),
158 AutoCatColumn::DescriptionEquals => self.description_equals.clone(),
159 AutoCatColumn::DescriptionFull => self.description_full.clone(),
160 AutoCatColumn::FullDescriptionContains => self.full_description_contains.clone(),
161 AutoCatColumn::AmountContains => self.amount_contains.clone(),
162 },
163 Err(_) => self.other_fields.get(header).cloned().unwrap_or_default(),
164 }
165 }
166
167 fn set_original_order(&mut self, original_order: u64) {
168 self.original_order = Some(original_order)
169 }
170
171 fn get_original_order(&self) -> Option<u64> {
172 self.original_order
173 }
174}
175
176#[derive(Default, Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AutoCatColumn {
180 #[default]
182 Category,
183 Description,
185 DescriptionContains,
187 AccountContains,
189 InstitutionContains,
191 AmountMin,
193 AmountMax,
195 AmountEquals,
197 DescriptionEquals,
199 DescriptionFull,
201 FullDescriptionContains,
203 AmountContains,
205}
206
207serde_plain::derive_display_from_serialize!(AutoCatColumn);
208serde_plain::derive_fromstr_from_deserialize!(AutoCatColumn);
209
210impl AutoCatColumn {
211 pub fn from_header(header: impl AsRef<str>) -> Res<AutoCatColumn> {
212 let header_str = header.as_ref();
213 match header_str {
214 CATEGORY_STR => Ok(AutoCatColumn::Category),
215 DESCRIPTION_STR => Ok(AutoCatColumn::Description),
216 DESCRIPTION_CONTAINS_STR => Ok(AutoCatColumn::DescriptionContains),
217 ACCOUNT_CONTAINS_STR => Ok(AutoCatColumn::AccountContains),
218 INSTITUTION_CONTAINS_STR => Ok(AutoCatColumn::InstitutionContains),
219 AMOUNT_MIN_STR => Ok(AutoCatColumn::AmountMin),
220 AMOUNT_MAX_STR => Ok(AutoCatColumn::AmountMax),
221 AMOUNT_EQUALS_STR => Ok(AutoCatColumn::AmountEquals),
222 DESCRIPTION_EQUALS_STR => Ok(AutoCatColumn::DescriptionEquals),
223 DESCRIPTION_FULL_STR => Ok(AutoCatColumn::DescriptionFull),
224 FULL_DESCRIPTION_CONTAINS_STR => Ok(AutoCatColumn::FullDescriptionContains),
225 AMOUNT_CONTAINS_STR => Ok(AutoCatColumn::AmountContains),
226 bad => bail!("Invalid AutoCat column name '{bad}'"),
227 }
228 }
229}
230
231fn parse_optional_amount(s: &str) -> Res<Option<Amount>> {
233 if s.is_empty() {
234 return Ok(None);
235 }
236 Ok(Some(
237 Amount::from_str(s).context(format!("Failed to parse amount value: {s}"))?,
238 ))
239}
240
241fn optional_amount_to_string(amount: &Option<Amount>) -> String {
243 match amount {
244 Some(a) => a.to_string(),
245 None => String::new(),
246 }
247}
248
249pub(super) const CATEGORY_STR: &str = "Category";
250pub(super) const DESCRIPTION_STR: &str = "Description";
251pub(super) const DESCRIPTION_CONTAINS_STR: &str = "Description Contains";
252pub(super) const ACCOUNT_CONTAINS_STR: &str = "Account Contains";
253pub(super) const INSTITUTION_CONTAINS_STR: &str = "Institution Contains";
254pub(super) const AMOUNT_MIN_STR: &str = "Amount Min";
255pub(super) const AMOUNT_MAX_STR: &str = "Amount Max";
256pub(super) const AMOUNT_EQUALS_STR: &str = "Amount Equals";
257pub(super) const DESCRIPTION_EQUALS_STR: &str = "Description Equals";
258pub(super) const DESCRIPTION_FULL_STR: &str = "Description Full";
259pub(super) const FULL_DESCRIPTION_CONTAINS_STR: &str = "Full Description Contains";
260pub(super) const AMOUNT_CONTAINS_STR: &str = "Amount Contains";
261
262#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
268pub struct AutoCatUpdates {
269 #[serde(skip_serializing_if = "Option::is_none")]
272 #[arg(long)]
273 pub category: Option<String>,
274
275 #[serde(skip_serializing_if = "Option::is_none")]
278 #[arg(long)]
279 pub description: Option<String>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
284 #[arg(long)]
285 pub description_contains: Option<String>,
286
287 #[serde(skip_serializing_if = "Option::is_none")]
289 #[arg(long)]
290 pub account_contains: Option<String>,
291
292 #[serde(skip_serializing_if = "Option::is_none")]
295 #[arg(long)]
296 pub institution_contains: Option<String>,
297
298 #[serde(skip_serializing_if = "Option::is_none")]
301 #[arg(long, value_parser = utils::parse_amount)]
302 pub amount_min: Option<Amount>,
303
304 #[serde(skip_serializing_if = "Option::is_none")]
307 #[arg(long, value_parser = utils::parse_amount)]
308 pub amount_max: Option<Amount>,
309
310 #[serde(skip_serializing_if = "Option::is_none")]
312 #[arg(long, value_parser = utils::parse_amount)]
313 pub amount_equals: Option<Amount>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 #[arg(long)]
318 pub description_equals: Option<String>,
319
320 #[serde(skip_serializing_if = "Option::is_none")]
322 #[arg(long)]
323 pub description_full: Option<String>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
327 #[arg(long)]
328 pub full_description_contains: Option<String>,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 #[arg(long)]
333 pub amount_contains: Option<String>,
334
335 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
337 #[arg(long = "other-field", value_parser = utils::parse_key_val)]
338 pub other_fields: BTreeMap<String, String>,
339}