rakit 0.1.7 → 0.1.8

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: 8f146d5f635716871e7960854ae13c5f64dcf353d50b7b57a808f81258580972
4
- data.tar.gz: 80f4539d5cb9e02c506789a3ebb4862a280eb750aea7b88449dbcf54222cf6f4
3
+ metadata.gz: 618281ffaa52461777b6cf921c5290e89dc503ef845adc9410d4934d917ed285
4
+ data.tar.gz: c3b90837302fb00dac1987e6c181307480ab7133fe113dc65805b307e0c2ed3a
5
5
  SHA512:
6
- metadata.gz: fe9c41e4505e458900c66bfa5fed2041fed850f568fdcb44cf0843468278a5320317d84d6b97c1b76bc303f683f79ca1b9994a69224574599c8ec5c216b00925
7
- data.tar.gz: fc4fd21d2dbefede571552edc32d3f8e62e323ed008e09d7831dd282ad216aca8ba7221af3515f56b2d76897898e40f295d075dbbfb058bb2749d32aeba54507
6
+ metadata.gz: 9c9b06b9a2fce44ff8cb4b99993c2a6caff13a9b5155cb2202b23000faccdc82853201134bff2cef57bd17400e038d86d6ef3437239c26409587ed78ef3b3c30
7
+ data.tar.gz: 4b3624c2093bde3d3f0a42959a511793e231dc2fba0a6e2cf7baac4613812355764a11a195cd76ec8abfa7c082813e38243e7276e94de19505fd33499827d0ca
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "google/protobuf"
5
+ require "rakit/word_cloud"
6
+
7
+ module Rakit
8
+ module CLI
9
+ module WordCloud
10
+ class << self
11
+ # @param argv [Array<String>] arguments after "word-cloud"
12
+ # @return [Integer] exit code
13
+ def run(argv)
14
+ return usage_stderr("Expected command: status | install | generate") if argv.empty?
15
+
16
+ cmd = argv.shift
17
+ case cmd
18
+ when "status" then run_status(argv)
19
+ when "install" then run_install(argv)
20
+ when "generate" then run_generate(argv)
21
+ else
22
+ $stderr.puts "Unknown command: #{cmd}"
23
+ 1
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def parse_format(argv)
30
+ format = "console"
31
+ args = argv.dup
32
+ i = args.index("--format")
33
+ if i && args[i + 1]
34
+ format = args[i + 1]
35
+ args.delete_at(i + 1)
36
+ args.delete_at(i)
37
+ end
38
+ [format, args]
39
+ end
40
+
41
+ def run_status(argv)
42
+ format, _ = parse_format(argv)
43
+ st = Rakit::WordCloud.status({})
44
+ render_tool_status(st, format)
45
+ 0
46
+ end
47
+
48
+ def run_install(argv)
49
+ format, _ = parse_format(argv)
50
+ result = Rakit::WordCloud.install({})
51
+ render_install_result(result, format)
52
+ result.success ? 0 : 1
53
+ end
54
+
55
+ def run_generate(argv)
56
+ format, rest = parse_format(argv)
57
+ opts = parse_generate_opts(rest)
58
+ return 1 if opts[:error]
59
+
60
+ request = build_generate_request(opts)
61
+ return 1 unless request
62
+
63
+ stdin_content = opts[:stdin] ? $stdin.read : nil
64
+ result = Rakit::WordCloud.generate(request, stdin_content: stdin_content)
65
+
66
+ if opts[:publish_site_name] && result.success && defined?(Rakit::StaticWebServer)
67
+ out_dir = ::File.dirname(result.output_image.to_s)
68
+ begin
69
+ Rakit::StaticWebServer.publish(opts[:publish_site_name], out_dir)
70
+ rescue => e
71
+ $stderr.puts "Publish failed: #{e.message}"
72
+ return 1
73
+ end
74
+ elsif opts[:publish_site_name] && result.success && !defined?(Rakit::StaticWebServer)
75
+ $stderr.puts "Publish requested but StaticWebServer not available. Require the static web server feature."
76
+ return 1
77
+ end
78
+
79
+ render_generate_result(result, format)
80
+ result.success ? 0 : 1
81
+ end
82
+
83
+ def parse_generate_opts(argv)
84
+ opts = {
85
+ format: "console",
86
+ text_file: nil,
87
+ inline_text: nil,
88
+ stdin: false,
89
+ out: nil,
90
+ width: nil,
91
+ height: nil,
92
+ seed: nil,
93
+ mask: nil,
94
+ font: nil,
95
+ exclude_words: nil,
96
+ max_words: nil,
97
+ auto_install: false,
98
+ publish_site_name: nil,
99
+ working_directory: nil
100
+ }
101
+ args = argv.dup
102
+ while (arg = args.shift)
103
+ case arg
104
+ when "--text" then opts[:text_file] = args.shift
105
+ when "--inline" then opts[:inline_text] = args.shift
106
+ when "--stdin" then opts[:stdin] = true
107
+ when "--out" then opts[:out] = args.shift
108
+ when "--width" then opts[:width] = (args.shift || "0").to_i
109
+ when "--height" then opts[:height] = (args.shift || "0").to_i
110
+ when "--seed" then opts[:seed] = (args.shift || "-1").to_i
111
+ when "--mask" then opts[:mask] = args.shift
112
+ when "--font" then opts[:font] = args.shift
113
+ when "--exclude-words" then opts[:exclude_words] = args.shift
114
+ when "--max-words" then opts[:max_words] = (args.shift || "0").to_i
115
+ when "--auto-install" then opts[:auto_install] = true
116
+ when "--publish" then opts[:publish_site_name] = args.shift
117
+ when "--working-directory" then opts[:working_directory] = args.shift
118
+ when /^--/
119
+ $stderr.puts "Unknown option: #{arg}"
120
+ opts[:error] = true
121
+ end
122
+ end
123
+ opts
124
+ end
125
+
126
+ def build_generate_request(opts)
127
+ unless opts[:out] && opts[:out].to_s.strip != ""
128
+ $stderr.puts "generate requires --out PATH"
129
+ return nil
130
+ end
131
+
132
+ has_file = opts[:text_file] && opts[:text_file].to_s.strip != ""
133
+ has_inline = opts[:inline_text] && opts[:inline_text].to_s.strip != ""
134
+ has_stdin = opts[:stdin]
135
+ count = [has_file, has_inline, has_stdin].count(true)
136
+ unless count == 1
137
+ $stderr.puts "Provide exactly one of: --text FILE, --inline \"string\", or --stdin"
138
+ return nil
139
+ end
140
+
141
+ gen = Rakit::Generated
142
+ config = gen::WordCloudConfig.new(
143
+ wcloud_path: "",
144
+ auto_install: opts[:auto_install],
145
+ working_directory: (opts[:working_directory] || "").to_s
146
+ )
147
+
148
+ text = if opts[:text_file] && opts[:text_file].to_s.strip != ""
149
+ gen::TextSource.new(text_file: opts[:text_file].to_s)
150
+ elsif opts[:inline_text] && opts[:inline_text].to_s.strip != ""
151
+ gen::TextSource.new(inline_text: opts[:inline_text].to_s)
152
+ else
153
+ gen::TextSource.new(stdin: true)
154
+ end
155
+
156
+ req = gen::GenerateRequest.new(
157
+ config: config,
158
+ text: text,
159
+ output_image: opts[:out].to_s,
160
+ width: opts[:width] || 0,
161
+ height: opts[:height] || 0,
162
+ rng_seed: opts[:seed] || -1,
163
+ mask_image: (opts[:mask] || "").to_s,
164
+ font_file: (opts[:font] || "").to_s,
165
+ exclude_words_file: (opts[:exclude_words] || "").to_s,
166
+ max_words: opts[:max_words] || 0
167
+ )
168
+ req
169
+ end
170
+
171
+ def render_tool_status(st, format)
172
+ case format
173
+ when "json"
174
+ puts ({ wcloud_found: st.wcloud_found, wcloud_path: st.wcloud_path, cargo_found: st.cargo_found, cargo_path: st.cargo_path }.to_json)
175
+ when "proto-json"
176
+ puts Google::Protobuf.encode_json(st)
177
+ else
178
+ puts "wcloud: #{st.wcloud_found ? st.wcloud_path : 'not found'}"
179
+ puts "cargo: #{st.cargo_found ? st.cargo_path : 'not found'}"
180
+ end
181
+ end
182
+
183
+ def render_install_result(result, format)
184
+ case format
185
+ when "json"
186
+ puts ({ success: result.success, message: result.message, stderr: result.stderr }.to_json)
187
+ when "proto-json"
188
+ puts Google::Protobuf.encode_json(result)
189
+ else
190
+ unless result.success
191
+ $stderr.puts result.message
192
+ $stderr.puts result.stderr if result.stderr.to_s.strip != ""
193
+ else
194
+ puts result.message
195
+ end
196
+ end
197
+ end
198
+
199
+ def render_generate_result(result, format)
200
+ unless result.success
201
+ $stderr.puts result.message
202
+ $stderr.puts result.stderr if result.stderr.to_s.strip != "" && result.stderr != result.message
203
+ end
204
+ case format
205
+ when "json"
206
+ h = { success: result.success, message: result.message, output_image: result.output_image, exit_code: result.exit_code }
207
+ puts h.to_json
208
+ when "proto-json"
209
+ puts Google::Protobuf.encode_json(result)
210
+ else
211
+ puts result.message if result.success
212
+ end
213
+ end
214
+
215
+ def usage_stderr(msg)
216
+ $stderr.puts msg
217
+ $stderr.puts " word-cloud status [--format console|json|proto-json]"
218
+ $stderr.puts " word-cloud install [--format ...]"
219
+ $stderr.puts " word-cloud generate (--text FILE|--inline \"string\"|--stdin) --out PATH [options]"
220
+ 1
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generated/word_cloud_pb"
4
+ require "fileutils"
5
+ require "open3"
6
+
7
+ module Rakit
8
+ module WordCloud
9
+ class << self
10
+ attr_accessor :wcloud_path, :auto_install, :working_directory
11
+ end
12
+
13
+ self.auto_install = false
14
+
15
+ # Validate path has no traversal. If relative, must be under base_dir; absolute paths allowed.
16
+ def self.path_safe?(path, base_dir)
17
+ return false if path.nil? || path.to_s.strip.empty?
18
+ p = path.to_s.strip
19
+ return false if p.include?("..")
20
+ base = ::File.expand_path(base_dir.to_s)
21
+ expanded = ::File.expand_path(p, base)
22
+ # Absolute path: allow (no traversal)
23
+ return true if p.start_with?("/") || (p.size >= 2 && p[1] == ":")
24
+ expanded == base || expanded.start_with?(base + ::File::SEPARATOR)
25
+ end
26
+
27
+ # Validate text file path: exists, readable file, no traversal. base_dir = working_directory or Dir.pwd.
28
+ def self.validate_text_file_path(path, base_dir)
29
+ base = ::File.expand_path(base_dir.to_s)
30
+ expanded = ::File.expand_path(path.to_s, base)
31
+ return [false, "Path contains traversal (..) or is outside working directory"] unless path_safe?(path, base_dir)
32
+ return [false, "File does not exist: #{path}"] unless ::File.exist?(expanded)
33
+ return [false, "Not a file: #{path}"] unless ::File.file?(expanded)
34
+ return [false, "File not readable: #{path}"] unless ::File.readable?(expanded)
35
+ [true, expanded]
36
+ end
37
+
38
+ # Validate output image path: parent creatable/writable, no traversal.
39
+ def self.validate_output_path(path, base_dir)
40
+ base = ::File.expand_path(base_dir.to_s)
41
+ expanded = ::File.expand_path(path.to_s, base)
42
+ return [false, "Path contains traversal (..) or is outside working directory"] unless path_safe?(path, base_dir)
43
+ parent = ::File.dirname(expanded)
44
+ begin
45
+ FileUtils.mkdir_p(parent) unless ::File.directory?(parent)
46
+ rescue SystemCallError => e
47
+ return [false, "Cannot create output directory: #{e.message}"]
48
+ end
49
+ return [false, "Output path not writable: #{path}"] unless ::File.writable?(parent)
50
+ [true, expanded]
51
+ end
52
+
53
+ # Validate optional file path (mask, font, exclude-words): if present, must exist and be readable.
54
+ def self.validate_optional_file_path(path, base_dir, label)
55
+ return [true, nil] if path.nil? || path.to_s.strip.empty?
56
+ base = ::File.expand_path(base_dir.to_s)
57
+ expanded = ::File.expand_path(path.to_s, base)
58
+ return [false, "#{label}: path contains traversal or is outside working directory"] unless path_safe?(path, base_dir)
59
+ return [false, "#{label} file does not exist: #{path}"] unless ::File.exist?(expanded)
60
+ return [false, "#{label} not a file: #{path}"] unless ::File.file?(expanded)
61
+ return [false, "#{label} not readable: #{path}"] unless ::File.readable?(expanded)
62
+ [true, expanded]
63
+ end
64
+
65
+ # Resolve wcloud: config path, then ENV RAKIT_WCLOUD_BINARY, then which wcloud.
66
+ def self.resolve_wcloud(config = {})
67
+ path = config[:wcloud_path] || config["wcloud_path"] || self.wcloud_path
68
+ return path if path && path.to_s.strip != "" && ::File.executable?(::File.expand_path(path))
69
+ env_path = ENV["RAKIT_WCLOUD_BINARY"]
70
+ return env_path if env_path && env_path.to_s.strip != "" && ::File.executable?(::File.expand_path(env_path))
71
+ which("wcloud")
72
+ end
73
+
74
+ # Resolve cargo: which cargo.
75
+ def self.resolve_cargo(_config = {})
76
+ which("cargo")
77
+ end
78
+
79
+ # Status: resolve wcloud and cargo; no side effects. Returns ToolStatus.
80
+ def self.status(config = {})
81
+ cfg = config.is_a?(Hash) ? config : {}
82
+ wcloud_path = cfg[:wcloud_path] || cfg["wcloud_path"] || self.wcloud_path
83
+ wcloud_bin = resolve_wcloud(cfg)
84
+ cargo_bin = resolve_cargo(cfg)
85
+ Rakit::Generated::ToolStatus.new(
86
+ wcloud_found: !wcloud_bin.nil?,
87
+ wcloud_path: wcloud_bin.to_s,
88
+ cargo_found: !cargo_bin.nil?,
89
+ cargo_path: cargo_bin.to_s
90
+ )
91
+ end
92
+
93
+ # Install: run cargo install wcloud. Returns GenerateResult with success, message, stderr.
94
+ def self.install(config = {})
95
+ cfg = config.is_a?(Hash) ? config : {}
96
+ cargo_bin = resolve_cargo(cfg)
97
+ unless cargo_bin
98
+ return fail_result("", "cargo not found. Install Rust from https://rustup.rs/ then run: cargo install wcloud", -1)
99
+ end
100
+ _stdout, stderr, status = Open3.capture3(cargo_bin, "install", "wcloud")
101
+ Rakit::Generated::GenerateResult.new(
102
+ success: status.success?,
103
+ message: status.success? ? "wcloud installed or already present" : stderr.to_s.strip,
104
+ output_image: "",
105
+ exit_code: status.exitstatus,
106
+ stdout: "",
107
+ stderr: stderr.to_s
108
+ )
109
+ end
110
+
111
+ def self.which(cmd)
112
+ exe = nil
113
+ ENV["PATH"].to_s.split(::File::PATH_SEPARATOR).each do |dir|
114
+ full = ::File.join(dir.strip, cmd)
115
+ if ::File.executable?(full)
116
+ exe = full
117
+ break
118
+ end
119
+ end
120
+ exe
121
+ end
122
+
123
+ # Generate word cloud. request is GenerateRequest. Optional stdin_content when text source is stdin.
124
+ # Returns GenerateResult. Does not leave partial output on validation or tool failure.
125
+ def self.generate(request, stdin_content: nil)
126
+ cfg = request.config || Rakit::Generated::WordCloudConfig.new
127
+ base_dir = (cfg.working_directory.to_s.strip != "") ? cfg.working_directory : (self.working_directory || Dir.pwd)
128
+
129
+ # Resolve text content and validate non-empty
130
+ content, err = resolve_text_content(request.text, base_dir, stdin_content)
131
+ unless content
132
+ return fail_result(request.output_image.to_s, err, -1)
133
+ end
134
+
135
+ # Validate output path (creates parent if missing)
136
+ ok, out_path = validate_output_path(request.output_image.to_s, base_dir)
137
+ unless ok
138
+ return fail_result(request.output_image.to_s, out_path, -1)
139
+ end
140
+
141
+ # Optional file paths (mask, font, exclude-words)
142
+ mask_path = request.mask_image.to_s.strip
143
+ if mask_path != ""
144
+ ok, err = validate_optional_file_path(mask_path, base_dir, "Mask image")
145
+ return fail_result(out_path, err, -1) unless ok
146
+ end
147
+ font_path = request.font_file.to_s.strip
148
+ if font_path != ""
149
+ ok, err = validate_optional_file_path(font_path, base_dir, "Font file")
150
+ return fail_result(out_path, err, -1) unless ok
151
+ end
152
+ exclude_path = request.exclude_words_file.to_s.strip
153
+ if exclude_path != ""
154
+ ok, err = validate_optional_file_path(exclude_path, base_dir, "Exclude-words file")
155
+ return fail_result(out_path, err, -1) unless ok
156
+ end
157
+
158
+ config_hash = { wcloud_path: cfg.wcloud_path.to_s, auto_install: cfg.auto_install }
159
+ wcloud_bin = resolve_wcloud(config_hash)
160
+ if wcloud_bin.nil? && cfg.auto_install
161
+ install_result = install(config_hash)
162
+ unless install_result.success
163
+ return fail_result(out_path, "wcloud not found and auto-install failed: #{install_result.message}", -1)
164
+ end
165
+ wcloud_bin = resolve_wcloud(config_hash)
166
+ end
167
+ unless wcloud_bin
168
+ return fail_result(out_path, "wcloud not found. Install with: cargo install wcloud", -1)
169
+ end
170
+
171
+ argv = build_wcloud_argv(request, out_path, mask_path, font_path, exclude_path)
172
+ stdout, stderr, exit_code = run_wcloud(wcloud_bin, argv, stdin: content)
173
+
174
+ result = Rakit::Generated::GenerateResult.new(
175
+ success: exit_code == 0,
176
+ message: exit_code == 0 ? "OK" : stderr.to_s.strip,
177
+ output_image: out_path,
178
+ exit_code: exit_code,
179
+ stdout: stdout.to_s,
180
+ stderr: stderr.to_s,
181
+ wcloud_resolved_path: wcloud_bin
182
+ )
183
+ unless result.success
184
+ ::File.unlink(out_path) if ::File.exist?(out_path)
185
+ end
186
+ result
187
+ end
188
+
189
+ def self.fail_result(output_image, message, exit_code)
190
+ Rakit::Generated::GenerateResult.new(
191
+ success: false,
192
+ message: message.to_s,
193
+ output_image: output_image.to_s,
194
+ exit_code: exit_code,
195
+ stdout: "",
196
+ stderr: message.to_s
197
+ )
198
+ end
199
+
200
+ def self.resolve_text_content(text_source, base_dir, stdin_content)
201
+ return [nil, "Text source is empty; provide non-empty text."] if text_source.nil?
202
+
203
+ case text_source.source
204
+ when :text_file
205
+ path = text_source.text_file.to_s.strip
206
+ return [nil, "Text file path is empty."] if path.empty?
207
+ ok, expanded = validate_text_file_path(path, base_dir)
208
+ return [nil, expanded] unless ok
209
+ content = ::File.read(expanded)
210
+ return [nil, "Text source is empty; provide non-empty text."] if content.to_s.strip.empty?
211
+ return [content, nil]
212
+ when :inline_text
213
+ content = text_source.inline_text.to_s
214
+ return [nil, "Text source is empty; provide non-empty text."] if content.strip.empty?
215
+ return [content, nil]
216
+ when :stdin
217
+ content = stdin_content.to_s
218
+ return [nil, "Text source is empty; provide non-empty text."] if content.strip.empty?
219
+ return [content, nil]
220
+ else
221
+ [nil, "Invalid text source."]
222
+ end
223
+ end
224
+
225
+ def self.build_wcloud_argv(request, out_path, mask_path, font_path, exclude_path)
226
+ argv = ["-o", out_path]
227
+ argv += ["--width", request.width.to_s] if request.width && request.width > 0
228
+ argv += ["--height", request.height.to_s] if request.height && request.height > 0
229
+ argv += ["--seed", request.rng_seed.to_s] if request.rng_seed && request.rng_seed >= 0
230
+ argv += ["--mask", mask_path] if mask_path != ""
231
+ argv += ["--font", font_path] if font_path != ""
232
+ argv += ["--exclude-words", exclude_path] if exclude_path != ""
233
+ argv += ["--max-words", request.max_words.to_s] if request.max_words && request.max_words > 0
234
+ argv
235
+ end
236
+
237
+ # Run wcloud with argv (no shell). Optional stdin: string. Returns [stdout, stderr, exit_status].
238
+ def self.run_wcloud(wcloud_bin, argv, stdin: nil)
239
+ if stdin
240
+ stdout, stderr, status = Open3.capture3(wcloud_bin, *argv, stdin_data: stdin)
241
+ else
242
+ stdout, stderr, status = Open3.capture3(wcloud_bin, *argv)
243
+ end
244
+ [stdout.to_s, stderr.to_s, status.exitstatus]
245
+ end
246
+ end
247
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rakit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - rakit
@@ -131,6 +131,7 @@ files:
131
131
  - lib/rakit/azure/dev_ops.rb
132
132
  - lib/rakit/cli/file.rb
133
133
  - lib/rakit/cli/markdown.rb
134
+ - lib/rakit/cli/word_cloud.rb
134
135
  - lib/rakit/cli/word_count.rb
135
136
  - lib/rakit/file.rb
136
137
  - lib/rakit/gem.rb
@@ -140,6 +141,7 @@ files:
140
141
  - lib/rakit/shell.rb
141
142
  - lib/rakit/static_web_server.rb
142
143
  - lib/rakit/task.rb
144
+ - lib/rakit/word_cloud.rb
143
145
  - lib/rakit/word_count.rb
144
146
  homepage: https://gitlab.com/gems/rakit
145
147
  licenses: