switchbot-cli 0.1.16

A command-line tool for controlling SwitchBot devices using the SwitchBot API.
Documentation
use crate::{Aliases, UserInput};
use clap::Parser;
use std::{fs, path::PathBuf, time::Duration};
use switchbot_api::{Device, SwitchBot};

#[derive(Debug, Default, Parser, serde::Deserialize, serde::Serialize)]
#[command(version, about)]
pub(crate) struct Args {
    /// The token for the authentication.
    #[arg(long, default_value_t, env = "SWITCHBOT_TOKEN")]
    pub token: String,
    /// The secret for the authentication.
    #[arg(long, default_value_t, env = "SWITCHBOT_SECRET")]
    pub secret: String,

    /// Clear the saved authentication.
    #[arg(long)]
    #[serde(skip)]
    pub clear: bool,

    /// Add/remove aliases ("alias=value" to add, omit the value to remove).
    #[arg(short, long = "alias")]
    #[serde(skip)]
    pub alias_updates: Vec<String>,

    /// The interval for remote devices in seconds [default: 0.5].
    #[arg(long)]
    #[serde(skip)]
    pub pause: Option<f64>,

    /// The minimum number of tasks to parallelize.
    #[arg(short = 'P', long, default_value_t = 2)]
    #[serde(skip)]
    pub parallel_threshold: usize,

    #[arg(skip)]
    #[serde(default)]
    pub aliases: Aliases,

    #[serde(skip)]
    pub commands: Vec<String>,

    #[arg(skip)]
    #[serde(default, rename = "version")]
    pub config_version: u8,
}

impl Args {
    pub fn new_from_args() -> Self {
        let mut args = Args::parse();
        if let Err(error) = args.merge_config() {
            log::debug!("Load config error: {error}");
        }
        args.ensure_default();
        args
    }

    pub fn process(&mut self) -> anyhow::Result<()> {
        if let Some(seconds) = self.pause {
            Device::set_default_min_internal_for_remote_devices(Duration::from_secs_f64(seconds));
        }
        if !self.alias_updates.is_empty() {
            self.update_aliases();
        }
        Ok(())
    }

    pub fn create_switch_bot(&mut self) -> anyhow::Result<SwitchBot> {
        self.ensure_auth()?;
        Ok(SwitchBot::new_with_authentication(
            &self.token,
            &self.secret,
        ))
    }

    pub fn ensure_auth(&mut self) -> anyhow::Result<()> {
        log::trace!("ensure_auth: {} {}", self.token, self.secret);
        if self.token.is_empty() {
            let mut input = UserInput::new_with_prompt("Token> ");
            self.token = input.read_line()?.into();
        }
        if self.secret.is_empty() {
            let mut input = UserInput::new_with_prompt("Secret> ");
            self.secret = input.read_line()?.into();
        }
        Ok(())
    }

    pub fn clear_auth(&mut self) {
        self.token = String::default();
        self.secret = String::default();
    }

    pub fn ensure_default(&mut self) {
        if self.config_version < 1 {
            self.aliases.extend([
                ("on".into(), "turnOn".into()),
                ("off".into(), "turnOff".into()),
            ]);
            self.config_version = 1;
        }
        if self.config_version < 2 {
            self.aliases.insert_if_missing("d", "devices");
            self.config_version = 2;
        }
        if self.config_version < 3 {
            self.aliases.insert_if_missing("h", "help");
            self.config_version = 3;
        }
    }

    pub fn update_aliases(&mut self) {
        let updates = std::mem::take(&mut self.alias_updates);
        for update in &updates {
            self.aliases.update(update);
        }
        self.alias_updates = updates;
    }

    pub fn merge_config(&mut self) -> anyhow::Result<()> {
        let mut args: Args = Self::load()?;
        if self.clear {
            args.clear_auth();
        }
        self.merge(&args);
        Ok(())
    }

    fn merge(&mut self, other: &Args) {
        if self.token.is_empty() {
            self.token = other.token.clone();
        }
        if self.secret.is_empty() {
            self.secret = other.secret.clone();
        }
        self.aliases.extend(other.aliases.clone());
    }

    pub fn load() -> anyhow::Result<Args> {
        let path = Self::config_path()?;
        log::debug!("load config: {path:?}");
        let json = fs::read_to_string(&path)?;
        let args: Args = serde_json::from_str(&json)?;
        Ok(args)
    }

    pub fn save(&self) -> anyhow::Result<()> {
        let path = Self::config_path()?;
        log::debug!("save config: {path:?}");
        fs::create_dir_all(path.parent().unwrap())?;
        let json = serde_json::to_string(self)?;
        fs::write(&path, json)?;
        Ok(())
    }

    fn config_path() -> anyhow::Result<PathBuf> {
        if let Some(dirs) = directories::ProjectDirs::from("", "kojii", "switchbot") {
            let dir = dirs.config_dir();
            let path = dir.join("config.json");
            return Ok(path);
        }
        Err(anyhow::anyhow!("No config directory found"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ensure_default() {
        let mut args = Args::default();
        assert_eq!(args.config_version, 0);
        assert_eq!(args.aliases.len(), 0);
        args.ensure_default();
        assert_eq!(args.config_version, 3);
        assert_eq!(args.aliases.len(), 4);
    }

    #[test]
    fn args_from_json_no_alias() -> anyhow::Result<()> {
        let args: Args = serde_json::from_str(r#"{"token":"test_token", "secret":"test_secret"}"#)?;
        assert_eq!(args.token, "test_token");
        assert!(args.aliases.is_empty());
        Ok(())
    }

    #[test]
    fn update_aliases() {
        let mut args = Args::default();
        assert_eq!(args.aliases.len(), 0);
        args.alias_updates = vec!["a=b".into(), "c=d".into()];
        args.update_aliases();
        assert_eq!(args.aliases.len(), 2);
        assert_eq!(args.aliases.get("a").unwrap(), "b");
        assert_eq!(args.aliases.get("c").unwrap(), "d");
    }
}