wme-cli 0.1.3

CLI tool for the Wikimedia Enterprise API
//! Utility functions for the CLI.
//!
//! This module provides common utility functions used across the CLI,
//! including formatting helpers, conversion utilities, and user prompts.

use std::io::{self, Write};

/// Format a byte size in human-readable format.
///
/// Converts a byte count to a human-readable string with appropriate
/// units (B, KB, MB, GB, TB).
///
/// # Examples
///
/// ```
/// use wme_cli::util::format_size;
///
/// assert_eq!(format_size(500), "500 B");
/// assert_eq!(format_size(1500), "1.50 KB");
/// assert_eq!(format_size(1_500_000), "1.50 MB");
/// assert_eq!(format_size(1_500_000_000), "1.50 GB");
/// ```
pub fn format_size(value: u64) -> String {
    format_size_f64(value as f64)
}

/// Format a byte size in human-readable format (f64 version).
///
/// Converts a byte count to a human-readable string with appropriate
/// units (B, KB, MB, GB, TB). This version accepts f64 for API responses
/// that may include fractional sizes.
///
/// # Examples
///
/// ```
/// use wme_cli::util::format_size_f64;
///
/// assert_eq!(format_size_f64(500.0), "500 B");
/// assert_eq!(format_size_f64(0.001), "0.00 B");
/// ```
pub fn format_size_f64(value: f64) -> String {
    const TB: f64 = 1_000_000_000_000.0;
    const GB: f64 = 1_000_000_000.0;
    const MB: f64 = 1_000_000.0;
    const KB: f64 = 1_000.0;

    if value >= TB {
        format!("{:.2} TB", value / TB)
    } else if value >= GB {
        format!("{:.2} GB", value / GB)
    } else if value >= MB {
        format!("{:.2} MB", value / MB)
    } else if value >= KB {
        format!("{:.2} KB", value / KB)
    } else {
        format!("{:.0} B", value)
    }
}

/// Format a size from API response.
///
/// The API returns sizes with a unit_text (e.g., "MB", "GB") and a value.
/// This function formats them together (e.g., "146.06 MB").
///
/// # Examples
///
/// ```
/// use wme_cli::util::format_api_size;
/// use wme_models::metadata::Size;
///
/// let size = Size {
///     unit_text: "MB".to_string(),
///     value: 146.06,
/// };
/// assert_eq!(format_api_size(&size), "146.06 MB");
///
/// let size = Size {
///     unit_text: "GB".to_string(),
///     value: 25.5,
/// };
/// assert_eq!(format_api_size(&size), "25.50 GB");
/// ```
pub fn format_api_size(size: &wme_models::metadata::Size) -> String {
    // Format based on the magnitude for consistent decimal places
    if size.value >= 100.0 {
        format!("{:.0} {}", size.value, size.unit_text)
    } else if size.value >= 10.0 {
        format!("{:.1} {}", size.value, size.unit_text)
    } else {
        format!("{:.2} {}", size.value, size.unit_text)
    }
}

/// Build RequestParams from global options.
///
/// This function constructs a `RequestParams` struct from the CLI's
/// global options, including fields, filters, and limit.
pub fn build_params(
    opts: &crate::commands::GlobalOpts,
) -> anyhow::Result<Option<wme_models::RequestParams>> {
    let mut params = wme_models::RequestParams::default();

    // Add fields if specified
    if let Some(fields_str) = &opts.fields {
        let fields: Vec<String> = fields_str
            .split(',')
            .map(|s| s.trim().to_string())
            .collect();
        params.fields = Some(fields);
    }

    // Add filters if specified
    if !opts.filter.is_empty() {
        let mut filters = Vec::new();
        for filter in &opts.filter {
            if let Some((field, value)) = filter.split_once('=') {
                filters.push(wme_models::Filter {
                    field: field.to_string(),
                    value: wme_models::FilterValue::String(value.to_string()),
                });
            } else {
                anyhow::bail!("Invalid filter format: {}. Expected field=value", filter);
            }
        }
        params.filters = Some(filters);
    }

    // Add limit if specified
    if let Some(limit) = opts.limit {
        params.limit = Some(limit as u32);
    }

    // Only return Some if we have any params set
    if params.fields.is_some() || params.filters.is_some() || params.limit.is_some() {
        Ok(Some(params))
    } else {
        Ok(None)
    }
}

