1use 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#[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 pub async fn create(dir: impl Into<PathBuf>, secret_file: &Path, url: &str) -> Result<Self> {
57 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 let root = utils::canonicalize(&maybe_relative)
66 .await
67 .pub_result(ErrorType::Internal)?;
68
69 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 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 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 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 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 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 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 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 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 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 pub fn client_secret_path(&self) -> PathBuf {
228 self.resolve_secrets_file_path(self.config_file.client_secret_path())
229 }
230
231 pub fn token_path(&self) -> PathBuf {
233 self.resolve_secrets_file_path(self.config_file.token_path())
234 }
235
236 pub(crate) fn backup(&self) -> Backup {
238 Backup::new(self)
239 }
240
241 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#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
264struct ConfigFile {
265 app_name: String,
267
268 config_version: u8,
270
271 sheet_url: String,
273
274 backup_copies: u32,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
280 client_secret_path: Option<PathBuf>,
281
282 #[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 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 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 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 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 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 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
387fn extract_spreadsheet_id(url: &str) -> Res<&str> {
395 if url.is_empty() {
397 return Ok(url);
398 }
399
400 let parts: Vec<&str> = url.split('/').collect();
403 for (i, part) in parts.iter().enumerate() {
404 if *part == "d" && i + 1 < parts.len() {
405 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 let config = Config::create(&home_dir, &secret_source_file, &sheet_url)
442 .await
443 .unwrap();
444
445 assert_eq!(sheet_url, config.sheet_url());
447 assert_eq!(
448 "7KpXm2RfZwNJgs84QhVYno5DU6iM9Wlr3bCzAv1txRpL",
449 config.spreadsheet_id()
450 );
451
452 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 original_config.save(&config_path).await.unwrap();
516
517 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 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 let empty = "";
636 let id_empty = extract_spreadsheet_id(empty).unwrap();
637 assert_eq!(id_empty, "");
638 }
639}