docit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e48afeb14f8b73781bf089a31b30616c00d4aa053f079668fbecbc7215ea13e2
4
- data.tar.gz: dd1ea68be0fb59dd014af55bb457fe110e2da2227fe56a0216d75c5051046215
3
+ metadata.gz: cb189284c9d9b8ad90f9d39fccaea0265223c3be58009b95b51b0dfbc12a3d78
4
+ data.tar.gz: 5bd6b3277e7686470f1c24bc2824b2cd9ec97ebc785d9e19fefe9fafe7a2be1d
5
5
  SHA512:
6
- metadata.gz: ba5dadbd366f719b4420c515ad252380e4e5fd66137bb183e6b8d109426b0cf828eed12918658ea3a9af6e55d73f2c8e806e7a92c9d600d3a58e1fb643319576
7
- data.tar.gz: 17690c65bec465a9b3479d80375a558a72bec94fd5677f8389e028d07d7b915e146bfa16b380ba0fb89aef33b848980033bcd38eb51ba04c6ef899c1c7c6663f
6
+ metadata.gz: f921f704eb18446ecc475377d7fa8f0604c34349182139fa79ac26dd76260e04028b0b4443ca300e877298c96423ac5d8cc04b5a0887c6fa379a2752de8d6c21
7
+ data.tar.gz: 1b6fbfc6aae77f31e74700243535e12be9c6caae790e87614903e7538e769746d774e4d31899fc3617fee01896f0f2a7e2a489fa0026d35e08cc5afd602e7c13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-04-11
4
+
5
+ ### Added
6
+ - Unified install generator: `rails g docit:install` now offers AI auto-docs, manual scaffolding, or skip
7
+ - Manual scaffold mode: creates doc file placeholders with TODO markers, injects `use_docs` into controllers
8
+ - `AutodocRunner` class: reusable orchestrator for AI doc generation (used by both install generator and rake task)
9
+ - `ScaffoldGenerator` class: creates placeholder doc files for manual documentation
10
+ - Groq provider support (free tier) alongside OpenAI and Anthropic
11
+ - AI configuration generator: `rails g docit:ai_setup`
12
+ - Tasks: `rails docit:autodoc` with preview support via `DRY_RUN=1`
13
+ - Auto-injection of `use_docs` into controllers after doc generation
14
+ - Auto-injection of `config.tag` entries into initializer
15
+
16
+ ### Changed
17
+ - AI setup flows now retry invalid menu input instead of exiting immediately
18
+ - AI setup flows now hide API key input in interactive terminals
19
+ - `rails docit:autodoc` now supports `DRY_RUN=1` for preview mode
20
+ - AI autodoc now warns before sending controller source to external providers
21
+ - Swagger UI assets are pinned to a specific `swagger-ui-dist` version
22
+
23
+ ### Fixed
24
+ - `.docit_ai.yml` is now written with restricted file permissions
25
+ - AI provider clients now return a clean Docit error when an upstream service responds with invalid JSON
26
+ - Swagger UI now escapes the generated spec URL before embedding it in JavaScript
27
+ - Manual scaffolds now use `200` for `PUT` and `PATCH` responses by default
28
+
3
29
  ## [0.1.0] - 2026-04-08
4
30
 
5
31
  - Initial release
data/CONTRIBUTING.md CHANGED
@@ -33,7 +33,7 @@ Docit aims for high test coverage and an extensive test suite. Tests enable us t
33
33
 
34
34
  ```bash
35
35
  # Fork the repo on GitHub, then:
36
- git clone https://github.com/YOURGITHUBNAME/docit.git
36
+ git clone https://github.com/S13G/docit.git
37
37
  cd docit
38
38
 
39
39
  # Install dependencies
data/README.md CHANGED
@@ -1,11 +1,9 @@
1
1
  # Docit
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/docit.svg)](https://rubygems.org/gems/docit)
4
- [![CI](https://github.com/S13G/docket/actions/workflows/ci.yml/badge.svg)](https://github.com/S13G/docket/actions/workflows/ci.yml)
5
3
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](https://www.ruby-lang.org)
6
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
5
 
8
- Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0 docs as clean DSL macros directly on your controller actions: no separate doc files, no RSpec integration required. Just annotate and go.
6
+ Decorator-style API documentation for Ruby on Rails. Write OpenAPI 3.0.3 docs with clean controller DSL macros, separate doc modules, or AI-assisted scaffolding for undocumented endpoints.
9
7
 
10
8
  Inspired by [drf-spectacular](https://github.com/tfranzel/drf-spectacular) for Django REST Framework.
11
9
 
@@ -24,13 +22,19 @@ bundle install
24
22
  rails generate docit:install
25
23
  ```
