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        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}