inquirex 0.5.0 → 0.6.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +119 -0
  4. data/docs/badges/coverage_badge.svg +2 -2
  5. data/examples/03_send_email_actions.rb +97 -0
  6. data/lib/inquirex/accumulator.rb +29 -0
  7. data/lib/inquirex/actions/action.rb +68 -0
  8. data/lib/inquirex/actions/base.rb +41 -0
  9. data/lib/inquirex/actions/custom.rb +31 -0
  10. data/lib/inquirex/actions/outbox.rb +57 -0
  11. data/lib/inquirex/actions/runner.rb +52 -0
  12. data/lib/inquirex/actions/send_email.rb +174 -0
  13. data/lib/inquirex/actions/template.rb +95 -0
  14. data/lib/inquirex/actions/webhook.rb +139 -0
  15. data/lib/inquirex/actions.rb +57 -0
  16. data/lib/inquirex/answers.rb +13 -0
  17. data/lib/inquirex/completion_metadata.rb +82 -0
  18. data/lib/inquirex/definition.rb +67 -5
  19. data/lib/inquirex/dsl/action_builder.rb +53 -0
  20. data/lib/inquirex/dsl/flow_builder.rb +49 -6
  21. data/lib/inquirex/engine/state_serializer.rb +6 -4
  22. data/lib/inquirex/engine.rb +97 -5
  23. data/lib/inquirex/errors.rb +5 -0
  24. data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
  25. data/lib/inquirex/node.rb +2 -0
  26. data/lib/inquirex/rules/all.rb +12 -0
  27. data/lib/inquirex/rules/any.rb +11 -0
  28. data/lib/inquirex/rules/base.rb +6 -0
  29. data/lib/inquirex/rules/contains.rb +12 -0
  30. data/lib/inquirex/rules/equals.rb +12 -0
  31. data/lib/inquirex/rules/greater_than.rb +12 -0
  32. data/lib/inquirex/rules/less_than.rb +12 -0
  33. data/lib/inquirex/rules/not_empty.rb +12 -0
  34. data/lib/inquirex/validation/adapter.rb +1 -0
  35. data/lib/inquirex/validation/null_adapter.rb +5 -0
  36. data/lib/inquirex/version.rb +1 -1
  37. data/lib/inquirex/widget_registry.rb +1 -0
  38. data/lib/inquirex.rb +13 -0
  39. metadata +30 -4
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Declarative email effect. Builds a Mail::Message (the object ActionMailer
6
+ # itself wraps) from {{field}} templates and appends it to the outbox.
7
+ # Nothing is delivered — the host application sends the messages.
8
+ #
9
+ # Scalar fields (to, from, cc, bcc, reply_to, subject) and the text body
10
+ # render values verbatim; the html body HTML-escapes every interpolated
11
+ # value automatically. Both bodies given => multipart/alternative.
12
+ #
13
+ # Bodies accept an inline template String or { file: "path" }, which is
14
+ # read once at definition time and inlined — a definition rehydrated from
15
+ # JSON never touches the filesystem.
16
+ #
17
+ # Images: reference external URLs in the html body. Attachments are
18
+ # deliberately unsupported.
19
+ #
20
+ # The mail gem is a soft dependency, required only when a message is
21
+ # actually built. Rails hosts always have it (ActionMailer depends on it).
22
+ class SendEmail < Base
23
+ # Scalar header fields rendered verbatim via Template.render_text in #to_mail and #to_h.
24
+ SCALAR_FIELDS = %i[to from cc bcc reply_to subject].freeze
25
+
26
+ # @return [String] required recipient / subject templates ({{field}} placeholders allowed)
27
+ attr_reader :to, :subject
28
+
29
+ # @return [String, nil] optional address templates ({{field}} placeholders allowed)
30
+ attr_reader :from, :cc, :bcc, :reply_to
31
+
32
+ # @return [String, nil] body template, inlined at definition time when { file: } was given
33
+ attr_reader :text, :html
34
+
35
+ # @return [Hash{String => String}] extra headers (values support {{field}})
36
+ attr_reader :headers
37
+
38
+ # @param to [String] recipient template (required)
39
+ # @param subject [String] subject template (required)
40
+ # @param text [String, Hash, nil] plain-text body template or { file: }
41
+ # @param html [String, Hash, nil] HTML body template or { file: }
42
+ # @param headers [Hash] extra headers (values support {{field}})
43
+ # @raise [Errors::DefinitionError] when required fields are missing
44
+ def initialize(to:, subject:, from: nil, cc: nil, bcc: nil, reply_to: nil,
45
+ text: nil, html: nil, headers: {})
46
+ super()
47
+ @to = to
48
+ @from = from
49
+ @cc = cc
50
+ @bcc = bcc
51
+ @reply_to = reply_to
52
+ @subject = subject
53
+ @text = resolve_body(text)
54
+ @html = resolve_body(html)
55
+ @headers = headers.transform_keys(&:to_s).freeze
56
+ validate!
57
+ freeze
58
+ end
59
+
60
+ # Builds the message and appends it to the outbox.
61
+ #
62
+ # @param answers [Answers] completed answers
63
+ # @param outbox [Outbox] receives the built Mail::Message
64
+ # @return [void]
65
+ def call(answers, outbox)
66
+ outbox.add_message(to_mail(answers))
67
+ end
68
+
69
+ # Builds a Mail::Message from the templates and the given answers.
70
+ # Pure function — safe to call from a background job to rebuild
71
+ # messages from persisted answers.
72
+ #
73
+ # @param answers [Answers]
74
+ # @return [Mail::Message]
75
+ def to_mail(answers)
76
+ require_mail!
77
+ mail = ::Mail.new
78
+ SCALAR_FIELDS.each do |field|
79
+ value = public_send(field)
80
+ mail.public_send(:"#{field}=", Template.render_text(value, answers)) if value
81
+ end
82
+ @headers.each { |name, value| mail.header[name] = Template.render_text(value.to_s, answers) }
83
+ attach_bodies(mail, answers)
84
+ mail
85
+ end
86
+
87
+ # @return [Hash] wire format, same shape .from_h accepts
88
+ def to_h
89
+ hash = { "type" => "send_email" }
90
+ SCALAR_FIELDS.each do |field|
91
+ value = public_send(field)
92
+ hash[field.to_s] = value if value
93
+ end
94
+ hash["text"] = @text if @text
95
+ hash["html"] = @html if @html
96
+ hash["headers"] = @headers unless @headers.empty?
97
+ hash
98
+ end
99
+
100
+ # @param hash [Hash] string or symbol keys
101
+ # @return [SendEmail]
102
+ def self.from_h(hash)
103
+ fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
104
+ new(
105
+ to: fetch.call(:to),
106
+ from: fetch.call(:from),
107
+ cc: fetch.call(:cc),
108
+ bcc: fetch.call(:bcc),
109
+ reply_to: fetch.call(:reply_to),
110
+ subject: fetch.call(:subject),
111
+ text: fetch.call(:text),
112
+ html: fetch.call(:html),
113
+ headers: fetch.call(:headers) || {}
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def attach_bodies(mail, answers)
120
+ text = @text && Template.render_text(@text, answers)
121
+ html = @html && Template.render_html(@html, answers)
122
+ if text && html
123
+ mail.text_part = build_part("text/plain; charset=UTF-8", text)
124
+ mail.html_part = build_part("text/html; charset=UTF-8", html)
125
+ elsif html
126
+ mail.content_type = "text/html; charset=UTF-8"
127
+ mail.body = html
128
+ else
129
+ mail.body = text
130
+ end
131
+ end
132
+
133
+ def build_part(content_type, body)
134
+ part = ::Mail::Part.new
135
+ part.content_type = content_type
136
+ part.body = body
137
+ part
138
+ end
139
+
140
+ # Inline template string, or { file: "path" } read once at definition time.
141
+ def resolve_body(value)
142
+ return value if value.nil? || value.is_a?(String)
143
+
144
+ path = value.is_a?(Hash) && (value[:file] || value["file"])
145
+ return File.read(File.expand_path(path)) if path.is_a?(String)
146
+
147
+ raise Errors::DefinitionError,
148
+ "send_email body must be a template String or { file: \"path\" }, got #{value.inspect}"
149
+ end
150
+
151
+ def validate!
152
+ raise Errors::DefinitionError, "send_email requires to:" if blank?(@to)
153
+ raise Errors::DefinitionError, "send_email requires subject:" if blank?(@subject)
154
+ return unless @text.nil? && @html.nil?
155
+
156
+ raise Errors::DefinitionError, "send_email requires a text: or html: body"
157
+ end
158
+
159
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
160
+
161
+ def require_mail!
162
+ return if defined?(::Mail)
163
+
164
+ require "mail"
165
+ rescue LoadError
166
+ raise Errors::ActionError,
167
+ "send_email requires the mail gem — add `gem \"mail\"` to your Gemfile " \
168
+ "(Rails applications already have it via ActionMailer)"
169
+ end
170
+ end
171
+
172
+ register(:send_email, SendEmail)
173
+ end
174
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Inquirex
6
+ module Actions
7
+ # Renders {{field}} placeholders against collected answers.
8
+ #
9
+ # Placeholders are dot-notation keys resolved via Answers#to_flat_h
10
+ # ("{{email}}", "{{business.count}}"). The syntax is deliberately inert —
11
+ # no code execution — so flow definitions stored in a database can be
12
+ # rendered server-side safely and a visual editor can preview templates
13
+ # client-side. It matches the {{field}} placeholder convention used by
14
+ # inquirex-llm prompts.
15
+ #
16
+ # Rendering modes:
17
+ # - render_text — values inserted verbatim
18
+ # - render_html — every interpolated value is HTML-escaped
19
+ #
20
+ # The built-in {{answers_summary}} placeholder expands to all collected
21
+ # answers: "key: value" lines in text mode, a simple table in HTML mode.
22
+ # Unknown fields render as empty strings; array values join with ", ".
23
+ module Template
24
+ # Matches one {{field}} placeholder; capture 1 is the dot-notation key.
25
+ PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/
26
+ # Reserved placeholder key that expands to a summary of all collected answers.
27
+ SUMMARY_KEY = "answers_summary"
28
+
29
+ module_function
30
+
31
+ # @param string [String] template with {{field}} placeholders
32
+ # @param answers [Answers]
33
+ # @return [String]
34
+ def render_text(string, answers)
35
+ render(string, answers, html: false)
36
+ end
37
+
38
+ # @example Interpolated values are HTML-escaped
39
+ # answers = Inquirex::Answers.new(name: "Bob & Sons")
40
+ # Inquirex::Actions::Template.render_html("<p>{{name}}</p>", answers)
41
+ # # => "<p>Bob &amp; Sons</p>"
42
+ #
43
+ # @param string [String] template with {{field}} placeholders
44
+ # @param answers [Answers]
45
+ # @return [String]
46
+ def render_html(string, answers)
47
+ render(string, answers, html: true)
48
+ end
49
+
50
+ # Shared implementation behind render_text and render_html.
51
+ #
52
+ # @param string [String] template with {{field}} placeholders
53
+ # @param answers [Answers]
54
+ # @param html [Boolean] whether to HTML-escape interpolated values
55
+ # @return [String]
56
+ def render(string, answers, html:)
57
+ flat = answers.to_flat_h
58
+ string.gsub(PLACEHOLDER) do
59
+ key = Regexp.last_match(1)
60
+ next(html ? summary_html(flat) : summary_text(flat)) if key == SUMMARY_KEY
61
+
62
+ value = format_value(flat[key])
63
+ html ? CGI.escapeHTML(value) : value
64
+ end
65
+ end
66
+
67
+ # Formats a single answer value for interpolation.
68
+ #
69
+ # @param value [Object] raw answer value (nil renders as "")
70
+ # @return [String] arrays joined with ", ", everything else via #to_s
71
+ def format_value(value)
72
+ value.is_a?(Array) ? value.join(", ") : value.to_s
73
+ end
74
+
75
+ # Expands {{answers_summary}} in text mode.
76
+ #
77
+ # @param flat [Hash{String => Object}] flat answers, dot-notation keys
78
+ # @return [String] one "key: value" line per answer
79
+ def summary_text(flat)
80
+ flat.map { |key, value| "#{key}: #{format_value(value)}" }.join("\n")
81
+ end
82
+
83
+ # Expands {{answers_summary}} in HTML mode.
84
+ #
85
+ # @param flat [Hash{String => Object}] flat answers, dot-notation keys
86
+ # @return [String] a simple HTML table with one row per answer, fully escaped
87
+ def summary_html(flat)
88
+ rows = flat.map do |key, value|
89
+ "<tr><th>#{CGI.escapeHTML(key)}</th><td>#{CGI.escapeHTML(format_value(value))}</td></tr>"
90
+ end
91
+ %(<table class="inquirex-answers">#{rows.join}</table>)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Inquirex
6
+ module Actions
7
+ # Declarative webhook effect: POSTs the completed answers as a JSON
8
+ # envelope ({"answers" => {...}}) to a statically-declared URL.
9
+ #
10
+ # Security posture — the URL must be auditable and its host covered by the
11
+ # definition's allowed_domains declaration:
12
+ #
13
+ # - the URL is a literal string; {{field}} templates are rejected so the
14
+ # destination host can never depend on user input
15
+ # - https only; plain http is permitted solely for localhost development
16
+ # - userinfo (https://user@host/) is rejected
17
+ # - redirects are not followed (Net::HTTP does not follow them)
18
+ # - the host check runs in Definition#validate!, which every definition
19
+ # passes through — including JSON-rehydrated ones, so a tampered URL
20
+ # fails at load time, before anything executes
21
+ #
22
+ # A non-2xx response raises Errors::ActionError, which the Runner records
23
+ # as a :failed result without blocking other actions.
24
+ class Webhook < Base
25
+ # Default open/read timeout in seconds for the webhook POST.
26
+ DEFAULT_TIMEOUT = 10
27
+ # Hosts for which plain http is tolerated (local development only).
28
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
29
+
30
+ attr_reader :url, :headers, :timeout
31
+
32
+ # @param url [String] literal https URL (no {{field}} templates)
33
+ # @param headers [Hash] extra request headers (literal values)
34
+ # @param timeout [Integer] open/read timeout in seconds
35
+ def initialize(url:, headers: {}, timeout: DEFAULT_TIMEOUT)
36
+ super()
37
+ @url = url.to_s
38
+ @headers = headers.transform_keys(&:to_s).transform_values(&:to_s).freeze
39
+ @timeout = timeout.to_i
40
+ @uri = parse_and_check!(@url)
41
+ freeze
42
+ end
43
+
44
+ # @return [String] lowercase host of the webhook URL
45
+ def host = @uri.host.downcase
46
+
47
+ # POSTs the answers envelope to the declared URL.
48
+ #
49
+ # @param answers [Answers] completed answers
50
+ # @param _outbox [Outbox] unused — webhooks build no messages
51
+ # @return [Net::HTTPResponse] the 2xx response
52
+ # @raise [Errors::ActionError] when the endpoint responds non-2xx
53
+ def call(answers, _outbox)
54
+ require "net/http"
55
+ response = post(answers)
56
+ code = response.code.to_i
57
+ return response if (200..299).cover?(code)
58
+
59
+ raise Errors::ActionError, "webhook #{host} responded with HTTP #{response.code}"
60
+ end
61
+
62
+ # Enforced from Definition#validate!.
63
+ #
64
+ # @param definition [Definition] owning definition, source of allowed_domains
65
+ # @return [void]
66
+ # @raise [Errors::DefinitionError] when the host is not allowlisted
67
+ def validate_against(definition)
68
+ return if definition.allowed_host?(host)
69
+
70
+ raise Errors::DefinitionError,
71
+ "webhook url host #{host.inspect} is not covered by allowed_domains " \
72
+ "#{definition.allowed_domains.inspect} — declare it at the top of the definition"
73
+ end
74
+
75
+ # @return [Hash] wire format, same shape .from_h accepts
76
+ def to_h
77
+ hash = { "type" => "webhook", "url" => @url }
78
+ hash["headers"] = @headers unless @headers.empty?
79
+ hash["timeout"] = @timeout unless @timeout == DEFAULT_TIMEOUT
80
+ hash
81
+ end
82
+
83
+ # @param hash [Hash] string or symbol keys
84
+ # @return [Webhook]
85
+ def self.from_h(hash)
86
+ fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
87
+ new(
88
+ url: fetch.call(:url),
89
+ headers: fetch.call(:headers) || {},
90
+ timeout: fetch.call(:timeout) || DEFAULT_TIMEOUT
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ def post(answers)
97
+ request = Net::HTTP::Post.new(@uri)
98
+ request["Content-Type"] = "application/json"
99
+ @headers.each { |name, value| request[name] = value }
100
+ request.body = JSON.generate("answers" => answers.to_h)
101
+
102
+ Net::HTTP.start(
103
+ @uri.host,
104
+ @uri.port,
105
+ use_ssl: @uri.scheme == "https",
106
+ open_timeout: @timeout,
107
+ read_timeout: @timeout
108
+ ) { |http| http.request(request) }
109
+ end
110
+
111
+ def parse_and_check!(url)
112
+ if url.include?("{{")
113
+ raise Errors::DefinitionError,
114
+ "webhook url does not support {{field}} templates — the destination must be static"
115
+ end
116
+
117
+ uri = parse(url)
118
+ raise Errors::DefinitionError, "webhook url must be http(s): #{url.inspect}" unless uri.is_a?(URI::HTTP)
119
+ raise Errors::DefinitionError, "webhook url must include a host: #{url.inspect}" if uri.host.to_s.empty?
120
+ raise Errors::DefinitionError, "webhook url must not include userinfo: #{url.inspect}" if uri.userinfo
121
+
122
+ if uri.scheme == "http" && !LOCAL_HOSTS.include?(uri.host.downcase)
123
+ raise Errors::DefinitionError,
124
+ "webhook url must use https (plain http is allowed only for localhost): #{url.inspect}"
125
+ end
126
+
127
+ uri
128
+ end
129
+
130
+ def parse(url)
131
+ URI.parse(url)
132
+ rescue URI::InvalidURIError => e
133
+ raise Errors::DefinitionError, "webhook url is not a valid URL: #{e.message}"
134
+ end
135
+ end
136
+
137
+ register(:webhook, Webhook)
138
+ end
139
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Post-completion actions: named side-effect declarations that run
5
+ # server-side after a flow finishes, with access to the collected answers.
6
+ #
7
+ # The DSL word `action` groups one or more *effects* (send_email, run, ...).
8
+ # Effects are looked up in a registry keyed by their DSL verb, so new effect
9
+ # types — a webhook, a save_record in inquirex-rails — plug in without core
10
+ # changes: register the class and it gains both the DSL word and JSON wire
11
+ # support.
12
+ #
13
+ # Inquirex::Actions.register(:webhook, MyGem::WebhookEffect)
14
+ #
15
+ # Actions never deliver anything themselves. send_email builds Mail::Message
16
+ # objects into Answers#outbox; the host application decides how to send them.
17
+ module Actions
18
+ @registry = {}
19
+
20
+ class << self
21
+ # Registers an effect class under a DSL verb name.
22
+ #
23
+ # @param type [Symbol] DSL verb (e.g. :send_email)
24
+ # @param klass [Class] an Actions::Base subclass
25
+ def register(type, klass)
26
+ @registry[type.to_sym] = klass
27
+ end
28
+
29
+ # @param type [Symbol, String]
30
+ # @return [Boolean]
31
+ def registered?(type)
32
+ @registry.key?(type.to_sym)
33
+ end
34
+
35
+ # @param type [Symbol, String]
36
+ # @return [Class]
37
+ # @raise [Errors::SerializationError] for unknown effect types
38
+ def lookup(type)
39
+ @registry.fetch(type.to_sym) do
40
+ raise Errors::SerializationError, "Unknown action effect type: #{type.inspect}"
41
+ end
42
+ end
43
+
44
+ # @return [Array<Symbol>] registered effect verbs
45
+ def types = @registry.keys
46
+
47
+ # Runs all of the definition's actions against the given answers.
48
+ #
49
+ # @param definition [Definition]
50
+ # @param answers [Answers, Hash]
51
+ # @return [Answers] with #outbox populated
52
+ def run(definition, answers)
53
+ Runner.new(definition).call(answers)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -84,10 +84,22 @@ module Inquirex
84
84
  end
85
85
 
86
86
  # Number of top-level answer keys.
87
+ #
88
+ # @return [Integer]
87
89
  def size
88
90
  @data.size
89
91
  end
90
92
 
93
+ # Mail::Message objects (and result trail) built by post-completion
94
+ # actions. Deliberately excluded from #to_h, #to_flat_h, #to_json and
95
+ # #== — the outbox rides alongside the answer data, never inside it.
96
+ # Delivery is the host application's responsibility.
97
+ #
98
+ # @return [Actions::Outbox]
99
+ def outbox
100
+ @outbox ||= Actions::Outbox.new
101
+ end
102
+
91
103
  # Merge another hash or Answers into this one (returns new Answers instance).
92
104
  #
93
105
  # @param other [Hash, Answers]
@@ -97,6 +109,7 @@ module Inquirex
97
109
  Answers.new(@data.merge(other_data))
98
110
  end
99
111
 
112
+ # @return [String] debug representation including the underlying hash
100
113
  def inspect
101
114
  "#<Inquirex::Answers #{@data.inspect}>"
102
115
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "json"
5
+
6
+ module Inquirex
7
+ # An OpenStruct describing how a flow reached completion: which rendering
8
+ # engine collected the answers, and any environment details that renderer
9
+ # chose to attach (host uname, user, IP addresses, terminal, ...).
10
+ #
11
+ # Only :engine and :engine_version are required members — enforced at
12
+ # construction. Everything else is free-form OpenStruct behavior: unset
13
+ # members read as nil, assignment creates members, nested OpenStructs
14
+ # (e.g. uname) are welcome.
15
+ #
16
+ # @example
17
+ # meta = Inquirex::CompletionMetadata.new(
18
+ # engine: "inquirex-tty", engine_version: "0.5.0",
19
+ # uname: OpenStruct.new(Etc.uname)
20
+ # )
21
+ # meta.engine # => "inquirex-tty"
22
+ # meta.uname.machine # => "arm64"
23
+ # meta.hostname # => nil (unset members read as nil)
24
+ # meta.to_h # => plain nested Hash, JSON-ready
25
+ class CompletionMetadata < OpenStruct
26
+ # Members that must be provided at construction; everything else is optional.
27
+ REQUIRED_MEMBERS = %i[engine engine_version].freeze
28
+
29
+ # Exists solely to make :engine and :engine_version required keywords —
30
+ # OpenStruct itself would accept anything.
31
+ #
32
+ # @param engine [String] rendering front-end name (e.g. "inquirex-tty")
33
+ # @param engine_version [String] rendering front-end version
34
+ # @param extra [Hash] any additional, optional members
35
+ def initialize(engine:, engine_version:, **extra) # rubocop:disable Lint/UselessMethodDefinition
36
+ super
37
+ end
38
+
39
+ # Rebuilds an instance from a hash (e.g. persisted engine state after a
40
+ # JSON round-trip). Keys may be strings or symbols; nested hashes come
41
+ # back as OpenStructs so dot-access survives the round-trip.
42
+ #
43
+ # @param hash [Hash, nil]
44
+ # @return [CompletionMetadata, nil] nil when hash is nil or empty
45
+ # @raise [ArgumentError] when :engine or :engine_version is missing
46
+ def self.from_h(hash)
47
+ return nil if hash.nil? || hash.empty?
48
+
49
+ members = hash.to_h { |k, v| [k.to_sym, v.is_a?(Hash) ? OpenStruct.new(v) : v] }
50
+ new(**members)
51
+ end
52
+
53
+ # OpenStruct#to_h is shallow; deep-convert nested OpenStructs (uname et
54
+ # al.) so state serialization and JSON output stay plain data.
55
+ #
56
+ # @return [Hash]
57
+ def to_h
58
+ deep_plain(super)
59
+ end
60
+
61
+ # @return [String] JSON representation
62
+ def to_json(*)
63
+ JSON.generate(to_h)
64
+ end
65
+
66
+ # @return [String] debug representation of the deep-plain member hash
67
+ def inspect
68
+ "#<Inquirex::CompletionMetadata #{to_h.inspect}>"
69
+ end
70
+
71
+ private
72
+
73
+ def deep_plain(value)
74
+ case value
75
+ when OpenStruct then deep_plain(value.to_h)
76
+ when Hash then value.transform_values { |v| deep_plain(v) }
77
+ when Array then value.map { |v| deep_plain(v) }
78
+ else value
79
+ end
80
+ end
81
+ end
82
+ end