spikard-cli 0.15.6-rc.16

Command-line interface for building and validating Spikard applications
Documentation
//! Ruby code formatter.
//!
//! Implements the `Formatter` trait for Ruby code generation, ensuring output
//! adheres to Ruby 3.2+ standards, Rubocop conventions, and follows spikard's
//! type safety patterns.
//!
//! # Features
//!
//! - **Headers**: `frozen_string_literal` magic comment, auto-generation notices
//! - **Imports**: Grouped and sorted (stdlib, then gems, alphabetically)
//! - **Docstrings**: YARD format with proper indentation
//! - **Spacing**: Single blank line between class/module definitions

use super::{Formatter, HeaderMetadata, Import, Section};
use std::collections::BTreeMap;

/// Ruby code formatter implementing language-specific conventions
///
/// Formats generated Ruby code to comply with:
/// - Ruby 3.2+ syntax requirements
/// - Rubocop linting rules
/// - YARD documentation standards
/// - RBS type annotation compatibility
///
/// # Example
///
/// ```
/// use spikard_cli::codegen::formatters::{Formatter, RubyFormatter, HeaderMetadata, Import};
///
/// let formatter = RubyFormatter::new();
/// let metadata = HeaderMetadata {
///     auto_generated: true,
///     schema_file: Some("api.openapi.json".to_string()),
///     generator_version: Some("0.6.2".to_string()),
/// };
///
/// let header = formatter.format_header(&metadata);
/// assert!(header.contains("frozen_string_literal"));
/// assert!(header.contains("DO NOT EDIT"));
/// ```
#[derive(Debug, Clone)]
pub struct RubyFormatter;

impl RubyFormatter {
    /// Create a new Ruby code formatter
    #[must_use]
    pub const fn new() -> Self {
        Self
    }

    /// Determine if a require is from the Ruby standard library.
    fn is_stdlib(name: &str) -> bool {
        matches!(
            name,
            "json"
                | "yaml"
                | "time"
                | "date"
                | "set"
                | "digest"
                | "fileutils"
                | "pathname"
                | "net/http"
                | "uri"
                | "stringio"
                | "tmpdir"
                | "tempfile"
                | "thread"
                | "socket"
                | "openssl"
                | "csv"
                | "logger"
                | "singleton"
                | "forwardable"
                | "delegate"
                | "optparse"
                | "getoptlong"
                | "timeout"
                | "securerandom"
                | "base64"
                | "rexml"
                | "webrick"
                | "erb"
        )
    }
}

impl Default for RubyFormatter {
    fn default() -> Self {
        Self::new()
    }
}

impl Formatter for RubyFormatter {
    fn format_header(&self, metadata: &HeaderMetadata) -> String {
        let mut header = String::new();

        // Magic comment: frozen_string_literal (required for Ruby 3+)
        header.push_str("# frozen_string_literal: true\n");

        if metadata.auto_generated {
            header.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
            if let Some(schema) = &metadata.schema_file {
                header.push_str(&format!("# Schema: {schema}\n"));
            }
            if let Some(version) = &metadata.generator_version {
                header.push_str(&format!("# Generator: Spikard {version}\n"));
            }
        }

        header
    }

    fn format_imports(&self, imports: &[Import]) -> String {
        if imports.is_empty() {
            return String::new();
        }

        // Separate stdlib and gem requires
        let mut stdlib_requires = BTreeMap::new();
        let mut gem_requires = BTreeMap::new();

        for import in imports {
            if Self::is_stdlib(&import.module) {
                stdlib_requires.insert(import.module.clone(), import.items.clone());
            } else {
                gem_requires.insert(import.module.clone(), import.items.clone());
            }
        }

        let mut result = String::new();

        // Write stdlib requires first
        for module in stdlib_requires.keys() {
            result.push_str(&format!("require '{module}'\n"));
        }

        // Add blank line between stdlib and gems if both exist
        if !stdlib_requires.is_empty() && !gem_requires.is_empty() {
            result.push('\n');
        }

        // Write gem requires
        for module in gem_requires.keys() {
            result.push_str(&format!("require '{module}'\n"));
        }

        // Remove trailing newline (will be added during merge)
        if result.ends_with('\n') {
            result.pop();
        }

        result
    }

    fn format_docstring(&self, content: &str) -> String {
        let lines: Vec<&str> = content.lines().collect();

        if lines.is_empty() {
            return String::new();
        }

        let mut result = String::new();

        if lines.len() == 1 {
            // Single-line YARD comment
            result.push_str(&format!("# {}", lines[0]));
        } else {
            // Multi-line YARD documentation
            for line in lines {
                if line.trim().is_empty() {
                    result.push_str("#\n");
                } else {
                    result.push_str(&format!("# {line}\n"));
                }
            }
            // Remove trailing newline added by last iteration
            if result.ends_with('\n') {
                result.pop();
            }
        }

        result
    }

