wme-cli 0.1.3

CLI tool for the Wikimedia Enterprise API
//! Output formatting utilities for the Wikimedia Enterprise CLI.
//!
//! This module provides functionality for formatting CLI output in various
//! formats: JSON (pretty-printed), NDJSON (newline-delimited), ASCII tables,
//! or suppressed (quiet mode).
//!
//! # Output Formats
//!
//! - `Json` - Pretty-printed JSON (default)
//! - `Ndjson` - Newline-delimited JSON for streaming
//! - `Table` - ASCII table format for list commands
//! - `Quiet` - Suppress all output except errors
//!
//! # Example
//!
//! ```rust
//! use wme_cli::output::{OutputHandler, OutputFormat};
//! use serde_json::json;
//!
//! # fn example() -> anyhow::Result<()> {
//! let handler = OutputHandler::new(OutputFormat::Json);
//! let data = json!({"name": "example", "value": 42});
//! handler.output(&data)?;
//! # Ok(())
//! # }
//! ```

use clap::ValueEnum;
use serde::Serialize;

/// Available output formats for CLI responses.
///
/// This enum defines the supported output formats and is used with
/// clap's derive macro for command-line argument parsing.
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum OutputFormat {
    /// Pretty-printed JSON output (default).
    ///
    /// Formats JSON responses with indentation and line breaks for readability.
    #[default]
    Json,

    /// Newline-delimited JSON.
    ///
    /// Outputs each JSON object on a single line, suitable for streaming
    /// and processing with tools like `jq`.
    Ndjson,

    /// ASCII table format.
    ///
    /// Formats data as an ASCII table. Note: Not all data types support
    /// table formatting; complex objects fall back to JSON.
    Table,

    /// Suppress all output except errors.
    ///
    /// Only error messages are displayed. Useful for scripts where
    /// only the exit code matters.
    Quiet,
}

/// Output handler for formatting and displaying CLI responses.
///
/// Provides methods for outputting data in various formats while respecting
/// the quiet mode setting. Created with a specific output format and used
/// throughout command handlers to ensure consistent output formatting.
///
/// # Example
///
/// ```rust
/// use wme_cli::output::{OutputHandler, OutputFormat};
///
/// # fn example() -> anyhow::Result<()> {
/// let handler = OutputHandler::new(OutputFormat::Json);
///
/// // Output serializable data
/// let data = vec!["item1", "item2"];
/// handler.output(&data)?;
///
/// // Output raw strings
/// handler.output_raw("Raw text output");
/// # Ok(())
/// # }
/// ```
pub struct OutputHandler {
    format: OutputFormat,
    quiet: bool,
}

impl OutputHandler {
    /// Create a new output handler with the specified format.
    ///
    /// # Arguments
    ///
    /// * `format` - The output format to use
    pub fn new(format: OutputFormat) -> Self {
        Self {
            format,
            quiet: matches!(format, OutputFormat::Quiet),
        }
    }

    /// Output a serializable value in the configured format.
    ///
    /// Serializes the value and prints it according to the output format:
    /// - `Json` - Pretty-printed JSON
    /// - `Ndjson` - Single-line JSON
    /// - `Table` - Falls back to pretty-printed JSON
    /// - `Quiet` - No output
    ///
    /// # Arguments
    ///
    /// * `value` - Any serializable value
    ///
    /// # Errors
    ///
    /// Returns an error if serialization fails.
    pub fn output<T: Serialize>(&self, value: &T) -> anyhow::Result<()> {
        if self.quiet {
            return Ok(());
        }

        match self.format {
            OutputFormat::Json => {
                let json = serde_json::to_string_pretty(value)?;
                println!("{}", json);
            }
            OutputFormat::Ndjson => {
                let json = serde_json::to_string(value)?;
                println!("{}", json);
            }
            OutputFormat::Table => {
                // For table format, we need to handle differently based on type
                // This is a placeholder - specific commands will handle table formatting
                let json = serde_json::to_string_pretty(value)?;
                println!("{}", json);
            }
            OutputFormat::Quiet => {}
        }

        Ok(())
    }

    /// Output a raw string.
    ///
    /// Prints the string directly without serialization.
    /// Respects quiet mode (no output in quiet mode).
    ///
    /// # Arguments
    ///
    /// * `s` - The string to output
    pub fn output_raw(&self, s: &str) {
        if !self.quiet {
            println!("{}", s);
        }
    }
}