26
24
 
27
- The generator does two things:
25
+ The install generator does everything in one step:
28
26
 
29
27
  1. Creates `config/initializers/docit.rb` with default settings
30
28
  2. Mounts the Swagger UI engine at `/api-docs` in your routes
29
+ 3. Asks how you'd like to set up your docs:
30
+ - **AI automatic docs** — configure an AI provider, then Docit scans your routes and generates complete documentation for every endpoint
31
+ - **Manual docs** — Docit scans your routes and creates scaffolded doc files with TODO placeholders, injects `use_docs` into controllers, and lets you fill in the details
32
+ - **Skip** — just install the base config and set up docs later
31
33
 
32
34
  Visit `/api-docs` to see your interactive API documentation.
33
35
 
36
+ If you choose AI setup, Docit stores your provider config in `.docit_ai.yml` with restricted file permissions and adds that file to `.gitignore` when possible.
37
+
34
38
  ## Configuration
35
39
 
36
40
  Edit `config/initializers/docit.rb`:
@@ -374,6 +378,57 @@ end
374
378
 
375
379
  `type: :file` maps to `{ type: "string", format: "binary" }` in the OpenAPI spec.
376
380
 
381
+ ## AI Automatic Documentation
382
+
383
+ Docit can generate complete API documentation using AI. This works with OpenAI, Anthropic, or Groq (free tier available).
384
+
385
+ ### Quick start (included in install)
386
+
387
+ When you run `rails generate docit:install` and choose option 1 (AI automatic docs), everything is set up automatically — provider configuration, doc generation, controller wiring, and tag injection.
388
+
389
+ Before the first AI request, Docit warns that your controller source code will be sent to the selected provider and asks for confirmation in interactive terminals.
390
+
391
+ ### Standalone commands
392
+
393
+ You can also set up AI docs separately:
394
+
395
+ ```bash
396
+ # Configure your AI provider (one-time setup)
397
+ rails generate docit:ai_setup
398
+
399
+ # Generate docs for all undocumented endpoints
400
+ rails docit:autodoc
401
+
402
+ # Generate docs for a specific controller
403
+ rails docit:autodoc[Api::V1::UsersController]
404
+
405
+ # Preview what would be generated without writing files
406
+ DRY_RUN=1 rails docit:autodoc
407
+ ```
408
+
409
+ ### Supported providers
410
+
411
+ | Provider | Notes |
412
+ |------------|-------|
413
+ | OpenAI | Requires API key from platform.openai.com |
414
+ | Anthropic | Requires API key from console.anthropic.com |
415
+ | Groq | Free tier at console.groq.com |
416
+
417
+ AI configuration is stored in `.docit_ai.yml`.
418
+ If your app does not have a `.gitignore`, add `.docit_ai.yml` manually.
419
+
420
+ ### What the AI generates
421
+
422
+ For each undocumented endpoint, Docit:
423
+
424
+ 1. Reads the controller source code
425
+ 2. Inspects the route (HTTP method, path, parameters)
426
+ 3. Writes the generated doc block to `app/docs/`
427
+ 4. Injects `use_docs` into the controller
428
+ 5. Adds tag descriptions to the initializer
429
+
430
+ Do not use AI autodoc on controllers that contain secrets, proprietary business rules, or internal comments you do not want sent to an external provider.
431
+
377
432
  ## How it works
378
433
 
379
434
  1. `swagger_doc` registers an **Operation** for each controller action in a global **Registry**
@@ -401,7 +456,7 @@ GET /api-docs/spec
401
456
  ## Development
402
457
 
