spikard_cli/codegen/formatters/
ruby.rs1use super::{Formatter, HeaderMetadata, Import, Section};
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone)]
42pub struct RubyFormatter;
43
44impl RubyFormatter {
45 #[must_use]
47 pub const fn new() -> Self {
48 Self
49 }
50
51 fn is_stdlib(name: &str) -> bool {
53 matches!(
54 name,
55 "json"
56 | "yaml"
57 | "time"
58 | "date"
59 | "set"
60 | "digest"
61 | "fileutils"
62 | "pathname"
63 | "net/http"
64 | "uri"
65 | "stringio"
66 | "tmpdir"
67 | "tempfile"
68 | "thread"
69 | "socket"
70 | "openssl"
71 | "csv"
72 | "logger"
73 | "singleton"
74 | "forwardable"
75 | "delegate"
76 | "optparse"
77 | "getoptlong"
78 | "timeout"
79 | "securerandom"
80 | "base64"
81 | "rexml"
82 | "webrick"
83 | "erb"
84 )
85 }
86}
87
88impl Default for RubyFormatter {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl Formatter for RubyFormatter {
95 fn format_header(&self, metadata: &HeaderMetadata) -> String {
96 let mut header = String::new();
97
98 header.push_str("# frozen_string_literal: true\n");
100
101 if metadata.auto_generated {
102 header.push_str("# DO NOT EDIT - Auto-generated by Spikard CLI\n");
103 if let Some(schema) = &metadata.schema_file {
104 header.push_str(&format!("# Schema: {schema}\n"));
105 }
106 if let Some(version) = &metadata.generator_version {
107 header.push_str(&format!("# Generator: Spikard {version}\n"));
108 }
109 }
110
111 header
112 }
113
114 fn format_imports(&self, imports: &[Import]) -> String {
115 if imports.is_empty() {
116 return String::new();
117 }
118
119 let mut stdlib_requires = BTreeMap::new();
121 let mut gem_requires = BTreeMap::new();
122
123 for import in imports {
124 if Self::is_stdlib(&import.module) {
125 stdlib_requires.insert(import.module.clone(), import.items.clone());
126 } else {
127 gem_requires.insert(import.module.clone(), import.items.clone());
128 }
129 }
130
131 let mut result = String::new();
132
133 for module in stdlib_requires.keys() {
135 result.push_str(&format!("require '{module}'\n"));
136 }
137
138 if !stdlib_requires.is_empty() && !gem_requires.is_empty() {
140 result.push('\n');
141 }
142
143 for module in gem_requires.keys() {
145 result.push_str(&format!("require '{module}'\n"));
146 }
147
148 if result.ends_with('\n') {
150 result.pop();
151 }
152
153 result
154 }
155
156 fn format_docstring(&self, content: &str) -> String {
157 let lines: Vec<&str> = content.lines().collect();
158
159 if lines.is_empty() {
160 return String::new();
161 }
162
163 let mut result = String::new();
164
165 if lines.len() == 1 {
166 result.push_str(&format!("# {}", lines[0]));
168 } else {
169 for line in lines {
171 if line.trim().is_empty() {
172 result.push_str("#\n");
173 } else {
174 result.push_str(&format!("# {line}\n"));
175 }
176 }
177 if result.ends_with('\n') {
179 result.pop();
180 }
181 }
182
183 result
184 }
185
186 fn merge_sections(&self, sections: &[Section]) -> String {
187 let mut parts = Vec::new();
188
189 let mut header = String::new();
191 let mut imports = String::new();
192 let mut body = String::new();
193
194 for section in sections {
195 match section {
196 Section::Header(h) => header = h.clone(),
197 Section::Imports(i) => imports = i.clone(),
198 Section::Body(b) => body = b.clone(),
199 }
200 }
201
202 if !header.is_empty() {
204 parts.push(header);
205 }
206
207 if !imports.is_empty() {
208 parts.push(imports);
209 }
210
211 if !body.is_empty() {
212 parts.push(body);
213 }
214
215 let result = parts.join("\n\n");
217
218 if result.is_empty() {
220 String::new()
221 } else if result.ends_with('\n') {
222 result
223 } else {
224 format!("{result}\n")
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn test_format_header_with_metadata() {
235 let formatter = RubyFormatter::new();
236 let metadata = HeaderMetadata {
237 auto_generated: true,
238 schema_file: Some("api.openapi.json".to_string()),
239 generator_version: Some("0.6.2".to_string()),
240 };
241 let header = formatter.format_header(&metadata);
242 assert!(header.starts_with("# frozen_string_literal: true"));
243 assert!(header.contains("DO NOT EDIT"));
244 assert!(header.contains("api.openapi.json"));
245 assert!(header.contains("0.6.2"));
246 }
247
248 #[test]
249 fn test_format_header_minimal() {
250 let formatter = RubyFormatter::new();
251 let metadata = HeaderMetadata {
252 auto_generated: false,
253 schema_file: None,
254 generator_version: None,
255 };
256 let header = formatter.format_header(&metadata);
257 assert!(header.starts_with("# frozen_string_literal: true"));
258 }
259
260 #[test]
261 fn test_format_imports_stdlib_first() {
262 let formatter = RubyFormatter::new();
263 let imports = vec![
264 Import {
265 module: "json".to_string(),
266 items: vec![],
267 is_type_only: false,
268 },
269 Import {
270 module: "sinatra".to_string(),
271 items: vec![],
272 is_type_only: false,
273 },
274 ];
275
276 let result = formatter.format_imports(&imports);
277 let json_pos = result.find("'json'").expect("json require");
278 let sinatra_pos = result.find("'sinatra'").expect("sinatra require");
279 assert!(json_pos < sinatra_pos, "stdlib should come before gems");
280 assert!(result.contains("\n\n"), "Should have blank line between groups");
281 }
282
283 #[test]
284 fn test_format_imports_sorted() {
285 let formatter = RubyFormatter::new();
286 let imports = vec![
287 Import {
288 module: "yaml".to_string(),
289 items: vec![],
290 is_type_only: false,
291 },
292 Import {
293 module: "json".to_string(),
294 items: vec![],
295 is_type_only: false,
296 },
297 ];
298
299 let result = formatter.format_imports(&imports);
300 let json_pos = result.find("'json'").expect("json require");
301 let yaml_pos = result.find("'yaml'").expect("yaml require");
302 assert!(json_pos < yaml_pos, "Should be sorted alphabetically");
303 }
304
305 #[test]
306 fn test_is_stdlib() {
307 assert!(RubyFormatter::is_stdlib("json"));
308 assert!(RubyFormatter::is_stdlib("yaml"));
309 assert!(RubyFormatter::is_stdlib("net/http"));
310 assert!(!RubyFormatter::is_stdlib("rails"));
311 assert!(!RubyFormatter::is_stdlib("sinatra"));
312 }
313
314 #[test]
315 fn test_format_docstring_single_line() {
316 let formatter = RubyFormatter::new();
317 let doc = formatter.format_docstring("User API handler");
318 assert_eq!(doc, "# User API handler");
319 }
320
321 #[test]
322 fn test_format_docstring_multiline() {
323 let formatter = RubyFormatter::new();
324 let content = "User API handler\nHandles user CRUD\nOperations";
325 let doc = formatter.format_docstring(content);
326 assert!(doc.starts_with("# User API handler"));
327 assert!(doc.contains("# Handles user CRUD"));
328 assert!(doc.contains("# Operations"));
329 }
330
331 #[test]
332 fn test_format_docstring_preserves_empty_lines() {
333 let formatter = RubyFormatter::new();
334 let content = "User API\n\nDetailed description";
335 let doc = formatter.format_docstring(content);
336 assert!(doc.contains("#\n"));
337 }
338
339 #[test]
340 fn test_merge_sections() {
341 let formatter = RubyFormatter::new();
342 let sections = vec![
343 Section::Header("# frozen_string_literal: true".to_string()),
344 Section::Imports("require 'json'".to_string()),
345 Section::Body("class User\nend".to_string()),
346 ];
347
348 let result = formatter.merge_sections(§ions);
349 assert!(result.contains("frozen_string_literal"));
350 assert!(result.contains("require"));
351 assert!(result.contains("class"));
352 assert!(result.ends_with('\n'));
353 }
354
355 #[test]
356 fn test_merge_sections_blank_line_between() {
357 let formatter = RubyFormatter::new();
358 let sections = vec![
359 Section::Header("# Header".to_string()),
360 Section::Imports("require 'json'".to_string()),
361 ];
362
363 let result = formatter.merge_sections(§ions);
364 assert!(result.contains("\n\n"));
365 }
366}