t-tech-investments 0.1.0 → 0.1.1

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/git-flow.mdc +49 -5
  3. data/.cursor/rules/sdk-architecture.mdc +30 -0
  4. data/.cursor/rules/sdk-code-standards.mdc +22 -0
  5. data/.cursor/rules/sdk-patterns.mdc +22 -0
  6. data/.cursor/rules/sdk-testing.mdc +19 -0
  7. data/CHANGELOG.md +4 -0
  8. data/README.md +195 -4
  9. data/Rakefile +4 -0
  10. data/contracts.lock +6 -0
  11. data/lib/t/tech/investments/client.rb +70 -0
  12. data/lib/t/tech/investments/coercers.rb +171 -0
  13. data/lib/t/tech/investments/configuration.rb +74 -0
  14. data/lib/t/tech/investments/errors.rb +24 -0
  15. data/lib/t/tech/investments/proto/common_pb.rb +42 -0
  16. data/lib/t/tech/investments/proto/google/api/field_behavior_pb.rb +19 -0
  17. data/lib/t/tech/investments/proto/instruments_pb.rb +157 -0
  18. data/lib/t/tech/investments/proto/instruments_services_pb.rb +115 -0
  19. data/lib/t/tech/investments/proto/marketdata_pb.rb +99 -0
  20. data/lib/t/tech/investments/proto/marketdata_services_pb.rb +68 -0
  21. data/lib/t/tech/investments/proto/operations_pb.rb +78 -0
  22. data/lib/t/tech/investments/proto/operations_services_pb.rb +67 -0
  23. data/lib/t/tech/investments/proto/orders_pb.rb +64 -0
  24. data/lib/t/tech/investments/proto/orders_services_pb.rb +69 -0
  25. data/lib/t/tech/investments/proto/sandbox_pb.rb +37 -0
  26. data/lib/t/tech/investments/proto/sandbox_services_pb.rb +75 -0
  27. data/lib/t/tech/investments/proto/signals_pb.rb +37 -0
  28. data/lib/t/tech/investments/proto/signals_services_pb.rb +36 -0
  29. data/lib/t/tech/investments/proto/stoporders_pb.rb +45 -0
  30. data/lib/t/tech/investments/proto/stoporders_services_pb.rb +38 -0
  31. data/lib/t/tech/investments/proto/users_pb.rb +47 -0
  32. data/lib/t/tech/investments/proto/users_services_pb.rb +51 -0
  33. data/lib/t/tech/investments/proto_loader.rb +49 -0
  34. data/lib/t/tech/investments/services/market_data_facade.rb +35 -0
  35. data/lib/t/tech/investments/services/market_data_stream_session.rb +148 -0
  36. data/lib/t/tech/investments/services/registry.rb +38 -0
  37. data/lib/t/tech/investments/services/unary_adapter.rb +64 -0
  38. data/lib/t/tech/investments/services.rb +6 -0
  39. data/lib/t/tech/investments/transport.rb +137 -0
  40. data/lib/t/tech/investments/version.rb +1 -1
  41. data/lib/t/tech/investments.rb +30 -1
  42. data/script/regenerate_proto +119 -0
  43. data/sig/t/tech/investments.rbs +114 -1
  44. metadata +67 -4
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module T
4
+ module Tech
5
+ module Investments
6
+ # Loads generated proto files so that Service/Stub classes and messages are available.
7
+ # Adds proto directory to $LOAD_PATH and requires contract files in dependency order.
8
+ module ProtoLoader
9
+ PROTO_DIR = File.expand_path("proto", __dir__)
10
+
11
+ # Contract _pb.rb (messages); order respects dependencies.
12
+ PB_FILES = %w[
13
+ common_pb
14
+ instruments_pb
15
+ marketdata_pb
16
+ operations_pb
17
+ orders_pb
18
+ sandbox_pb
19
+ signals_pb
20
+ stoporders_pb
21
+ users_pb
22
+ ].freeze
23
+
24
+ # Service definitions (depend on _pb).
25
+ SERVICES_FILES = %w[
26
+ users_services_pb
27
+ instruments_services_pb
28
+ marketdata_services_pb
29
+ operations_services_pb
30
+ orders_services_pb
31
+ sandbox_services_pb
32
+ signals_services_pb
33
+ stoporders_services_pb
34
+ ].freeze
35
+
36
+ module_function
37
+
38
+ def load!
39
+ return if @loaded
40
+
41
+ $LOAD_PATH.unshift(PROTO_DIR) unless $LOAD_PATH.include?(PROTO_DIR)
42
+ PB_FILES.each { |f| require f }
43
+ SERVICES_FILES.each { |f| require f }
44
+ @loaded = true
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module T
4
+ module Tech
5
+ module Investments
6
+ module Services
7
+ # Combines unary MarketData RPCs and #stream for MarketDataStream bidi.
8
+ # client.market_data.get_candles(...) -> unary; client.market_data.stream { |s| ... } -> stream session.
9
+ class MarketDataFacade
10
+ def initialize(unary_adapter, transport)
11
+ @unary_adapter = unary_adapter
12
+ @transport = transport
13
+ end
14
+
15
+ def stream(timeout: nil, &block)
16
+ session = MarketDataStreamSession.new(@transport, timeout: timeout)
17
+ block&.call(session)
18
+ end
19
+
20
+ def method_missing(method_name, *args, **kwargs, &block)
21
+ if @unary_adapter.respond_to?(method_name)
22
+ @unary_adapter.public_send(method_name, *args, **kwargs, &block)
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ def respond_to_missing?(method_name, include_private = false)
29
+ @unary_adapter.respond_to?(method_name, include_private) || super
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module T
4
+ module Tech
5
+ module Investments
6
+ module Services
7
+ # Session for MarketDataStream bidi: queue of outgoing requests, each_event yields
8
+ # Ruby-friendly events (ping ignored). Uses Coercers for request building and response mapping.
9
+ # Reconciliation: call get_my_subscriptions then resubscribe or restart stream as needed.
10
+ # Contract (Tinkoff::...) is resolved at runtime after ProtoLoader.load!
11
+ # rubocop:disable Metrics/ClassLength
12
+ class MarketDataStreamSession
13
+ CONTRACT_NS = "Tinkoff::Public::Invest::Api::Contract::V1"
14
+
15
+ REQUEST_SPECS = {
16
+ subscribe_candles: %i[SubscribeCandlesRequest subscribe_candles_request],
17
+ subscribe_order_book: %i[SubscribeOrderBookRequest subscribe_order_book_request],
18
+ subscribe_trades: %i[SubscribeTradesRequest subscribe_trades_request],
19
+ subscribe_info: %i[SubscribeInfoRequest subscribe_info_request],
20
+ subscribe_last_price: %i[SubscribeLastPriceRequest subscribe_last_price_request],
21
+ get_my_subscriptions: %i[GetMySubscriptions get_my_subscriptions]
22
+ }.freeze
23
+
24
+ # Response payload field name -> event type (we skip :ping)
25
+ RESPONSE_PAYLOAD_TO_TYPE = {
26
+ subscribe_candles_response: :subscribe_candles_response,
27
+ subscribe_order_book_response: :subscribe_order_book_response,
28
+ subscribe_trades_response: :subscribe_trades_response,
29
+ subscribe_info_response: :subscribe_info_response,
30
+ candle: :candle,
31
+ trade: :trade,
32
+ orderbook: :orderbook,
33
+ trading_status: :trading_status,
34
+ ping: :ping,
35
+ subscribe_last_price_response: :subscribe_last_price_response,
36
+ last_price: :last_price,
37
+ open_interest: :open_interest
38
+ }.freeze
39
+
40
+ def initialize(transport, timeout: nil)
41
+ @transport = transport
42
+ @timeout = timeout
43
+ @queue = Queue.new
44
+ end
45
+
46
+ def subscribe_candles(**kwargs)
47
+ push_request(:subscribe_candles, kwargs)
48
+ end
49
+
50
+ def subscribe_order_book(**kwargs)
51
+ push_request(:subscribe_order_book, kwargs)
52
+ end
53
+
54
+ def subscribe_trades(**kwargs)
55
+ push_request(:subscribe_trades, kwargs)
56
+ end
57
+
58
+ def subscribe_info(**kwargs)
59
+ push_request(:subscribe_info, kwargs)
60
+ end
61
+
62
+ def subscribe_last_price(**kwargs)
63
+ push_request(:subscribe_last_price, kwargs)
64
+ end
65
+
66
+ def get_my_subscriptions # rubocop:disable Naming/AccessorMethodName
67
+ push_request(:get_my_subscriptions, {})
68
+ end
69
+
70
+ # Iterates server responses as events (Hash with :type and :data). Ignores ping.
71
+ # rubocop:disable Metrics/MethodLength
72
+ # Request stream is fed from the queue (subscribe_* / get_my_subscriptions pushed earlier).
73
+ def each_event(&block)
74
+ return to_enum(:each_event) unless block
75
+
76
+ request_enum = build_request_enum
77
+ stream_stub = @transport.stub(contract::MarketDataStreamService::Stub)
78
+ response_enum = @transport.bidi_stream(
79
+ stream_stub, :market_data_stream, request_enum, timeout: @timeout
80
+ )
81
+ response_enum.each do |response|
82
+ event = response_to_event(response)
83
+ next if event.nil?
84
+
85
+ block.call(event)
86
+ end
87
+ end
88
+ # rubocop:enable Metrics/MethodLength
89
+
90
+ private
91
+
92
+ def contract
93
+ @contract ||= Object.const_get(CONTRACT_NS)
94
+ end
95
+
96
+ def push_request(kind, kwargs)
97
+ msg_name, oneof_key = REQUEST_SPECS.fetch(kind)
98
+ msg_class = contract.const_get(msg_name)
99
+ inner = kwargs.empty? ? msg_class.new : Coercers.to_request(msg_class, kwargs)
100
+ req = contract::MarketDataRequest.new(oneof_key => inner)
101
+ @queue.push(req)
102
+ end
103
+
104
+ def build_request_enum
105
+ Enumerator.new do |y|
106
+ loop { y.yield @queue.pop }
107
+ end
108
+ end
109
+
110
+ def response_to_event(response)
111
+ payload_type, payload = extract_payload(response)
112
+ return nil if payload_type.nil? || payload_type == :ping
113
+
114
+ event_type = RESPONSE_PAYLOAD_TO_TYPE[payload_type]
115
+ data = payload ? Coercers.to_hash(payload, ruby_friendly: true) : {}
116
+ { type: event_type, data: data }
117
+ end
118
+
119
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
120
+ def extract_payload(response)
121
+ if response.respond_to?(:which_oneof)
122
+ field = response.which_oneof("payload")
123
+ if field
124
+ field_sym = field.to_sym
125
+ val = response.public_send(field_sym)
126
+ return [field_sym, val] if val
127
+ end
128
+ end
129
+ RESPONSE_PAYLOAD_TO_TYPE.each_key do |f|
130
+ next unless response.respond_to?(f)
131
+
132
+ val = response.public_send(f)
133
+ next if val.nil?
134
+ return [f, val] if protobuf_message?(val) || val.to_s.to_s.strip != ""
135
+ end
136
+ [nil, nil]
137
+ end
138
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
139
+
140
+ def protobuf_message?(val)
141
+ val.respond_to?(:descriptor) && val.respond_to?(:to_h)
142
+ end
143
+ end
144
+ # rubocop:enable Metrics/ClassLength
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module T
4
+ module Tech
5
+ module Investments
6
+ module Services
7
+ # Maps SDK service names to gRPC Service classes. Used by Client to build adapters.
8
+ # Resolves constants after proto is loaded (no Tinkoff at require time).
9
+ module Registry
10
+ CONTRACT_NS = "Tinkoff::Public::Invest::Api::Contract::V1"
11
+
12
+ SERVICE_CLASS_PATHS = {
13
+ users: "#{CONTRACT_NS}::UsersService::Service",
14
+ instruments: "#{CONTRACT_NS}::InstrumentsService::Service",
15
+ market_data: "#{CONTRACT_NS}::MarketDataService::Service",
16
+ market_data_stream: "#{CONTRACT_NS}::MarketDataStreamService::Service",
17
+ operations: "#{CONTRACT_NS}::OperationsService::Service",
18
+ orders: "#{CONTRACT_NS}::OrdersService::Service",
19
+ sandbox: "#{CONTRACT_NS}::SandboxService::Service",
20
+ stop_orders: "#{CONTRACT_NS}::StopOrdersService::Service",
21
+ signals: "#{CONTRACT_NS}::SignalService::Service"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def service_class(name)
27
+ path = SERVICE_CLASS_PATHS.fetch(name) { raise KeyError, "Unknown service: #{name.inspect}" }
28
+ Object.const_get(path)
29
+ end
30
+
31
+ def service_names
32
+ SERVICE_CLASS_PATHS.keys
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module T
4
+ module Tech
5
+ module Investments
6
+ module Services
7
+ # Adapter that exposes unary RPCs from a gRPC Service class via Transport + Coercers.
8
+ # Methods are defined dynamically from service_class.rpc_descs (no streaming in this adapter).
9
+ class UnaryAdapter
10
+ attr_reader :service_class, :transport, :stub
11
+
12
+ def initialize(service_class, transport)
13
+ @service_class = service_class
14
+ @transport = transport
15
+ @stub = transport.stub(service_class.rpc_stub_class)
16
+ define_unary_methods!
17
+ end
18
+
19
+ private
20
+
21
+ def define_unary_methods!
22
+ service_class.rpc_descs.each_value do |desc|
23
+ next unless unary?(desc)
24
+
25
+ method_sym = rpc_name_to_snake(desc.name)
26
+ request_class = desc.input
27
+ response_class = desc.output
28
+ define_unary_method(method_sym, request_class, response_class)
29
+ end
30
+ end
31
+
32
+ def unary?(desc)
33
+ !stream_type?(desc.input) && !stream_type?(desc.output)
34
+ end
35
+
36
+ def stream_type?(val)
37
+ val.is_a?(::GRPC::RpcDesc::Stream)
38
+ end
39
+
40
+ def rpc_name_to_snake(name)
41
+ name.to_s
42
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
+ .downcase
45
+ .to_sym
46
+ end
47
+
48
+ def define_unary_method(method_sym, request_class, _response_class)
49
+ return if singleton_class.method_defined?(method_sym)
50
+
51
+ adapter = self
52
+ define_singleton_method(method_sym) do |**kwargs, &_block|
53
+ raw = kwargs.delete(:raw)
54
+ timeout = kwargs.delete(:timeout)
55
+ request = Coercers.to_request(request_class, kwargs)
56
+ response = adapter.transport.unary(adapter.stub, method_sym, request, timeout: timeout)
57
+ raw ? response : Coercers.to_hash(response, ruby_friendly: true)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "services/unary_adapter"
4
+ require_relative "services/registry"
5
+ require_relative "services/market_data_stream_session"
6
+ require_relative "services/market_data_facade"
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "grpc"
4
+
5
+ module T
6
+ module Tech
7
+ module Investments
8
+ # gRPC transport: channel, metadata, deadlines, unary calls, error mapping.
9
+ # No business logic; Services call Transport, not stubs directly.
10
+ class Transport
11
+ TRACKING_ID_KEY = "x-tracking-id"
12
+
13
+ attr_reader :config
14
+
15
+ def initialize(config_snapshot)
16
+ @config = config_snapshot
17
+ @channel = nil
18
+ @stubs = {}
19
+ end
20
+
21
+ # Returns a stub for the given stub class (e.g. UsersService::Stub).
22
+ # Stubs are cached per class. Channel uses TLS for default endpoint.
23
+ def stub(stub_class)
24
+ @stubs[stub_class] ||= stub_class.new(
25
+ config.endpoint,
26
+ channel_credentials,
27
+ channel_args: {}
28
+ )
29
+ end
30
+
31
+ # Performs a unary RPC: request metadata, deadline, call stub, map GRPC::BadStatus → Errors::GrpcError.
32
+ # @param stub_instance [Object] gRPC stub (from #stub)
33
+ # @param method_name [Symbol] e.g. :get_accounts
34
+ # @param request [Object] protobuf request message
35
+ # @param timeout [Integer, nil] optional override in seconds
36
+ # @return [Object] protobuf response message
37
+ def unary(stub_instance, method_name, request, timeout: nil)
38
+ opts = request_options(timeout)
39
+ stub_instance.public_send(method_name, request, opts)
40
+ rescue GRPC::BadStatus => e
41
+ raise map_bad_status(e)
42
+ end
43
+
44
+ # Bidirectional stream: sends request_enum, returns response Enumerator.
45
+ # Maps GRPC::BadStatus when iterating responses; extracts x-tracking-id from metadata.
46
+ # @param stub_instance [Object] gRPC stub (from #stub)
47
+ # @param method_name [Symbol] e.g. :market_data_stream
48
+ # @param request_enum [Enumerator] yields protobuf request messages
49
+ # @param timeout [Integer, nil] optional override in seconds
50
+ # @return [Enumerator] yields protobuf response messages; raises Errors::GrpcError on stream error
51
+ def bidi_stream(stub_instance, method_name, request_enum, timeout: nil)
52
+ opts = request_options(timeout)
53
+ response_enum = stub_instance.public_send(method_name, request_enum, opts)
54
+ wrap_response_enum(response_enum)
55
+ end
56
+
57
+ def close
58
+ @channel&.close
59
+ @channel = nil
60
+ @stubs.clear
61
+ end
62
+
63
+ private
64
+
65
+ def channel_credentials
66
+ return @channel_credentials if defined?(@channel_credentials) && @channel_credentials
67
+
68
+ certs_path = config.ssl_ca_certs.to_s.strip
69
+ if certs_path.empty?
70
+ @channel_credentials = GRPC::Core::ChannelCredentials.new
71
+ return @channel_credentials
72
+ end
73
+
74
+ pem = read_ssl_ca_certs(certs_path)
75
+ @channel_credentials = GRPC::Core::ChannelCredentials.new(pem)
76
+ end
77
+
78
+ def read_ssl_ca_certs(path)
79
+ pem = File.read(path)
80
+ if pem.to_s.strip.empty?
81
+ raise Errors::ConfigurationError, "SSL CA certs file is empty: #{path}"
82
+ end
83
+ pem
84
+ rescue Errno::ENOENT, Errno::EACCES => e
85
+ raise Errors::ConfigurationError, "SSL CA certs file is not readable: #{path} (#{e.class})"
86
+ end
87
+
88
+ def request_options(timeout)
89
+ deadline = Time.now + (timeout || config.timeout)
90
+ {
91
+ metadata: request_metadata,
92
+ deadline: deadline
93
+ }
94
+ end
95
+
96
+ def request_metadata
97
+ meta = { "authorization" => "Bearer #{config.token}" }
98
+ meta["x-app-name"] = config.app_name unless config.app_name.to_s.empty?
99
+ meta
100
+ end
101
+
102
+ def map_bad_status(err)
103
+ tracking_id = extract_tracking_id(err)
104
+ Errors::GrpcError.new(
105
+ err.message.to_s,
106
+ code: err.code,
107
+ details: err.details.to_s,
108
+ tracking_id: tracking_id
109
+ )
110
+ end
111
+
112
+ def extract_tracking_id(bad_status)
113
+ return nil unless bad_status.respond_to?(:metadata) && bad_status.metadata
114
+
115
+ meta = normalized_metadata_hash(bad_status.metadata)
116
+ return nil unless meta.is_a?(Hash)
117
+
118
+ meta[TRACKING_ID_KEY] || meta[TRACKING_ID_KEY.tr("-", "_")] || meta[:x_tracking_id]
119
+ end
120
+
121
+ def normalized_metadata_hash(raw)
122
+ return nil unless raw.is_a?(Hash)
123
+
124
+ raw.key?(:metadata) ? raw[:metadata] : raw
125
+ end
126
+
127
+ def wrap_response_enum(response_enum)
128
+ Enumerator.new do |y|
129
+ response_enum.each { |msg| y.yield msg }
130
+ rescue GRPC::BadStatus => e
131
+ raise map_bad_status(e)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -3,7 +3,7 @@
3
3
  module T
4
4
  module Tech
5
5
  module Investments
6
- VERSION = "0.1.0"
6
+ VERSION = "0.1.1"
7
7
  end
8
8
  end
9
9
  end
@@ -6,7 +6,36 @@ module T
6
6
  module Tech
7
7
  module Investments
8
8
  class Error < StandardError; end
9
- # Your code goes here...
9
+ end
10
+ end
11
+ end
12
+
13
+ require_relative "investments/errors"
14
+ require_relative "investments/configuration"
15
+ require_relative "investments/transport"
16
+ require_relative "investments/coercers"
17
+ require_relative "investments/proto_loader"
18
+ require_relative "investments/services"
19
+ require_relative "investments/client"
20
+
21
+ module T
22
+ module Tech
23
+ module Investments
24
+ class << self
25
+ attr_accessor :configuration
26
+ end
27
+
28
+ self.configuration = Configuration.from_env
29
+
30
+ def self.configure
31
+ yield configuration if block_given?
32
+ configuration
33
+ end
34
+
35
+ def self.client(overrides = {})
36
+ config = configuration.merge(overrides)
37
+ Client.new(config.snapshot)
38
+ end
10
39
  end
11
40
  end
12
41
  end
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerates Ruby protobuf and gRPC code from invest-contracts.
5
+ # Reads contracts.lock for repository and ref, then runs grpc_tools_ruby_protoc.
6
+ # Run from project root: bundle exec ruby script/regenerate_proto
7
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
8
+
9
+ require "english"
10
+ require "json"
11
+ require "fileutils"
12
+ require "open3"
13
+
14
+ PROJECT_ROOT = File.expand_path("..", __dir__)
15
+ CONTRACTS_LOCK = File.join(PROJECT_ROOT, "contracts.lock")
16
+ OUTPUT_DIR = File.join(PROJECT_ROOT, "lib", "t", "tech", "investments", "proto")
17
+ TMP_REPO = File.join(PROJECT_ROOT, "tmp", "investAPI")
18
+
19
+ def load_lock
20
+ data = JSON.parse(File.read(CONTRACTS_LOCK))
21
+ [data["repository"], data["ref"], File.join(data["contracts_path"] || "src/docs/contracts", "")]
22
+ end
23
+
24
+ def clone_or_fetch(repo_url, ref)
25
+ FileUtils.mkdir_p(File.dirname(TMP_REPO))
26
+ if Dir.exist?(TMP_REPO)
27
+ system("git", "-C", TMP_REPO, "fetch", "origin", "tag", ref, "--no-tags", out: File::NULL, err: File::NULL) ||
28
+ system("git", "-C", TMP_REPO, "fetch", "origin", ref, out: File::NULL, err: File::NULL)
29
+ else
30
+ system("git", "clone", "--depth", "1", "--no-single-branch", repo_url, TMP_REPO,
31
+ out: File::NULL, err: File::NULL) ||
32
+ system("git", "clone", repo_url, TMP_REPO, out: File::NULL, err: File::NULL)
33
+ return false unless $CHILD_STATUS.success?
34
+
35
+ system("git", "-C", TMP_REPO, "fetch", "origin", ref,
36
+ out: File::NULL, err: File::NULL)
37
+ end
38
+ system("git", "-C", TMP_REPO, "checkout", ref, out: File::NULL, err: File::NULL)
39
+ end
40
+
41
+ def proto_include_path
42
+ grpc_tools = Gem::Specification.find_by_name("grpc-tools")
43
+ base = grpc_tools.full_gem_path
44
+ %w[include src].each do |sub|
45
+ path = File.join(base, sub)
46
+ ts = File.join(path, "google", "protobuf", "timestamp.proto")
47
+ return path if File.file?(ts)
48
+ end
49
+ nil
50
+ rescue Gem::MissingSpecError
51
+ nil
52
+ end
53
+
54
+ def run_protoc(contracts_dir, extra_include)
55
+ FileUtils.rm_rf(OUTPUT_DIR)
56
+ FileUtils.mkdir_p(OUTPUT_DIR)
57
+
58
+ proto_files = Dir[File.join(contracts_dir, "**", "*.proto")].sort
59
+ proto_files.reject! { |f| f.include?("google/protobuf/") } # skip if any vendored
60
+ if proto_files.empty?
61
+ # Flat list in contracts_path only
62
+ contracts_path = File.dirname(contracts_dir.chomp("/"))
63
+ proto_files = Dir[File.join(contracts_path, "*.proto")].sort
64
+ proto_files += Dir[File.join(contracts_path, "google", "api", "*.proto")].sort
65
+ contracts_dir = contracts_path
66
+ end
67
+
68
+ args = ["-I", contracts_dir]
69
+ args += ["-I", extra_include] if extra_include
70
+ args += ["--ruby_out", OUTPUT_DIR, "--grpc_out", OUTPUT_DIR]
71
+ args += proto_files
72
+
73
+ stdout, stderr, status = Open3.capture3("bundle", "exec", "grpc_tools_ruby_protoc", *args, chdir: PROJECT_ROOT)
74
+ unless status.success?
75
+ warn stderr
76
+ warn stdout
77
+ return false
78
+ end
79
+ true
80
+ end
81
+
82
+ def main
83
+ Dir.chdir(PROJECT_ROOT)
84
+
85
+ unless File.file?(CONTRACTS_LOCK)
86
+ warn "contracts.lock not found at #{CONTRACTS_LOCK}"
87
+ exit 1
88
+ end
89
+
90
+ repo_url, ref, contracts_subpath = load_lock
91
+ contracts_dir = File.join(TMP_REPO, contracts_subpath.to_s.chomp("/"))
92
+ contracts_dir = File.join(TMP_REPO, "src/docs/contracts") unless Dir.exist?(contracts_dir)
93
+
94
+ puts "Repository: #{repo_url} @ #{ref}"
95
+ puts "Contracts: #{contracts_dir}"
96
+
97
+ unless clone_or_fetch(repo_url, ref)
98
+ warn "Failed to clone/fetch repository"
99
+ exit 1
100
+ end
101
+
102
+ unless Dir.exist?(contracts_dir)
103
+ warn "Contracts directory not found: #{contracts_dir}"
104
+ exit 1
105
+ end
106
+
107
+ extra_include = proto_include_path
108
+ puts "Output: #{OUTPUT_DIR}"
109
+
110
+ unless run_protoc(contracts_dir, extra_include)
111
+ warn "protoc failed"
112
+ exit 1
113
+ end
114
+
115
+ puts "Done. Generated files in #{OUTPUT_DIR}"
116
+ end
117
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
118
+
119
+ main