rakit 0.1.4 → 0.1.5

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: 6dccf1b9c3ff38dfd13effcd855e9185861e7f2be033bf6b3d4592a1280fdc63
4
- data.tar.gz: 9a02e557e8d7611503598acc9040711c6af37f1ad8c315b1f6eb10e65ecd3308
3
+ metadata.gz: 4cf9325de6845aa1235b153b42503a92c8604f447a6e6e402c891412a1157905
4
+ data.tar.gz: ce566e52e4889d22eb42dff316fa989b414d34f4d0b14d9595b509e5913c702f
5
5
  SHA512:
6
- metadata.gz: c638ae4a880d61900c8252de617b501c3580a1b56a87c234ac2e72f9084d658012b174136f67cc8f40aa8add72aee9f4ab0a6df71e76a011a6e7ee264b5acbfb
7
- data.tar.gz: 4cd01b862624d2a21e2d70a0a78163447ac1c33a1eceedbfce54dfecacb9a2b339fde3f11ce9f6f49dd7451ad9f4f2b55248646df05b5961f07b76d716773783
6
+ metadata.gz: c8da52aef46f781cdf227fc0494047bdbf64869ce7dea91797ca8a79f61d41fd3a490fdb332b10413b0b2be292c2f00e0b8c2703b944eeb57779a3a517327d94
7
+ data.tar.gz: c9c856269882482e5e9f838e5f1f7ca89ca2ea951296cc0b24f46b642b641277bab3f833e38f670f00ad1b878cb14a678986c15b80db6a3029c0e6e1033a309d
data/exe/rakit ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CLI for rakit gem. Subcommands: file (list|copy), word-count (--json-keys), static-web-server (start|stop|running|publish).
5
+
6
+ require "rakit"
7
+
8
+ # Allow tests to override root via environment
9
+ Rakit::StaticWebServer.root = ENV["RAKIT_WWW_ROOT"] if ENV["RAKIT_WWW_ROOT"]
10
+
11
+ def usage_stderr(msg = nil)
12
+ $stderr.puts msg if msg
13
+ $stderr.puts "Usage: rakit <subcommand> [options] [args]"
14
+ $stderr.puts " file list <directory> [--recursive] [--include-hidden] [--format console|json|proto-json]"
15
+ $stderr.puts " file copy <source> <destination> [--overwrite] [--create-directories] [--format ...]"
16
+ $stderr.puts " word-count <file>|--stdin --json-keys [--format console|json|proto-json] [options]"
17
+ $stderr.puts " static-web-server <start|stop|running|publish> [options] [args]"
18
+ $stderr.puts " start [--port PORT] Start server (idempotent)"
19
+ $stderr.puts " stop Stop server"
20
+ $stderr.puts " running Exit 0 if running, non-zero otherwise"
21
+ $stderr.puts " publish <site_name> <source_dir> Publish static site"
22
+ end
23
+
24
+ def main(argv = ARGV)
25
+ return usage_stderr("Expected subcommand.") if argv.empty?
26
+
27
+ sub = argv[0]
28
+ if sub == "file"
29
+ argv.shift
30
+ require "rakit/cli/file"
31
+ return Rakit::CLI::File.run(argv)
32
+ end
33
+
34
+ if sub == "word-count" || sub == "word_count"
35
+ argv.shift
36
+ require "rakit/cli/word_count"
37
+ return Rakit::CLI::WordCount.run(argv)
38
+ end
39
+
40
+ if sub != "static-web-server"
41
+ usage_stderr("Unknown subcommand: #{sub}")
42
+ return 1
43
+ end
44
+
45
+ argv.shift
46
+ cmd = argv.shift
47
+ case cmd
48
+ when "start"
49
+ port = Rakit::StaticWebServer.port
50
+ while (arg = argv.shift)
51
+ if arg == "--port" && argv[0]
52
+ port = argv.shift.to_i
53
+ end
54
+ end
55
+ Rakit::StaticWebServer.start(port: port)
56
+ return 0
57
+ when "stop"
58
+ Rakit::StaticWebServer.stop
59
+ return 0
60
+ when "running"
61
+ return Rakit::StaticWebServer.running? ? 0 : 1
62
+ when "publish"
63
+ site_name = argv.shift
64
+ source_dir = argv.shift
65
+ unless site_name && source_dir
66
+ usage_stderr("publish requires <site_name> and <source_dir>")
67
+ return 1
68
+ end
69
+ Rakit::StaticWebServer.publish(site_name, source_dir)
70
+ return 0
71
+ else
72
+ usage_stderr(cmd ? "Unknown command: #{cmd}" : "Expected command after static-web-server.")
73
+ return 1
74
+ end
75
+ rescue ArgumentError, Errno::ENOENT, Errno::EACCES, Errno::EPERM => e
76
+ $stderr.puts e.message
77
+ return 1
78
+ rescue => e
79
+ $stderr.puts "Error: #{e.message}"
80
+ return 1
81
+ end
82
+
83
+ exit main(ARGV.dup)
@@ -2,7 +2,8 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # source: azure.devops.proto
4
4
 
5
- require "google/protobuf"
5
+ require 'google/protobuf'
6
+
6
7
 
