denki 0.2.0

CLI for controlling TP-Link smart home devices — denki (電気) means electricity in Japanese
Documentation
use crate::output::terminal_text;
use anyhow::{Result, bail};
use colored::Colorize;

use crate::hosts;

fn format_alias_rows(list: &[(String, hosts::HostEntry)]) -> String {
    if list.is_empty() {
        return format!(
            "No saved aliases. Use `denki alias <name> <ip> [--klap | --tapo]` to add one.\nFile: {}",
            terminal_text(&hosts::path_display())
        );
    }

    let mut out = String::new();
    out.push_str(&format!(
        "{:<30} {:<18} {}\n",
        "Name".bold(),
        "IP".bold(),
        "Protocol".bold()
    ));
    out.push_str(&format!("{}\n", "─".repeat(58).dimmed()));
    for (name, entry) in list {
        out.push_str(&format!(
            "{:<30} {:<18} {}\n",
            terminal_text(name),
            terminal_text(&entry.ip),
            format_args!(
                "{}{}{}",
                entry.protocol,
                if entry.protocol == hosts::Protocol::Kasa && !entry.group_approved {
                    " (group approval required)"
                } else {
                    ""
                },
                if entry.physically_confirmed {
                    " (physically confirmed by user)"
                } else {
                    " (not physically confirmed)"
                }
            )
        ));
    }
    out.push_str(&format!(
        "({} aliases in {})",
        list.len(),
        terminal_text(&hosts::path_display())
    ));
    out
}

pub fn handle_alias(name: &str, ip: &str, klap: bool, tapo: bool) -> Result<()> {
    if klap && tapo {
        bail!("Choose either --klap or --tapo");
    }
    let protocol = if tapo {
        hosts::Protocol::Tapo
    } else if klap {
        hosts::Protocol::Klap
    } else {
        hosts::Protocol::Kasa
    };
    hosts::set(name, ip, protocol.clone())?;
    crate::output::record(serde_json::json!({"alias":name,"ip":ip,"protocol":protocol}));
    let tag = if tapo {
        " (tapo: auto TPAP/KLAP)".dimmed()
    } else if klap {
        " (klap)".dimmed()
    } else {
        "".normal()
    };
    crate::output::println!(
        "Saved: {} → {}{}",
        terminal_text(name).bold(),
        terminal_text(ip),
        tag
    );
    Ok(())
}

pub fn handle_unalias(name: &str) -> Result<()> {
    if hosts::remove(name)? {
        crate::output::record(serde_json::json!({"removed":name}));
        crate::output::println!("Removed alias \"{}\"", terminal_text(name));
    } else {
        bail!("No alias named \"{name}\" found");
    }
    Ok(())
}

pub fn handle_aliases() -> Result<()> {
    let list = hosts::list()?;
    crate::output::record(
        serde_json::json!({"aliases":list.iter().map(|(alias,e)| serde_json::json!({"alias":alias,"ip":e.ip,"protocol":e.protocol,"identity_known":e.device_id.is_some(),"physically_confirmed":e.physically_confirmed,"group_approved":e.protocol != hosts::Protocol::Kasa || e.group_approved})).collect::<Vec<_>>()}),
    );
    crate::output::println!("{}", format_alias_rows(&list));
    Ok(())
}

/// Save an explicit user declaration; do not imply protocol/model verification.
pub fn handle_identify(name: &str, confirmed: bool) -> Result<()> {
    hosts::identify(name, confirmed)?;
    crate::output::record(serde_json::json!({"alias":name,"physically_confirmed":confirmed}));
    crate::output::println!(
        "{}: {}",
        terminal_text(name),
        if confirmed {
            "physical identification recorded from your confirmation"
        } else {
            "physical identification cleared"
        }
    );
    Ok(())
}

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

    fn entry(ip: &str, protocol: hosts::Protocol) -> hosts::HostEntry {
        hosts::HostEntry {
            ip: ip.to_string(),
            protocol,
            device_id: None,
            group_approved: false,
            physically_confirmed: false,
        }
    }

    #[test]
    fn format_alias_rows_handles_empty_list() {
        let rendered = format_alias_rows(&[]);
        assert!(rendered.contains("No saved aliases"), "{rendered}");
        assert!(rendered.contains("denki alias"), "{rendered}");
    }

    #[test]
    fn format_alias_rows_includes_table_content() {
        let rendered = format_alias_rows(&[(
            "Lamp".to_string(),
            entry("192.168.1.10", hosts::Protocol::Kasa),
        )]);
        assert!(rendered.contains("Lamp"), "{rendered}");
        assert!(rendered.contains("192.168.1.10"), "{rendered}");
        assert!(rendered.contains("kasa"), "{rendered}");
    }
}