1use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
7use anyhow::Result;
8use heck::ToPascalCase;
9use std::path::Path;
10use std::path::PathBuf;
11
12pub struct RubyScaffolder;
14
15impl ProjectScaffolder for RubyScaffolder {
16 fn scaffold(&self, _project_dir: &Path, project_name: &str) -> Result<Vec<ScaffoldedFile>> {
17 let snake_name = project_name.replace('-', "_").to_lowercase();
18 let module_name = snake_name.to_pascal_case();
19
20 let mut files = vec![];
21
22 files.push(ScaffoldedFile::new(PathBuf::from("Gemfile"), self.generate_gemfile()));
23
24 files.push(ScaffoldedFile::new(
25 PathBuf::from(".gitignore"),
26 self.generate_gitignore(),
27 ));
28
29 files.push(ScaffoldedFile::new(
30 PathBuf::from("README.md"),
31 self.generate_readme(&snake_name),
32 ));
33
34 files.push(ScaffoldedFile::new(
35 PathBuf::from("bin/server"),
36 self.generate_server_script(&snake_name, &module_name),
37 ));
38
39 files.push(ScaffoldedFile::new(
40 PathBuf::from(format!("lib/{snake_name}.rb")),
41 self.generate_app_rb(&module_name),
42 ));
43
44 files.push(ScaffoldedFile::new(
45 PathBuf::from(format!("sig/{snake_name}.rbs")),
46 self.generate_app_rbs(&snake_name, &module_name),
47 ));
48
49 files.push(ScaffoldedFile::new(
50 PathBuf::from(format!("spec/{snake_name}_spec.rb")),
51 self.generate_app_spec_rb(&snake_name, &module_name),
52 ));
53
54 files.push(ScaffoldedFile::new(
55 PathBuf::from("spec/spec_helper.rb"),
56 self.generate_spec_helper(),
57 ));
58
59 files.push(ScaffoldedFile::new(PathBuf::from(".rspec"), self.generate_rspec()));
60
61 files.push(ScaffoldedFile::new(PathBuf::from("Rakefile"), self.generate_rakefile()));
62
63 Ok(files)
64 }
65
66 fn next_steps(&self, project_name: &str) -> Vec<String> {
67 vec![
68 format!("cd {}", project_name),
69 "bundle install".to_string(),
70 "bundle exec ruby bin/server".to_string(),
71 ]
72 }
73}
74
75impl RubyScaffolder {
76 fn generate_gemfile(&self) -> String {
77 let version = env!("CARGO_PKG_VERSION");
78 format!(
79 r#"# frozen_string_literal: true
80
81source "https://rubygems.org"
82
83ruby ">= 3.2.0"
84
85gem "spikard", "~> {version}"
86
87group :development, :test do
88 gem "rspec", "~> 3.13"
89 gem "steep", "~> 1.9"
90 gem "rubocop", "~> 1.64"
91end
92"#
93 )
94 }
95
96 fn generate_gitignore(&self) -> String {
97 r"# Dependencies
98/vendor/
99
100# IDE
101.vscode/
102.idea/
103*.swp
104*.swo
105*~
106
107# Testing
108/coverage/
109/.rspec_status
110/.rspec_results
111
112# Environment
113.env
114.env.local
115
116# RubyMine
117.rmvrc
118
119# Temp files
120*.tmp
121*.log
122
123# OS
124.DS_Store
125Thumbs.db
126
127# Steep
128.steep.log
129"
130 .to_string()
131 }
132
133 fn generate_readme(&self, snake_name: &str) -> String {
134 format!(
135 r"# {snake_name}
136
137A Spikard Ruby application.
138
139## Requirements
140
141- Ruby 3.2+
142- Bundler
143
144## Installation
145
146```bash
147bundle install
148```
149
150## Development
151
152Start the development server:
153
154```bash
155bundle exec ruby bin/server
156```
157
158The server will start on `http://127.0.0.1:8000`.
159
160## Testing
161
162Run tests:
163
164```bash
165bundle exec rspec
166```
167
168Run tests with coverage:
169
170```bash
171COVERAGE=true bundle exec rspec
172```
173
174## Type Checking
175
176Steep performs static type checking using RBS type annotations:
177
178```bash
179bundle exec steep check
180```
181
182## Linting & Formatting
183
184Lint the code:
185
186```bash
187bundle exec rubocop
188```
189
190Auto-fix issues:
191
192```bash
193bundle exec rubocop -A
194```
195
196## Next Steps
197
1981. Install dependencies: `bundle install`
1992. Start the server: `bundle exec ruby bin/server`
2003. Make requests to `http://localhost:8000/health` to verify
2014. Write your handlers in `lib/{snake_name}.rb`
2025. Add tests in `spec/`
203
204## Project Structure
205
206```
207my-app/
208├── bin/server # Runnable entrypoint
209├── lib/{snake_name}.rb # Main application code
210├── sig/{snake_name}.rbs # RBS type definitions
211├── spec/ # RSpec tests
212├── Gemfile # Ruby dependencies
213├── Rakefile # Rake tasks
214└── README.md
215```
216
217## Type Annotations
218
219This project uses RBS (Ruby Signature) files for type safety. Steep provides static type checking:
220
221- Type definitions in `sig/{snake_name}.rbs`
222- Main code in `lib/{snake_name}.rb`
223- Run `bundle exec steep check` to verify types
224
225## Documentation
226
227- [Spikard Documentation](https://github.com/Goldziher/spikard)
228- [Ruby Documentation](https://ruby-doc.org)
229- [RBS Guide](https://github.com/ruby/rbs)
230- [Steep Documentation](https://github.com/soutaro/steep)
231"
232 )
233 }
234
235 fn generate_server_script(&self, snake_name: &str, module_name: &str) -> String {
236 format!(
237 r#"#!/usr/bin/env ruby
238# frozen_string_literal: true
239
240require_relative '../lib/{snake_name}'
241
242app = {module_name}.build_app
243
244puts 'Starting Spikard Ruby server on http://127.0.0.1:8000'
245puts 'Press Ctrl+C to stop'
246puts ''
247
248app.run
249"#
250 )
251 }
252
253 fn generate_app_rb(&self, module_name: &str) -> String {
254 format!(
255 r#"# frozen_string_literal: true
256
257require 'json'
258require 'spikard'
259require 'time'
260
261module {module_name}
262 def self.build_app
263 app = Spikard::App.new(
264 port: 8000,
265 host: '127.0.0.1'
266 )
267
268 app.get '/' do |_request|
269 {{
270 message: 'Hello from Spikard Ruby!',
271 timestamp: Time.now.iso8601
272 }}
273 end
274
275 app.get '/health' do |_request|
276 {{
277 status: 'healthy',
278 timestamp: Time.now.iso8601
279 }}
280 end
281
282 app.post '/echo' do |request|
283 body = request.body.is_a?(Hash) ? request.body : nil
284 {{
285 echoed: true,
286 body: body,
287 received_at: Time.now.iso8601
288 }}
289 rescue StandardError => e
290 {{
291 status: 400,
292 body: {{
293 error: 'Invalid request body',
294 code: 'invalid_body',
295 details: e.message
296 }}
297 }}
298 end
299
300 app
301 end
302end
303"#
304 )
305 }
306
307 fn generate_app_rbs(&self, snake_name: &str, module_name: &str) -> String {
308 format!(
309 r"# Type definitions for {snake_name}
310
311module {module_name}
312 def self.build_app: () -> Spikard::App
313end
314
315module Spikard
316 class App
317 def initialize: (port: Integer, host: String) -> void
318 def get: (String) -> void
319 def post: (String) -> void
320 def put: (String) -> void
321 def delete: (String) -> void
322 def patch: (String) -> void
323 def run: () -> void
324 end
325
326 class Request
327 def body: Hash[String, untyped] | nil
328 def headers: Hash[String, String]
329 def path: String
330 def method: String
331 def params: Hash[String, String]
332 def json: -> Hash[String, untyped]
333 end
334
335 class Response
336 def self.json: (Hash[String, untyped]) -> Response
337 def self.text: (String) -> Response
338 def self.status: (Integer, untyped) -> Response
339 end
340end
341
342"
343 )
344 }
345
346 fn generate_app_spec_rb(&self, snake_name: &str, module_name: &str) -> String {
347 format!(
348 r"# frozen_string_literal: true
349
350require 'spec_helper'
351require_relative '../lib/{snake_name}'
352
353RSpec.describe {module_name} do
354 describe '.build_app' do
355 it 'creates a Spikard application' do
356 expect(described_class.build_app).to be_a(Spikard::App)
357 end
358 end
359
360 describe 'generated routes' do
361 it 'builds an app without raising' do
362 expect {{ described_class.build_app }}.not_to raise_error
363 end
364 end
365end
366"
367 )
368 }
369
370 fn generate_spec_helper(&self) -> String {
371 r"# frozen_string_literal: true
372
373require 'bundler/setup'
374require 'spikard'
375"
376 .to_string()
377 }
378
379 fn generate_rspec(&self) -> String {
380 r"--require spec_helper
381--format documentation
382--color
383"
384 .to_string()
385 }
386
387 fn generate_rakefile(&self) -> String {
388 r"# frozen_string_literal: true
389
390require 'bundler/setup'
391
392desc 'Run the application'
393task :run do
394 exec('bundle exec ruby bin/server')
395end
396
397desc 'Run RSpec tests'
398task :spec do
399 exec('bundle exec rspec')
400end
401
402desc 'Run Steep type checking'
403task :type_check do
404 exec('bundle exec steep check')
405end
406
407desc 'Run RuboCop linter'
408task :lint do
409 exec('bundle exec rubocop')
410end
411
412desc 'Auto-fix RuboCop issues'
413task :lint_fix do
414 exec('bundle exec rubocop -A')
415end
416
417task default: :spec
418"
419 .to_string()
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use tempfile::TempDir;
427
428 #[test]
429 fn test_ruby_scaffold_creates_files() -> Result<()> {
430 let temp_dir = TempDir::new()?;
431 let scaffolder = RubyScaffolder;
432 let files = scaffolder.scaffold(temp_dir.path(), "test_app")?;
433
434 assert!(!files.is_empty(), "Should create multiple files");
435
436 let file_paths: Vec<_> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
437
438 assert!(file_paths.iter().any(|p| p == "Gemfile"));
439 assert!(file_paths.iter().any(|p| p == ".gitignore"));
440 assert!(file_paths.iter().any(|p| p == "README.md"));
441 assert!(file_paths.iter().any(|p| p == "bin/server"));
442 assert!(file_paths.iter().any(|p| p.contains("lib/test_app.rb")));
443 assert!(file_paths.iter().any(|p| p.contains("sig/test_app.rbs")));
444 assert!(file_paths.iter().any(|p| p.contains("spec/test_app_spec.rb")));
445 assert!(file_paths.iter().any(|p| p == "spec/spec_helper.rb"));
446 assert!(file_paths.iter().any(|p| p == ".rspec"));
447 assert!(file_paths.iter().any(|p| p == "Rakefile"));
448
449 Ok(())
450 }
451
452 #[test]
453 fn test_ruby_scaffold_gemfile_valid() -> Result<()> {
454 let temp_dir = TempDir::new()?;
455 let scaffolder = RubyScaffolder;
456 let files = scaffolder.scaffold(temp_dir.path(), "my_app")?;
457
458 let gemfile = files.iter().find(|f| f.path.file_name().unwrap() == "Gemfile").unwrap();
459
460 assert!(gemfile.content.contains("ruby \">= 3.2.0\""));
461 assert!(gemfile.content.contains("spikard"));
462 assert!(gemfile.content.contains("rspec"));
463 assert!(gemfile.content.contains("steep"));
464 assert!(gemfile.content.contains("rubocop"));
465
466 Ok(())
467 }
468
469 #[test]
470 fn test_ruby_scaffold_rbs_type_definitions() -> Result<()> {
471 let temp_dir = TempDir::new()?;
472 let scaffolder = RubyScaffolder;
473 let files = scaffolder.scaffold(temp_dir.path(), "test_app")?;
474
475 let rbs = files
476 .iter()
477 .find(|f| f.path.to_string_lossy().ends_with(".rbs"))
478 .unwrap();
479
480 assert!(rbs.content.contains("module Spikard"));
481 assert!(rbs.content.contains("class App"));
482 assert!(rbs.content.contains("class Request"));
483 assert!(rbs.content.contains("class Response"));
484
485 Ok(())
486 }
487
488 #[test]
489 fn test_ruby_scaffold_app_rb_has_handlers() -> Result<()> {
490 let temp_dir = TempDir::new()?;
491 let scaffolder = RubyScaffolder;
492 let files = scaffolder.scaffold(temp_dir.path(), "test_app")?;
493
494 let app_rb = files
495 .iter()
496 .find(|f| f.path.to_string_lossy().ends_with("lib/test_app.rb"))
497 .unwrap();
498
499 assert!(app_rb.content.contains("module TestApp"));
500 assert!(app_rb.content.contains("def self.build_app"));
501 assert!(app_rb.content.contains("Spikard::App.new"));
502 assert!(app_rb.content.contains("app.get"));
503 assert!(app_rb.content.contains("app.post"));
504 assert!(app_rb.content.contains("'/'"));
505 assert!(app_rb.content.contains("'/health'"));
506 assert!(app_rb.content.contains("'/echo'"));
507
508 Ok(())
509 }
510
511 #[test]
512 fn test_ruby_scaffold_spec_file_exists() -> Result<()> {
513 let temp_dir = TempDir::new()?;
514 let scaffolder = RubyScaffolder;
515 let files = scaffolder.scaffold(temp_dir.path(), "my_app")?;
516
517 let spec = files
518 .iter()
519 .find(|f| f.path.to_string_lossy().ends_with("spec/my_app_spec.rb"))
520 .unwrap();
521
522 assert!(spec.content.contains("describe"));
523 assert!(spec.content.contains("it"));
524 assert!(spec.content.contains("expect"));
525
526 Ok(())
527 }
528
529 #[test]
530 fn test_ruby_next_steps() {
531 let scaffolder = RubyScaffolder;
532 let steps = scaffolder.next_steps("my_app");
533
534 assert!(!steps.is_empty());
535 assert!(steps[0].contains("my_app"));
536 assert!(steps.iter().any(|s| s.contains("bundle install")));
537 assert!(steps.iter().any(|s| s.contains("bundle exec ruby bin/server")));
538 }
539}