7
8
  descriptor_data = "\n\x12\x61zure.devops.proto\x12\x0brakit.azure\"L\n\x08Pipeline\x12\x0b\n\x03org\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0bpipeline_id\x18\x03 \x01(\x05\x12\r\n\x05token\x18\x04 \x01(\t\"R\n\x18GetPipelineResultRequest\x12\'\n\x08pipeline\x18\x01 \x01(\x0b\x32\x15.rakit.azure.Pipeline\x12\r\n\x05token\x18\x02 \x01(\t\"C\n\x0ePipelineStatus\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0e\n\x06\x65rrors\x18\x02 \x03(\t\x12\x10\n\x08warnings\x18\x03 \x03(\t\"8\n\x0ePipelineResult\x12&\n\x04runs\x18\x01 \x03(\x0b\x32\x18.rakit.azure.PipelineRun\"\x97\x01\n\x0bPipelineRun\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\t\x12\x0e\n\x06result\x18\x04 \x01(\t\x12\x14\n\x0c\x63reated_date\x18\x05 \x01(\t\x12\x15\n\rfinished_date\x18\x06 \x01(\t\x12\"\n\x06stages\x18\x07 \x03(\x0b\x32\x12.rakit.azure.Stage\"&\n\x05Issue\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\"G\n\x03Job\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\"\n\x06issues\x18\x03 \x03(\x0b\x32\x12.rakit.azure.Issue\"i\n\x05Stage\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x1e\n\x04jobs\x18\x03 \x03(\x0b\x32\x10.rakit.azure.Job\x12\"\n\x06issues\x18\x04 \x03(\x0b\x32\x12.rakit.azure.Issue\"`\n\x0eTimelineRecord\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0e\n\x06result\x18\x03 \x01(\t\x12\"\n\x06issues\x18\x04 \x03(\x0b\x32\x12.rakit.azure.Issue\"\xa8\x01\n\x14PipelineResultDetail\x12\x12\n\nsuccessful\x18\x01 \x01(\x08\x12\x0e\n\x06\x65rrors\x18\x02 \x01(\t\x12\x10\n\x08warnings\x18\x03 \x01(\t\x12%\n\x03run\x18\x04 \x01(\x0b\x32\x18.rakit.azure.PipelineRun\x12\x33\n\x0e\x66\x61iled_records\x18\x05 \x03(\x0b\x32\x1b.rakit.azure.TimelineRecord2i\n\x0ePipelineServer\x12W\n\x11GetPipelineResult\x12%.rakit.azure.GetPipelineResultRequest\x1a\x1b.rakit.azure.PipelineResultB\x0f\xea\x02\x0cRakit::Azureb\x06proto3"
8
9
 
@@ -2,8 +2,7 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # source: data.proto
4
4
 
5
- require 'google/protobuf'
6
-
5
+ require "google/protobuf"
7
6
 
8
7
  descriptor_data = "\n\ndata.proto\x12\nrakit.data\"h\n\x05Index\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1e.rakit.data.Index.EntriesEntry\x1a.\n\x0c\x45ntriesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*^\n\x0c\x45xportFormat\x12\x19\n\x15PROTOBUF_BINARY_FILES\x10\x00\x12\x17\n\x13PROTOBUF_JSON_FILES\x10\x01\x12\x1a\n\x16PROTOBUF_BINARY_ZIPPED\x10\x02\x42\x0e\xea\x02\x0bRakit::Datab\x06proto3"
9
8
 
@@ -2,7 +2,8 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # source: example.proto
4
4
 
5
- require "google/protobuf"
5
+ require 'google/protobuf'
6
+
6
7
 
7
8
  descriptor_data = "\n\rexample.proto\x12\rrakit.example\"\x1c\n\x0cHelloRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\" \n\rHelloResponse\x12\x0f\n\x07message\x18\x01 \x01(\tB\x13\xea\x02\x10Rakit::Generatedb\x06proto3"
