switchkit 0.2.0

Vendor-neutral abstraction for smart-plug devices (Shelly, Tasmota).
Documentation
//! Vendor-aware classification of destructive commands, so any consumer (CLI,
//! web app) can guard confirmation prompts uniformly across Tasmota and Shelly.
//!
//! - [`Hazard::Destructive`] - resets, reflashes, remaps hardware, or writes
//!   config; always guarded, with a reason.
//! - [`Hazard::RequiresConfirmation`] - not on the known-safe list (unknown
//!   commands/methods); guarded because we cannot confirm it is safe.
//! - [`Hazard::Safe`] - a known read or basic reversible-control command.

use crate::target::Vendor;

/// The hazard classification of a command string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Hazard {
    /// At least one (sub)command/method resets, reflashes, remaps hardware, or
    /// writes config. Carries a reason.
    Destructive(String),
    /// Not destructive, but not known-safe either, so it must be confirmed
    /// before sending.
    RequiresConfirmation,
    /// A known read or basic reversible-control command/method.
    Safe,
}

/// Classify a raw command/RPC method string for `vendor`.
pub fn classify(vendor: Vendor, command: &str) -> Hazard {
    match vendor {
        Vendor::Tasmota => tasmota::classify(command),
        Vendor::Shelly => shelly::classify(command),
    }
}

/// Tasmota console command classification, ported from
/// `tasmota_core::guardrail::classify`. switchkit does not depend on
/// `tasmota-core` (that would be a dependency cycle), so this table is a copy,
/// not a delegation.
mod tasmota {
    use super::Hazard;

    /// Command words that reset, reflash, or remap hardware. Uppercased for matching.
    const DESTRUCTIVE: &[&str] = &[
        "RESET",        // factory / settings reset
        "UPGRADE",      // OTA firmware flash
        "UPLOAD",       // OTA upload trigger
        "OTAURL",       // sets the OTA source for a flash
        "MODULE",       // changes hardware module (remaps GPIO)
        "TEMPLATE",     // applies a GPIO template
        "GPIO",         // remaps a pin
        "GPIOS",        // remaps pins
        "WEBGETCONFIG", // pull-based config restore
        "RESTORE",      // config restore
    ];

    /// Command words that only read state (with or without arguments). Everything not
    /// here or a relay-control `POWER`, including config writes like `SetOption`,
    /// `EnergyConfig`, `Sensor`, and unknown commands, requires confirmation.
    const SAFE: &[&str] = &["STATUS", "STATE"];

    /// Split a command into subcommands, expanding a leading `Backlog`/`Backlog0`.
    fn subcommands(command: &str) -> Vec<String> {
        let trimmed = command.trim();
        let mut parts = trimmed.splitn(2, char::is_whitespace);
        let head = parts.next().unwrap_or("").trim();
        if head.eq_ignore_ascii_case("backlog") || head.eq_ignore_ascii_case("backlog0") {
            let rest = parts.next().unwrap_or("");
            return rest
                .split(';')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
        }
        vec![trimmed.to_string()]
    }

    /// The command word (first whitespace-delimited token), uppercased.
    fn command_word(sub: &str) -> String {
        sub.split(char::is_whitespace)
            .next()
            .unwrap_or("")
            .to_ascii_uppercase()
    }

    /// `POWER`/`POWER1..N` are basic relay control (reversible, like the `on`/`off`
    /// commands), which do not prompt for a single target.
    fn is_relay_control(word: &str) -> bool {
        match word.strip_prefix("POWER") {
            Some("") => true,
            Some(rest) => rest.parse::<u8>().is_ok(),
            None => false,
        }
    }

    fn is_safe_word(word: &str) -> bool {
        SAFE.contains(&word) || is_relay_control(word)
    }

    /// Classify a raw command (possibly a `Backlog`). A destructive subcommand
    /// anywhere makes the whole command destructive; otherwise any non-safe
    /// subcommand requires confirmation.
    pub fn classify(command: &str) -> Hazard {
        let mut requires_confirmation = false;
        for sub in subcommands(command) {
            let word = command_word(&sub);
            if DESTRUCTIVE.contains(&word.as_str()) {
                return Hazard::Destructive(format!("`{}` is a destructive command", sub.trim()));
            }
            if !is_safe_word(&word) {
                requires_confirmation = true;
            }
        }
        if requires_confirmation {
            Hazard::RequiresConfirmation
        } else {
            Hazard::Safe
        }
    }
}

/// Shelly Gen2/3 RPC method classification, matched on the method-name suffix
/// after the last `.` (e.g. `FactoryReset` from `Shelly.FactoryReset`),
/// case-insensitively for robustness.
mod shelly {
    use super::Hazard;

    /// The last dot-delimited component of an RPC method name.
    fn method_suffix(method: &str) -> &str {
        method.rsplit('.').next().unwrap_or(method)
    }

