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}