partiri-cli 0.2.0

Partiri CLI — Deploy and manage services on Partiri Cloud
use std::io::Read;
use std::path::PathBuf;

use inquire::{Confirm, Text};
use owo_colors::OwoColorize;

use crate::error::Result;
use crate::output::{ctx, print_success};

pub struct AuthArgs {
    pub key: Option<String>,
    pub key_stdin: bool,
    pub force: bool,
}

/// Path to the credentials file where the API key is stored.
/// Returns `~/.config/partiri/key`.
pub fn credentials_path() -> Option<PathBuf> {
    let base = dirs::config_dir().or_else(dirs::home_dir)?;
    Some(base.join("partiri").join("key"))
}

/// Read the stored API key from the credentials file.
pub fn read_key() -> Option<String> {
    let path = credentials_path()?;
    std::fs::read_to_string(&path)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

pub fn run(args: AuthArgs) -> Result<()> {
    let creds = credentials_path()
        .ok_or("Could not determine config directory. Set $HOME or $XDG_CONFIG_HOME.")?;

    let non_interactive = args.key.is_some() || args.key_stdin || ctx().no_input;

    if !non_interactive {
        println!("\n{}\n", "  partiri auth".bold().cyan());
    }

    // Handle non-interactive flag paths first.
    if let Some(key) = args.key {
        return write_validated_key(&creds, key, args.force);
    }
    if args.key_stdin {
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .map_err(|e| format!("Failed to read API key from stdin: {e}"))?;
        return write_validated_key(&creds, buf, args.force);
    }
    if ctx().no_input {
        return Err(
            "auth requires --key, --key-stdin, or an interactive terminal. Pass --key <KEY> or --key-stdin."
                .into(),
        );
    }

    // Interactive path (existing behaviour).
    let current = read_key();
    match current {
        Some(ref existing) => {
            let masked = mask_key(existing);
            println!("  Current key: {}", masked.bold());
            let update = Confirm::new("Replace it with a new key?")
                .with_default(false)
                .prompt()
                .map_err(|_| "Cancelled.")?;
            if !update {
                return Ok(());
            }
        }
        None => {
            println!(
                "  {}",
                "No API key configured. Get one from https://partiri.cloud/settings/api-keys"
                    .dimmed()
            );
        }
    }

    let key = Text::new("Paste your API key:")
        .prompt()
        .map_err(|_| "Cancelled.")?;

    write_validated_key(&creds, key, true)
}

fn write_validated_key(creds: &PathBuf, raw: String, force: bool) -> Result<()> {
    let key = raw.trim().to_string();
    if key.is_empty() {
        return Err("API key cannot be empty.".into());
    }
    if key.len() < 64 {
        return Err("Api Key does not look right. Check that you pasted the full key.".into());
    }
    if key.chars().any(|c| c.is_control()) {
        return Err("API key contains control characters; check the value you provided.".into());
    }

    // Refuse to overwrite an existing key from a TTY without --force, mirroring `gh auth login --with-token`.
    if !force && !ctx().no_input && read_key().is_some() {
        // Currently no_input is false and a key already exists — the user is on a TTY and didn't pass --force.
        return Err(
            "An API key is already configured. Pass --force to overwrite, or run 'partiri auth' with no flags."
                .into(),
        );
    }

    save_credentials_file(creds, &key)
        .map_err(|e| format!("Failed to write credentials to {}: {e}", creds.display()))?;

    let saved = std::fs::read_to_string(creds)
        .map(|s| s.trim().to_string())
        .unwrap_or_default();
    if saved != key {
        return Err(
            "Key file was written but content does not match. Check file permissions.".into(),
        );
    }
    print_success(&format!("Key saved to {}.", creds.display()));
    Ok(())
}

fn save_credentials_file(path: &PathBuf, key: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    #[cfg(unix)]
    {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(key.as_bytes())?;
    }
    #[cfg(not(unix))]
    {
        std::fs::write(path, key)?;
    }
    Ok(())
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

fn mask_key(key: &str) -> String {
    let chars: Vec<char> = key.chars().collect();
    if chars.len() <= 4 {
        return "****".to_string();
    }
    let head: String = chars[..4].iter().collect();
    let tail: String = chars[chars.len() - 4..].iter().collect();
    format!("{head}{tail}")
}

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

    // ─── mask_key ─────────────────────────────────────────────────────────────

    #[test]
    fn mask_key_empty_returns_stars() {
        assert_eq!(mask_key(""), "****");
    }

    #[test]
    fn mask_key_three_chars_returns_stars() {
        assert_eq!(mask_key("abc"), "****");
    }

    #[test]
    fn mask_key_exactly_four_returns_stars() {
        assert_eq!(mask_key("abcd"), "****");
    }

    #[test]
    fn mask_key_five_chars_shows_overlap() {
        let result = mask_key("abcde");
        assert!(
            result.starts_with("abcd"),
            "should start with first 4 chars"
        );
        assert!(result.ends_with("bcde"), "should end with last 4 chars");
    }

    #[test]
    fn mask_key_long_shows_first_and_last_four() {
        let result = mask_key("abcdefghij");
        assert!(result.starts_with("abcd"), "should start with first 4");
        assert!(result.ends_with("ghij"), "should end with last 4");
        // Middle characters should not appear
        assert!(!result.contains("efgh"));
    }

    #[test]
    fn mask_key_contains_ellipsis_separator() {
        let result = mask_key("abcdefghij");
        // The separator is the Unicode ellipsis U+2026
        assert!(
            result.contains('\u{2026}'),
            "should use ellipsis separator: {}",
            result
        );
    }

    // ─── credentials_path ─────────────────────────────────────────────────────

    #[test]
    fn credentials_path_returns_some() {
        assert!(credentials_path().is_some());
    }

    #[test]
    fn credentials_path_ends_with_partiri_key() {
        let path = credentials_path().unwrap();
        assert!(
            path.ends_with("partiri/key"),
            "expected …/partiri/key, got {:?}",
            path
        );
    }

    // ─── save_credentials_file ────────────────────────────────────────────────

    #[test]
    fn save_credentials_creates_dirs_and_file() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("partiri").join("key");
        save_credentials_file(&path, "my-secret-key").unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert_eq!(content, "my-secret-key");
    }

    #[test]
    fn save_credentials_overwrites_existing() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("partiri").join("key");
        save_credentials_file(&path, "old-key").unwrap();
        save_credentials_file(&path, "new-key").unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert_eq!(content, "new-key");
    }
}