denki 0.1.2

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

use crate::creds;

const MAX_PASSWORD_BYTES: u64 = 4096;

fn read_password(reader: impl std::io::Read) -> Result<String> {
    use std::io::Read;
    let mut bytes = Vec::new();
    reader
        .take(MAX_PASSWORD_BYTES + 1)
        .read_to_end(&mut bytes)?;
    anyhow::ensure!(
        bytes.len() <= MAX_PASSWORD_BYTES as usize,
        "Password input is too long"
    );
    if bytes.ends_with(b"\n") {
        bytes.pop();
        if bytes.ends_with(b"\r") {
            bytes.pop();
        }
    }
    let password =
        String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("Password input must be UTF-8"))?;
    anyhow::ensure!(
        !password.is_empty() && !password.contains(['\n', '\r', '\0']),
        "Provide exactly one non-empty password line"
    );
    Ok(password)
}

pub fn handle_login(email: &str, password_stdin: bool) -> Result<()> {
    use std::io::IsTerminal;
    let password = if password_stdin {
        anyhow::ensure!(
            !std::io::stdin().is_terminal(),
            "--password-stdin requires redirected input; omit it for the hidden prompt"
        );
        read_password(std::io::stdin().lock())?
    } else {
        rpassword::prompt_password("Tapo password: ")
            .map_err(|e| anyhow::anyhow!("Failed to read password: {e}"))?
    };
    creds::save(email, &password)?;
    crate::output::record(serde_json::json!({"credentials_saved":true}));
    crate::output::println!(
        "Tapo credentials saved to {}",
        terminal_text(&creds::path_display())
    );
    crate::output::println!(
        "(On Unix, only your account can read this file. Use TAPO_USER/TAPO_PASS env vars to override.)"
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn password_stdin_preserves_spaces_and_removes_only_line_ending() {
        assert_eq!(
            read_password(&b"  synthetic secret  \r\n"[..]).unwrap(),
            "  synthetic secret  "
        );
        assert_eq!(read_password(&b"synthetic"[..]).unwrap(), "synthetic");
    }
    #[test]
    fn password_stdin_rejects_empty_multiline_and_oversized_input_without_echo() {
        for bytes in [
            b"".to_vec(),
            b"\n".to_vec(),
            b"sentinel\nsecret".to_vec(),
            vec![b'x'; 4097],
            vec![255],
            b"secret\0".to_vec(),
        ] {
            let error = read_password(bytes.as_slice()).unwrap_err().to_string();
            assert!(!error.contains("sentinel"));
            assert!(!error.contains("secret"));
        }
    }
}