lib/
config.rs

1#![allow(missing_docs)]
2//! This module holds struct and helpers for parameters and configuration
3//!
4use crate::offtime::{Off, OffDays};
5use crate::utils::parse_from_hmstr;
6use ::structopt::clap::AppSettings;
7use anyhow::{bail, Context, Result};
8use chrono::Local;
9use directories_next::ProjectDirs;
10use figment::{
11    providers::{Format, Serialized, Toml},
12    Figment,
13};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::fs;
16use std::path::PathBuf;
17use std::process::Command;
18use structopt;
19use structopt::clap::arg_enum;
20use tracing::{debug, info, warn};
21
22arg_enum! {
23/// Enum used to encode `secret_type` parameter (password or token)
24///
25/// When set to [Password], the secret is used to obtain a session token
26/// by using the login API. When set to [Token], the secret is a private access
27/// token directly usable to access API.
28#[derive(Serialize, Deserialize,Debug)]
29pub enum SecretType {
30    Token,
31    Password,
32}
33}
34
35/// Status that shall be send when a wifi with `wifi_string` is being seen.
36#[derive(Debug, PartialEq)]
37pub struct WifiStatusConfig {
38    /// wifi SSID substring associated to this object custom status
39    pub wifi_string: String,
40    /// string description of the emoji that will be set as a custom status (like `home` for
41    /// `:home:` mattermost emoji.
42    pub emoji: String,
43    /// custom status text description
44    pub text: String,
45}
46
47/// Implement [`std::str::FromStr`] for [`WifiStatusConfig`] which allows to call `parse` from a
48/// string representation:
49/// ```
50/// use lib::config::WifiStatusConfig;
51/// let wsc : WifiStatusConfig = "wifinet::house::Working home".parse().unwrap();
52/// assert_eq!(wsc, WifiStatusConfig {
53///                     wifi_string: "wifinet".to_owned(),
54///                     emoji:"house".to_owned(),
55///                     text: "Working home".to_owned() });
56/// ```
57impl std::str::FromStr for WifiStatusConfig {
58    type Err = anyhow::Error;
59    fn from_str(s: &str) -> Result<Self, Self::Err> {
60        let splitted: Vec<&str> = s.split("::").collect();
61        if splitted.len() != 3 {
62            bail!(
63                "Expect status argument to contain two and only two :: separator (in '{}')",
64                &s
65            );
66        }
67        Ok(WifiStatusConfig {
68            wifi_string: splitted[0].to_owned(),
69            emoji: splitted[1].to_owned(),
70            text: splitted[2].to_owned(),
71        })
72    }
73}
74
75// Courtesy of structopt_flags crate
76/// [`structopt::StructOpt`] implementing the verbosity parameter
77#[derive(structopt::StructOpt, Debug, Clone)]
78pub struct QuietVerbose {
79    /// Increase the output's verbosity level
80    ///
81    /// Pass many times to increase verbosity level, up to 3.
82    #[structopt(
83        name = "quietverbose",
84        long = "verbose",
85        short = "v",
86        parse(from_occurrences),
87        conflicts_with = "quietquiet",
88        global = true
89    )]
90    verbosity_level: u8,
91
92    /// Decrease the output's verbosity level.
93    ///
94    /// Used once, it will set error log level.
95    /// Used twice, will silent the log completely
96    #[structopt(
97        name = "quietquiet",
98        long = "quiet",
99        short = "q",
100        parse(from_occurrences),
101        conflicts_with = "quietverbose",
102        global = true
103    )]
104    quiet_level: u8,
105}
106
107impl Default for QuietVerbose {
108    fn default() -> Self {
109        QuietVerbose {
110            verbosity_level: 1,
111            quiet_level: 0,
112        }
113    }
114}
115
116impl Serialize for QuietVerbose {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        serializer.serialize_str(self.get_level_filter())
122    }
123}
124
125fn de_from_str<'de, D>(deserializer: D) -> Result<QuietVerbose, D::Error>
126where
127    D: Deserializer<'de>,
128{
129    let s = String::deserialize(deserializer)?;
130    match s.to_ascii_lowercase().as_ref() {
131        "off" => Ok(QuietVerbose {
132            verbosity_level: 0,
133            quiet_level: 2,
134        }),
135        "error" => Ok(QuietVerbose {
136            verbosity_level: 0,
137            quiet_level: 1,
138        }),
139        "warn" => Ok(QuietVerbose {
140            verbosity_level: 0,
141            quiet_level: 0,
142        }),
143        "info" => Ok(QuietVerbose {
144            verbosity_level: 1,
145            quiet_level: 0,
146        }),
147        "debug" => Ok(QuietVerbose {
148            verbosity_level: 2,
149            quiet_level: 0,
150        }),
151        _ => Ok(QuietVerbose {
152            verbosity_level: 3,
153            quiet_level: 0,
154        }),
155    }
156}
157
158impl QuietVerbose {
159    /// Returns the string associated to the current verbose level
160    pub fn get_level_filter(&self) -> &str {
161        let quiet: i8 = if self.quiet_level > 1 {
162            2
163        } else {
164            self.quiet_level as i8
165        };
166        let verbose: i8 = if self.verbosity_level > 2 {
167            3
168        } else {
169            self.verbosity_level as i8
170        };
171        match verbose - quiet {
172            -2 => "Off",
173            -1 => "Error",
174            0 => "Warn",
175            1 => "Info",
176            2 => "Debug",
177            _ => "Trace",
178        }
179    }
180}
181
182#[derive(structopt::StructOpt, Serialize, Deserialize, Debug)]
183/// Automate mattermost status with the help of wifi network
184///
185/// Use current visible wifi SSID to automate your mattermost status.
186/// This program is meant to either be running in background or be call regularly
187/// with option `--delay 0`.
188/// It will then update your mattermost custom status according to the config file
189#[structopt(global_settings(&[AppSettings::ColoredHelp, AppSettings::ColorAuto]))]
190pub struct Args {
191    /// wifi interface name
192    #[serde(skip_serializing_if = "Option::is_none")]
193    #[structopt(short, long, env, name = "itf_name")]
194    pub interface_name: Option<String>,
195
196    /// Status configuration triplets (:: separated)
197    ///
198    /// Each triplet shall have the format:
199    /// "wifi_substring::emoji_name::status_text". If `wifi_substring` is empty, the ssociated
200    /// status will be used for off time.
201    #[serde(skip_serializing_if = "Vec::is_empty")]
202    #[structopt(short, long, name = "wifi_substr::emoji::text")]
203    pub status: Vec<String>,
204
205    /// mattermost URL
206    #[serde(skip_serializing_if = "Option::is_none")]
207    #[structopt(short = "u", long, env, name = "url")]
208    pub mm_url: Option<String>,
209
210    /// User name used for mattermost login or for password or private token lookup in OS keyring.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    #[structopt(long, env, name = "username")]
213    pub mm_user: Option<String>,
214
215    /// Type of secret. Either `Password` (default) or `Token`
216    #[serde(skip_serializing_if = "Option::is_none")]
217    #[structopt(short = "t", long, env, possible_values = &SecretType::variants(), case_insensitive = true)]
218    pub secret_type: Option<SecretType>,
219
220    /// Service name used for mattermost secret lookup in OS keyring.
221    ///
222    /// The secret is either a `password` (default) or a`token` according to
223    /// `secret_type` option
224    #[serde(skip_serializing_if = "Option::is_none")]
225    #[structopt(long, env, name = "token service name")]
226    pub keyring_service: Option<String>,
227
228    /// mattermost private Token
229    ///
230    /// Usage of this option may leak your personal token. It is recommended to
231    /// use `mm_token_cmd` or `keyring_service`.
232    ///
233    /// The secret is either a `password` (default) or a`token` according to
234    /// `secret_type` option
235    #[serde(skip_serializing_if = "Option::is_none")]
236    #[structopt(long, env, hide_env_values = true, name = "token")]
237    pub mm_secret: Option<String>,
238
239    /// mattermost secret command
240    ///
241    /// The secret is either a `password` (default) or a`token` according to
242    /// `secret_type` option
243    #[serde(skip_serializing_if = "Option::is_none")]
244    #[structopt(long, env, name = "command")]
245    pub mm_secret_cmd: Option<String>,
246
247    /// directory for state file
248    ///
249    /// Will use content of XDG_CACHE_HOME if unset.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    #[structopt(long, env, parse(from_os_str), name = "cache dir")]
252    pub state_dir: Option<PathBuf>,
253
254    /// beginning of status update with the format hh:mm
255    ///
256    /// Before this time the status won't be updated
257    #[serde(skip_serializing_if = "Option::is_none")]
258    #[structopt(short, long, env, name = "begin hh:mm")]
259    pub begin: Option<String>,
260
261    /// end of status update with the format hh:mm
262    ///
263    /// After this time the status won't be updated
264    #[serde(skip_serializing_if = "Option::is_none")]
265    #[structopt(short, long, env, name = "end hh:mm")]
266    pub end: Option<String>,
267
268    /// Expiration time with the format hh:mm
269    ///
270    /// This parameter is used to set the custom status expiration time
271    /// Set to "0" to avoid setting expiration time
272    #[serde(skip_serializing_if = "Option::is_none")]
273    #[structopt(long, env, name = "expiry hh:mm")]
274    pub expires_at: Option<String>,
275
276    /// delay between wifi SSID polling in seconds
277    #[serde(skip_serializing_if = "Option::is_none")]
278    #[structopt(long, env)]
279    pub delay: Option<u32>,
280
281    #[allow(missing_docs)]
282    #[structopt(flatten)]
283    #[serde(deserialize_with = "de_from_str")]
284    pub verbose: QuietVerbose,
285
286    #[structopt(skip)]
287    /// Days off for which the custom status shall not be changed
288    pub offdays: OffDays,
289}
290
291impl Default for Args {
292    fn default() -> Args {
293        let res = Args {
294            #[cfg(target_os = "linux")]
295            interface_name: Some("wlan0".into()),
296            #[cfg(target_os = "windows")]
297            interface_name: Some("Wireless Network Connection".into()),
298            #[cfg(target_os = "macos")]
299            interface_name: Some("en0".into()),
300            status: ["home::house::working at home".to_string()].to_vec(),
301            delay: Some(60),
302            state_dir: Some(
303                ProjectDirs::from("net", "ams", "automattermostatus")
304                    .expect("Unable to find a project dir")
305                    .cache_dir()
306                    .to_owned(),
307            ),
308            mm_user: None,
309            keyring_service: None,
310            mm_secret: None,
311            mm_secret_cmd: None,
312            secret_type: Some(SecretType::Password),
313            mm_url: Some("https://mattermost.example.com".into()),
314            verbose: QuietVerbose {
315                verbosity_level: 1,
316                quiet_level: 0,
317            },
318            expires_at: Some("19:30".to_string()),
319            begin: Some("8:00".to_string()),
320            end: Some("19:30".to_string()),
321            offdays: OffDays::default(),
322        };
323        res
324    }
325}
326
327impl Off for Args {
328    fn is_off_time(&self) -> bool {
329        self.offdays.is_off_time() // The day is off, so we are off
330            || if let Some(begin) = parse_from_hmstr(&self.begin) {
331                    Local::now() < begin // now is before begin, we are off
332                } else {
333                    false // now is after begin, we are on duty if not after end
334                }
335            || if let Some(end) = parse_from_hmstr(&self.end) {
336                    Local::now() > end // now is after end, we are off
337                } else {
338                    false // now is before end, we are on duty
339                }
340    }
341}
342
343impl Args {
344    /// Update `args.mm_secret`  with the one fetched from OS keyring
345    ///
346    pub fn update_secret_with_keyring(mut self) -> Result<Self> {
347        if let Some(user) = &self.mm_user {
348            if let Some(service) = &self.keyring_service {
349                let keyring = keyring::Keyring::new(service, user);
350                let secret = keyring.get_password().with_context(|| {
351                    format!("Querying OS keyring (user: {}, service: {})", user, service)
352                })?;
353                self.mm_secret = Some(secret);
354            } else {
355                warn!("User is defined for keyring lookup but service is not");
356                info!("Skipping keyring lookup");
357            }
358        }
359        Ok(self)
360    }
361
362    /// Update `args.mm_secret`  with the standard output of
363    /// `args.mm_secret_cmd` if defined.
364    ///
365    /// If the secret is a password, `secret` will be updated later when login to the mattermost
366    /// server
367    pub fn update_secret_with_command(mut self) -> Result<Args> {
368        if let Some(command) = &self.mm_secret_cmd {
369            let params =
370                shell_words::split(command).context("Splitting mm_token_cmd into shell words")?;
371            debug!("Running command {}", command);
372            let output = Command::new(&params[0])
373                .args(&params[1..])
374                .output()
375                .context(format!("Error when running {}", &command))?;
376            let secret = String::from_utf8_lossy(&output.stdout);
377            if secret.len() == 0 {
378                bail!("command '{}' returns nothing", &command);
379            }
380            // /!\ Do not spit secret on stdout on released binary.
381            //debug!("setting secret to {}", secret);
382            self.mm_secret = Some(secret.to_string());
383        }
384        Ok(self)
385    }
386
387    /// Merge with precedence default [`Args`], config file and command line parameters.
388    pub fn merge_config_and_params(&self) -> Result<Args> {
389        let default_args = Args::default();
390        debug!("default Args : {:#?}", default_args);
391        let conf_dir = ProjectDirs::from("net", "ams", "automattermostatus")
392            .expect("Unable to find a project dir")
393            .config_dir()
394            .to_owned();
395        fs::create_dir_all(&conf_dir)
396            .with_context(|| format!("Creating conf dir {:?}", &conf_dir))?;
397        let conf_file = conf_dir.join("automattermostatus.toml");
398        if !conf_file.exists() {
399            info!("Write {:?} default config file", &conf_file);
400            fs::write(&conf_file, toml::to_string(&Args::default())?)
401                .unwrap_or_else(|_| panic!("Unable to write default config file {:?}", conf_file));
402        }
403
404        let config_args: Args = Figment::from(Toml::file(&conf_file)).extract()?;
405        debug!("config Args : {:#?}", config_args);
406        debug!("parameter Args : {:#?}", self);
407        // Merge config Default → Config File → command line args
408        let res = Figment::from(Serialized::defaults(Args::default()))
409            .merge(Toml::file(&conf_file))
410            .merge(Serialized::defaults(self))
411            .extract()
412            .context("Merging configuration file and parameters")?;
413        debug!("Merged config and parameters : {:#?}", res);
414        Ok(res)
415    }
416}