tiller_sync/
args.rs

1//! These structs provide the CLI interface for the tiller CLI.
2
3use crate::commands::FormulasMode;
4use crate::error::{ErrorType, IntoResult};
5use crate::model::{Amount, AutoCatUpdates, CategoryUpdates, Date, TransactionUpdates};
6use crate::utils;
7use crate::Result;
8use anyhow::anyhow;
9use clap::{Parser, Subcommand};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::convert::Infallible;
14use std::fmt::{Display, Formatter};
15use std::ops::Deref;
16use std::path::{Path, PathBuf};
17use std::str::FromStr;
18use tracing::error;
19use tracing_subscriber::filter::LevelFilter;
20
21/// tiller: A command-line tool for manipulating financial data.
22///
23/// The purpose of this program is to download your financial transactions from a Tiller Google
24/// sheet (see https://tiller.com) into a local datastore. There you can manipulate tham as you
25/// wish and then sync your changes back to your Tiller sheet.
26///
27/// You will need set up a Google Docs API Key and OAuth for this. See the README at
28/// https://github.com/webern/tiller-sync for documentation on how to set this up.
29///
30/// There is also a mode in which an AI agent, like Claude or Claude Code, can use this program
31/// through the mcp subcommand.
32#[derive(Debug, Parser, Clone)]
33pub struct Args {
34    #[clap(flatten)]
35    common: Common,
36
37    #[command(subcommand)]
38    command: Command,
39}
40
41impl Args {
42    pub fn new(common: Common, command: Command) -> Self {
43        Self { common, command }
44    }
45
46    pub fn common(&self) -> &Common {
47        &self.common
48    }
49
50    pub fn command(&self) -> &Command {
51        &self.command
52    }
53}
54
55#[derive(Subcommand, Debug, Clone)]
56pub enum Command {
57    /// Create the data directory and initialize the configuration files.
58    ///
59    /// This is the first command you should run when setting up the tiller CLI. You need to get a
60    /// few things ready beforehand.
61    ///
62    /// - Decide what directory you want to store data in and pass this as --tiller-home. By
63    ///   default, It will be $HOME/tiller. If you want it somewhere else then you should specify
64    ///   it.
65    ///
66    /// - Get the URL of your Tiller Google Sheet and pass it as --sheet-url.
67    ///
68    /// - Set up your Google Sheets API Access credentials and download them to a file. You will
69    ///   pass this as --api-key. Unfortunately, this is a process that requires a lot of steps.
70    ///   Detailed instructions have been provided in the GitHub documentation, please see
71    ///   https://github.com/webern/tiller-sync for help with this.
72    ///
73    Init(InitArgs),
74    /// Authenticate with Google Sheets via OAuth.
75    Auth(AuthArgs),
76    /// Upload or Download Transactions, Categories and AutoCat tabs to/from your Tiller Sheet.
77    Sync(SyncArgs),
78    /// Run as an MCP (Model Context Protocol) server for AI agent integration.
79    ///
80    /// This launches a long-running process that communicates via JSON-RPC over stdin/stdout.
81    /// MCP clients (like Claude Code) launch this as a subprocess.
82    Mcp(McpArgs),
83    /// Update a transaction, category, or autocat rule in the local database.
84    Update(Box<UpdateArgs>),
85    /// Delete a transaction, category, or autocat rule from the local database.
86    Delete(DeleteArgs),
87    /// Insert a new transaction, category, or autocat rule into the local database.
88    Insert(Box<InsertArgs>),
89    /// Execute a read-only SQL query against the local SQLite database.
90    ///
91    /// The query interface enforces read-only access. Any write attempt will be rejected.
92    /// Result sets can be large - use LIMIT clauses to control output size.
93    Query(QueryArgs),
94    /// Display database schema information.
95    ///
96    /// Returns tables, columns, types, indexes, foreign keys, column descriptions, and row counts.
97    Schema(SchemaArgs),
98}
99
100/// Arguments common to all subcommands.
101#[derive(Debug, Parser, Clone)]
102pub struct Common {
103    /// The logging verbosity. One of, from least to most verbose:
104    /// off, error, warn, info, debug, trace
105    ///
106    /// This can be overridden by the RUST_LOG environment variable.
107    #[arg(long, default_value_t = LevelFilter::INFO)]
108    log_level: LevelFilter,
109
110    /// The directory where tiller data and configuration is held. Defaults to ~/tiller
111    #[arg(long, env = "TILLER_HOME", default_value_t = default_tiller_home())]
112    tiller_home: DisplayPath,
113}
114
115impl Common {
116    pub fn new(log_level: LevelFilter, tiller_home: PathBuf) -> Self {
117        Self {
118            log_level,
119            tiller_home: tiller_home.into(),
120        }
121    }
122
123    pub fn log_level(&self) -> LevelFilter {
124        self.log_level
125    }
126
127    pub fn tiller_home(&self) -> &DisplayPath {
128        &self.tiller_home
129    }
130}
131
132/// (Not shown): Args for the `tiller init` command.
133#[derive(Debug, Parser, Clone)]
134pub struct InitArgs {
135    /// The URL to your Tiller Google sheet. It looks like this:
136    /// https://docs.google.com/spreadsheets/d/1a7Km9FxQwRbPt82JvN4LzYpH5OcGnWsT6iDuE3VhMjX
137    #[arg(long)]
138    sheet_url: String,
139
140    /// The path to your downloaded OAuth 2.0 client credentials. This file will be copied to the
141    /// default secrets location in the main data directory.
142    #[arg(long)]
143    client_secret: PathBuf,
144}
145
146impl InitArgs {
147    pub fn new(sheet_url: impl Into<String>, secret: impl Into<PathBuf>) -> Self {
148        Self {
149            sheet_url: sheet_url.into(),
150            client_secret: secret.into(),
151        }
152    }
153
154    pub fn sheet_url(&self) -> &str {
155        &self.sheet_url
156    }
157
158    pub fn client_secret(&self) -> &Path {
159        &self.client_secret
160    }
161}
162
163#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum UpDown {
166    Up,
167    #[default]
168    Down,
169}
170
171serde_plain::derive_display_from_serialize!(UpDown);
172serde_plain::derive_fromstr_from_deserialize!(UpDown);
173
174/// (Not shown): Args for the `tiller auth` command.
175#[derive(Debug, Parser, Clone)]
176pub struct AuthArgs {
177    /// Verify and refresh authentication.
178    #[arg(long)]
179    verify: bool,
180}
181
182impl AuthArgs {
183    pub fn new(verify: bool) -> Self {
184        Self { verify }
185    }
186
187    pub fn verify(&self) -> bool {
188        self.verify
189    }
190}
191
192/// (Not shown): Args for the `tiller sync` command.
193#[derive(Debug, Parser, Clone)]
194pub struct SyncArgs {
195    /// The direction to sync: "up" or "down"
196    direction: UpDown,
197
198    /// The path to the OAuth 2.0 client credentials file, defaults to $TILLER_HOME/.secrets/client_secret.json
199    client_secret: Option<PathBuf>,
200
201    /// The path to the Google OAuth token file, defaults to $TILLER_HOME/.secrets/token.json
202    oauth_token: Option<PathBuf>,
203
204    /// Force sync up even if conflicts are detected or sync-down backup is missing
205    #[arg(long)]
206    force: bool,
207
208    /// How to handle formulas during sync up: unknown, preserve, or ignore.
209    /// - unknown: Error if formulas exist (default)
210    /// - preserve: Write formulas back to original positions
211    /// - ignore: Skip all formulas, only write values
212    #[arg(long, value_enum, default_value_t = FormulasMode::Unknown)]
213    formulas: FormulasMode,
214}
215
216impl SyncArgs {
217    pub fn new(direction: UpDown, secret: Option<PathBuf>, oath_token: Option<PathBuf>) -> Self {
218        Self {
219            direction,
220            client_secret: secret,
221            oauth_token: oath_token,
222            force: false,
223            formulas: FormulasMode::Unknown,
224        }
225    }
226
227    pub fn direction(&self) -> UpDown {
228        self.direction
229    }
230
231    pub fn client_secret(&self) -> Option<&PathBuf> {
232        self.client_secret.as_ref()
233    }
234
235    pub fn oath_token(&self) -> Option<&PathBuf> {
236        self.oauth_token.as_ref()
237    }
238
239    pub fn force(&self) -> bool {
240        self.force
241    }
242
243    pub fn formulas(&self) -> FormulasMode {
244        self.formulas
245    }
246}
247
248/// Args for the `tiller mcp` command.
249#[derive(Debug, Parser, Clone, Default)]
250pub struct McpArgs {
251    // No additional arguments for now.
252    // The --tiller-home flag is inherited from Common.
253}
254
255// =============================================================================
256// Query command structs
257// =============================================================================
258
259/// Output format for query results.
260#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
261#[serde(rename_all = "lowercase")]
262pub enum OutputFormat {
263    /// JSON array of objects where each row is a self-describing object with column names as keys.
264    #[default]
265    Json,
266    /// Markdown table format.
267    #[serde(alias = "md")]
268    Markdown,
269    /// CSV format.
270    Csv,
271}
272
273serde_plain::derive_display_from_serialize!(OutputFormat);
274serde_plain::derive_fromstr_from_deserialize!(OutputFormat);
275
276/// Args for the `tiller query` command.
277///
278/// Execute arbitrary read-only SQL queries against the local SQLite database. The query interface
279/// is designed primarily for AI agents via MCP, with CLI as a secondary interface.
280///
281/// **Read-only access**: The query interface enforces read-only access using a separate SQLite
282/// connection. Any write attempt (INSERT, UPDATE, DELETE) will be rejected by SQLite.
283///
284/// **Warning**: Result sets can be large. Use LIMIT clauses to control output size.
285#[derive(Debug, Clone, Parser, Serialize, Deserialize, JsonSchema)]
286#[schemars(title = "QueryArgs")]
287pub struct QueryArgs {
288    /// The SQL query to execute. Must be a valid SQLite SELECT statement.
289    pub sql: String,
290
291    /// Output format: json, markdown, or csv. Defaults to json for CLI.
292    #[arg(long, default_value = "json")]
293    pub format: OutputFormat,
294}
295
296impl QueryArgs {
297    pub fn new(sql: impl Into<String>, format: OutputFormat) -> Self {
298        Self {
299            sql: sql.into(),
300            format,
301        }
302    }
303}
304
305/// Args for the `tiller schema` command.
306///
307/// Returns database schema information including tables, columns, types, indexes, foreign keys,
308/// column descriptions, and row counts.
309#[derive(Debug, Clone, Parser, Serialize, Deserialize, JsonSchema, Default)]
310#[schemars(title = "SchemaArgs")]
311pub struct SchemaArgs {
312    /// Include internal metadata tables (sheet_metadata, formulas, schema_version) in the output.
313    /// By default, only data tables (transactions, categories, autocat) are shown.
314    #[arg(long, default_value = "false")]
315    #[serde(default)]
316    pub include_metadata: bool,
317}
318
319/// Args for the `tiller update` command.
320#[derive(Debug, Parser, Clone)]
321pub struct UpdateArgs {
322    #[command(subcommand)]
323    entity: UpdateSubcommand,
324}
325
326impl UpdateArgs {
327    pub fn entity(&self) -> &UpdateSubcommand {
328        &self.entity
329    }
330}
331
332/// Subcommands for `tiller update`.
333#[derive(Subcommand, Debug, Clone)]
334pub enum UpdateSubcommand {
335    /// Updates one or more transactions in the local SQLite database by their IDs. At least one
336    /// transaction ID must be provided. When more than one ID is provided, all specified
337    /// transactions are updated with the same field values.
338    ///
339    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
340    Transactions(Box<UpdateTransactionsArgs>),
341
342    /// Updates one or more categories in the local SQLite database by their names. At least one
343    /// category name must be provided. When more than one name is provided, all specified
344    /// categories are updated with the same field values.
345    ///
346    /// Due to `ON UPDATE CASCADE` foreign key constraints, renaming a category automatically
347    /// updates all references in transactions and autocat rules.
348    ///
349    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
350    Categories(UpdateCategoriesArgs),
351
352    /// Updates one or more AutoCat rules in the local SQLite database by their IDs. At least one
353    /// ID must be provided. When more than one ID is provided, all specified rules are updated
354    /// with the same field values.
355    ///
356    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
357    Autocats(UpdateAutoCatsArgs),
358}
359
360/// Args for the `tiller update transactions` command.
361///
362/// Updates one or more transactions in the local SQLite database by their IDs. At least one
363/// transaction ID must be provided. When more than one ID is provided, all specified
364/// transactions are updated with the same field values.
365///
366/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
367#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
368pub struct UpdateTransactionsArgs {
369    /// One or more transaction IDs to update. All specified transactions will receive the same
370    /// updates.
371    #[arg(long, num_args = 1..)]
372    ids: Vec<String>,
373
374    /// The fields to update. Only fields with values will be modified; unspecified fields remain
375    /// unchanged.
376    #[clap(flatten)]
377    updates: TransactionUpdates,
378}
379
380impl UpdateTransactionsArgs {
381    pub fn new<S, I>(ids: I, updates: TransactionUpdates) -> Result<Self>
382    where
383        S: Into<String>,
384        I: IntoIterator<Item = S>,
385    {
386        let ids: Vec<String> = ids.into_iter().map(|s| s.into()).collect();
387        if ids.is_empty() {
388            return Err(anyhow!("At least one ID is required")).pub_result(ErrorType::Request);
389        }
390        Ok(Self { ids, updates })
391    }
392
393    pub fn ids(&self) -> &[String] {
394        &self.ids
395    }
396
397    pub fn updates(&self) -> &TransactionUpdates {
398        &self.updates
399    }
400}
401
402/// Args for the `tiller update categories` command.
403///
404/// Updates one or more categories in the local SQLite database by their names. At least one
405/// category name must be provided. When more than one name is provided, all specified
406/// categories are updated with the same field values.
407///
408/// The category name is the primary key. To rename a category, provide a single name and set
409/// the `--category` update field to the new name.
410///
411/// Due to `ON UPDATE CASCADE` foreign key constraints, renaming a category automatically
412/// updates all references in transactions and autocat rules.
413///
414/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
415#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
416pub struct UpdateCategoriesArgs {
417    /// One or more category names to update. All specified categories will receive the same
418    /// updates.
419    #[arg(long, num_args = 1..)]
420    names: Vec<String>,
421
422    /// The fields to update. Only fields with values will be modified; unspecified fields remain
423    /// unchanged.
424    #[clap(flatten)]
425    updates: CategoryUpdates,
426}
427
428impl UpdateCategoriesArgs {
429    pub fn new<S, I>(names: I, updates: CategoryUpdates) -> Result<Self>
430    where
431        S: Into<String>,
432        I: IntoIterator<Item = S>,
433    {
434        let names: Vec<String> = names.into_iter().map(|s| s.into()).collect();
435        if names.is_empty() {
436            return Err(anyhow!("At least one category name is required"))
437                .pub_result(ErrorType::Request);
438        }
439        Ok(Self { names, updates })
440    }
441
442    pub fn names(&self) -> &[String] {
443        &self.names
444    }
445
446    pub fn updates(&self) -> &CategoryUpdates {
447        &self.updates
448    }
449}
450
451/// Args for the `tiller update autocats` command.
452///
453/// Updates one or more AutoCat rules in the local SQLite database by their IDs. At least one
454/// ID must be provided. When more than one ID is provided, all specified rules are updated
455/// with the same field values.
456///
457/// AutoCat rules have a synthetic auto-increment primary key that is assigned when first
458/// synced down or inserted locally.
459///
460/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
461#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
462pub struct UpdateAutoCatsArgs {
463    /// One or more AutoCat rule IDs to update. All specified rules will receive the same
464    /// updates.
465    #[arg(long, num_args = 1..)]
466    ids: Vec<String>,
467
468    /// The fields to update. Only fields with values will be modified; unspecified fields remain
469    /// unchanged.
470    #[clap(flatten)]
471    updates: AutoCatUpdates,
472}
473
474impl UpdateAutoCatsArgs {
475    pub fn new<S, I>(ids: I, updates: AutoCatUpdates) -> Result<Self>
476    where
477        S: Into<String>,
478        I: IntoIterator<Item = S>,
479    {
480        let ids: Vec<String> = ids.into_iter().map(|s| s.into()).collect();
481        if ids.is_empty() {
482            return Err(anyhow!("At least one AutoCat ID is required"))
483                .pub_result(ErrorType::Request);
484        }
485        Ok(Self { ids, updates })
486    }
487
488    pub fn ids(&self) -> &[String] {
489        &self.ids
490    }
491
492    pub fn updates(&self) -> &AutoCatUpdates {
493        &self.updates
494    }
495}
496
497// =============================================================================
498// Delete command structs
499// =============================================================================
500
501/// Arguments for `tiller delete` commands.
502#[derive(Debug, Parser, Clone)]
503pub struct DeleteArgs {
504    #[command(subcommand)]
505    entity: DeleteSubcommand,
506}
507
508impl DeleteArgs {
509    pub fn entity(&self) -> &DeleteSubcommand {
510        &self.entity
511    }
512}
513
514/// Subcommands for `tiller delete`.
515#[derive(Subcommand, Debug, Clone)]
516pub enum DeleteSubcommand {
517    /// Deletes one or more transactions from the local SQLite database by their IDs. At least one
518    /// transaction ID must be provided.
519    ///
520    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
521    ///
522    /// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
523    /// yet, you can restore the transactions by running `sync down` to re-download from the sheet.
524    Transactions(DeleteTransactionsArgs),
525
526    /// Deletes one or more categories from the local SQLite database by their names.
527    ///
528    /// Due to `ON DELETE RESTRICT` foreign key constraints, a category cannot be deleted if any
529    /// transactions or AutoCat rules reference it. You must first update or delete those
530    /// references before deleting the category.
531    ///
532    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
533    ///
534    /// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
535    /// yet, you can restore the categories by running `sync down` to re-download from the sheet.
536    Categories(DeleteCategoriesArgs),
537
538    /// Deletes one or more AutoCat rules from the local SQLite database by their IDs.
539    ///
540    /// AutoCat rules have synthetic auto-increment IDs assigned when first synced down or inserted
541    /// locally.
542    ///
543    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
544    ///
545    /// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
546    /// yet, you can restore the rules by running `sync down` to re-download from the sheet.
547    Autocats(DeleteAutoCatsArgs),
548}
549
550/// Args for the `tiller delete transactions` command.
551///
552/// Deletes one or more transactions from the local SQLite database by their IDs. At least one
553/// transaction ID must be provided.
554///
555/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
556///
557/// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
558/// yet, you can restore the transactions by running `sync down` to re-download from the sheet.
559#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
560pub struct DeleteTransactionsArgs {
561    /// One or more transaction IDs to delete.
562    #[arg(long = "id", required = true)]
563    ids: Vec<String>,
564}
565
566impl DeleteTransactionsArgs {
567    pub fn new<S, I>(ids: I) -> Result<Self>
568    where
569        S: Into<String>,
570        I: IntoIterator<Item = S>,
571    {
572        let ids: Vec<String> = ids.into_iter().map(|s| s.into()).collect();
573        if ids.is_empty() {
574            return Err(anyhow!("At least one ID is required")).pub_result(ErrorType::Request);
575        }
576        Ok(Self { ids })
577    }
578
579    pub fn ids(&self) -> &[String] {
580        &self.ids
581    }
582}
583
584/// Args for the `tiller delete categories` command.
585///
586/// Deletes one or more categories from the local SQLite database by their names. At least one
587/// category name must be provided.
588///
589/// Due to `ON DELETE RESTRICT` foreign key constraints, a category cannot be deleted if any
590/// transactions or AutoCat rules reference it. You must first update or delete those references
591/// before deleting the category.
592///
593/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
594///
595/// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
596/// yet, you can restore the categories by running `sync down` to re-download from the sheet.
597#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
598pub struct DeleteCategoriesArgs {
599    /// One or more category names to delete.
600    #[arg(long = "name", required = true)]
601    names: Vec<String>,
602}
603
604impl DeleteCategoriesArgs {
605    pub fn new<S, I>(names: I) -> Result<Self>
606    where
607        S: Into<String>,
608        I: IntoIterator<Item = S>,
609    {
610        let names: Vec<String> = names.into_iter().map(|s| s.into()).collect();
611        if names.is_empty() {
612            return Err(anyhow!("At least one category name is required"))
613                .pub_result(ErrorType::Request);
614        }
615        Ok(Self { names })
616    }
617
618    pub fn names(&self) -> &[String] {
619        &self.names
620    }
621}
622
623/// Args for the `tiller delete autocats` command.
624///
625/// Deletes one or more AutoCat rules from the local SQLite database by their IDs. At least one
626/// ID must be provided.
627///
628/// AutoCat rules have synthetic auto-increment IDs assigned when first synced down or inserted
629/// locally.
630///
631/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
632///
633/// **Warning**: This operation cannot be undone locally. However, if you haven't run `sync up`
634/// yet, you can restore the rules by running `sync down` to re-download from the sheet.
635#[derive(Debug, Parser, Clone, Serialize, Deserialize, JsonSchema)]
636pub struct DeleteAutoCatsArgs {
637    /// One or more AutoCat rule IDs to delete.
638    #[arg(long = "id", required = true)]
639    ids: Vec<String>,
640}
641
642impl DeleteAutoCatsArgs {
643    pub fn new<S, I>(ids: I) -> Result<Self>
644    where
645        S: Into<String>,
646        I: IntoIterator<Item = S>,
647    {
648        let ids: Vec<String> = ids.into_iter().map(|s| s.into()).collect();
649        if ids.is_empty() {
650            return Err(anyhow!("At least one ID is required")).pub_result(ErrorType::Request);
651        }
652        Ok(Self { ids })
653    }
654
655    pub fn ids(&self) -> &[String] {
656        &self.ids
657    }
658}
659
660/// Arguments for `tiller insert` commands.
661#[derive(Debug, Parser, Clone)]
662pub struct InsertArgs {
663    #[command(subcommand)]
664    entity: InsertSubcommand,
665}
666
667impl InsertArgs {
668    pub fn entity(&self) -> &InsertSubcommand {
669        &self.entity
670    }
671}
672
673/// Subcommands for `tiller insert`.
674#[derive(Subcommand, Debug, Clone)]
675pub enum InsertSubcommand {
676    /// Inserts a new transaction into the local SQLite database.
677    ///
678    /// A unique transaction ID is automatically generated with a `user-` prefix to distinguish it
679    /// from Tiller-created transactions. The generated ID is returned on success.
680    ///
681    /// The `date` and `amount` fields are required. All other fields are optional.
682    ///
683    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
684    Transaction(Box<InsertTransactionArgs>),
685
686    /// Inserts a new category into the local SQLite database.
687    ///
688    /// The category name is required and must be unique as it serves as the primary key.
689    /// The name is returned on success.
690    ///
691    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
692    Category(InsertCategoryArgs),
693
694    /// Inserts a new AutoCat rule into the local SQLite database.
695    ///
696    /// AutoCat rules define automatic categorization criteria for transactions. The primary key
697    /// is auto-generated and returned on success.
698    ///
699    /// All fields are optional - an empty rule can be created and updated later. However, a useful
700    /// rule typically needs at least a category and one or more filter criteria.
701    ///
702    /// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
703    Autocat(Box<InsertAutoCatArgs>),
704}
705
706/// Args for the `tiller insert transaction` command.
707///
708/// Inserts a new transaction into the local SQLite database. A unique transaction ID is
709/// automatically generated with a `user-` prefix.
710///
711/// The `date` and `amount` fields are required. All other fields are optional.
712///
713/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
714///
715/// See tiller documentation for more information about the semantic meanings of transaction
716/// columns: <https://help.tiller.com/en/articles/432681-transactions-sheet-columns>
717#[derive(Debug, Clone, Parser, Serialize, Deserialize, JsonSchema)]
718#[schemars(title = "InsertTransactionArgs")]
719pub struct InsertTransactionArgs {
720    /// The posted date (when the transaction cleared) or transaction date (when the transaction
721    /// occurred). Posted date takes priority except for investment accounts. **Required.**
722    #[arg(long)]
723    pub date: Date,
724
725    /// Transaction value where income and credits are positive; expenses and debits are negative.
726    /// **Required.**
727    #[arg(long)]
728    pub amount: Amount,
729
730    /// Cleaned-up merchant information from your bank.
731    #[serde(skip_serializing_if = "Option::is_none")]
732    #[arg(long)]
733    pub description: Option<String>,
734
735    /// The account name as it appears on your bank's website or your custom nickname from Tiller
736    /// Console.
737    #[serde(skip_serializing_if = "Option::is_none")]
738    #[arg(long)]
739    pub account: Option<String>,
740
741    /// Last four digits of the bank account number (e.g., "xxxx1102").
742    #[serde(skip_serializing_if = "Option::is_none")]
743    #[arg(long)]
744    pub account_number: Option<String>,
745
746    /// Financial institution name (e.g., "Bank of America").
747    #[serde(skip_serializing_if = "Option::is_none")]
748    #[arg(long)]
749    pub institution: Option<String>,
750
751    /// First day of the transaction's month, useful for pivot tables and reporting.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    #[arg(long)]
754    pub month: Option<Date>,
755
756    /// Sunday date of the transaction's week for weekly breakdowns.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    #[arg(long)]
759    pub week: Option<Date>,
760
761    /// Unmodified merchant details directly from your bank, including codes and numbers.
762    #[serde(skip_serializing_if = "Option::is_none")]
763    #[arg(long)]
764    pub full_description: Option<String>,
765
766    /// A unique ID assigned to your accounts by Tiller's systems. Important for troubleshooting;
767    /// do not delete.
768    #[serde(skip_serializing_if = "Option::is_none")]
769    #[arg(long)]
770    pub account_id: Option<String>,
771
772    /// Check number when available for checks you write.
773    #[serde(skip_serializing_if = "Option::is_none")]
774    #[arg(long)]
775    pub check_number: Option<String>,
776
777    /// When the transaction was added to the spreadsheet.
778    #[serde(skip_serializing_if = "Option::is_none")]
779    #[arg(long)]
780    pub date_added: Option<Date>,
781
782    /// Normalized merchant name standardizing variants (e.g., "Amazon" for multiple Amazon
783    /// formats). Optional automated column.
784    #[serde(skip_serializing_if = "Option::is_none")]
785    #[arg(long)]
786    pub merchant_name: Option<String>,
787
788    /// Data provider's category suggestion based on merchant knowledge. Optional automated column;
789    /// not included in core templates.
790    #[serde(skip_serializing_if = "Option::is_none")]
791    #[arg(long)]
792    pub category_hint: Option<String>,
793
794    /// User-assigned category. Non-automated by default to promote spending awareness; AutoCat
795    /// available for automation. Must reference an existing category name.
796    #[serde(skip_serializing_if = "Option::is_none")]
797    #[arg(long)]
798    pub category: Option<String>,
799
800    /// Custom notes about specific transactions. Leveraged by Category Rollup reports.
801    #[serde(skip_serializing_if = "Option::is_none")]
802    #[arg(long)]
803    pub note: Option<String>,
804
805    /// User-defined tags for additional transaction categorization.
806    #[serde(skip_serializing_if = "Option::is_none")]
807    #[arg(long)]
808    pub tags: Option<String>,
809
810    /// Date when AutoCat automatically categorized or updated a transaction. Google Sheets Add-on
811    /// column.
812    #[serde(skip_serializing_if = "Option::is_none")]
813    #[arg(long)]
814    pub categorized_date: Option<Date>,
815
816    /// For reconciling transactions to bank statements. Google Sheets Add-on column.
817    #[serde(skip_serializing_if = "Option::is_none")]
818    #[arg(long)]
819    pub statement: Option<String>,
820
821    /// Supports workflows including CSV imports. Google Sheets Add-on column.
822    #[serde(skip_serializing_if = "Option::is_none")]
823    #[arg(long)]
824    pub metadata: Option<String>,
825
826    /// Custom columns not part of the standard Tiller schema.
827    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
828    #[arg(long = "other-field", value_parser = utils::parse_key_val)]
829    pub other_fields: BTreeMap<String, String>,
830}
831
832/// Args for the `tiller insert category` command.
833///
834/// Inserts a new category into the local SQLite database. The category name is required and
835/// must be unique as it serves as the primary key.
836///
837/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
838///
839/// See tiller documentation for more information about the Categories sheet:
840/// <https://help.tiller.com/en/articles/432680-categories-sheet>
841#[derive(Debug, Clone, Parser, Serialize, Deserialize, JsonSchema)]
842#[schemars(title = "InsertCategoryArgs")]
843pub struct InsertCategoryArgs {
844    /// The name of the category. This is the primary key and must be unique. **Required.**
845    #[arg(long)]
846    pub name: String,
847
848    /// The group this category belongs to. Groups organize related categories together for
849    /// reporting purposes (e.g., "Food", "Transportation", "Housing"). All categories should have
850    /// a Group assigned.
851    #[serde(skip_serializing_if = "Option::is_none")]
852    #[arg(long)]
853    pub group: Option<String>,
854
855    /// The type classification for this category. Common types include "Expense", "Income", and
856    /// "Transfer". All categories should have a Type assigned.
857    #[serde(skip_serializing_if = "Option::is_none")]
858    #[arg(long, name = "type")]
859    pub r#type: Option<String>,
860
861    /// Controls visibility in reports. Set to "Hide" to exclude this category from reports.
862    /// This is useful for categories like credit card payments or internal transfers that you
863    /// don't want appearing in spending reports.
864    #[serde(skip_serializing_if = "Option::is_none")]
865    #[arg(long)]
866    pub hide_from_reports: Option<String>,
867
868    /// Custom columns not part of the standard Tiller schema.
869    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
870    #[arg(long = "other-field", value_parser = utils::parse_key_val)]
871    pub other_fields: BTreeMap<String, String>,
872}
873
874/// Args for the `tiller insert autocat` command.
875///
876/// Inserts a new AutoCat rule into the local SQLite database. The primary key is auto-generated
877/// and returned on success.
878///
879/// All fields are optional - an empty rule can be created and updated later. However, a useful
880/// rule typically needs at least a category and one or more filter criteria.
881///
882/// Changes are made locally only. Use `sync up` to upload local changes to the Google Sheet.
883///
884/// See tiller documentation for more information about AutoCat:
885/// <https://help.tiller.com/en/articles/3792984-autocat-for-google-sheets>
886#[derive(Debug, Clone, Parser, Serialize, Deserialize, JsonSchema)]
887#[schemars(title = "InsertAutoCatArgs")]
888pub struct InsertAutoCatArgs {
889    /// The category to assign when this rule matches. This is an override column - when filter
890    /// conditions match, this category value gets applied to matching transactions. Must reference
891    /// an existing category name.
892    #[serde(skip_serializing_if = "Option::is_none")]
893    #[arg(long)]
894    pub category: Option<String>,
895
896    /// Override column to standardize or clean up transaction descriptions. For example, replace
897    /// "Seattle Starbucks store 1234" with simply "Starbucks".
898    #[serde(skip_serializing_if = "Option::is_none")]
899    #[arg(long)]
900    pub description: Option<String>,
901
902    /// Filter criteria: searches the Description column for matching text (case-insensitive).
903    /// Supports multiple keywords wrapped in quotes and separated by commas (OR-ed together).
904    #[serde(skip_serializing_if = "Option::is_none")]
905    #[arg(long)]
906    pub description_contains: Option<String>,
907
908    /// Filter criteria: searches the Account column for matching text to narrow rule application.
909    #[serde(skip_serializing_if = "Option::is_none")]
910    #[arg(long)]
911    pub account_contains: Option<String>,
912
913    /// Filter criteria: searches the Institution column for matching text to narrow rule
914    /// application.
915    #[serde(skip_serializing_if = "Option::is_none")]
916    #[arg(long)]
917    pub institution_contains: Option<String>,
918
919    /// Filter criteria: minimum transaction amount (absolute value). Use with Amount Max to set
920    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
921    #[serde(skip_serializing_if = "Option::is_none")]
922    #[arg(long, value_parser = utils::parse_amount)]
923    pub amount_min: Option<Amount>,
924
925    /// Filter criteria: maximum transaction amount (absolute value). Use with Amount Min to set
926    /// a range. For negative amounts (expenses), set Amount Polarity to "Negative".
927    #[serde(skip_serializing_if = "Option::is_none")]
928    #[arg(long, value_parser = utils::parse_amount)]
929    pub amount_max: Option<Amount>,
930
931    /// Filter criteria: exact amount to match.
932    #[serde(skip_serializing_if = "Option::is_none")]
933    #[arg(long, value_parser = utils::parse_amount)]
934    pub amount_equals: Option<Amount>,
935
936    /// Filter criteria: exact match for the Description column (more specific than "contains").
937    #[serde(skip_serializing_if = "Option::is_none")]
938    #[arg(long)]
939    pub description_equals: Option<String>,
940
941    /// Override column for the full/raw description field.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    #[arg(long)]
944    pub description_full: Option<String>,
945
946    /// Filter criteria: searches the Full Description column for matching text.
947    #[serde(skip_serializing_if = "Option::is_none")]
948    #[arg(long)]
949    pub full_description_contains: Option<String>,
950
951    /// Filter criteria: searches the Amount column as text for matching patterns.
952    #[serde(skip_serializing_if = "Option::is_none")]
953    #[arg(long)]
954    pub amount_contains: Option<String>,
955
956    /// Custom columns not part of the standard Tiller schema.
957    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
958    #[arg(long = "other-field", value_parser = utils::parse_key_val)]
959    pub other_fields: BTreeMap<String, String>,
960}
961
962fn default_tiller_home() -> DisplayPath {
963    DisplayPath(match dirs::home_dir() {
964        Some(home) => home.join("tiller"),
965        None => {
966            error!(
967                "There was an error when trying to get your home directory. You can get around \
968                this by providing --tiller-home or TILLER_HOME instead of relying on the default \
969                tiller home directory. If you continue using the program right now, you may have \
970                problems!",
971            );
972            PathBuf::from("tiller")
973        }
974    })
975}
976
977#[derive(Debug, Default, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
978pub struct DisplayPath(PathBuf);
979
980impl From<PathBuf> for DisplayPath {
981    fn from(value: PathBuf) -> Self {
982        DisplayPath(value)
983    }
984}
985
986impl Deref for DisplayPath {
987    type Target = Path;
988
989    fn deref(&self) -> &Self::Target {
990        &self.0
991    }
992}
993
994impl AsRef<Path> for DisplayPath {
995    fn as_ref(&self) -> &Path {
996        &self.0
997    }
998}
999
1000impl Display for DisplayPath {
1001    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1002        write!(f, "{}", self.0.to_string_lossy())
1003    }
1004}
1005
1006impl FromStr for DisplayPath {
1007    type Err = Infallible;
1008
1009    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
1010        Ok(Self(PathBuf::from(s)))
1011    }
1012}
1013
1014impl DisplayPath {
1015    pub fn new(path: PathBuf) -> Self {
1016        Self(path)
1017    }
1018
1019    pub fn path(&self) -> &Path {
1020        &self.0
1021    }
1022}