small_bin/
config.rs

1use crate::*;
2use anyhow::{Context, Result};
3use chrono::{Datelike, Local, NaiveTime, Weekday};
4use serde::{Deserialize, Serialize};
5use std::{fs, path::PathBuf};
6
7
8/// Multiple configurations for sync
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Configs {
11    pub configs: Vec<Config>,
12    pub notifications: NotificationSettings,
13    pub sounds: SoundSettings,
14    pub open_history_on_start: bool,
15}
16
17/// A single configuration entry
18#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
19pub struct Config {
20    pub username: String,
21    pub hostname: String,
22    pub ssh_key: String,
23    pub ssh_port: u16,
24    pub address: String,
25    pub remote_path: String,
26    pub ssh_key_pass: String,
27    pub watch_path: String,
28    pub active_at: String, // hour range when to activate it: example: "9:01:00-15:55:00"
29    pub active_on: Vec<Weekday>, // days of week when to activate it: example: ["Mon", "Tue", "Wed", "Thu", "Fri"]
30    pub default: bool,
31}
32
33
34#[derive(Debug, Copy, Clone, Serialize, Deserialize, Default)]
35pub struct NotificationSettings {
36    pub start: bool,
37    pub clipboard: bool,
38    pub upload: bool,
39    pub error: bool,
40}
41
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct SoundSettings {
45    pub start: bool,
46    pub start_sound: String,
47    pub clipboard: bool,
48    pub clipboard_sound: String,
49    pub upload: bool,
50    pub upload_sound: String,
51    pub error: bool,
52    pub error_sound: String,
53}
54
55
56#[derive(Debug, Clone, Default)]
57pub struct AppConfig {
58    pub configs: Vec<Config>,
59    pub notifications: NotificationSettings,
60    pub sounds: SoundSettings,
61    pub open_history_on_start: bool,
62    pub env: String,
63    pub fs_check_interval: u64,
64    pub amount_history_load: usize,
65    pub db_autodump_interval: u64,
66    pub ssh_connection_timeout: u64,
67    pub sftp_buffer_size: usize,
68    pub webapi_port: u16,
69}
70
71
72impl AppConfig {
73    pub fn new() -> Result<Self> {
74        let env = std::env::var("ENV").unwrap_or_else(|_| "prod".to_string());
75        let config_file = Self::default_config_file();
76
77        if !config_file.exists() {
78            anyhow::bail!("No configuration file: {:?}", config_file);
79        }
80
81        let config_content = fs::read_to_string(&config_file)
82            .context(format!("Cannot open config file: {:?}", config_file))?;
83
84        let config: Configs =
85            toml::from_str(&config_content).context("Failed to parse config file")?;
86
87        for config in &config.configs {
88            Self::validate_config(config)?;
89        }
90
91        let webapi_port = match env.as_str() {
92            "dev" => 8001,
93            "test" => 8002,
94            _ => 8000,
95        };
96
97        Ok(AppConfig {
98            configs: config.configs.clone(),
99            notifications: config.notifications,
100            sounds: config.sounds,
101            env,
102            open_history_on_start: config.open_history_on_start,
103            fs_check_interval: 1000, // ms
104            amount_history_load: 50,
105            db_autodump_interval: 21600000, // 6 hours in ms
106            ssh_connection_timeout: 30000,
107            sftp_buffer_size: 262144,
108            webapi_port,
109        })
110    }
111
112
113    fn validate_config(config: &Config) -> Result<()> {
114        if config.username.is_empty() {
115            anyhow::bail!("Required configuration value: username is empty!");
116        }
117        if config.hostname.is_empty() {
118            anyhow::bail!("Required configuration value: hostname is empty!");
119        }
120        if config.ssh_port == 0 {
121            anyhow::bail!("Required configuration value: ssh_port is zero!");
122        }
123        if config.address.is_empty() {
124            anyhow::bail!("Required configuration value: address is empty!");
125        }
126        if config.remote_path.is_empty() {
127            anyhow::bail!("Required configuration value: remote_path is empty!");
128        }
129        Ok(())
130    }
131
132
133    pub fn data_dir_base() -> &'static str {
134        if cfg!(target_os = "macos") {
135            "/Library/Small/"
136        } else {
137            "/.small/"
138        }
139    }
140
141
142    // selects the current config based on the time
143    pub fn select_config(&self) -> Result<Config> {
144        let now = Local::now();
145        let time = now.time();
146        let weekday = now.weekday();
147
148        let configs = &self.configs;
149        let config = configs.iter().find(|cfg| {
150            if !cfg.active_on.is_empty() && !cfg.active_on.contains(&weekday) {
151                debug!("Active_on doesn't contain the current day: {weekday}");
152                return false;
153            }
154            if cfg.active_at.is_empty() {
155                debug!("Active_at is empty and thee current day is {weekday}, meaning the config is valid for the whole day");
156                return true;
157            }
158            let range: Vec<&str> = cfg.active_at.split('-').collect();
159            if range.len() != 2 {
160                error!("Wrong format of the time range. Should be: HH:MM:SS-HH:MM:SS");
161                return false;
162            }
163            let time_start = NaiveTime::parse_from_str(range[0], "%H:%M:%S")
164                .expect("Valid time is expected");
165            let time_end = NaiveTime::parse_from_str(range[1], "%H:%M:%S")
166                .expect("Valid time is expected");
167
168            time >= time_start && time <= time_end
169        });
170
171        let default_config = configs
172            .iter()
173            .find(|cfg| cfg.default)
174            .expect("One of configs has to be the default!");
175
176        if let Some(cfg) = config {
177            debug!(
178                "Selected config: {configuration:?}",
179                configuration = Config {
180                    ssh_key_pass: String::from("<redacted>"), /* don't print the ssh key in logs */
181                    ..cfg.clone()
182                }
183            );
184        }
185
186        match config {
187            Some(cfg) => Ok(cfg.clone()),
188            None => {
189                debug!(
190                    "No config to select by the active_at range. Selecting the default one."
191                );
192                Ok(default_config.clone())
193            }
194        }
195    }
196
197
198    pub fn project_root_dir() -> PathBuf {
199        let home = home::home_dir().expect("Could not determine home directory");
200        home.join(Self::data_dir_base().trim_start_matches('/'))
201    }
202
203
204    pub fn project_dir(&self) -> PathBuf {
205        Self::project_root_dir().join(&self.env)
206    }
207
208
209    pub fn db_dumps_dir(&self) -> PathBuf {
210        Self::project_root_dir().join(format!(".sqlite-dumps-{}", self.env))
211    }
212
213
214    pub fn default_config_file() -> PathBuf {
215        Self::project_root_dir().join("config.toml")
216    }
217
218
219    pub fn database_path(&self) -> PathBuf {
220        self.project_dir().join("small.db")
221    }
222}