    fn merge_sections(&self, sections: &[Section]) -> String {
        let mut parts = Vec::new();

        // Extract and organize sections
        let mut header = String::new();
        let mut imports = String::new();
        let mut body = String::new();

        for section in sections {
            match section {
                Section::Header(h) => header = h.clone(),
                Section::Imports(i) => imports = i.clone(),
                Section::Body(b) => body = b.clone(),
            }
        }

        // Build final output
        if !header.is_empty() {
            parts.push(header);
        }

        if !imports.is_empty() {
            parts.push(imports);
        }

        if !body.is_empty() {
            parts.push(body);
        }

        // Join with single blank line between sections
        let result = parts.join("\n\n");

        // Ensure trailing newline
        if result.is_empty() {
            String::new()
        } else if result.ends_with('\n') {
            result
        } else {
            format!("{result}\n")
        }
    }
}

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

    #[test]
    fn test_format_header_with_metadata() {
        let formatter = RubyFormatter::new();
        let metadata = HeaderMetadata {
            auto_generated: true,
            schema_file: Some("api.openapi.json".to_string()),
            generator_version: Some("0.6.2".to_string()),
        };
        let header = formatter.format_header(&metadata);
        assert!(header.starts_with("# frozen_string_literal: true"));
        assert!(header.contains("DO NOT EDIT"));
        assert!(header.contains("api.openapi.json"));
        assert!(header.contains("0.6.2"));
    }

    #[test]
    fn test_format_header_minimal() {
        let formatter = RubyFormatter::new();
        let metadata = HeaderMetadata {
            auto_generated: false,
            schema_file: None,
            generator_version: None,
        };
        let header = formatter.format_header(&metadata);
        assert!(header.starts_with("# frozen_string_literal: true"));
    }

    #[test]
    fn test_format_imports_stdlib_first() {
        let formatter = RubyFormatter::new();
        let imports = vec![
            Import {
                module: "json".to_string(),
                items: vec![],
                is_type_only: false,
            },
            Import {
                module: "sinatra".to_string(),
                items: vec![],
                is_type_only: false,
            },
        ];

        let result = formatter.format_imports(&imports);
        let json_pos = result.find("'json'").expect("json require");
        let sinatra_pos = result.find("'sinatra'").expect("sinatra require");
        assert!(json_pos < sinatra_pos, "stdlib should come before gems");
        assert!(result.contains("\n\n"), "Should have blank line between groups");
    }

    #[test]
    fn test_format_imports_sorted() {
        let formatter = RubyFormatter::new();
        let imports = vec![
            Import {
                module: "yaml".to_string(),
                items: vec![],
                is_type_only: false,
            },
            Import {
                module: "json".to_string(),
                items: vec![],
                is_type_only: false,
            },
        ];

        let result = formatter.format_imports(&imports);
        let json_pos = result.find("'json'").expect("json require");
        let yaml_pos = result.find("'yaml'").expect("yaml require");
        assert!(json_pos < yaml_pos, "Should be sorted alphabetically");
    }

    #[test]
    fn test_is_stdlib() {
        assert!(RubyFormatter::is_stdlib("json"));
        assert!(RubyFormatter::is_stdlib("yaml"));
        assert!(RubyFormatter::is_stdlib("net/http"));
        assert!(!RubyFormatter::is_stdlib("rails"));
        assert!(!RubyFormatter::is_stdlib("sinatra"));
    }

    #[test]
    fn test_format_docstring_single_line() {
        let formatter = RubyFormatter::new();
        let doc = formatter.format_docstring("User API handler");
        assert_eq!(doc, "# User API handler");
    }

    #[test]
    fn test_format_docstring_multiline() {
        let formatter = RubyFormatter::new();
        let content = "User API handler\nHandles user CRUD\nOperations";
        let doc = formatter.format_docstring(content);
        assert!(doc.starts_with("# User API handler"));
        assert!(doc.contains("# Handles user CRUD"));
        assert!(doc.contains("# Operations"));
    }

    #[test]
    fn test_format_docstring_preserves_empty_lines() {
        let formatter = RubyFormatter::new();
        let content = "User API\n\nDetailed description";
        let doc = formatter.format_docstring(content);
        assert!(doc.contains("#\n"));
    }

    #[test]
    fn test_merge_sections() {
        let formatter = RubyFormatter::new();
        let sections = vec![
            Section::Header("# frozen_string_literal: true".to_string()),
            Section::Imports("require 'json'".to_string()),
            Section::Body("class User\nend".to_string()),
        ];

        let result = formatter.merge_sections(&sections);
        assert!(result.contains("frozen_string_literal"));
        assert!(result.contains("require"));
        assert!(result.contains("class"));
        assert!(result.ends_with('\n'));
    }

    #[test]
    fn test_merge_sections_blank_line_between() {
        let formatter = RubyFormatter::new();
        let sections = vec![
            Section::Header("# Header".to_string()),
            Section::Imports("require 'json'".to_string()),
        ];

        let result = formatter.merge_sections(&sections);
        assert!(result.contains("\n\n"));
    }
}