    /// Methods that reset, reflash, or overwrite device config.
    fn destructive_reason(method: &str, suffix: &str) -> Option<String> {
        if suffix.eq_ignore_ascii_case("FactoryReset") {
            return Some(format!("`{method}` performs a factory reset"));
        }
        if suffix.eq_ignore_ascii_case("Reboot") {
            return Some(format!("`{method}` reboots the device"));
        }
        if suffix.eq_ignore_ascii_case("SetConfig") {
            return Some(format!("`{method}` writes device configuration"));
        }
        if method.eq_ignore_ascii_case("Shelly.Update") {
            return Some(format!("`{method}` flashes new firmware"));
        }
        None
    }

    /// Read-only getters and `ListMethods`, plus `Switch.Set` as basic
    /// reversible relay control.
    fn is_safe(method: &str, suffix: &str) -> bool {
        suffix.eq_ignore_ascii_case("ListMethods")
            || suffix.to_ascii_uppercase().starts_with("GET")
            || method.eq_ignore_ascii_case("Switch.Set")
    }

    /// Classify a raw RPC method name.
    pub fn classify(command: &str) -> Hazard {
        let method = command.trim();
        let suffix = method_suffix(method);
        if let Some(reason) = destructive_reason(method, suffix) {
            return Hazard::Destructive(reason);
        }
        if is_safe(method, suffix) {
            return Hazard::Safe;
        }
        Hazard::RequiresConfirmation
    }
}

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

    #[test]
    fn tasmota_plain_reset_is_destructive() {
        assert!(matches!(
            classify(Vendor::Tasmota, "Reset 1"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn tasmota_status_is_safe() {
        assert_eq!(classify(Vendor::Tasmota, "Status 0"), Hazard::Safe);
    }

    #[test]
    fn tasmota_power_toggle_is_safe() {
        assert_eq!(classify(Vendor::Tasmota, "Power TOGGLE"), Hazard::Safe);
    }

    #[test]
    fn tasmota_config_write_requires_confirmation() {
        assert_eq!(
            classify(Vendor::Tasmota, "SetOption65 1"),
            Hazard::RequiresConfirmation
        );
    }

    #[test]
    fn tasmota_destructive_hidden_in_backlog_is_caught() {
        assert!(matches!(
            classify(Vendor::Tasmota, "Backlog Power ON; Reset 1"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn tasmota_all_safe_backlog_is_safe() {
        assert_eq!(
            classify(Vendor::Tasmota, "Backlog Power1 ON; Power2 OFF"),
            Hazard::Safe
        );
    }

    #[test]
    fn tasmota_upgrade_and_otaurl_are_destructive() {
        assert!(matches!(
            classify(Vendor::Tasmota, "Upgrade 1"),
            Hazard::Destructive(_)
        ));
        assert!(matches!(
            classify(Vendor::Tasmota, "OtaUrl http://192.0.2.10/f.bin"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn tasmota_unknown_command_requires_confirmation() {
        assert_eq!(
            classify(Vendor::Tasmota, "Wifi 0"),
            Hazard::RequiresConfirmation
        );
    }

    #[test]
    fn shelly_factory_reset_is_destructive() {
        assert!(matches!(
            classify(Vendor::Shelly, "Shelly.FactoryReset"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn shelly_set_config_is_destructive() {
        assert!(matches!(
            classify(Vendor::Shelly, "Sys.SetConfig"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn shelly_reboot_is_destructive() {
        assert!(matches!(
            classify(Vendor::Shelly, "Shelly.Reboot"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn shelly_update_is_destructive() {
        assert!(matches!(
            classify(Vendor::Shelly, "Shelly.Update"),
            Hazard::Destructive(_)
        ));
    }

    #[test]
    fn shelly_getters_are_safe() {
        assert_eq!(classify(Vendor::Shelly, "Switch.GetStatus"), Hazard::Safe);
        assert_eq!(
            classify(Vendor::Shelly, "Shelly.GetDeviceInfo"),
            Hazard::Safe
        );
    }

    #[test]
    fn shelly_switch_set_is_safe() {
        assert_eq!(classify(Vendor::Shelly, "Switch.Set"), Hazard::Safe);
    }

    #[test]
    fn shelly_unknown_method_requires_confirmation() {
        assert_eq!(
            classify(Vendor::Shelly, "Switch.Toggle"),
            Hazard::RequiresConfirmation
        );
    }

    #[test]
    fn shelly_matching_is_case_insensitive() {
        assert!(matches!(
            classify(Vendor::Shelly, "shelly.factoryreset"),
            Hazard::Destructive(_)
        ));
        assert_eq!(classify(Vendor::Shelly, "switch.getstatus"), Hazard::Safe);
    }
}