8
9
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: rakit.file.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x10rakit.file.proto\x12\nrakit.file\"T\n\nFileConfig\x12\x1a\n\x12\x63reate_directories\x18\x01 \x01(\x08\x12\x11\n\toverwrite\x18\x02 \x01(\x08\x12\x17\n\x0f\x66ollow_symlinks\x18\x03 \x01(\x08\"\x81\x01\n\x0bListRequest\x12&\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x16.rakit.file.FileConfig\x12\x11\n\tdirectory\x18\x02 \x01(\t\x12\x11\n\trecursive\x18\x03 \x01(\x08\x12\x0c\n\x04glob\x18\x04 \x01(\t\x12\x16\n\x0einclude_hidden\x18\x05 \x01(\x08\"\x7f\n\tFileEntry\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x14\n\x0cis_directory\x18\x03 \x01(\x08\x12\x12\n\nis_symlink\x18\x04 \x01(\x08\x12\x12\n\nsize_bytes\x18\x05 \x01(\x03\x12\x18\n\x10modified_unix_ms\x18\x06 \x01(\x03\"y\n\nListResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12&\n\x07\x65ntries\x18\x03 \x03(\x0b\x32\x15.rakit.file.FileEntry\x12\x11\n\texit_code\x18\x04 \x01(\x05\x12\x0e\n\x06stderr\x18\x05 \x01(\t\"Z\n\x0b\x43opyRequest\x12&\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x16.rakit.file.FileConfig\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x03 \x01(\t\"\x8c\x01\n\nCopyResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0e\n\x06source\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x65stination\x18\x04 \x01(\t\x12\x11\n\texit_code\x18\x05 \x01(\x05\x12\x0e\n\x06stderr\x18\x06 \x01(\t\x12\x14\n\x0c\x62ytes_copied\x18\x07 \x01(\x03\x62\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Rakit
14
+ module File
15
+ FileConfig = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.FileConfig").msgclass
16
+ ListRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.ListRequest").msgclass
17
+ FileEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.FileEntry").msgclass
18
+ ListResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.ListResult").msgclass
19
+ CopyRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.CopyRequest").msgclass
20
+ CopyResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.file.CopyResult").msgclass
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: rakit.word_count.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x16rakit.word_count.proto\x12\x10rakit.word_count\"\x9c\x01\n\x0fWordCountConfig\x12\x18\n\x10\x63\x61se_insensitive\x18\x01 \x01(\x08\x12\x18\n\x10split_camel_case\x18\x02 \x01(\x08\x12\x19\n\x11split_snake_kebab\x18\x03 \x01(\x08\x12\x18\n\x10min_token_length\x18\x04 \x01(\r\x12\x11\n\tstopwords\x18\x05 \x03(\t\x12\r\n\x05top_n\x18\x06 \x01(\r\"S\n\nJsonSource\x12\x13\n\tjson_file\x18\x01 \x01(\tH\x00\x12\x15\n\x0binline_json\x18\x02 \x01(\tH\x00\x12\x0f\n\x05stdin\x18\x03 \x01(\x08H\x00\x42\x08\n\x06source\"\xbb\x01\n\x10WordCountRequest\x12\x31\n\x06\x63onfig\x18\x01 \x01(\x0b\x32!.rakit.word_count.WordCountConfig\x12*\n\x04json\x18\x02 \x01(\x0b\x32\x1c.rakit.word_count.JsonSource\x12\x31\n\x04mode\x18\x03 \x01(\x0e\x32#.rakit.word_count.JsonWordCountMode\x12\x15\n\rinclude_paths\x18\x04 \x01(\x08\"*\n\nTokenCount\x12\r\n\x05token\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x02 \x01(\x04\"\xb1\x01\n\x0fWordCountResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12,\n\x06\x63ounts\x18\x03 \x03(\x0b\x32\x1c.rakit.word_count.TokenCount\x12\x11\n\texit_code\x18\x04 \x01(\x05\x12\x0e\n\x06stderr\x18\x05 \x01(\t\x12\x14\n\x0ctotal_tokens\x18\x06 \x01(\x04\x12\x15\n\runique_tokens\x18\x07 \x01(\x04*y\n\x11JsonWordCountMode\x12$\n JSON_WORD_COUNT_MODE_UNSPECIFIED\x10\x00\x12\x1d\n\x19JSON_WORD_COUNT_MODE_KEYS\x10\x01\x12\x1f\n\x1bJSON_WORD_COUNT_MODE_VALUES\x10\x02\x42\x13\xea\x02\x10Rakit::Generatedb\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Rakit
14
+ module Generated
15
+ WordCountConfig = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.WordCountConfig").msgclass
16
+ JsonSource = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.JsonSource").msgclass
17
+ WordCountRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.WordCountRequest").msgclass
18
+ TokenCount = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.TokenCount").msgclass
19
+ WordCountResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.WordCountResult").msgclass
20
+ JsonWordCountMode = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.word_count.JsonWordCountMode").enummodule
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: static_web_server.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x17static_web_server.proto\x12\x17rakit.static_web_server\"T\n\x15StaticWebServerConfig\x12\x16\n\x0eroot_directory\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12\x15\n\rhttps_enabled\x18\x03 \x01(\x08\"=\n\x0ePublishRequest\x12\x11\n\tsite_name\x18\x01 \x01(\t\x12\x18\n\x10source_directory\x18\x02 \x01(\t\"1\n\rPublishResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\"-\n\x0cServerStatus\x12\x0f\n\x07running\x18\x01 \x01(\x08\x12\x0c\n\x04port\x18\x02 \x01(\rB\x13\xea\x02\x10Rakit::Generatedb\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Rakit
14
+ module Generated
15
+ StaticWebServerConfig = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.static_web_server.StaticWebServerConfig").msgclass
16
+ PublishRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.static_web_server.PublishRequest").msgclass
17
+ PublishResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.static_web_server.PublishResult").msgclass
18
+ ServerStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.static_web_server.ServerStatus").msgclass
19
+ end
20
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "google/protobuf"
5
+ require "rakit/word_count"
6
+
7
+ module Rakit
8
+ module CLI
9
+ module WordCount
10
+ class << self
11
+ # @param argv [Array<String>] arguments after "word-count" / "word_count"
12
+ # @return [Integer] exit code
13
+ def run(argv)
14
+ opts = parse_argv(argv)
15
+ return 1 if opts[:error]
16
+
17
+ request = build_request(opts)
18
+ return 1 unless request
19
+
20
+ result = Rakit::WordCount.count(request)
21
+ render_result(result, opts[:format])
22
+ result.exit_code || (result.success ? 0 : 1)
23
+ end
24
+
25
+ private
26
+
27
+ def parse_argv(argv)
28
+ opts = {
29
+ format: "console",
30
+ case_insensitive: false,
31
+ split_camel_case: false,
32
+ split_snake_kebab: false,
33
+ min_token_length: 0,
34
+ top_n: 0,
35
+ stopwords: [],
36
+ json_keys: false,
37
+ stdin: false,
38
+ json_file: nil
39
+ }
40
+ args = argv.dup
41
+ while (arg = args.shift)
42
+ case arg
43
+ when "--json-keys" then opts[:json_keys] = true
44
+ when "--stdin" then opts[:stdin] = true
45
+ when "--format"
46
+ opts[:format] = args.shift || "console"
47
+ when "--case-insensitive" then opts[:case_insensitive] = true
48
+ when "--split-camel-case" then opts[:split_camel_case] = true
49
+ when "--split-snake-kebab" then opts[:split_snake_kebab] = true
50
+ when "--min-token-length"
51
+ opts[:min_token_length] = (args.shift || "0").to_i
52
+ when "--top"
53
+ opts[:top_n] = (args.shift || "0").to_i
54
+ when "--stopword"
55
+ opts[:stopwords] << args.shift if args[0]
56
+ when /^--/
57
+ $stderr.puts "Unknown option: #{arg}"
58
+ opts[:error] = true
59
+ else
60
+ opts[:json_file] = arg
61
+ end
62
+ end
63
+ opts
64
+ end
65
+
66
+ def build_request(opts)
67
+ unless opts[:json_keys]
68
+ $stderr.puts "word-count requires --json-keys for MVP"
69
+ return nil
70
+ end
71
+
72
+ gen = Rakit::Generated
73
+ config = gen::WordCountConfig.new(
74
+ case_insensitive: opts[:case_insensitive],
75
+ split_camel_case: opts[:split_camel_case],
76
+ split_snake_kebab: opts[:split_snake_kebab],
77
+ min_token_length: opts[:min_token_length],
78
+ stopwords: opts[:stopwords],
79
+ top_n: opts[:top_n]
80
+ )
81
+
82
+ if opts[:stdin]
83
+ json_str = $stdin.read
84
+ json = gen::JsonSource.new(inline_json: json_str)
85
+ elsif opts[:json_file] && !opts[:json_file].empty?
86
+ json = gen::JsonSource.new(json_file: opts[:json_file])
87
+ else
88
+ $stderr.puts "Provide a JSON file path or use --stdin"
89
+ return nil
90
+ end
91
+
92
+ gen::WordCountRequest.new(
93
+ config: config,
94
+ json: json,
95
+ mode: :JSON_WORD_COUNT_MODE_KEYS
96
+ )
97
+ end
98
+
99
+ def render_result(result, format)
100
+ case format
101
+ when "json"
102
+ puts result_to_json(result)
103
+ when "proto-json"
104
+ puts Google::Protobuf.encode_json(result)
105
+ else
106
+ render_console(result)
107
+ end
108
+ $stderr.puts result.stderr if result.stderr && !result.stderr.empty? && !result.success
109
+ end
110
+
111
+ def render_console(result)
112
+ result.counts.each do |tc|
113
+ puts "#{tc.token}\t#{tc.count}"
114
+ end
115
+ end
116
+
117
+ def result_to_json(result)
118
+ h = {
119
+ success: result.success,
120
+ message: result.message.to_s,
121
+ counts: result.counts.map { |tc| { token: tc.token, count: tc.count } },
122
+ exit_code: result.exit_code,
123
+ total_tokens: result.total_tokens,
124
+ unique_tokens: result.unique_tokens
125
+ }
126
+ JSON.generate(h)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
data/lib/rakit/gem.rb CHANGED
@@ -11,29 +11,29 @@ module Rakit
11
11
  FileUtils.mkdir_p(out_dir)
