use super::{Formatter, HeaderMetadata, Import, Section};
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct RubyFormatter;
impl RubyFormatter {
#[must_use]
pub const fn new() -> Self {
Self
}
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();
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();
}
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();
for module in stdlib_requires.keys() {
result.push_str(&format!("require '{module}'\n"));
}
if !stdlib_requires.is_empty() && !gem_requires.is_empty() {
result.push('\n');
}
for module in gem_requires.keys() {
result.push_str(&format!("require '{module}'\n"));
}
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 {
result.push_str(&format!("# {}", lines[0]));
} else {
for line in lines {
if line.trim().is_empty() {
result.push_str("#\n");
} else {
result.push_str(&format!("# {line}\n"));
}
}
if result.ends_with('\n') {
result.pop();
}
}
result
}
fn merge_sections(&self, sections: &[Section]) -> String {
let mut parts = Vec::new();
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(),
}
}
if !header.is_empty() {
parts.push(header);
}
if !imports.is_empty() {
parts.push(imports);
}
if !body.is_empty() {
parts.push(body);
}
let result = parts.join("\n\n");
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(§ions);
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(§ions);
assert!(result.contains("\n\n"));
}
}