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