12
12
  gem_file = ::Gem::Package.build(spec)
13
13
  FileUtils.mv(gem_file, out_dir)
14
- File.join(out_dir, gem_file)
14
+ ::File.join(out_dir, gem_file)
15
15
  end
16
16
 
17
17
  # Publish the gem to rubygems.org. Loads the gemspec from gemspec_path and
18
18
  # expects the .gem file in dirname(gemspec_path)/artifacts/. Run package first.
19
19
  def self.publish(gemspec_path)
20
- path = File.expand_path(gemspec_path)
20
+ path = ::File.expand_path(gemspec_path)
21
21
  spec = ::Gem::Specification.load(path)
22
- out_dir = File.join(File.dirname(path), "artifacts")
23
- gem_path = File.join(out_dir, "#{spec.full_name}.gem")
22
+ out_dir = ::File.join(::File.dirname(path), "artifacts")
23
+ gem_path = ::File.join(out_dir, "#{spec.full_name}.gem")
24
24
  push(gem_path)
25
25
  end
26
26
 
27
27
  # Bump the last digit of the version in the gemspec file (e.g. "0.1.0" -> "0.1.1").
28
28
  # Writes the file in place. Returns the new version string.
29
29
  def self.bump(gemspec_path)
30
- content = File.read(gemspec_path)
30
+ content = ::File.read(gemspec_path)
31
31
  content.sub!(/^(\s*s\.version\s*=\s*["'])([\d.]+)(["'])/) do
32
32
  segs = Regexp.last_match(2).split(".")
33
33
  segs[-1] = (segs[-1].to_i + 1).to_s
34
34
  "#{Regexp.last_match(1)}#{segs.join(".")}#{Regexp.last_match(3)}"
35
35
  end or raise "No s.version line found in #{gemspec_path}"
36
- File.write(gemspec_path, content)
36
+ ::File.write(gemspec_path, content)
37
37
  content[/s\.version\s*=\s*["']([^"']+)["']/, 1]
38
38
  end
39
39
 
@@ -41,9 +41,9 @@ module Rakit
41
41
  # published, warns and returns without pushing. Raises if the file is missing
42
42
  # or if gem push fails.
43
43
  def self.push(gem_path)
44
- raise "Gem not found: #{gem_path}. Run rake package first." unless File.file?(gem_path)
44
+ raise "Gem not found: #{gem_path}. Run rake package first." unless ::File.file?(gem_path)
45
45
 
46
- base = File.basename(gem_path, ".gem")
46
+ base = ::File.basename(gem_path, ".gem")
47
47
  parts = base.split("-")
48
48
  version = parts.pop
49
49
  name = parts.join("-")
data/lib/rakit/git.rb CHANGED
@@ -4,12 +4,18 @@ module Rakit
4
4
  module Git
5
5
  # Sync the current directory with the remote (git pull, then git push).
6
6
  # Runs from Dir.pwd. Raises if not a git repo or if pull/push fails.
7
+ # If the current branch has no remote tracking branch, pull and push are skipped (no error).
7
8
  def self.sync(dir = nil)
8
9
  require_relative "shell"
9
- target = dir ? File.expand_path(dir) : Dir.pwd
10
- raise "Not a git repository: #{target}" unless File.directory?(File.join(target, ".git"))
10
+ target = dir ? ::File.expand_path(dir) : Dir.pwd
11
+ raise "Not a git repository: #{target}" unless ::File.directory?(::File.join(target, ".git"))
11
12
 
12
13
  Dir.chdir(target) do
14
+ check = Rakit::Shell.run("git rev-parse --abbrev-ref @{u}")
15
+ if check.exit_status != 0
16
+ # No upstream configured for current branch; skip sync
17
+ return
18
+ end
13
19
  result = Rakit::Shell.run("git pull")
14
20
  raise "git pull failed" unless result.exit_status == 0
15
21
  result = Rakit::Shell.run("git push")
@@ -22,8 +28,8 @@ module Rakit
22
28
  # Raises if not a git repo or if add fails.
23
29
  def self.integrate(commit_message = nil, dir = nil)
24
30
  require_relative "shell"
25
- target = dir ? File.expand_path(dir) : Dir.pwd
26
- raise "Not a git repository: #{target}" unless File.directory?(File.join(target, ".git"))
31
+ target = dir ? ::File.expand_path(dir) : Dir.pwd
32
+ raise "Not a git repository: #{target}" unless ::File.directory?(::File.join(target, ".git"))
27
33
 
28
34
  message = commit_message || "Integrate"
29
35
  Dir.chdir(target) do
@@ -5,19 +5,21 @@ require "fileutils"
5
5
  module Rakit
6
6
  module Protobuf
7
7
  # Generate Ruby from .proto files.
8
- # proto_dir: directory containing .proto files (and -I root)
9
- # ruby_out: directory for generated *_pb.rb files
8
+ # proto_dir: directory containing .proto files (and -I root); may be relative to base_dir
9
+ # ruby_out: directory for generated *_pb.rb files; may be relative to base_dir
10
+ # base_dir: directory used to expand relative proto_dir and ruby_out (default: Dir.pwd)
10
11
  # protoc: executable name/path (default: "protoc")
11
12
  # Returns ruby_out if generation ran, nil if no proto files found.
12
- def self.generate(proto_dir:, ruby_out:, protoc: "protoc")
13
+ def self.generate(proto_dir:, ruby_out:, base_dir: Dir.pwd, protoc: "protoc")
14
+ expanded_proto_dir = File.expand_path(proto_dir, base_dir)
15
+ expanded_ruby_out = File.expand_path(ruby_out, base_dir)
13
16
  # output, in grey, " generating code from .proto files..."
14
17
  puts "\e[30m generating code from .proto files...\e[0m"
15
- expanded_proto_dir = File.expand_path(proto_dir)
16
18
  proto_files = Dir[File.join(expanded_proto_dir, "**", "*.proto")]
17
19
  return nil if proto_files.empty?
18
20
 
19
- FileUtils.mkdir_p(ruby_out)
20
- args = ["-I", expanded_proto_dir, "--ruby_out=#{ruby_out}", *proto_files]
21
+ FileUtils.mkdir_p(expanded_ruby_out)
22
+ args = ["-I", expanded_proto_dir, "--ruby_out=#{expanded_ruby_out}", *proto_files]
21
23
  system(protoc, *args) or raise "protoc failed"
22
24
 
23
25
  # output a green checkmark and the command that was run
@@ -25,14 +27,14 @@ module Rakit
25
27
  # output that the files were generated
26
28
  #puts " Generated #{proto_files.size} files in #{ruby_out}"
27
29
  # output the files that were generated (all files in the ruby_out directory), once per line
28
- ruby_out_files = Dir[File.join(ruby_out, "**", "*_pb.rb")]
30
+ ruby_out_files = Dir[File.join(expanded_ruby_out, "**", "*_pb.rb")]
29
31
  ruby_out_files.each do |file|
30
32
  # output, in grey, " #{File.basename(file)}"
31
33
  puts "\e[30m #{File.basename(file)}\e[0m"
32
34
  end
33
35
  # output the number of files that were generated
34
- #puts " Generated #{ruby_out_files.size} files in #{ruby_out}"
35
- ruby_out
36
+ #puts " Generated #{ruby_out_files.size} files in #{expanded_ruby_out}"
37
+ expanded_ruby_out
36
38
  end
37
39
  end
38
40
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "generated/static_web_server_pb"
5
+
6
+ module Rakit
7
+ # Publish static sites into a configurable root, regenerate a root index, and control a local HTTP server (start/stop/running).
8
+ # CLI: +rakit static-web-server+ (start|stop|running|publish). See contracts/ruby-api.md and quickstart in specs/003-static-web-server/quickstart.md.
9
+ module StaticWebServer
10
+ SITE_NAME_REGEX = /\A[a-z0-9\-]+\z/.freeze
11
+
12
+ class << self
13
+ # @return [String] Static root directory (default ~/.rakit/www_root). Used by publish and server lifecycle.
14
+ attr_accessor :root
15
+ # @return [Integer] Default port for start (default 5099). Used when no override passed to start.
16
+ attr_accessor :port
17
+ end
18
+
19
+ self.root = ::File.expand_path("~/.rakit/www_root")
20
+ self.port = 5099
21
+
22
+ # T005: Validate site name; raise ArgumentError before any filesystem write.
23
+ def self.validate_site_name!(site_name)
24
+ return if site_name.is_a?(String) && site_name.match?(SITE_NAME_REGEX)
25
+ raise ArgumentError, "site_name must be lowercase alphanumeric and dashes only (e.g. my-site); got: #{site_name.inspect}"
26
+ end
27
+
28
+ # T006: PID file path for start/stop/running (research: ~/.rakit/static_web_server.pid).
29
+ def self.pid_file_path
30
+ ::File.expand_path("~/.rakit/static_web_server.pid")
31
+ end
32
+
33
+ # T007: Server binary (miniserve) on PATH; used by start. Returns path or nil.
34
+ def self.server_binary
35
+ @server_binary ||= begin
36
+ path = ENV["RAKIT_STATIC_SERVER_BINARY"]
37
+ return path if path && !path.empty?
38
+ which("miniserve")
39
+ end
40
+ end
41
+
42
+ def self.server_binary=(path)
43
+ @server_binary = path
44
+ end
45
+
46
+ # T008: Create root directory if missing; used before publish and start.
47
+ def self.ensure_root
48
+ FileUtils.mkdir_p(root)
49
+ end
50
+
51
+ # Publish static content from source_directory to root/site_name (atomic copy), then regenerate root index.
52
+ # @param site_name [String] Must match \\A[a-z0-9\-]+\\z (lowercase, alphanumeric, dashes only).
53
+ # @param source_directory [String] Existing directory path; contents are copied (no traversal outside allowed paths).
54
+ # @return [true] on success.
55
+ # @raise [ArgumentError] for invalid site_name, missing/invalid source_directory, or root not writable.
56
+ def self.publish(site_name, source_directory)
57
+ validate_site_name!(site_name)
58
+ src = ::File.expand_path(source_directory)
59
+ raise ArgumentError, "source_directory does not exist: #{source_directory}" unless ::File.exist?(src)
60
+ raise ArgumentError, "source_directory is not a directory: #{source_directory}" unless ::File.directory?(src)
61
+
62
+ ensure_root
63
+ # Check root is writable before we do any copy (T015 / edge case).
64
+ unless ::File.writable?(root)
65
+ raise ArgumentError, "root directory is not writable: #{root}"
66
+ end
67
+
68
+ target = ::File.join(root, site_name)
69
+ temp = ::File.join(root, ".tmp_#{site_name}_#{Process.pid}_#{rand(1_000_000)}")
70
+ FileUtils.mkdir_p(temp)
71
+ FileUtils.cp_r(::File.join(src, "."), temp)
72
+ FileUtils.rm_rf(target)
73
+ FileUtils.mv(temp, target)
74
+ regenerate_root_index
75
+ true
76
+ end
77
+
78
+ # Whether the server process is currently running (PID file + process check). No side effects.
79
+ # @return [Boolean] true if server is running, false otherwise.
80
+ def self.running?
81
+ path = pid_file_path
82
+ return false unless ::File.file?(path)
83
+ pid = ::File.read(path).strip.to_i
84
+ return false if pid <= 0
85
+ Process.getpgid(pid)
86
+ true
87
+ rescue Errno::ESRCH, Errno::EPERM
88
+ false
89
+ end
90
+
91
+ # Start background server serving root. Idempotent: if already running, no-op.
92
+ # @param options [Hash] :port (optional) override default port.
93
+ # @return [true] if server is running (started or already running).
94
+ # @raise [RuntimeError] if port in use or server binary (miniserve) not found.
95
+ def self.start(options = {})
96
+ return true if running?
97
+ ensure_root
98
+ p = options[:port] || port
99
+ raise "port #{p} is already in use; change port or stop the other process" if port_in_use?(p)
100
+ bin = server_binary
101
+ raise "static server binary (miniserve) not found on PATH; install miniserve or set RAKIT_STATIC_SERVER_BINARY" unless bin
102
+ root_path = ::File.expand_path(root)
103
+ pid = spawn(bin, root_path, "--port", p.to_s, out: ::File::NULL, err: ::File::NULL)
104
+ Process.detach(pid)
105
+ ::File.write(pid_file_path, pid.to_s)
106
+ true
107
+ end
108
+
109
+ STOP_TIMEOUT = 5
110
+
111
+ # Gracefully terminate server process; remove PID file. Fail-fast if not running.
112
+ # @return [true] if stopped.
113
+ # @raise [RuntimeError] if server was not running (no PID file or process not found).
114
+ def self.stop
115
+ path = pid_file_path
116
+ raise "server is not running (no PID file at #{path})" unless ::File.file?(path)
117
+ pid = ::File.read(path).strip.to_i
118
+ raise "server is not running (invalid PID in #{path})" if pid <= 0
119
+ begin
120
+ Process.getpgid(pid)
121
+ rescue Errno::ESRCH
122
+ ::File.delete(path)
123
+ raise "server is not running (process #{pid} not found)"
124
+ end
125
+ Process.kill(:TERM, pid)
126
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_TIMEOUT
127
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
128
+ begin
129
+ Process.wait(pid, Process::WNOHANG)
130
+ break
131
+ rescue Errno::ECHILD
132
+ break
133
+ end
134
+ sleep 0.1
135
+ end
136
+ begin
137
+ Process.getpgid(pid)
138
+ Process.kill(:KILL, pid)
139
+ Process.wait(pid)
140
+ rescue Errno::ESRCH, Errno::ECHILD
141
+ # already gone
142
+ end
143
+ ::File.delete(path) if ::File.file?(path)
144
+ true
145
+ end
146
+
147
+ # T017: Write root/index.html listing all site subdirectories alphabetically with links.
148
+ def self.regenerate_root_index
149
+ entries = Dir.entries(root).sort.select do |e|
150
+ e != "." && e != ".." && e != "index.html" && !e.start_with?(".") && ::File.directory?(::File.join(root, e))
151
+ end
152
+ html = ["<!DOCTYPE html>", "<html><head><meta charset=\"utf-8\"><title>Published sites</title></head><body>", "<h1>Published sites</h1>", "<ul>"]
153
+ entries.each { |name| html << "<li><a href=\"/#{name}/\">#{name}</a></li>" }
154
+ html << "</ul></body></html>"
155
+ ::File.write(::File.join(root, "index.html"), html.join("\n"))
156
+ end
157
+
158
+ class << self
159
+ private
160
+
161
+ def which(cmd)
162
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
163
+ ENV["PATH"].to_s.split(::File::PATH_SEPARATOR).each do |dir|
164
+ next if dir.empty?
165
+ exts.each do |ext|
166
+ exe = ::File.join(dir, "#{cmd}#{ext}")
167
+ return exe if ::File.executable?(exe) && !::File.directory?(exe)
168
+ end
169
+ end
170
+ nil
171
+ end
172
+
173
+ def port_in_use?(p)
174
+ require "socket"
175
+ TCPServer.new("127.0.0.1", p).close
176
+ false
177
+ rescue Errno::EADDRINUSE
178
+ true
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+ require "generated/rakit.word_count_pb"
6
+
7
+ module Rakit
8
+ # Schema-driven token frequency counting over JSON content (keys or values).
9
+ # CLI: +rakit word-count+ (--json-keys). See specs/004-word-count/contracts/ruby-api.md.
10
+ module WordCount
11
+ class << self
12
+ # @param request [Rakit::Generated::WordCountRequest]
13
+ # @return [Rakit::Generated::WordCountResult]
14
+ def count(request)
15
+ json_str = resolve_json_input(request)
16
+ return json_str if json_str.is_a?(Rakit::Generated::WordCountResult)
17
+
18
+ data = JSON.parse(json_str)
19
+ config = request.config || Rakit::Generated::WordCountConfig.new
20
+ keys = extract_keys(data)
21
+ tokens = keys.flat_map { |k| normalize_and_split(k, config) }
22
+ counts = filter_count_sort(tokens, config)
23
+ total = tokens.size
24
+ unique = counts.size
25
+
26
+ Rakit::Generated::WordCountResult.new(
27
+ success: true,
28
+ message: "",
29
+ counts: counts.map { |tok, cnt| Rakit::Generated::TokenCount.new(token: tok, count: cnt) },
30
+ exit_code: 0,
31
+ stderr: "",
32
+ total_tokens: total,
33
+ unique_tokens: unique
34
+ )
35
+ rescue JSON::ParserError => e
36
+ Rakit::Generated::WordCountResult.new(
37
+ success: false,
38
+ message: e.message,
39
+ exit_code: 1,
40
+ stderr: e.message
41
+ )
42
+ rescue Errno::ENOENT, Errno::EACCES => e
43
+ Rakit::Generated::WordCountResult.new(
44
+ success: false,
45
+ message: e.message,
46
+ exit_code: 1,
47
+ stderr: e.message
48
+ )
49
+ end
50
+
51
+ # T004: Recursive key extraction from JSON structure. Returns array of key strings (duplicates preserved).
52
+ def extract_keys(obj)
53
+ case obj
54
+ when Hash
55
+ obj.keys.map(&:to_s) + obj.values.flat_map { |v| extract_keys(v) }
56
+ when Array
57
+ obj.flat_map { |v| extract_keys(v) }
58
+ else
59
+ []
60
+ end
61
+ end
62
+
63
+ # T005: Normalize and split one key into tokens. Pipeline: case -> snake/kebab -> camelCase.
64
+ def normalize_and_split(token_string, config)
65
+ return [] if token_string.nil? || !token_string.is_a?(String)
66
+
67
+ s = token_string.dup
68
+ s = s.downcase if config.case_insensitive
69
+ parts = [s]
70
+ parts = parts.flat_map { |p| p.split(/_|-/) } if config.split_snake_kebab
71
+ parts = parts.flat_map { |p| p.split(/(?=[A-Z])/).reject(&:empty?) } if config.split_camel_case
72
+ parts.map(&:downcase).reject(&:empty?)
73
+ end
74
+
75
+ # T006: Filter (min_length, stopwords), count, sort (count desc, token asc), top_n.
76
+ def filter_count_sort(tokens, config)
77
+ min_len = config.min_token_length || 0
78
+ stopwords = config.stopwords || []
79
+ normalized_stop = config.case_insensitive ? stopwords.map(&:downcase).to_set : stopwords.to_set
80
+
81
+ filtered = tokens.select do |t|
82
+ compare_t = config.case_insensitive ? t.downcase : t
83
+ t.length >= min_len && !normalized_stop.include?(compare_t)
84
+ end
85
+ freq = filtered.tally
86
+ sorted = freq.sort_by { |token, count| [-count, token] }
87
+ top_n = config.top_n || 0
88
+ top_n.positive? ? sorted.take(top_n) : sorted
89
+ end
90
+
91
+ private
92
+
93
+ def resolve_json_input(request)
94
+ json = request.json
95
+ return error_result("No JSON source", 1) unless json
96
+
97
+ if json.json_file && !json.json_file.empty?
98
+ path = json.json_file
99
+ return error_result("File not found: #{path}", 1) unless ::File.file?(path)
100
+ return error_result("File not readable: #{path}", 1) unless ::File.readable?(path)
101
+ return ::File.read(path)
102
+ end
103
+
104
+ if json.inline_json && !json.inline_json.empty?
105
+ return json.inline_json
106
+ end
107
+
108
+ # stdin: caller must set inline_json with the read content (per contracts/ruby-api.md)
109
+ error_result("No JSON source (set json_file or inline_json)", 1)
110
+ end
111
+
112
+ def error_result(message, exit_code)
113
+ Rakit::Generated::WordCountResult.new(
114
+ success: false,
115
+ message: message,
116
+ exit_code: exit_code,
117
+ stderr: message
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end
data/lib/rakit.rb CHANGED
@@ -22,9 +22,11 @@ require_relative "rakit/gem"
22
22
  require_relative "rakit/git"
23
23
  require_relative "rakit/task"
24
24
  require_relative "rakit/protobuf"
25
- # Defer loading so rake tasks that don't need Shell/Data (e.g. clobber) work without google-protobuf.
25
+ # Defer loading so rake tasks that don't need Shell (e.g. clobber) work without google-protobuf.
26
26
  autoload :Shell, "rakit/shell"
27
- autoload :Data, "rakit/data"
27
+ require_relative "rakit/static_web_server"
28
+ require_relative "rakit/word_count"
29
+ require_relative "rakit/file"
28
30
  require_relative "rakit/azure/dev_ops"
29
31
 
30
32
  def run(cmd)
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rakit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - rakit
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -93,22 +93,29 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '5.0'
96
- executables: []
96
+ executables:
97
+ - rakit
97
98
  extensions: []
98
99
  extra_rdoc_files: []
99
100
  files:
101
+ - exe/rakit
100
102
  - lib/generated/azure.devops_pb.rb
101
103
  - lib/generated/data_pb.rb
102
104
  - lib/generated/example_pb.rb
105
+ - lib/generated/rakit.file_pb.rb
106
+ - lib/generated/rakit.word_count_pb.rb
103
107
  - lib/generated/shell_pb.rb
108
+ - lib/generated/static_web_server_pb.rb
104
109
  - lib/rakit.rb
105
110
  - lib/rakit/azure/dev_ops.rb
106
- - lib/rakit/data.rb
111
+ - lib/rakit/cli/word_count.rb
107
112
  - lib/rakit/gem.rb
108
113
  - lib/rakit/git.rb
109
114
  - lib/rakit/protobuf.rb
110
115
  - lib/rakit/shell.rb
116
+ - lib/rakit/static_web_server.rb
111
117
  - lib/rakit/task.rb
118
+ - lib/rakit/word_count.rb
112
119
  homepage: https://gitlab.com/gems/rakit
113
120
  licenses:
114
121
  - MIT
data/lib/rakit/data.rb DELETED
@@ -1,173 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
-
5
- require "generated/data_pb"
6
-
7
- module Rakit
8
- module Data
9
- # Export all stored messages from DataService.data_dir to export_dir in the given format.
10
- # If there are no .pb files under data_dir, no files are created.
11
- #
12
- # @param export_dir [String] target directory (created if needed)
13
- # @param export_format [Rakit::Data::ExportFormat] PROTOBUF_BINARY_FILES (mirror .pb layout),
14
- # PROTOBUF_JSON_FILES (same layout with .json), or PROTOBUF_BINARY_ZIPPED (single data.zip)
15
- # @return [void]
16
- # @raise [ArgumentError] if export_format is not a supported ExportFormat value
17
- def self.export(export_dir, export_format)
18
- base = DataService.data_dir
19
- export_dir = File.expand_path(export_dir)
20
- FileUtils.mkdir_p(export_dir)
21
-
22
- pb_paths = Dir.glob(File.join(base, "**", "*.pb"))
23
- return if pb_paths.empty?
24
-
25
- case export_format
26
- when ExportFormat::PROTOBUF_BINARY_FILES
27
- _export_binary_files(pb_paths, base, export_dir)
28
- when ExportFormat::PROTOBUF_JSON_FILES
29
- _export_json_files(pb_paths, base, export_dir)
30
- when ExportFormat::PROTOBUF_BINARY_ZIPPED
31
- _export_binary_zipped(pb_paths, base, export_dir)
32
- else
33
- raise ArgumentError, "unsupported export_format: #{export_format.inspect}"
34
- end
35
- end
36
-
37
- def self._rel_parts(base, path)
38
- rel = path[(base.end_with?(File::SEPARATOR) ? base.length : base.length + 1)..]
39
- parts = rel.split(File::SEPARATOR)
40
- type_name = parts[0..-2].join("::")
41
- unique_name = File.basename(parts[-1], ".pb")
42
- [type_name, unique_name, rel]
43
- end
44
-
45
- def self._export_binary_files(pb_paths, base, export_dir)
46
- pb_paths.each do |path|
47
- type_name, _unique_name, rel = _rel_parts(base, path)
48
- message = DataService.load(type_name, File.basename(path, ".pb"))
49
- out_path = File.join(export_dir, rel)
50
- FileUtils.mkdir_p(File.dirname(out_path))
51
- File.binwrite(out_path, message.class.encode(message))
52
- end
53
- end
54
-
55
- def self._export_json_files(pb_paths, base, export_dir)
56
- pb_paths.each do |path|
57
- type_name, unique_name, rel = _rel_parts(base, path)
58
- message = DataService.load(type_name, unique_name)
59
- out_rel = rel.sub(/\.pb\z/, ".json")
60
- out_path = File.join(export_dir, out_rel)
61
- FileUtils.mkdir_p(File.dirname(out_path))
62
- File.write(out_path, message.class.encode_json(message))
63
- end
64
- end
65
-
66
- def self._export_binary_zipped(pb_paths, base, export_dir)
67
- require "zip"
68
- zip_path = File.join(export_dir, "data.zip")
69
- FileUtils.rm_f(zip_path)
70
- Zip::File.open(zip_path, Zip::File::CREATE) do |zip|
71
- pb_paths.each do |path|
72
- type_name, unique_name, rel = _rel_parts(base, path)
73
- message = DataService.load(type_name, unique_name)
74
- zip.get_output_stream(rel) { |io| io.write(message.class.encode(message)) }
75
- end
76
- end
77
- end
78
-
79
- # Persist and retrieve protobuf message instances under a configurable data root.
80
- #
81
- # Storage layout: root is {data_dir} (default +~/.rakit/data+). Path for a message is
82
- # +data_dir/TYPE_PATH/unique_name.pb+. TYPE_PATH is the Ruby class name with +::+ replaced by
83
- # +File::SEPARATOR+ (e.g. +Rakit::Shell::Command+ → +Rakit/Shell/Command+). File content is
84
- # binary protobuf; no character encoding.
85
- module DataService
86
- # @return [String] current data root (default: expanded +~/.rakit/data+)
87
- def self.data_dir
88
- @data_dir ||= File.expand_path("~/.rakit/data")
89
- end
90
-
91
- # @param path [String] set the data root for subsequent operations (e.g. tests use a temp dir)
92
- # @return [void]
93
- def self.data_dir=(path)
94
- @data_dir = path
95
- end
96
-
97
- # Store a proto message under a unique name.
98
- # @param message [Object] instance of a generated protobuf message class
99
- # @param unique_name [String] non-empty, must not contain path separators or +..+
100
- # @return [void]
101
- # @raise [ArgumentError] if unique_name is empty/blank or contains path traversal
102
- # @raise [Errno::EACCES] etc. on permission failure
103
- def self.store(message, unique_name)
104
- _validate_unique_name!(unique_name)
105
- klass = message.class
106
- path = _path(klass.name, unique_name)
107
- FileUtils.mkdir_p(File.dirname(path))
108
- File.binwrite(path, klass.encode(message))
109
- end
110
-
111
- # Load a stored message.
112
- # @param type_name [String] Ruby class name (e.g. +"Rakit::Shell::Command"+)
113
- # @param unique_name [String] same rules as for store
114
- # @return [Object] decoded message instance
115
- # @raise [ArgumentError] if unique_name invalid
116
- # @raise [NameError] if type_name is not a valid constant
117
- # @raise [Errno::ENOENT] if the file does not exist
118
- def self.load(type_name, unique_name)
119
- _validate_unique_name!(unique_name)
120
- klass = Object.const_get(type_name.to_s)
121
- path = _path(klass.name, unique_name.to_s)
122
- raise Errno::ENOENT, path unless File.file?(path)
123
-
124
- klass.decode(File.binread(path))
125
- end
126
-
127
- # Remove a stored message by type and unique name (no-op if file absent).
128
- # @param type_name [String] Ruby class name
129
- # @param unique_name [String] same rules as for store
130
- # @return [void]
131
- # @raise [ArgumentError] if unique_name invalid
132
- def self.remove(type_name, unique_name)
133
- _validate_unique_name!(unique_name)
134
- path = _path(type_name.to_s, unique_name.to_s)
135
- File.delete(path) if File.file?(path)
136
- end
137
-
138
- # Return unique names (without +.pb+) for the given type.
139
- # @param type_name [String] Ruby class name for directory resolution
140
- # @return [Array<String>] empty if directory missing or no .pb files
141
- # @raise [NameError] if type_name is not a valid constant
142
- # @raise [ArgumentError] if type_name yields empty path segments (e.g. from _dir_for_type)
143
- def self.get_names(type_name)
144
- dir = _dir_for_type(type_name.to_s)
145
- return [] unless File.directory?(dir)
146
-
147
- Dir.children(dir).select { |f| File.file?(File.join(dir, f)) && f.end_with?(".pb") }.map { |f| f.chomp(".pb") }
148
- end
149
-
150
- def self._validate_unique_name!(unique_name)
151
- u = unique_name.to_s
152
- raise ArgumentError, "unique_name must be a non-empty string" if u.strip.empty?
153
- if u.include?("/") || u.include?("\\") || u.include?("..")
154
- raise ArgumentError, "unique_name must not contain path separators or '..'"
155
- end
156
- end
157
-
158
- def self._path(type_name, unique_name)
159
- dir = _dir_for_type(type_name)
160
- File.join(dir, "#{unique_name}.pb")
161
- end
162
-
163
- # PACKAGE_PATH/MESSAGE_NAME: e.g. Rakit::Shell::Command -> Rakit/Shell/Command
164
- def self._dir_for_type(type_name)
165
- parts = type_name.split("::")
166
- raise ArgumentError, "type_name must be a qualified constant path" if parts.empty?
167
-
168
- relative = parts.join(File::SEPARATOR)
169
- File.join(data_dir, relative)
170
- end
171
- end
172
- end
173
- end