403
458
  ```bash
404
- git clone https://github.com/S13G/docket.git
459
+ git clone https://github.com/S13G/docit.git
405
460
  cd docit
406
461
  bundle install
407
462
  bundle exec rspec # run all tests
@@ -409,7 +464,7 @@ bundle exec rspec # run all tests
409
464
 
410
465
  ## Contributing
411
466
 
412
- Bug reports and pull requests are welcome on [GitHub](https://github.com/S13G/docket).
467
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/S13G/docit).
413
468
 
414
469
  ## License
415
470
 
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Docit
4
6
  class UiController < ActionController::Base
7
+ SWAGGER_UI_VERSION = "5.32.2"
8
+
5
9
  def index
6
10
  render html: swagger_ui_html.html_safe, layout: false
7
11
  end
@@ -15,6 +19,7 @@ module Docit
15
19
 
16
20
  def swagger_ui_html
17
21
  spec_url = "#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
22
+ spec_url_json = JSON.generate(spec_url)
18
23
  escaped_title = ERB::Util.html_escape(Docit.configuration.title)
19
24
 
20
25
  <<~HTML
@@ -24,7 +29,7 @@ module Docit
24
29
  <meta charset="UTF-8">
25
30
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
26
31
  <title>#{escaped_title}</title>
27
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
32
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@#{SWAGGER_UI_VERSION}/swagger-ui.css" />
28
33
  <style>
29
34
  html { box-sizing: border-box; overflow-y: scroll; }
30
35
  *, *:before, *:after { box-sizing: inherit; }
@@ -33,10 +38,10 @@ module Docit
33
38
  </head>
34
39
  <body>
35
40
  <div id="swagger-ui"></div>
36
- <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
41
+ <script src="https://unpkg.com/swagger-ui-dist@#{SWAGGER_UI_VERSION}/swagger-ui-bundle.js"></script>
37
42
  <script>
38
43
  SwaggerUIBundle({
39
- url: "#{spec_url}",
44
+ url: #{spec_url_json},
40
45
  dom_id: '#swagger-ui',
41
46
  presets: [
42
47
  SwaggerUIBundle.presets.apis,
@@ -45,7 +50,14 @@ module Docit
45
50
  layout: "BaseLayout",
46
51
  deepLinking: true,
47
52
  showExtensions: true,
48
- showCommonExtensions: true
53
+ showCommonExtensions: true,
54
+ requestInterceptor: function(req) {
55
+ var url = new URL(req.url);
56
+ url.protocol = window.location.protocol;
57
+ url.host = window.location.host;
58
+ req.url = url.toString();
59
+ return req;
60
+ }
49
61
  })
50
62
  </script>
51
63
  </body>
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Docit
8
+ module Ai
9
+ class AnthropicClient
10
+ API_URL = "https://api.anthropic.com/v1/messages"
11
+ API_VERSION = "2023-06-01"
12
+
13
+ def initialize(api_key:, model:)
14
+ @api_key = api_key
15
+ @model = model
16
+ end
17
+
18
+ def generate(prompt)
19
+ uri = URI(API_URL)
20
+ request = Net::HTTP::Post.new(uri)
21
+ request["x-api-key"] = @api_key
22
+ request["anthropic-version"] = API_VERSION
23
+ request["Content-Type"] = "application/json"
24
+ request.body = {
25
+ model: @model,
26
+ max_tokens: 4096,
27
+ messages: [{ role: "user", content: prompt }]
28
+ }.to_json
29
+
30
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 15, read_timeout: 60) do |http|
31
+ http.request(request)
32
+ end
33
+
34
+ handle_response(response)
35
+ end
36
+
37
+ private
38
+
39
+ def handle_response(response)
40
+ body = parse_json(response, "Anthropic")
41
+
42
+ if response.is_a?(Net::HTTPSuccess) == false
43
+ message = body.dig("error", "message") || "Unknown API error"
44
+ raise Error, "Anthropic API error (#{response.code}): #{message}"
45
+ end
46
+
47
+ body.dig("content", 0, "text") || raise(Error, "Empty response from Anthropic")
48
+ end
49
+
50
+ def parse_json(response, provider_name)
51
+ JSON.parse(response.body)
52
+ rescue JSON::ParserError
53
+ raise Error, "#{provider_name} returned invalid JSON (HTTP #{response.code})"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Ai
5
+ class AutodocRunner
6
+ attr_reader :results
7
+
8
+ def initialize(controller_filter: nil, dry_run: false, input: $stdin, output: $stdout)
9
+ @controller_filter = controller_filter
10
+ @dry_run = dry_run
11
+ @input = input
12
+ @output = output
13
+ @results = { gaps: [], generated: 0, files: [], tags: [] }
14
+ end
15
+
16
+ def run
17
+ check_base_setup!
18
+ config = load_config
19
+ @output.puts "Using #{config.provider}"
20
+ @output.puts ""
21
+
22
+ gaps = detect_gaps
23
+ @results[:gaps] = gaps
24
+
25
+ if gaps.empty?
26
+ @output.puts "All endpoints are documented!"
27
+ return @results
28
+ end
29
+
30
+ print_gaps(gaps)
31
+
32
+ if @dry_run
33
+ @output.puts "[dry-run] No files written."
34
+ return @results
35
+ end
36
+
37
+ confirm_source_upload!(config)
38
+ generated = generate_docs(gaps, config)
39
+ write_docs(generated)
40
+ inject_tags(generated)
41
+
42
+ @output.puts "Review generated files and edit as needed."
43
+ @results
44
+ end
45
+
46
+ private
47
+
48
+ def load_config
49
+ Docit::Ai::Configuration.load
50
+ end
51
+
52
+ def check_base_setup!
53
+ if !(defined?(Rails) && Rails.respond_to?(:root) && Rails.root)
54
+ raise Docit::Error, "Docit requires a Rails application. Run this command from your app root."
55
+ end
56
+
57
+ initializer = Rails.root.join("config", "initializers", "docit.rb")
58
+ if File.exist?(initializer) == false
59
+ raise Docit::Error, "Docit is not installed. Run: rails generate docit:install"
60
+ end
61
+
62
+ routes_file = Rails.root.join("config", "routes.rb")
63
+ if File.exist?(routes_file) && !File.read(routes_file).include?("Docit::Engine")
64
+ @output.puts "Warning: Docit engine is not mounted in config/routes.rb"
65
+ @output.puts " Run: rails generate docit:install (or add: mount Docit::Engine => \"/api-docs\")"
66
+ @output.puts ""
67
+ end
68
+ end
69
+
70
+ def detect_gaps
71
+ detector = GapDetector.new(controller_filter: @controller_filter)
72
+ detector.detect
73
+ end
74
+
75
+ def print_gaps(gaps)
76
+ @output.puts "Found #{gaps.length} undocumented endpoint#{"s" if gaps.length > 1}:"
77
+ gaps.each { |g| @output.puts " #{g[:method].upcase} #{g[:path]} (#{g[:controller]}##{g[:action]})" }
78
+ @output.puts ""
79
+ end
80
+
81
+ def confirm_source_upload!(config)
82
+ @output.puts "Docit will send controller source code to #{config.provider.capitalize} to generate documentation."
83
+ @output.puts "Review the endpoints first if they contain secrets or proprietary logic."
84
+
85
+ return if !(@input.respond_to?(:tty?) && @input.tty?)
86
+
87
+ loop do
88
+ @output.print "Continue? (y/n): "
89
+ choice = @input.gets.to_s.strip.downcase
90
+
91
+ case choice
92
+ when "y", "yes"
93
+ @output.puts ""
94
+ return
95
+ when "n", "no"
96
+ raise Docit::Error, "Autodoc cancelled."
97
+ else
98
+ @output.puts "Please enter y or n."
99
+ end
100
+ end
101
+ end
102
+
103
+ def generate_docs(gaps, config)
104
+ client = Client.for(config)
105
+ generated = Hash.new { |h, k| h[k] = [] }
106
+
107
+ @output.puts "Generating documentation............."
108
+
109
+ gaps.each_with_index do |gap, index|
110
+ @output.print "[#{index + 1}/#{gaps.length}] Generating #{gap[:controller]}##{gap[:action]}..."
111
+
112
+ builder = PromptBuilder.new(gap: gap)
113
+ if builder.source_available? == false
114
+ @output.puts " skipped (controller source file not found)"
115
+ next
116
+ end
117
+
118
+ prompt = builder.build
119
+ doc_block = client.generate(prompt).strip
120
+ doc_block = strip_markdown_fences(doc_block)
121
+
122
+ generated[gap[:controller]] << doc_block
123
+ @results[:generated] += 1
124
+ @output.puts " done"
125
+ rescue Docit::Ai::Error => e
126
+ @output.puts " failed (#{e.message})"
127
+ end
128
+
129
+ generated
130
+ end
131
+
132
+ def write_docs(generated)
133
+ generated.each do |controller, blocks|
134
+ next if blocks.empty?
135
+
136
+ writer = DocWriter.new(controller_name: controller)
137
+ writer.write(blocks)
138
+ @results[:files] << writer.doc_file_path
139
+
140
+ relative = writer.doc_file_path.sub("#{Rails.root}/", "")
141
+ @output.puts " Wrote: #{relative}"
142
+
143
+ if writer.inject_use_docs
144
+ controller_relative = File.join("app", "controllers", "#{controller.underscore}.rb")
145
+ @output.puts " Added use_docs to #{controller_relative}"
146
+ end
147
+ end
148
+
149
+ @output.puts ""
150
+ @output.puts "Generated docs for #{@results[:generated]} endpoint#{"s" if @results[:generated] > 1} in #{@results[:files].length} file#{"s" if @results[:files].length > 1}."
151
+ end
152
+
153
+ def inject_tags(generated)
154
+ all_tags = generated.values.flatten.join("\n").scan(/tags\s+["']([^"']+)["']/).flatten
155
+ return unless all_tags.any?
156
+
157
+ injected = TagInjector.new(tags: all_tags).inject
158
+ injected.each { |tag| @output.puts " Added tag \"#{tag}\" to config/initializers/docit.rb" }
159
+ @results[:tags] = injected
160
+ end
161
+
162
+ def strip_markdown_fences(text)
163
+ text = text.sub(/\A```\w*\n/, "")
164
+ text.sub(/\n```\z/, "")
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Ai
5
+ class Error < Docit::Error; end
6
+
7
+ # Factory for AI provider clients.
8
+ module Client
9
+ def self.for(config)
10
+ case config.provider
11
+ when "openai"
12
+ OpenaiClient.new(api_key: config.api_key, model: config.model)
13
+ when "anthropic"
14
+ AnthropicClient.new(api_key: config.api_key, model: config.model)
15
+ when "groq"
16
+ GroqClient.new(api_key: config.api_key, model: config.model)
17
+ else
18
+ raise Error, "Unsupported provider: #{config.provider}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Docit
6
+ module Ai
7
+ class Configuration
8
+ CONFIG_FILE = ".docit_ai.yml"
9
+
10
+ PROVIDERS = %w[openai anthropic groq].freeze
11
+
12
+ DEFAULT_MODELS = {
13
+ "openai" => "gpt-4o-mini",
14
+ "anthropic" => "claude-haiku-4-5-20251001",
15
+ "groq" => "llama-3.3-70b-versatile"
16
+ }.freeze
17
+
18
+ attr_reader :provider, :model, :api_key
19
+
20
+ def initialize(provider:, model:, api_key:)
21
+ @provider = provider.to_s
22
+ @model = model.to_s
23
+ @api_key = api_key.to_s
24
+ end
25
+
26
+ def valid?
27
+ PROVIDERS.include?(provider) && !model.empty? && !api_key.empty?
28
+ end
29
+
30
+ class << self
31
+ def config_path
32
+ Rails.root.join(CONFIG_FILE)
33
+ end
34
+
35
+ def configured?
36
+ File.exist?(config_path)
37
+ end
38
+
39
+ def load
40
+ raise Error, "AI not configured. Run: rails generate docit:ai_setup" if configured? == false
41
+
42
+ data = YAML.safe_load_file(config_path, permitted_classes: [Symbol])
43
+ new(
44
+ provider: data["provider"],
45
+ model: data["model"],
46
+ api_key: data["api_key"]
47
+ )
48
+ end
49
+
50
+ def save(provider:, model:, api_key:)
51
+ config = new(provider: provider, model: model, api_key: api_key)
52
+ raise Error, "Invalid configuration" if config.valid? == false
53
+
54
+ File.write(config_path, {
55
+ "provider" => config.provider,
56
+ "model" => config.model,
57
+ "api_key" => config.api_key
58
+ }.to_yaml)
59
+ File.chmod(0o600, config_path)
60
+
61
+ config
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Docit
7
+ module Ai
8
+ class DocWriter
9
+ def initialize(controller_name:)
10
+ @controller_name = controller_name
11
+ @module_parts = build_module_parts
12
+ end
13
+
14
+ def doc_file_path
15
+ parts = @controller_name.underscore.delete_suffix("_controller").split("/")
16
+ filename = "#{parts.last}_docs.rb"
17
+ dir_parts = parts[0..-2]
18
+
19
+ File.join(Rails.root, "app", "docs", *dir_parts, filename)
20
+ end
21
+
22
+ def doc_module_name
23
+ @controller_name.delete_suffix("Controller").gsub("::", "::") + "Docs"
24
+ end
25
+
26
+ def file_exists?
27
+ File.exist?(doc_file_path)
28
+ end
29
+
30
+ def controller_has_use_docs?
31
+ path = controller_file_path
32
+ return false if path && File.exist?(path) == false
33
+
34
+ File.read(path).include?("use_docs")
35
+ end
36
+
37
+ def inject_use_docs
38
+ path = controller_file_path
39
+ return false if path && File.exist?(path) == false
40
+ return false if controller_has_use_docs?
41
+
42
+ content = File.read(path)
43
+ class_pattern = /^(\s*class\s+\S+.*$)/
44
+ match = content.match(class_pattern)
45
+ return false if match.nil?
46
+
47
+ indent = match[1][/^\s*/] + " "
48
+ use_docs_line = "#{indent}use_docs #{doc_module_name}\n"
49
+ content = content.sub(class_pattern, "\\1\n#{use_docs_line}")
50
+
51
+ File.write(path, content)
52
+ true
53
+ end
54
+
55
+ def write(doc_blocks)
56
+ if file_exists?
57
+ append_to_existing(doc_blocks)
58
+ else
59
+ create_new_file(doc_blocks)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def create_new_file(doc_blocks)
66
+ FileUtils.mkdir_p(File.dirname(doc_file_path))
67
+
68
+ content = build_new_file_content(doc_blocks)
69
+ File.write(doc_file_path, content)
70
+ end
71
+
72
+ def append_to_existing(doc_blocks)
73
+ content = File.read(doc_file_path)
74
+ insertion = doc_blocks.map { |block| indent_block(block, @module_parts.length + 1) }.join("\n\n")
75
+ closing_ends = "end\n" * @module_parts.length
76
+
77
+ content = content.rstrip
78
+ content = content.delete_suffix(closing_ends.rstrip)
79
+ content = "#{content.rstrip}\n\n#{insertion}\n#{closing_ends}"
80
+
81
+ File.write(doc_file_path, content)
82
+ end
83
+
84
+ def build_new_file_content(doc_blocks)
85
+ lines = ["# frozen_string_literal: true", ""]
86
+
87
+ @module_parts.each_with_index do |part, i|
88
+ lines << "#{" " * i}module #{part}"
89
+ end
90
+
91
+ depth = @module_parts.length
92
+ lines << "#{" " * depth}extend Docit::DocFile"
93
+
94
+ doc_blocks.each do |block|
95
+ lines << ""
96
+ lines << indent_block(block, depth)
97
+ end
98
+
99
+ @module_parts.length.times do |i|
100
+ lines << "#{" " * (@module_parts.length - 1 - i)}end"
101
+ end
102
+
103
+ lines.join("\n") + "\n"
104
+ end
105
+
106
+ def indent_block(block, depth)
107
+ prefix = " " * depth
108
+ block.strip.lines.map { |line| line.rstrip.empty? ? "" : "#{prefix}#{line.rstrip}" }.join("\n")
109
+ end
110
+
111
+ def build_module_parts
112
+ doc_module_name.split("::")
113
+ end
114
+
115
+ def controller_file_path
116
+ Rails.root.join("app", "controllers", "#{@controller_name.underscore}.rb").to_s
117
+ end
118
+ end
119
+ end
120
+ end