/// Format data as an ASCII table.
///
/// Creates a simple ASCII table from headers and rows. Each row must have
/// the same number of columns as the headers.
///
/// # Arguments
///
/// * `headers` - Column headers as string slices
/// * `rows` - Table data, where each inner Vec is a row
///
/// # Returns
///
/// Returns a formatted string containing the ASCII table, or "No data to display"
/// if rows is empty.
///
/// # Example
///
/// ```rust
/// use wme_cli::output::format_table;
///
/// let headers = vec!["Name", "Value"];
/// let rows = vec![
///     vec!["Item1".to_string(), "100".to_string()],
///     vec!["Item2".to_string(), "200".to_string()],
/// ];
/// let table = format_table(&headers, &rows);
/// assert!(table.contains("Name"));
/// assert!(table.contains("Item1"));
/// ```
pub fn format_table(headers: &[&str], rows: &[Vec<String>]) -> String {
    use tabled::Tabled;

    #[derive(Tabled)]
    #[allow(dead_code)]
    struct TableRow {
        #[tabled(skip)]
        _dummy: (),
    }

    if rows.is_empty() {
        return "No data to display".to_string();
    }

    // Simple table formatting
    let mut output = String::new();

    // Calculate column widths
    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
    for row in rows {
        for (i, cell) in row.iter().enumerate() {
            if i < widths.len() {
                widths[i] = widths[i].max(cell.len());
            }
        }
    }

    // Print headers
    let header_row: Vec<String> = headers
        .iter()
        .enumerate()
        .map(|(i, h)| format!("{:width$}", h, width = widths.get(i).unwrap_or(&10)))
        .collect();
    output.push_str(&header_row.join(" | "));
    output.push('\n');

    // Print separator
    let separator: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
    output.push_str(&separator.join("-+-"));
    output.push('\n');

    // Print rows
    for row in rows {
        let formatted_row: Vec<String> = row
            .iter()
            .enumerate()
            .map(|(i, cell)| format!("{:width$}", cell, width = widths.get(i).unwrap_or(&10)))
            .collect();
        output.push_str(&formatted_row.join(" | "));
        output.push('\n');
    }

    output
}

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

    #[test]
    fn test_output_handler_json_output() {
        let handler = OutputHandler::new(OutputFormat::Json);
        let data = json!({"name": "test", "value": 42});
        // Should serialize successfully
        let result = handler.output(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_output_handler_ndjson_output() {
        let handler = OutputHandler::new(OutputFormat::Ndjson);
        let data = json!({"name": "test", "value": 42});
        // Should serialize successfully
        let result = handler.output(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_output_handler_table_output() {
        let handler = OutputHandler::new(OutputFormat::Table);
        let data = json!({"name": "test", "value": 42});
        // Table format falls back to pretty JSON
        let result = handler.output(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_output_handler_quiet_mode() {
        let handler = OutputHandler::new(OutputFormat::Quiet);
        let data = json!({"name": "test", "value": 42});
        // Should return Ok without producing output
        let result = handler.output(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn test_output_handler_output_raw() {
        let handler = OutputHandler::new(OutputFormat::Json);
        // Should not fail
        handler.output_raw("Raw text output");
    }

    #[test]
    fn test_output_handler_output_raw_quiet() {
        let handler = OutputHandler::new(OutputFormat::Quiet);
        // Should not produce output in quiet mode
        handler.output_raw("This should not appear");
    }

    #[test]
    fn test_output_format_variants() {
        // Verify all variants exist and Default is Json
        assert!(matches!(OutputFormat::Json, OutputFormat::Json));
        assert!(matches!(OutputFormat::Ndjson, OutputFormat::Ndjson));
        assert!(matches!(OutputFormat::Table, OutputFormat::Table));
        assert!(matches!(OutputFormat::Quiet, OutputFormat::Quiet));

        let default: OutputFormat = Default::default();
        assert!(matches!(default, OutputFormat::Json));
    }

    #[test]
    fn test_format_table_empty() {
        let headers = vec!["Name", "Value"];
        let rows: Vec<Vec<String>> = vec![];
        let result = format_table(&headers, &rows);
        assert_eq!(result, "No data to display");
    }

    #[test]
    fn test_format_table_single_row() {
        let headers = vec!["Name", "Value"];
        let rows = vec![vec!["Test".to_string(), "100".to_string()]];
        let result = format_table(&headers, &rows);

        assert!(result.contains("Name"));
        assert!(result.contains("Value"));
        assert!(result.contains("Test"));
        assert!(result.contains("100"));
        // Should have header, separator, and one row (3 lines + newline)
        assert!(result.lines().count() >= 3);
    }

    #[test]
    fn test_format_table_multiple_rows() {
        let headers = vec!["ID", "Name"];
        let rows = vec![
            vec!["1".to_string(), "First".to_string()],
            vec!["2".to_string(), "Second".to_string()],
            vec!["3".to_string(), "Third".to_string()],
        ];
        let result = format_table(&headers, &rows);

        assert!(result.contains("ID"));
        assert!(result.contains("Name"));
        assert!(result.contains("1"));
        assert!(result.contains("First"));
        assert!(result.contains("2"));
        assert!(result.contains("Second"));
        assert!(result.contains("3"));
        assert!(result.contains("Third"));
    }

    #[test]
    fn test_format_table_with_alignment() {
        let headers = vec!["Short", "VeryLongHeader"];
        let rows = vec![vec!["A".to_string(), "B".to_string()]];
        let result = format_table(&headers, &rows);

        // Both headers should be present
        assert!(result.contains("Short"));
        assert!(result.contains("VeryLongHeader"));
    }
}