Skip to main content

tiller_sync/api/
mod.rs

1//! This module encapsulates the interactions with the Google OAuth and the Google Sheets API.
2//!
3//! - A `Sheet` trait exists to abstract away interactions with Google sheets.
4//! - A `Tiller` trait exists to abstract away the specifics of a tiller Google sheet.
5
6mod files;
7mod oauth;
8mod sheet;
9mod sheet_test_client;
10mod tiller;
11
12use crate::api::sheet::GoogleSheet;
13use crate::api::tiller::TillerImpl;
14use crate::model::TillerData;
15use crate::Config;
16use crate::Result;
17pub(super) use oauth::TokenProvider;
18pub(super) use sheet_test_client::TestSheet;
19use std::env::VarError;
20
21use crate::error::{ErrorType, IntoResult, Res};
22#[cfg(test)]
23pub(super) use sheet_test_client::{SheetCall, TestSheetState};
24
25// OAuth scopes required for Sheets API access and Drive file operations (backup copies)
26// Note: `drive` scope (not `drive.file`) is required because `drive.file` only grants access
27// to files created by this app, not pre-existing files like the user's Tiller spreadsheet.
28const OAUTH_SCOPES: &[&str] = &[
29    "https://www.googleapis.com/auth/spreadsheets",
30    "https://www.googleapis.com/auth/drive",
31];
32
33// These are the sheet tab names that we care about.
34pub(crate) const TRANSACTIONS: &str = "Transactions";
35pub(crate) const CATEGORIES: &str = "Categories";
36pub(crate) const AUTO_CAT: &str = "AutoCat";
37
38/// Represents a range of data to write to a sheet.
39#[derive(Debug, Clone, PartialEq)]
40pub struct SheetRange {
41    /// The A1 notation range, e.g., "Transactions!A1:ZZ1"
42    pub range: String,
43    /// The data to write (rows of cells)
44    pub values: Vec<Vec<String>>,
45}
46
47/// For testing purposes, this can be placed into the environment to cause the application to use
48/// seeded, testing, in-memory data instead of accessing a live Google sheet.
49pub(crate) const MODE_ENV: &str = "TILLER_SYNC_IN_TEST_MODE";
50
51/// An enum representing whether the app is in testing mode or using a live Google sheet.
52#[derive(Default, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
53pub enum Mode {
54    /// The app is live, using a real Google sheet.
55    #[default]
56    Google,
57    /// The app is in testing mode, using fake, in-memory data.
58    Testing,
59}
60
61impl Mode {
62    /// Check for the environment variable `TILLER_SYNC_IN_TEST_MODE`. If it exists, returns
63    /// `Mode::Testing`, if not, returns `Mode::Google`.
64    //
65    // The idea here is that the `Mode` should be pushed down from `main.rs`. We should never used
66    // from_env() in tests. The only place it should ever be used is in `main.rs`.
67    pub fn from_env() -> Self {
68        match std::env::var(MODE_ENV) {
69            Err(VarError::NotPresent) => Self::Google,
70            _ => Self::Testing,
71        }
72    }
73}
74
75/// Construct a `Sheet` object based on `mode`.
76/// - For `Mode::Google`: creates a token provider and constructs a Google sheet object
77/// - For `Mode::Test`: does not construct a token provider and constructs a Test sheet object
78pub async fn sheet(config: Config, mode: Mode) -> Result<Box<dyn Sheet>> {
79    let sheet_client: Box<dyn Sheet> = match mode {
80        Mode::Google => {
81            let token_provider =
82                TokenProvider::load(config.client_secret_path(), config.token_path())
83                    .await
84                    .pub_result(ErrorType::Config)?;
85            Box::new(
86                GoogleSheet::new(config.clone(), token_provider)
87                    .await
88                    .pub_result(ErrorType::Internal)?,
89            )
90        }
91        Mode::Testing => Box::new(TestSheet::new_with_seed_data(config.spreadsheet_id())),
92    };
93
94    Ok(sheet_client)
95}
96
97/// Construct a `Tiller` client, which will use `sheet` to communicate with Google sheets (or, in
98/// testing mode, will use in-memory seed data).
99pub async fn tiller(sheet: Box<dyn Sheet>) -> Res<impl Tiller> {
100    TillerImpl::new(sheet).await
101}
102
103#[async_trait::async_trait]
104pub trait Sheet: Send {
105    /// Get the data from a Google sheet.
106    async fn get(&mut self, sheet_name: &str) -> Res<Vec<Vec<String>>>;
107
108    /// Get the formulas from a Google sheet (returns formulas for formula cells, values for non-formula cells).
109    async fn get_formulas(&mut self, sheet_name: &str) -> Res<Vec<Vec<String>>>;
110
111    /// Clear specified ranges in the spreadsheet.
112    /// Each range should be in A1 notation, e.g., "Transactions!A2:ZZ".
113    async fn clear_ranges(&mut self, ranges: &[&str]) -> Res<()>;
114
115    /// Write data to specified ranges in the spreadsheet.
116    /// Uses ValueInputOption::UserEntered so Sheets can parse dates, numbers, and formulas.
117    async fn write_ranges(&mut self, data: &[SheetRange]) -> Res<()>;
118
119    /// Create a copy of the spreadsheet using the Google Drive API.
120    /// Returns the file ID of the new copy.
121    async fn copy_spreadsheet(&mut self, new_name: &str) -> Res<String>;
122}
123
124#[async_trait::async_trait]
125pub trait Tiller {
126    /// Get the data from the Tiller Google sheet.
127    async fn get_data(&mut self) -> Res<TillerData>;
128
129    /// Create a backup copy of the spreadsheet.
130    /// Returns the file ID of the new copy.
131    async fn copy_spreadsheet(&mut self, new_name: &str) -> Res<String>;
132
133    /// Clear and write data to the Google sheet.
134    /// This clears all data rows (preserving headers) and writes new data.
135    async fn clear_and_write_data(&mut self, data: &TillerData) -> Res<()>;
136
137    /// Verify that the write was successful by re-fetching row counts.
138    /// Returns the counts (transactions, categories, autocat) if verification passes.
139    async fn verify_write(&mut self, expected: &TillerData) -> Res<(usize, usize, usize)>;
140}
141
142#[tokio::test]
143async fn test_sync_down_behavior() {
144    use crate::model::{Amount, RowCol};
145    use std::str::FromStr as _;
146
147    let client = Box::new(TestSheet::new_with_seed_data("test_sync_down_behavior"));
148    let mut tiller = crate::api::tiller(client).await.unwrap();
149    let tiller_data = tiller.get_data().await.unwrap();
150
151    // Check that the test data is coming through correctly with an =ABS(E1) formula in
152    // Custom Column
153    for (tix, transaction) in tiller_data.transactions.data().iter().enumerate() {
154        let amount = &transaction.amount;
155        let abs = Amount::from_str(transaction.other_fields.get("Custom Column").unwrap()).unwrap();
156        assert_eq!(amount.value().abs(), abs.value());
157        let formula = format!("=ABS(E{})", tix + 2);
158        let cix = tiller_data
159            .transactions
160            .mapping()
161            ._header_index("Custom Column")
162            .unwrap();
163        let formula_cell = RowCol::new(tix, cix);
164        let found_formula = tiller_data
165            .transactions
166            .formulas()
167            .get(&formula_cell)
168            .unwrap()
169            .to_owned();
170        assert_eq!(formula, found_formula);
171    }
172
173    // Round-trip the JSON serialization/deserialization
174    let tiller_data_serialized = serde_json::to_string_pretty(&tiller_data).unwrap();
175    let tiller_data_deserialized: TillerData =
176        serde_json::from_str(&tiller_data_serialized).unwrap();
177    let tiller_data_serialized_again =
178        serde_json::to_string_pretty(&tiller_data_deserialized).unwrap();
179    assert_eq!(tiller_data, tiller_data_deserialized);
180    assert_eq!(tiller_data_serialized, tiller_data_serialized_again)
181}