dr_talon 0.1.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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +270 -0
  6. data/Rakefile +10 -0
  7. data/exe/talon +6 -0
  8. data/lib/dr_talon/cli/engine.rb +274 -0
  9. data/lib/dr_talon/cli/generate.rb +29 -0
  10. data/lib/dr_talon/cli.rb +343 -0
  11. data/lib/dr_talon/config.rb +29 -0
  12. data/lib/dr_talon/credentials.rb +94 -0
  13. data/lib/dr_talon/deps/dependency.rb +238 -0
  14. data/lib/dr_talon/deps/git.rb +69 -0
  15. data/lib/dr_talon/deps/installer.rb +207 -0
  16. data/lib/dr_talon/deps/manifest.rb +220 -0
  17. data/lib/dr_talon/deps/tree.rb +97 -0
  18. data/lib/dr_talon/deps.rb +62 -0
  19. data/lib/dr_talon/doctor.rb +161 -0
  20. data/lib/dr_talon/engine/fetchers.rb +251 -0
  21. data/lib/dr_talon/engine/git_ignore.rb +63 -0
  22. data/lib/dr_talon/engine/importer.rb +103 -0
  23. data/lib/dr_talon/engine/inspector.rb +113 -0
  24. data/lib/dr_talon/engine/installation.rb +77 -0
  25. data/lib/dr_talon/engine/materializer.rb +172 -0
  26. data/lib/dr_talon/engine/spec.rb +38 -0
  27. data/lib/dr_talon/engine/store.rb +119 -0
  28. data/lib/dr_talon/engine.rb +50 -0
  29. data/lib/dr_talon/errors.rb +5 -0
  30. data/lib/dr_talon/file_copy.rb +49 -0
  31. data/lib/dr_talon/generators/new_project.rb +289 -0
  32. data/lib/dr_talon/generators/talon_code.rb +92 -0
  33. data/lib/dr_talon/http.rb +120 -0
  34. data/lib/dr_talon/paths.rb +115 -0
  35. data/lib/dr_talon/project.rb +101 -0
  36. data/lib/dr_talon/runner.rb +188 -0
  37. data/lib/dr_talon/talon_framework.rb +103 -0
  38. data/lib/dr_talon/templates/bare/main_tests.rb.tt +7 -0
  39. data/lib/dr_talon/templates/bare/rubocop.yml.tt +8 -0
  40. data/lib/dr_talon/templates/bare/solargraph.yml.tt +24 -0
  41. data/lib/dr_talon/templates/lib/app/main.rb.tt +13 -0
  42. data/lib/dr_talon/templates/lib/lib/library.rb.tt +4 -0
  43. data/lib/dr_talon/templates/lib/lib/version.rb.tt +3 -0
  44. data/lib/dr_talon/templates/lib/rubocop.yml.tt +9 -0
  45. data/lib/dr_talon/templates/lib/solargraph.yml.tt +23 -0
  46. data/lib/dr_talon/templates/lib/test/test_helper.rb.tt +1 -0
  47. data/lib/dr_talon/templates/lib/test/tests.rb.tt +8 -0
  48. data/lib/dr_talon/templates/shared/Gemfile.tt +10 -0
  49. data/lib/dr_talon/templates/shared/README.md.tt +45 -0
  50. data/lib/dr_talon/templates/shared/editorconfig.tt +9 -0
  51. data/lib/dr_talon/templates/shared/gitignore.tt +19 -0
  52. data/lib/dr_talon/templates/talon/game/dependencies.rb.tt +3 -0
  53. data/lib/dr_talon/templates/talon/game/main.rb.tt +9 -0
  54. data/lib/dr_talon/templates/talon/game/spin.rb.tt +7 -0
  55. data/lib/dr_talon/templates/talon/game/title.rb.tt +8 -0
  56. data/lib/dr_talon/templates/talon/game/title_tests.rb.tt +16 -0
  57. data/lib/dr_talon/templates/talon/generate/component.rb.tt +19 -0
  58. data/lib/dr_talon/templates/talon/generate/component_tests.rb.tt +15 -0
  59. data/lib/dr_talon/templates/talon/generate/prefab.rb.tt +11 -0
  60. data/lib/dr_talon/templates/talon/generate/prefab_tests.rb.tt +15 -0
  61. data/lib/dr_talon/templates/talon/generate/scene.rb.tt +14 -0
  62. data/lib/dr_talon/templates/talon/generate/scene_tests.rb.tt +13 -0
  63. data/lib/dr_talon/test_report.rb +76 -0
  64. data/lib/dr_talon/version.rb +5 -0
  65. data/lib/dr_talon.rb +17 -0
  66. data/sig/dr_talon.rbs +524 -0
  67. metadata +142 -0
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module DrTalon
6
+ module Generators
7
+ # +talon new PATH+: creates a game (+bare+ template, DragonRuby's full
8
+ # layout) or a library (+lib+ template, game-only layout) with a pinned,
9
+ # installed engine.
10
+ #
11
+ # Each public method is a step, run in order by {Thor::Group#invoke_all}.
12
+ class NewProject < Thor::Group
13
+ include Thor::Actions
14
+
15
+ TEMPLATES = %w[bare lib talon].freeze
16
+ NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]*\z/
17
+
18
+ argument :path, type: :string, desc: "Directory to create; its name is the project name"
19
+
20
+ class_option :template, type: :string,
21
+ desc: "bare (a game), talon (a game on the Talon framework) or lib (a library); default from config.yml, else bare"
22
+ class_option :lib, type: :boolean, default: false, desc: "Shorthand for --template lib"
23
+ class_option :engine, type: :string, desc: "Engine to pin, e.g. 7.23-pro (default: the newest cached)"
24
+ class_option :commit_engine, type: :boolean,
25
+ desc: "Commit the engine to git instead of ignoring it and restoring with `talon engine use` (asked if omitted)"
26
+ class_option :title, type: :string, desc: "Game title for game_metadata.txt (default: from the name)"
27
+ class_option :dev_id, type: :string, desc: "itch.io username for game_metadata.txt (default: from config.yml)"
28
+ class_option :dev_title, type: :string, desc: "Developer display name for game_metadata.txt (default: from config.yml)"
29
+ class_option :skip_git, type: :boolean, default: false, desc: "Don't run git init"
30
+ class_option :skip_bundle, type: :boolean, default: false, desc: "Don't run bundle install"
31
+ class_option :skip_deps, type: :boolean, default: false, desc: "Don't vendor dependencies (the talon template's Talon)"
32
+
33
+ def self.source_root
34
+ File.expand_path("../templates", __dir__)
35
+ end
36
+
37
+ def check_destination
38
+ raise Error, "Invalid project name \"#{name}\". Use letters, digits, - and _, starting with a letter." unless NAME_PATTERN.match?(name)
39
+ raise Error, "Unknown template \"#{template_name}\". Expected one of #{TEMPLATES.join(", ")}." unless TEMPLATES.include?(template_name)
40
+
41
+ root = File.expand_path(path)
42
+ raise Error, "#{root} already exists and isn't empty." if File.exist?(root) && !(File.directory?(root) && Dir.empty?(root))
43
+
44
+ self.destination_root = root
45
+ end
46
+
47
+ def resolve_engine
48
+ engine
49
+ end
50
+
51
+ def ask_about_committing_engine
52
+ commit_engine?
53
+ end
54
+
55
+ def create_game
56
+ (layout == :full) ? copy_stock_game : create_library
57
+ template "bare/main_tests.rb.tt", "mygame/tests/main_tests.rb" if template_name == "bare"
58
+ create_talon_game if talon?
59
+ end
60
+
61
+ def create_tooling
62
+ template "shared/Gemfile.tt", "Gemfile"
63
+ template "shared/editorconfig.tt", ".editorconfig"
64
+ template "shared/gitignore.tt", ".gitignore"
65
+ template "shared/README.md.tt", "README.md"
66
+ template "#{tooling_template}/rubocop.yml.tt", ".rubocop.yml"
67
+ template "#{tooling_template}/solargraph.yml.tt", ".solargraph.yml"
68
+ end
69
+
70
+ def fill_metadata
71
+ return unless layout == :full
72
+
73
+ file = File.join(destination_root, "mygame", "metadata", "game_metadata.txt")
74
+ return unless File.file?(file)
75
+
76
+ values = {
77
+ "devid" => options[:dev_id] || config.dev_id ||
78
+ ask_in_terminal("Your itch.io username, for game_metadata.txt (blank to set it later):"),
79
+ "devtitle" => options[:dev_title] || config.dev_title ||
80
+ ask_in_terminal("Your name as shown in builds (blank to set it later):"),
81
+ "gameid" => name.downcase.tr("_", "-"),
82
+ "gametitle" => title,
83
+ "version" => "0.1",
84
+ "icon" => "metadata/icon.png"
85
+ }.compact
86
+
87
+ content = values.reduce(File.read(file)) do |text, (key, value)|
88
+ # Set the first assignment, uncommenting DragonRuby's "# key=..." form.
89
+ text.sub(/^[ \t]*(?:#[ \t]*)?#{Regexp.escape(key)}=.*$/) { "#{key}=#{value}" }
90
+ end
91
+ File.write(file, content)
92
+ say_status :update, "mygame/metadata/game_metadata.txt"
93
+ end
94
+
95
+ def install_engine
96
+ project = Project.new(destination_root)
97
+ materializer = Engine::Materializer.new(project)
98
+ result = materializer.install(engine)
99
+ project.pin!(engine)
100
+ say_status :engine, "#{engine.id} (#{result.copied} files)"
101
+ say_status :create, Project::PIN_FILE
102
+
103
+ Engine::GitIgnore.write(File.join(destination_root, ".gitignore"), materializer.manifest.files) unless commit_engine?
104
+ end
105
+
106
+ def install_dependencies
107
+ return unless talon? && !options[:skip_deps]
108
+
109
+ changes = Deps::Installer.new(Project.new(destination_root)).install
110
+ changes.each { |change| say_status :vendor, "#{change.name} (#{change.dependency&.key})" }
111
+ rescue DrTalon::Error => e
112
+ say_status :skip, "vendoring Talon failed (#{e.message}); run `talon install` when you're online", :yellow
113
+ end
114
+
115
+ def git_init
116
+ return if options[:skip_git]
117
+ return say_status(:skip, "git init (git not found)", :yellow) unless executable?("git")
118
+
119
+ inside(destination_root) { run "git init --quiet", abort_on_failure: false }
120
+ end
121
+
122
+ def bundle_install
123
+ return if options[:skip_bundle]
124
+ return say_status(:skip, "bundle install (bundler not found)", :yellow) unless executable?("bundle")
125
+
126
+ # Run against the new Gemfile, not whichever bundle talon runs in.
127
+ if defined?(Bundler)
128
+ Bundler.with_unbundled_env { bundle_in_destination }
129
+ else
130
+ bundle_in_destination
131
+ end
132
+ end
133
+
134
+ def print_next_steps
135
+ relative = path
136
+ say ""
137
+ say "Created #{name} with DragonRuby #{engine.id}.", :green
138
+ say " cd #{relative}"
139
+ say((layout == :full) ? " talon run" : " talon test")
140
+ say " talon generate scene NAME" if talon?
141
+ say " talon doctor"
142
+ end
143
+
144
+ no_commands do
145
+ def name
146
+ File.basename(File.expand_path(path))
147
+ end
148
+
149
+ # @return [String] the name as a Ruby file name: my-lib -> my_lib
150
+ def file_name
151
+ name.tr("-", "_")
152
+ end
153
+
154
+ def module_name
155
+ name.split(/[-_]/).map { |part| part[0].upcase + part[1..] }.join
156
+ end
157
+
158
+ def title
159
+ options[:title] || name.split(/[-_]/).map(&:capitalize).join(" ")
160
+ end
161
+
162
+ def template_name
163
+ @template_name ||= options[:lib] ? "lib" : (options[:template] || config.default_template)
164
+ end
165
+
166
+ def layout
167
+ (template_name == "lib") ? :game_only : :full
168
+ end
169
+
170
+ def talon?
171
+ template_name == "talon"
172
+ end
173
+
174
+ # The talon template is a bare game plus Talon.
175
+ def tooling_template
176
+ talon? ? "bare" : template_name
177
+ end
178
+
179
+ def talon_require
180
+ TalonFramework::REQUIRE
181
+ end
182
+
183
+ def talon_repository
184
+ TalonFramework::REPOSITORY
185
+ end
186
+
187
+ def requires_block
188
+ [TalonFramework::BEGIN_MARKER, %(require "app/components/spin.rb"), %(require "app/scenes/title.rb"),
189
+ TalonFramework::END_MARKER].join("\n")
190
+ end
191
+
192
+ def dependencies_hint
193
+ (layout == :full) ? "mygame/vendor/" : "vendor/"
194
+ end
195
+
196
+ def engine
197
+ @engine ||=
198
+ if options[:engine]
199
+ store.find_native(Engine::Spec.parse(options[:engine]))
200
+ else
201
+ store.latest_native || raise(Engine::NotFound,
202
+ "No #{Engine.host_platform} engine is cached. Import one first: talon engine import <zip>")
203
+ end
204
+ end
205
+
206
+ # Asked once unless --commit-engine/--no-commit-engine was given.
207
+ # Libraries default to not committing: DragonRuby asks that public
208
+ # repos not include its tooling.
209
+ def commit_engine?
210
+ return @commit_engine unless @commit_engine.nil?
211
+
212
+ @commit_engine =
213
+ if !options[:commit_engine].nil?
214
+ options[:commit_engine]
215
+ elsif layout == :game_only
216
+ false
217
+ else
218
+ yes?("Commit the DragonRuby engine to git? DragonRuby recommends it for private/commercial games. " \
219
+ "If not, it's gitignored and restored with `talon engine use`. [y/N]")
220
+ end
221
+ end
222
+
223
+ def config
224
+ @config ||= Config.load
225
+ end
226
+
227
+ def store
228
+ @store ||= Engine::Store.new(Paths.new.engines_dir)
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ def copy_stock_game
235
+ source = File.join(engine.path, "mygame")
236
+ raise Engine::InvalidEngine, "#{engine.id} has no mygame/ to start from." unless File.directory?(source)
237
+
238
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: source).sort.each do |relative|
239
+ next if File.basename(relative).match?(/\A\.\.?\z/) || File.basename(relative) == ".DS_Store"
240
+
241
+ from = File.join(source, relative)
242
+ to = File.join(destination_root, "mygame", relative)
243
+ if File.directory?(from)
244
+ FileUtils.mkdir_p(to)
245
+ else
246
+ FileUtils.mkdir_p(File.dirname(to))
247
+ FileCopy.copy(from, to)
248
+ end
249
+ end
250
+ say_status :create, "mygame (from #{engine.id})"
251
+ end
252
+
253
+ def create_talon_game
254
+ template "talon/game/main.rb.tt", "mygame/app/main.rb", force: true
255
+ template "talon/game/title.rb.tt", "mygame/app/scenes/title.rb"
256
+ template "talon/game/spin.rb.tt", "mygame/app/components/spin.rb"
257
+ template "talon/game/title_tests.rb.tt", "mygame/tests/scenes/title_tests.rb"
258
+ template "talon/game/dependencies.rb.tt", "mygame/dependencies.rb"
259
+ end
260
+
261
+ def create_library
262
+ template "lib/app/main.rb.tt", "app/main.rb"
263
+ template "lib/lib/library.rb.tt", "lib/#{file_name}.rb"
264
+ template "lib/lib/version.rb.tt", "lib/#{file_name}/version.rb"
265
+ template "lib/test/test_helper.rb.tt", "test/test_helper.rb"
266
+ template "lib/test/tests.rb.tt", "test/#{file_name}_tests.rb"
267
+ end
268
+
269
+ # dragonruby-publish needs devid and devtitle, so ask for them in a
270
+ # terminal; scripts and CI aren't prompted.
271
+ def ask_in_terminal(question)
272
+ return nil unless $stdin.tty?
273
+
274
+ answer = ask(question).to_s.strip
275
+ answer.empty? ? nil : answer
276
+ end
277
+
278
+ def bundle_in_destination
279
+ inside(destination_root) { run "bundle install --quiet", abort_on_failure: false }
280
+ end
281
+
282
+ def executable?(command)
283
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
284
+ File.executable?(File.join(dir, command)) && !File.directory?(File.join(dir, command))
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module DrTalon
6
+ module Generators
7
+ # +talon generate scene|component|prefab NAME+: a Talon class in
8
+ # app/<kind>s/, a DragonRuby test in tests/<kind>s/, and its require in
9
+ # app/main.rb's managed block.
10
+ class TalonCode < Thor::Group
11
+ include Thor::Actions
12
+
13
+ KINDS = %w[scene component prefab].freeze
14
+
15
+ argument :kind, type: :string, desc: KINDS.join(", ")
16
+ argument :name, type: :string, desc: "e.g. MainMenu or main_menu"
17
+
18
+ class_option :skip_test, type: :boolean, default: false, desc: "Don't create a test"
19
+ class_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
20
+
21
+ def self.source_root
22
+ File.expand_path("../templates/talon/generate", __dir__)
23
+ end
24
+
25
+ def check
26
+ raise Error, "Unknown kind \"#{kind}\". Expected one of #{KINDS.join(", ")}." unless KINDS.include?(kind)
27
+
28
+ TalonFramework.require_used!(project)
29
+ self.destination_root = project.game_dir
30
+
31
+ existing = files.select { |path| File.exist?(File.join(destination_root, path)) }
32
+ return if existing.empty? || options[:force]
33
+
34
+ raise Error, "#{existing.join(" and ")} already exist#{"s" if existing.one?}. Pass --force to overwrite."
35
+ end
36
+
37
+ def create_code
38
+ template "#{kind}.rb.tt", code_path, force: options[:force]
39
+ end
40
+
41
+ def create_test
42
+ template "#{kind}_tests.rb.tt", test_path, force: options[:force] unless options[:skip_test]
43
+ end
44
+
45
+ def add_require
46
+ main = File.join(destination_root, "app", "main.rb")
47
+ line = %(require "#{file_require}")
48
+
49
+ case TalonFramework.add_require(main, line)
50
+ when :added then say_status :update, "app/main.rb (#{line})"
51
+ when :present then say_status :identical, "app/main.rb (#{line})", :blue
52
+ else
53
+ say "\nAdd this to app/main.rb (it has no `talon generate` block):\n #{line}"
54
+ end
55
+ end
56
+
57
+ no_commands do
58
+ def project
59
+ @project ||= Project.locate
60
+ end
61
+
62
+ def class_name
63
+ TalonFramework.names(name).first
64
+ end
65
+
66
+ def file_name
67
+ TalonFramework.names(name).last
68
+ end
69
+
70
+ def code_path
71
+ "app/#{kind}s/#{file_name}.rb"
72
+ end
73
+
74
+ def test_path
75
+ "tests/#{kind}s/#{file_name}_tests.rb"
76
+ end
77
+
78
+ def file_require
79
+ code_path
80
+ end
81
+
82
+ def talon_require
83
+ TalonFramework.require_path(project)
84
+ end
85
+
86
+ def files
87
+ options[:skip_test] ? [code_path] : [code_path, test_path]
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module DrTalon
7
+ # Minimal HTTP(S) GETs with redirects, for dependencies and engine
8
+ # downloads. Basic auth is only sent to the host it was given for.
9
+ class HTTP
10
+ MAX_REDIRECTS = 5
11
+
12
+ # A request failed or returned a non-2xx status.
13
+ class Error < DrTalon::Error; end
14
+
15
+ # The server answered 401.
16
+ class Unauthorized < Error; end
17
+
18
+ Auth = Data.define(:username, :password)
19
+
20
+ # @param url [String]
21
+ # @param auth [Auth, nil]
22
+ # @return [String] the response body
23
+ # @raise [Error]
24
+ def get(url, auth: nil)
25
+ follow(url, auth) { |response| return response.body.to_s }
26
+ end
27
+
28
+ # Posts a form and returns the body (no redirects).
29
+ #
30
+ # @param url [String]
31
+ # @param form [Hash{String => String}]
32
+ # @return [String]
33
+ # @raise [Error]
34
+ def post_form(url, form)
35
+ uri = http_uri(url)
36
+ request = Net::HTTP::Post.new(uri, headers)
37
+ request.set_form_data(form)
38
+ perform(uri, request) do |response|
39
+ check!(uri, response)
40
+ raise Error, "POST #{uri} returned #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
41
+
42
+ return response.body.to_s
43
+ end
44
+ end
45
+
46
+ # Streams a response body to +path+.
47
+ #
48
+ # @param url [String]
49
+ # @param path [String]
50
+ # @param auth [Auth, nil]
51
+ # @yieldparam received [Integer] bytes so far
52
+ # @yieldparam total [Integer, nil] Content-Length, if sent
53
+ # @return [String] +path+
54
+ # @raise [Error]
55
+ def download(url, path, auth: nil)
56
+ follow(url, auth) do |response|
57
+ total = response.content_length
58
+ received = 0
59
+ File.open(path, "wb") do |file|
60
+ response.read_body do |chunk|
61
+ file.write(chunk)
62
+ received += chunk.bytesize
63
+ yield received, total if block_given?
64
+ end
65
+ end
66
+ return path
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Yields the first successful response; the caller reads its body.
73
+ def follow(url, auth)
74
+ uri = http_uri(url)
75
+ origin = uri.host
76
+
77
+ (MAX_REDIRECTS + 1).times do
78
+ redirect = nil
79
+ request = Net::HTTP::Get.new(uri, headers)
80
+ request.basic_auth(auth.username, auth.password) if auth && uri.host == origin
81
+ perform(uri, request) do |response|
82
+ check!(uri, response)
83
+ case response
84
+ when Net::HTTPSuccess then yield response
85
+ when Net::HTTPRedirection then redirect = http_uri(URI.join(uri, response["location"]))
86
+ else raise Error, "GET #{uri} returned #{response.code} #{response.message}"
87
+ end
88
+ end
89
+ uri = redirect
90
+ end
91
+
92
+ raise Error, "GET #{url} redirected more than #{MAX_REDIRECTS} times"
93
+ end
94
+
95
+ def http_uri(url)
96
+ uri = URI(url)
97
+ raise Error, "Only http(s) URLs are supported: #{url}" unless uri.is_a?(URI::HTTP)
98
+
99
+ uri
100
+ rescue URI::InvalidURIError => e
101
+ raise Error, "Invalid URL #{url}: #{e.message}"
102
+ end
103
+
104
+ def headers
105
+ {"User-Agent" => "dr_talon/#{VERSION}"}
106
+ end
107
+
108
+ def check!(uri, response)
109
+ raise Unauthorized, "#{uri.host} rejected the username or password (401)" if response.is_a?(Net::HTTPUnauthorized)
110
+ end
111
+
112
+ def perform(uri, request, &block)
113
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 120) do |http|
114
+ http.request(request, &block)
115
+ end
116
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError => e
117
+ raise Error, "#{request.method} #{uri} failed: #{e.message}"
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module DrTalon
6
+ # Resolves where talon keeps its global state: cached engines, downloads,
7
+ # config and credentials.
8
+ #
9
+ # - Linux/BSD: XDG base directories (+XDG_DATA_HOME+, +XDG_CACHE_HOME+,
10
+ # +XDG_CONFIG_HOME+), defaulting to +~/.local/share+, +~/.cache+ and
11
+ # +~/.config+.
12
+ # - macOS: +~/Library/Application Support+ and +~/Library/Caches+, unless
13
+ # the XDG variables are set explicitly.
14
+ # - Windows: +%LOCALAPPDATA%+ for data and cache, +%APPDATA%+ for config.
15
+ #
16
+ # Setting +DR_TALON_HOME+ puts everything under that one directory, which is
17
+ # handy for tests and for isolating experiments.
18
+ class Paths
19
+ APP_NAME = "dr_talon"
20
+
21
+ # @param env [Hash{String => String}] environment to read overrides from
22
+ # @param host_os [String] value of +RbConfig::CONFIG["host_os"]+
23
+ # @param home [String] the user's home directory
24
+ def initialize(env: ENV, host_os: RbConfig::CONFIG["host_os"], home: Dir.home)
25
+ @env = env
26
+ @platform = platform_for(host_os)
27
+ @home = home
28
+ end
29
+
30
+ # @return [String] root for persistent data such as cached engines
31
+ def data_dir
32
+ @data_dir ||= resolve(:data)
33
+ end
34
+
35
+ # @return [String] root for disposable files such as downloaded zips
36
+ def cache_dir
37
+ @cache_dir ||= resolve(:cache)
38
+ end
39
+
40
+ # @return [String] root for user configuration
41
+ def config_dir
42
+ @config_dir ||= resolve(:config)
43
+ end
44
+
45
+ # @return [String] where unpacked engines are stored
46
+ def engines_dir
47
+ File.join(data_dir, "engines")
48
+ end
49
+
50
+ # @return [String] where downloaded engine zips are kept
51
+ def downloads_dir
52
+ File.join(cache_dir, "downloads")
53
+ end
54
+
55
+ # @return [String]
56
+ def config_file
57
+ File.join(config_dir, "config.yml")
58
+ end
59
+
60
+ # @return [String] dragonruby.org credentials, written with mode 0600
61
+ def credentials_file
62
+ File.join(config_dir, "credentials.yml")
63
+ end
64
+
65
+ private
66
+
67
+ XDG_VARS = {data: "XDG_DATA_HOME", cache: "XDG_CACHE_HOME", config: "XDG_CONFIG_HOME"}.freeze
68
+ XDG_DEFAULTS = {data: [".local", "share"], cache: [".cache"], config: [".config"]}.freeze
69
+
70
+ def resolve(kind)
71
+ if (root = present(@env["DR_TALON_HOME"]))
72
+ return File.join(root, kind.to_s)
73
+ end
74
+
75
+ case @platform
76
+ when :windows then windows_dir(kind)
77
+ when :macos then present(@env[XDG_VARS[kind]]) ? xdg_dir(kind) : macos_dir(kind)
78
+ else xdg_dir(kind)
79
+ end
80
+ end
81
+
82
+ def xdg_dir(kind)
83
+ base = present(@env[XDG_VARS[kind]]) || File.join(@home, *XDG_DEFAULTS[kind])
84
+ File.join(base, APP_NAME)
85
+ end
86
+
87
+ def macos_dir(kind)
88
+ base = (kind == :cache) ? "Caches" : "Application Support"
89
+ File.join(@home, "Library", base, APP_NAME)
90
+ end
91
+
92
+ def windows_dir(kind)
93
+ local = present(@env["LOCALAPPDATA"]) || File.join(@home, "AppData", "Local")
94
+ roaming = present(@env["APPDATA"]) || File.join(@home, "AppData", "Roaming")
95
+
96
+ case kind
97
+ when :data then File.join(local, APP_NAME)
98
+ when :cache then File.join(local, APP_NAME, "cache")
99
+ when :config then File.join(roaming, APP_NAME)
100
+ end
101
+ end
102
+
103
+ def platform_for(host_os)
104
+ case host_os
105
+ when /darwin/ then :macos
106
+ when /mswin|mingw|cygwin/ then :windows
107
+ else :unix
108
+ end
109
+ end
110
+
111
+ def present(value)
112
+ value unless value.nil? || value.empty?
113
+ end
114
+ end
115
+ end