Skip to main content

tiller_sync/
config.rs

1//! Configuration file handling for Tiller.
2//!
3//! The configuration file is stored at `$TILLER_HOME/config.json` and contains settings for
4//! the Tiller application including the Google Sheet URL, backup settings, and authentication
5//! file paths.
6
7use crate::backup::Backup;
8use crate::db::Db;
9use crate::error::{ErrorType, IntoResult, Res};
10use crate::{utils, Result};
11use anyhow::{anyhow, Context};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15const APP_NAME: &str = "tiller";
16const CONFIG_VERSION: u8 = 1;
17const BACKUP_COPIES: u32 = 5;
18const SECRETS: &str = ".secrets";
19const BACKUPS: &str = ".backups";
20const CLIENT_SECRET_JSON: &str = "client_secret.json";
21const TOKEN_JSON: &str = "token.json";
22const CONFIG_JSON: &str = "config.json";
23const TILLER_SQLITE: &str = "tiller.sqlite";
24
25/// The `Config` object represents the configuration of the app. You instantiate it by providing
26/// the path to `$TILLER_HOME` and from there it loads `$TILLER_HOME/config.json`. It provides
27/// paths to other items that are either configurable or are expected in a certain location within
28/// the tiller home directory.
29#[derive(Debug, Clone)]
30pub struct Config {
31    root: PathBuf,
32    backups: PathBuf,
33    secrets: PathBuf,
34    config_path: PathBuf,
35    config_file: ConfigFile,
36    db: Db,
37    spreadsheet_id: String,
38    sqlite_path: PathBuf,
39}
40
41impl Config {
42    /// Creates the data directory, its subdirectories and:
43    /// - Creates an initial `config.json` file using `sheet_url` along with default settings
44    /// - Copies `secret_file` into its default location in the data dir.
45    ///
46    /// # Arguments
47    /// - `dir` - The directory that will be the root of data directory, e.g. `$HOME/tiller`
48    /// - `secret_file` - The downloaded OAuth 2.0 client credentials JSON needed to start the Google
49    ///   OAuth workflow. This will be copied from the `secret_file` path to its default location and
50    ///   name in the data directory.
51    /// - `sheet_url` - The URL of the Google Sheet where the Tiller financial data is stored.
52    ///   e.g.https://docs.google.com/spreadsheets/d/1a7Km9FxQwRbPt82JvN4LzYpH5OcGnWsT6iDuE3VhMjX
53    ///
54    /// # Errors
55    /// - Returns an error if any file operations fail.
56    pub async fn create(dir: impl Into<PathBuf>, secret_file: &Path, url: &str) -> Result<Self> {
57        // Create the directory if it does not exist
58        let maybe_relative = dir.into();
59        utils::make_dir(&maybe_relative)
60            .await
61            .context("Unable to create the tiller home directory")
62            .pub_result(ErrorType::Config)?;
63
64        // Canonicalize the directory path
65        let root = utils::canonicalize(&maybe_relative)
66            .await
67            .pub_result(ErrorType::Internal)?;
68
69        // Create the subdirectories
70        let backups_dir = root.join(".backups");
71        utils::make_dir(&backups_dir)
72            .await
73            .pub_result(ErrorType::Internal)?;
74        let secrets_dir = root.join(".secrets");
75        utils::make_dir(&secrets_dir)
76            .await
77            .pub_result(ErrorType::Internal)?;
78
79        // Copy the Google OAuth client credentials file to its default location in the data dir
80        let secret_destination = secrets_dir.join(CLIENT_SECRET_JSON);
81        utils::copy(secret_file, &secret_destination)
82            .await
83            .pub_result(ErrorType::Internal)?;
84        let config_path = root.join(CONFIG_JSON);
85
86        // Create and save an initial ConfigFile in the datastore
87        let config_file = ConfigFile {
88            app_name: APP_NAME.to_string(),
89            config_version: CONFIG_VERSION,
90            sheet_url: url.to_string(),
91            backup_copies: BACKUP_COPIES,
92            client_secret_path: None,
93            token_path: None,
94        };
95        config_file.save(&config_path).await?;
96
97        // Initialize the SQLite database
98        let db_path = root.join(TILLER_SQLITE);
99        let db = Db::init(&db_path)
100            .await
101            .context("Unable to create SQLite DB")
102            .pub_result(ErrorType::Database)?;
103
104        // Extract the spreadsheet ID from the URL
105        let spreadsheet_id = extract_spreadsheet_id(url)
106            .context("Failed to extract spreadsheet ID from sheet URL")
107            .pub_result(ErrorType::Config)?
108            .to_string();
109
110        // Return a new `Config` object that represents a data directory that is ready to use
111        Ok(Self {
112            root,
113            backups: backups_dir,
114            secrets: secrets_dir,
115            config_path,
116            config_file,
117            db,
118            spreadsheet_id,
119            sqlite_path: db_path,
120        })
121    }
122
123    /// This will
124    /// - validate that the  `tiller_home` exists and that the config file exists
125    /// - load the config file
126    /// - validate that the backups and secrets directories exist
127    /// - return the loaded configuration object
128    pub async fn load(tiller_home: impl Into<PathBuf>) -> Result<Self> {
129        let maybe_relative = tiller_home.into();
130        let root = utils::canonicalize(&maybe_relative)
131            .await
132            .pub_result(ErrorType::Internal)?;
133
134        // Validate that the home directory exists.
135        let _ = utils::read_dir(&root)
136            .await
137            .context("Tiller Home is missing")
138            .pub_result(ErrorType::Internal)?;
139
140        let config_path = root.join("config.json");
141        if !config_path.is_file() {
142            return Err(anyhow!(
143                "The config file is missing '{}'",
144                config_path.display()
145            ))
146            .pub_result(ErrorType::Config);
147        }
148        let config_file = ConfigFile::load(&config_path).await?;
149
150        // Extract the spreadsheet ID from the URL
151        let spreadsheet_id = extract_spreadsheet_id(&config_file.sheet_url)
152            .context("Failed to extract spreadsheet ID from sheet URL")
153            .pub_result(ErrorType::Config)?
154            .to_string();
155
156        // Load the SQLite database
157        let db_path = root.join(TILLER_SQLITE);
158        let db = Db::load(&db_path)
159            .await
160            .context("Unable to load SQLite DB")
161            .pub_result(ErrorType::Database)?;
162
163        let config = Self {
164            root: root.clone(),
165            backups: root.join(BACKUPS),
166            secrets: root.join(SECRETS),
167            config_path,
168            config_file,
169            db,
170            spreadsheet_id,
171            sqlite_path: db_path,
172        };
173        if !config.backups.is_dir() {
174            return Err(anyhow!(
175                "The backups directory is missing '{}'",
176                config.backups.display()
177            ))
178            .pub_result(ErrorType::Config);
179        }
180        if !config.secrets.is_dir() {
181            return Err(anyhow!(
182                "The secrets directory is missing '{}'",
183                config.secrets.display()
184            ))
185            .pub_result(ErrorType::Config);
186        }
187        Ok(config)
188    }
189
190    pub fn root(&self) -> &Path {
191        &self.root
192    }
193
194    pub fn config_path(&self) -> &Path {
195        &self.config_path
196    }
197
198    pub(crate) fn db(&self) -> &Db {
199        &self.db
200    }
201
202    pub fn backups(&self) -> &Path {
203        &self.backups
204    }
205
206    pub fn secrets(&self) -> &Path {
207        &self.secrets
208    }
209
210    pub fn sheet_url(&self) -> &str {
211        &self.config_file.sheet_url
212    }
213
214    pub fn spreadsheet_id(&self) -> &str {
215        &self.spreadsheet_id
216    }
217
218    pub fn sqlite_path(&self) -> &Path {
219        &self.sqlite_path
220    }
221
222    pub fn backup_copies(&self) -> u32 {
223        self.config_file.backup_copies
224    }
225
226    /// Returns the stored `client_secret_path` if it is absolute, otherwise resolves the relative path.
227    pub fn client_secret_path(&self) -> PathBuf {
228        self.resolve_secrets_file_path(self.config_file.client_secret_path())
229    }
230
231    /// Returns the stored `token_path` if it is absolute, otherwise resolves the relative path.
232    pub fn token_path(&self) -> PathBuf {
233        self.resolve_secrets_file_path(self.config_file.token_path())
234    }
235
236    /// Creates a new `Backup` instance for managing backup files.
237    pub(crate) fn backup(&self) -> Backup {
238        Backup::new(self)
239    }
240
241    /// Checks if `p` is relative, and if so, resolves it. Returns it unchanged if it is absolute.
242    fn resolve_secrets_file_path(&self, p: PathBuf) -> PathBuf {
243        if p.is_absolute() {
244            return p;
245        }
246        self.root.join(p)
247    }
248}
249
250/// Represents the serialization and deserialization format of the configuration file.
251///
252/// Example configuration:
253/// ```json
254/// {
255///   "app_name": "tiller",
256///   "config_version": "v0.1.0",
257///   "sheet_url": "https://docs.google.com/spreadsheets/d/7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL",
258///   "backup_copies": 5,
259///   "client_secret_path": ".secrets/client_secret.json",
260///   "token_path": ".secrets/token.json"
261/// }
262/// ```
263#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
264struct ConfigFile {
265    /// Application name, should always be "tiller"
266    app_name: String,
267
268    /// Configuration file version
269    config_version: u8,
270
271    /// URL to the Tiller Google Sheet
272    sheet_url: String,
273
274    /// Number of backup copies to keep
275    backup_copies: u32,
276
277    /// Path to the OAuth 2.0 client credentials file (optional, relative to config.json or absolute)
278    /// Defaults to $TILLER_HOME/.secrets/client_secret.json if not specified
279    #[serde(skip_serializing_if = "Option::is_none")]
280    client_secret_path: Option<PathBuf>,
281
282    /// Path to the OAuth token file (optional, relative to config.json or absolute)
283    /// Defaults to $TILLER_HOME/.secrets/token.json if not specified
284    #[serde(skip_serializing_if = "Option::is_none")]
285    token_path: Option<PathBuf>,
286}
287
288impl Default for ConfigFile {
289    fn default() -> Self {
290        Self {
291            app_name: APP_NAME.to_string(),
292            config_version: CONFIG_VERSION,
293            sheet_url: String::new(),
294            backup_copies: 5,
295            client_secret_path: None,
296            token_path: None,
297        }
298    }
299}
300
301impl ConfigFile {
302    /// Loads a ConfigFile asynchronously from the specified path.
303    ///
304    /// # Arguments
305    /// * `path` - Path to the config.json file
306    ///
307    /// # Errors
308    /// Returns an error if the file cannot be read or parsed
309    pub async fn load(path: impl AsRef<Path>) -> Result<Self> {
310        let path = path.as_ref();
311        let content = utils::read(path).await.pub_result(ErrorType::Internal)?;
312
313        let config: ConfigFile = serde_json::from_str(&content)
314            .with_context(|| format!("Failed to parse config file at {}", path.display()))
315            .pub_result(ErrorType::Config)?;
316
317        // Validate app_name
318        if config.app_name != APP_NAME {
319            return Err(anyhow!(
320                "Invalid app_name in config file: expected '{}', got '{}'",
321                APP_NAME,
322                config.app_name
323            ))
324            .pub_result(ErrorType::Config);
325        }
326
327        Ok(config)
328    }
329
330    /// Saves the ConfigFile to the specified path.
331    ///
332    /// # Arguments
333    /// * `path` - Path where the config.json file should be saved
334    ///
335    /// # Errors
336    /// Returns an error if the file cannot be written
337    pub async fn save(&self, path: impl AsRef<Path>) -> Result<()> {
338        let p = path.as_ref();
339        let data = serde_json::to_string_pretty(self)
340            .context("Unable to serialize config")
341            .pub_result(ErrorType::Internal)?;
342        utils::write(p, data)
343            .await
344            .context("Unable to write config file")
345            .pub_result(ErrorType::Internal)
346    }
347
348    #[cfg(test)]
349    /// Creates a new ConfigFile with the specified settings.
350    pub fn new(
351        sheet_url: String,
352        backup_copies: u32,
353        client_secret_path: Option<PathBuf>,
354        token_path: Option<PathBuf>,
355    ) -> Self {
356        Self {
357            app_name: APP_NAME.to_string(),
358            config_version: CONFIG_VERSION,
359            sheet_url,
360            backup_copies,
361            client_secret_path,
362            token_path,
363        }
364    }
365
366    /// Gets the client secret path.
367    ///
368    /// If the path is relative, it should be interpreted as relative to the config.json file.
369    /// If None, defaults to $TILLER_HOME/.secrets/client_secret.json
370    pub fn client_secret_path(&self) -> PathBuf {
371        self.client_secret_path
372            .clone()
373            .unwrap_or_else(|| PathBuf::from(SECRETS).join(CLIENT_SECRET_JSON))
374    }
375
376    /// Gets the token path.
377    ///
378    /// If the path is relative, it should be interpreted as relative to the config.json file.
379    /// If None, defaults to $TILLER_HOME/.secrets/token.json
380    pub fn token_path(&self) -> PathBuf {
381        self.token_path
382            .clone()
383            .unwrap_or_else(|| PathBuf::from(SECRETS).join(TOKEN_JSON))
384    }
385}
386
387/// Extracts the spreadsheet ID from a Google Sheets URL
388///
389/// # Arguments
390/// * `url` - The Google Sheets URL (e.g., "https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/...")
391///
392/// # Returns
393/// The spreadsheet ID or an error if the URL format is invalid. Returns an empty string if the URL is empty.
394fn extract_spreadsheet_id(url: &str) -> Res<&str> {
395    // Handle empty URL case
396    if url.is_empty() {
397        return Ok(url);
398    }
399
400    // URL format: https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/...
401    // or: https://docs.google.com/spreadsheets/d/SPREADSHEET_ID?foo=bar
402    let parts: Vec<&str> = url.split('/').collect();
403    for (i, part) in parts.iter().enumerate() {
404        if *part == "d" && i + 1 < parts.len() {
405            // Extract the ID and remove any query parameters or fragments
406            let id_part = parts[i + 1];
407            let id = id_part
408                .split('?')
409                .next()
410                .unwrap_or(id_part)
411                .split('#')
412                .next()
413                .unwrap_or(id_part);
414            return Ok(id);
415        }
416    }
417    Err(anyhow::anyhow!(
418        "Invalid Google Sheets URL format. Expected: https://docs.google.com/spreadsheets/d/SPREADSHEET_ID"
419    ))
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use tempfile::TempDir;
426    use utils;
427
428    #[tokio::test]
429    async fn test_config_create() {
430        use tempfile::TempDir;
431        let dir = TempDir::new().unwrap();
432        let home_dir = dir.path().join("tiller_home");
433        let secret_source_file = dir.path().join("x.txt");
434        let secret_content = "12345";
435        let sheet_url = "https://docs.google.com/spreadsheets/d/7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL/edit";
436        utils::write(&secret_source_file, secret_content)
437            .await
438            .unwrap();
439
440        // Run the function under test:
441        let config = Config::create(&home_dir, &secret_source_file, &sheet_url)
442            .await
443            .unwrap();
444
445        // Check some values on the config object
446        assert_eq!(sheet_url, config.sheet_url());
447        assert_eq!(
448            "7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL",
449            config.spreadsheet_id()
450        );
451
452        // Check for some files in the directory
453        let found_secret_content = utils::read(&config.client_secret_path()).await.unwrap();
454        assert_eq!(secret_content, found_secret_content);
455
456        assert!(config.backups().is_dir());
457        assert!(config.secrets().is_dir());
458    }
459
460    #[tokio::test]
461    async fn test_config() {
462        use tempfile::TempDir;
463        let dir = TempDir::new().unwrap();
464        let home_dir = dir.path().to_owned();
465        let secret_file = dir.path().join("foo.json");
466        utils::write(&secret_file, "{}").await.unwrap();
467        let url = "https://example.com/spreadsheets/d/MySheetIDX";
468        let config = Config::create(home_dir, &secret_file, &url).await.unwrap();
469        assert!(utils::read_dir(config.backups()).await.is_ok());
470        assert!(utils::read_dir(config.secrets()).await.is_ok());
471        assert_eq!("MySheetIDX", config.spreadsheet_id());
472    }
473
474    #[test]
475    fn test_config_file_new() {
476        let config = ConfigFile::new(
477            "https://docs.google.com/spreadsheets/d/test".to_string(),
478            10,
479            Some(PathBuf::from("custom/client_secret.json")),
480            Some(PathBuf::from("custom/token.json")),
481        );
482
483        assert_eq!(
484            config.sheet_url,
485            "https://docs.google.com/spreadsheets/d/test"
486        );
487        assert_eq!(config.backup_copies, 10);
488    }
489
490    #[test]
491    fn test_config_file_default() {
492        let config = ConfigFile::default();
493        assert_eq!(config.sheet_url, "");
494        assert_eq!(config.backup_copies, 5);
495        assert_eq!(
496            config.client_secret_path(),
497            PathBuf::from(SECRETS).join(CLIENT_SECRET_JSON)
498        );
499        assert_eq!(config.token_path(), PathBuf::from(SECRETS).join(TOKEN_JSON));
500    }
501
502    #[tokio::test]
503    async fn test_config_file_save_and_load() {
504        let temp_dir = TempDir::new().unwrap();
505        let config_path = temp_dir.path().join("config.json");
506
507        let original_config = ConfigFile::new(
508            "https://docs.google.com/spreadsheets/d/test123".to_string(),
509            7,
510            Some(PathBuf::from(".secrets/my_key.json")),
511            Some(PathBuf::from(".secrets/my_token.json")),
512        );
513
514        // Save the config
515        original_config.save(&config_path).await.unwrap();
516
517        // Load it back
518        let loaded_config = ConfigFile::load(&config_path).await.unwrap();
519
520        assert_eq!(original_config, loaded_config);
521    }
522
523    #[tokio::test]
524    async fn test_config_file_load_with_minimal_config() {
525        let temp_dir = TempDir::new().unwrap();
526        let config_path = temp_dir.path().join("config.json");
527
528        let json = r#"{
529            "app_name": "tiller",
530            "config_version": 1,
531            "sheet_url": "https://docs.google.com/spreadsheets/d/minimal",
532            "backup_copies": 3
533        }"#;
534
535        tokio::fs::write(&config_path, json).await.unwrap();
536
537        let config = ConfigFile::load(&config_path).await.unwrap();
538
539        assert_eq!(
540            config.sheet_url,
541            "https://docs.google.com/spreadsheets/d/minimal"
542        );
543        assert_eq!(config.backup_copies, 3);
544        assert_eq!(
545            config.client_secret_path(),
546            PathBuf::from(SECRETS).join(CLIENT_SECRET_JSON)
547        );
548        assert_eq!(config.token_path(), PathBuf::from(SECRETS).join(TOKEN_JSON));
549    }
550
551    #[tokio::test]
552    async fn test_config_file_load_invalid_app_name() {
553        let temp_dir = TempDir::new().unwrap();
554        let config_path = temp_dir.path().join("config.json");
555
556        let json = r#"{
557            "app_name": "wrong_app",
558            "config_version": 1,
559            "sheet_url": "https://docs.google.com/spreadsheets/d/test",
560            "backup_copies": 5
561        }"#;
562
563        tokio::fs::write(&config_path, json).await.unwrap();
564
565        let result = ConfigFile::load(&config_path).await;
566        assert!(result.is_err());
567        assert!(result.unwrap_err().to_string().contains("Invalid app_name"));
568    }
569
570    #[test]
571    fn test_config_file_serialization_omits_none_fields() {
572        let config = ConfigFile::new(
573            "https://docs.google.com/spreadsheets/d/test".to_string(),
574            5,
575            None,
576            None,
577        );
578
579        let json = serde_json::to_string(&config).unwrap();
580        assert!(!json.contains("client_secret_path"));
581        assert!(!json.contains("token_path"));
582    }
583
584    #[tokio::test]
585    async fn test_config_file_save_file() {
586        let original = ConfigFile::new(
587            "https://docs.google.com/spreadsheets/d/test".to_string(),
588            5,
589            None,
590            None,
591        );
592
593        let t = TempDir::new().unwrap();
594        let path = t.path().join("file.json");
595        original.save(&path).await.unwrap();
596
597        let read = ConfigFile::load(&path).await.unwrap();
598
599        assert_eq!(original, read);
600    }
601
602    #[test]
603    fn test_extract_spreadsheet_id_1() {
604        let url = "https://docs.google.com/spreadsheets/d/7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL/edit";
605        let id = extract_spreadsheet_id(url).unwrap();
606        assert_eq!(id, "7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL");
607
608        let url2 = "https://docs.google.com/spreadsheets/d/ABC123";
609        let id2 = extract_spreadsheet_id(url2).unwrap();
610        assert_eq!(id2, "ABC123");
611
612        let invalid = "https://example.com/invalid";
613        assert!(extract_spreadsheet_id(invalid).is_err());
614
615        // Empty URL should return empty string
616        let empty = "";
617        let id_empty = extract_spreadsheet_id(empty).unwrap();
618        assert_eq!(id_empty, "");
619    }
620
621    #[test]
622    fn test_extract_spreadsheet_id_2() {
623        let url = "https://docs.google.com/spreadsheets/d/7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL?foo=bar";
624        let id = extract_spreadsheet_id(url).unwrap();
625        assert_eq!(id, "7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL");
626
627        let url2 = "https://docs.google.com/spreadsheets/d/ABC123";
628        let id2 = extract_spreadsheet_id(url2).unwrap();
629        assert_eq!(id2, "ABC123");
630
631        let invalid = "https://example.com/invalid";
632        assert!(extract_spreadsheet_id(invalid).is_err());
633
634        // Empty URL should return empty string
635        let empty = "";
636        let id_empty = extract_spreadsheet_id(empty).unwrap();
637        assert_eq!(id_empty, "");
638    }
639}