tiller_sync/model/
auto_cat.rs

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
12/// Represents the AutoCat data from an AutoCat sheet, including the header mapping.
13pub type AutoCats = Items<AutoCat>;
14
15/// Represents a single row from the AutoCat sheet.
16#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub struct AutoCat {
19    /// The category to assign when this rule matches. This is an override column - when filter
20    /// conditions match, this category value gets applied to matching transactions.
21    pub(crate) category: String,
22
23    /// Override column to standardize or clean up transaction descriptions. For example, replace
24    /// "Seattle Starbucks store 1234" with simply "Starbucks".
25    pub(crate) description: String,
26
27    /// Filter criteria: searches the Description column for matching text (case-insensitive).
28    /// Supports multiple keywords wrapped in quotes and separated by commas (OR-ed together).
29    pub(crate) description_contains: String,
30
31    /// Filter criteria: searches the Account column for matching text to narrow rule application.
32    pub(crate) account_contains: String,
33
34    /// Filter criteria: searches the Institution column for matching text to narrow rule
35    /// application.
36    pub(crate) institution_contains: String,
37
38    /// Filter criteria: minimum transaction amount (absolute value). Use with Amount Max to set
39    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
40    pub(crate) amount_min: Option<Amount>,
41
42    /// Filter criteria: maximum transaction amount (absolute value). Use with Amount Min to set
43    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
44    pub(crate) amount_max: Option<Amount>,
45
46    /// Filter criteria: exact amount to match.
47    pub(crate) amount_equals: Option<Amount>,
48
49    /// Filter criteria: exact match for the Description column (more specific than "contains").
50    pub(crate) description_equals: String,
51
52    /// Override column for the full/raw description field.
53    pub(crate) description_full: String,
54
55    /// Filter criteria: searches the Full Description column for matching text.
56    pub(crate) full_description_contains: String,
57
58    /// Filter criteria: searches the Amount column as text for matching patterns.
59    pub(crate) amount_contains: String,
60
61    /// Custom columns not part of the standard Tiller schema.
62    pub(crate) other_fields: BTreeMap<String, String>,
63
64    /// Row position from last sync down (0-indexed); None for locally-added rows.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub(crate) original_order: Option<u64>,
67}
68
69impl AutoCat {
70    /// Set any of the fields on `self` that are set in `update`.
71    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/// Represents the known columns that should be found in the AutoCat sheet.
177#[derive(Default, Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AutoCatColumn {
180    /// The category to assign when this rule matches.
181    #[default]
182    Category,
183    /// Override column to standardize or clean up transaction descriptions.
184    Description,
185    /// Filter criteria: searches the Description column for matching text (case-insensitive).
186    DescriptionContains,
187    /// Filter criteria: searches the Account column for matching text.
188    AccountContains,
189    /// Filter criteria: searches the Institution column for matching text.
190    InstitutionContains,
191    /// Filter criteria: minimum transaction amount (absolute value).
192    AmountMin,
193    /// Filter criteria: maximum transaction amount (absolute value).
194    AmountMax,
195    /// Filter criteria: exact amount to match.
196    AmountEquals,
197    /// Filter criteria: exact match for the Description column.
198    DescriptionEquals,
199    /// Override column for the full/raw description field.
200    DescriptionFull,
201    /// Filter criteria: searches the Full Description column for matching text.
202    FullDescriptionContains,
203    /// Filter criteria: searches the Amount column as text for matching patterns.
204    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
231/// Parses an optional amount value
232fn 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
241/// Converts an optional amount to a string for sheet output
242fn 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/// The fields to update in an AutoCat rule. Only set values will be changed, unset values will
263/// not be changed.
264///
265/// See tiller documentation for more information about AutoCat:
266/// <https://help.tiller.com/en/articles/3792984-autocat-for-google-sheets>
267#[derive(Debug, Default, Clone, Parser, Serialize, Deserialize, JsonSchema)]
268pub struct AutoCatUpdates {
269    /// The category to assign when this rule matches. This is an override column - when filter
270    /// conditions match, this category value gets applied to matching transactions.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    #[arg(long)]
273    pub category: Option<String>,
274
275    /// Override column to standardize or clean up transaction descriptions. For example, replace
276    /// "Seattle Starbucks store 1234" with simply "Starbucks".
277    #[serde(skip_serializing_if = "Option::is_none")]
278    #[arg(long)]
279    pub description: Option<String>,
280
281    /// Filter criteria: searches the Description column for matching text (case-insensitive).
282    /// Supports multiple keywords wrapped in quotes and separated by commas (OR-ed together).
283    #[serde(skip_serializing_if = "Option::is_none")]
284    #[arg(long)]
285    pub description_contains: Option<String>,
286
287    /// Filter criteria: searches the Account column for matching text to narrow rule application.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    #[arg(long)]
290    pub account_contains: Option<String>,
291
292    /// Filter criteria: searches the Institution column for matching text to narrow rule
293    /// application.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    #[arg(long)]
296    pub institution_contains: Option<String>,
297
298    /// Filter criteria: minimum transaction amount (absolute value). Use with Amount Max to set
299    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
300    #[serde(skip_serializing_if = "Option::is_none")]
301    #[arg(long, value_parser = utils::parse_amount)]
302    pub amount_min: Option<Amount>,
303
304    /// Filter criteria: maximum transaction amount (absolute value). Use with Amount Min to set
305    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
306    #[serde(skip_serializing_if = "Option::is_none")]
307    #[arg(long, value_parser = utils::parse_amount)]
308    pub amount_max: Option<Amount>,
309
310    /// Filter criteria: exact amount to match.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    #[arg(long, value_parser = utils::parse_amount)]
313    pub amount_equals: Option<Amount>,
314
315    /// Filter criteria: exact match for the Description column (more specific than "contains").
316    #[serde(skip_serializing_if = "Option::is_none")]
317    #[arg(long)]
318    pub description_equals: Option<String>,
319
320    /// Override column for the full/raw description field.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    #[arg(long)]
323    pub description_full: Option<String>,
324
325    /// Filter criteria: searches the Full Description column for matching text.
326    #[serde(skip_serializing_if = "Option::is_none")]
327    #[arg(long)]
328    pub full_description_contains: Option<String>,
329
330    /// Filter criteria: searches the Amount column as text for matching patterns.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    #[arg(long)]
333    pub amount_contains: Option<String>,
334
335    /// Custom columns not part of the standard Tiller schema.
336    #[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}