Skip to main content

spikard_cli/init/
ruby.rs

1//! Ruby Project Scaffolder
2//!
3//! Generates a minimal Ruby project structure with Spikard integration.
4//! Follows modern Ruby conventions with RBS type annotations and `RSpec` testing.
5
6use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
7use anyhow::Result;
8use heck::ToPascalCase;
9use std::path::Path;
10use std::path::PathBuf;
11
12/// Ruby project scaffolder
13pub 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        // Gemfile
23        files.push(ScaffoldedFile::new(PathBuf::from("Gemfile"), self.generate_gemfile()));
24
25        // .gitignore
26        files.push(ScaffoldedFile::new(
27            PathBuf::from(".gitignore"),
28            self.generate_gitignore(),
29        ));
30
31        // README.md
32        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        // lib/my_app.rb
43        files.push(ScaffoldedFile::new(
44            PathBuf::from(format!("lib/{snake_name}.rb")),
45            self.generate_app_rb(&module_name),
46        ));
47
48        // sig/my_app.rbs
49        files.push(ScaffoldedFile::new(
50            PathBuf::from(format!("sig/{snake_name}.rbs")),
51            self.generate_app_rbs(&snake_name, &module_name),
52        ));
53
54        // spec/my_app_spec.rb
55        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        // .rspec
66        files.push(ScaffoldedFile::new(PathBuf::from(".rspec"), self.generate_rspec()));
67
68        // Rakefile (optional, for task automation)
69        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        // Check expected files exist in the vec
445        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}