/// Prompt the user for a username via stdin.
///
/// Displays the provided prompt message and reads the user's input
/// from standard input, trimming whitespace.
///
/// # Arguments
///
/// * `prompt` - The message to display before reading input
///
/// # Returns
///
/// Returns `Ok(String)` containing the trimmed user input, or an `Err`
/// if reading from stdin fails.
///
/// # Examples
///
/// ```no_run
/// use wme_cli::util::prompt_username;
///
/// let username = prompt_username("Enter username: ").unwrap();
/// ```
pub fn prompt_username(prompt: &str) -> anyhow::Result<String> {
    print!("{}", prompt);
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;

    Ok(input.trim().to_string())
}

/// Prompt the user for a password via stdin (hidden input).
///
/// Displays the provided prompt message and reads the user's input
/// from standard input without echoing the characters (for security).
/// Uses the `rpassword` crate for secure password entry.
///
/// # Arguments
///
/// * `prompt` - The message to display before reading input
///
/// # Returns
///
/// Returns `Ok(String)` containing the password, or an `Err`
/// if reading from stdin fails.
///
/// # Examples
///
/// ```no_run
/// use wme_cli::util::prompt_password;
///
/// let password = prompt_password("Enter password: ").unwrap();
/// ```
pub fn prompt_password(prompt: &str) -> anyhow::Result<String> {
    let password = rpassword::prompt_password(prompt)?;
    Ok(password)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::GlobalOpts;
    use crate::config::Config;
    use crate::output::OutputFormat;

    #[test]
    fn test_format_size_bytes() {
        assert_eq!(format_size(500), "500 B");
        assert_eq!(format_size(0), "0 B");
        assert_eq!(format_size(999), "999 B");
    }

    #[test]
    fn test_format_size_kilobytes() {
        assert_eq!(format_size(1_000), "1.00 KB");
        assert_eq!(format_size(1_500), "1.50 KB");
        assert_eq!(format_size(999_999), "1000.00 KB");
    }

    #[test]
    fn test_format_size_megabytes() {
        assert_eq!(format_size(1_000_000), "1.00 MB");
        assert_eq!(format_size(1_500_000), "1.50 MB");
        assert_eq!(format_size(999_999_999), "1000.00 MB");
    }

    #[test]
    fn test_format_size_gigabytes() {
        assert_eq!(format_size(1_000_000_000), "1.00 GB");
        assert_eq!(format_size(1_500_000_000), "1.50 GB");
        assert_eq!(format_size(999_999_999_999), "1000.00 GB");
    }

    #[test]
    fn test_format_size_terabytes() {
        assert_eq!(format_size(1_000_000_000_000), "1.00 TB");
        assert_eq!(format_size(1_500_000_000_000), "1.50 TB");
    }

    #[test]
    fn test_build_params_empty() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: None,
            filter: vec![],
            limit: None,
            config: Config::default(),
        };

        let params = build_params(&opts).unwrap();
        assert!(params.is_none());
    }

    #[test]
    fn test_build_params_with_fields() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: Some("name,url,abstract".to_string()),
            filter: vec![],
            limit: None,
            config: Config::default(),
        };

        let params = build_params(&opts).unwrap();
        assert!(params.is_some());
        let params = params.unwrap();
        assert_eq!(
            params.fields,
            Some(vec![
                "name".to_string(),
                "url".to_string(),
                "abstract".to_string()
            ])
        );
    }

    #[test]
    fn test_build_params_with_limit() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: None,
            filter: vec![],
            limit: Some(100),
            config: Config::default(),
        };

        let params = build_params(&opts).unwrap();
        assert!(params.is_some());
        let params = params.unwrap();
        assert_eq!(params.limit, Some(100));
    }

    #[test]
    fn test_build_params_with_filters() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: None,
            filter: vec!["project=enwiki".to_string(), "language=en".to_string()],
            limit: None,
            config: Config::default(),
        };

        let params = build_params(&opts).unwrap();
        assert!(params.is_some());
        let params = params.unwrap();
        assert!(params.filters.is_some());
        let filters = params.filters.unwrap();
        assert_eq!(filters.len(), 2);
    }

    #[test]
    fn test_build_params_invalid_filter() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: None,
            filter: vec!["invalid_filter".to_string()],
            limit: None,
            config: Config::default(),
        };

        let result = build_params(&opts);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("Invalid filter format"));
    }

    #[test]
    fn test_build_params_combined() {
        let opts = GlobalOpts {
            token: None,
            output: OutputFormat::Json,
            fields: Some("name".to_string()),
            filter: vec!["project=enwiki".to_string()],
            limit: Some(50),
            config: Config::default(),
        };

        let params = build_params(&opts).unwrap();
        assert!(params.is_some());
        let params = params.unwrap();
        assert!(params.fields.is_some());
        assert!(params.filters.is_some());
        assert_eq!(params.limit, Some(50));
    }
}