aspera-cli 4.27.2 → 4.27.4

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 (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -5,417 +5,41 @@ require 'aspera/cli/extended_value'
5
5
  require 'aspera/cli/error'
6
6
  require 'aspera/cli/special_values'
7
7
  require 'aspera/cli/terminal_formatter'
8
- require 'aspera/colors'
9
- require 'aspera/secret_hider'
8
+ require 'aspera/cli/option_types'
9
+ require 'aspera/cli/option_registry'
10
+ require 'aspera/cli/command_line'
11
+ require 'aspera/cli/prompt'
12
+ require 'aspera/schema/validator'
10
13
  require 'aspera/log'
11
14
  require 'aspera/assert'
12
15
  require 'aspera/dot_container'
13
- require 'aspera/schema/registry'
14
- require 'io/console'
15
16
  require 'terminal-table'
17
+ require 'aspera/rainbow'
18
+ using Rainbow
16
19
 
17
20
  module Aspera
18
21
  module Cli
19
- # Exception raised when schema is asked (`help`)
20
- class SchemaRequest < Error
21
- # @return [String, nil] path to schema file
22
- attr_reader :path
23
-
24
- # @param type [Symbol] :argument or :option
25
- # @param name [String] name of the option/argument
26
- # @param schema_path [String, nil] path to schema file, or `nil` if not available
27
- def initialize(type, name, schema_path)
28
- super("#{type}: #{name}")
29
- @path = schema_path
30
- end
31
- end
32
-
33
- module BoolValue
34
- # boolean options are set to true/false from the following values
35
- YES_SYM = :yes
36
- NO_SYM = :no
37
- FALSE_VALUES = [NO_SYM, false].freeze
38
- TRUE_VALUES = [YES_SYM, true].freeze
39
- private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
40
- # Boolean values
41
- # @return [Array<true, false, :yes, :no>]
42
- ALL = (TRUE_VALUES + FALSE_VALUES).freeze
43
- # `false` and `true`
44
- TYPES = [FalseClass, TrueClass].freeze
45
- SYMBOLS = [NO_SYM, YES_SYM].freeze
46
- # @return [Boolean] `true` if value is a value for `true` in ALL
47
- def true?(enum)
48
- Aspera.assert_values(enum, ALL) { 'boolean' }
49
- TRUE_VALUES.include?(enum)
50
- end
51
-
52
- # @return [:yes, :no]
53
- def to_sym(enum) = true?(enum) ? YES_SYM : NO_SYM
54
-
55
- # @return [Boolean] `true` if value is a value for `true` or `false` in ALL
56
- def symbol?(sym)
57
- ALL.include?(sym)
58
- end
59
- module_function :true?, :to_sym, :symbol?
60
- end
61
-
62
- # Type specifiers for the `allowed:` parameter of option declarations.
63
- # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
64
- # Internal (do not pass as `allowed:`):
65
- # ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
66
- # STRING - the implicit default; equivalent to omitting `allowed:` entirely
67
- module Type
68
- # Option value is a String or Array of Strings (cumulative)
69
- STRING_ARRAY = [Array, String].freeze
70
- # Option value is a Symbol from a constrained list; use as prefix: SYMBOL_ARRAY + [:val1, :val2]
71
- SYMBOL_ARRAY = [Array, Symbol].freeze
72
- # Option value is coerced to Integer
73
- INTEGER = [Integer].freeze
74
- # Option value is a Boolean
75
- BOOLEAN = BoolValue::TYPES
76
- # Option has no value — it is a flag switch (e.g. `-N`, `--help`)
77
- NONE = [].freeze
78
- # Internal: derived when allowed: is an Array<Symbol>; do not pass directly
79
- ENUM = [Symbol].freeze
80
- # Internal: implicit default (String); equivalent to omitting allowed: entirely
81
- STRING = [String].freeze
82
- end
83
-
84
- # Description of option, how to manage
85
- class OptionValue
86
- # [Array(Class)] List of allowed types
87
- attr_reader :types, :sensitive, :schema, :option, :deprecation
88
- # [Array] List of allowed values (Symbols and specific values)
89
- attr_accessor :values
90
- # [String] Help section group name (set by Parser#group)
91
- attr_accessor :group
92
- # [Proc, nil] Block to call for flag options (TYPES_NONE)
93
- attr_accessor :block
94
-
95
- # @param option [Symbol] Name of option
96
- # @param description [String, nil] Description for help; if nil, derived from schema
97
- # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
98
- # @param handler [Hash, nil] Accessor: keys: :o(object) and :m(method); nil for local storage
99
- # @param deprecation [String] Deprecation message
100
- # @param schema [String] Declaration of schema
101
- # `allowed`:
102
- # - `nil` No validation, so just a string
103
- # - `Class` The single allowed Class
104
- # - `Array<Class>` Multiple allowed classes
105
- # - `Array<Symbol>` List of allowed values
106
- def initialize(option:, description: nil, allowed: Type::STRING, handler: nil, deprecation: nil, schema: nil)
107
- Log.log.trace1 { "option: #{option}, allowed: #{allowed}" }
108
- @option = option
109
- @description = description
110
- @group = nil
111
- @block = nil
112
- # by default passwords and secrets are sensitive, else specify when declaring the option
113
- @sensitive = SecretHider.instance.secret?(@option, '')
114
- @deprecation = deprecation
115
- @schema = schema
116
- # Start with local storage; bind_handler wires the delegation if a handler is given.
117
- @object = nil
118
- @read_method = nil
119
- @write_method = nil
120
- @access = :local
121
- bind_handler(handler) unless handler.nil?
122
- @types = nil
123
- @values = nil
124
- allowed = infer_allowed_from_schema(schema, allowed) if schema
125
- apply_allowed(allowed) unless allowed.nil?
126
- Log.log.trace1 { "declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
127
- end
128
-
129
- private
130
-
131
- # Derive the `allowed:` value from the schema when not explicitly provided.
132
- # Returns `allowed` unchanged when the schema provides no usable type information.
133
- # @param schema [String] schema identifier
134
- # @param allowed [Object] caller-supplied allowed value (may be nil or Type::STRING)
135
- # @return [Object] resolved allowed (Hash, Array, or the original value)
136
- def infer_allowed_from_schema(schema, allowed)
137
- return allowed unless allowed.nil? || allowed.eql?(Type::STRING)
138
-
139
- schema_reader = Schema::Registry.instance.reader(schema) rescue nil
140
- schema_node = schema_reader&.current
141
- return allowed unless schema_node
142
-
143
- case schema_node['type']
144
- when 'object' then return Hash
145
- when 'array' then return Array
146
- end
147
- # No top-level type: inspect oneOf/anyOf branches; if all resolve to 'object', infer Hash
148
- composite_key = (%w[oneOf anyOf] & schema_node.keys).first
149
- return allowed unless composite_key
150
-
151
- branch_types = schema_node[composite_key].map do |branch|
152
- resolved = branch['$ref'] ? schema_reader.resolve_ref(branch['$ref']).current : branch
153
- resolved['type']
154
- end
155
- if branch_types.all?('object')
156
- Hash
157
- else
158
- Aspera.assert(
159
- !allowed.nil? && !allowed.eql?(Type::STRING),
160
- "option :#{@option}: schema '#{schema}' has mixed-type oneOf branches #{branch_types.uniq}: specify allowed: explicitly"
161
- )
162
- allowed
163
- end
164
- end
165
-
166
- # Initialise @types and @values from the resolved `allowed` specifier.
167
- # @param allowed [Class, Array<Class>, Array<Symbol>] resolved allowed value (never nil)
168
- def apply_allowed(allowed)
169
- allowed = [allowed] if allowed.is_a?(Class)
170
- Aspera.assert_type(allowed, Array)
171
- if allowed.take(Type::SYMBOL_ARRAY.length) == Type::SYMBOL_ARRAY
172
- # Special case: array of defined symbol values
173
- @types = Type::SYMBOL_ARRAY
174
- @values = allowed[Type::SYMBOL_ARRAY.length..]
175
- assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
176
- elsif allowed.all?(Class)
177
- @types = allowed
178
- @values = BoolValue::ALL if allowed.eql?(Type::BOOLEAN)
179
- if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
180
- assign_value([], where: 'array default', warn_deprecation: false)
181
- elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
182
- assign_value({}, where: 'hash default', warn_deprecation: false)
183
- end
184
- elsif allowed.all?(Symbol)
185
- @types = Type::ENUM
186
- @values = allowed
187
- else
188
- Aspera.error_unexpected_value(allowed)
189
- end
190
- end
191
-
192
- public
193
-
194
- # Wire (or re-wire) the getter/setter delegation for this option.
195
- # Safe to call after construction - used by Parser#set_handler to bind a composed
196
- # instance variable that did not exist at class-load time (Category C handlers).
197
- # @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
198
- # @return [nil]
199
- def bind_handler(handler)
200
- Aspera.assert_type(handler, Hash) { 'handler' }
201
- # Capture any value already stored locally before switching to delegated storage.
202
- # This transfers defaults (and any preset values already applied) to the new target.
203
- pending_value = @access.eql?(:local) ? @object : nil
204
- @object = handler[:o]
205
- @read_method = handler[:m]
206
- @write_method = "#{@read_method}=".to_sym
207
- @access = if @object.respond_to?(@write_method)
208
- :attr # delegation via attr_accessor-style m / m=
209
- else
210
- :custom # delegation via generic 3-arg method m(sym, :get/:set, val)
211
- end
212
- Aspera.assert(@object.respond_to?(@read_method)) { "#{@object} does not respond to #{@read_method}" }
213
- Log.log.trace1 { "bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
214
- # Push the pending local value to the new target if one was stored
215
- assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
216
- nil
217
- end
218
-
219
- # @return [String] description of the option: explicit one, or first line of schema description
220
- def description
221
- return @description unless @description.nil?
222
- return if @schema.nil?
223
- schema_node = Schema::Registry.instance.reader(@schema).current
224
- first_line = (schema_node['title'] || schema_node['description'].to_s).lines.first.to_s.strip
225
- first_line.end_with?('.') ? first_line[0..-2] : first_line
226
- end
227
-
228
- # Reset stored value to nil
229
- # @return [nil]
230
- def clear
231
- @object = nil
232
- end
233
-
234
- # Get current option value
235
- # @param log [Boolean] whether to log the value retrieval
236
- # @return [Object] current value
237
- def value(log: true)
238
- current_value =
239
- case @access
240
- when :local then @object
241
- when :attr then @object.send(@read_method)
242
- when :custom then @object.send(@read_method, @option, :get)
243
- end
244
- Log.log.trace1 { "#{@option} -> (#{current_value.class})#{current_value}" } if log
245
- current_value
246
- end
247
-
248
- # Assign value to option.
249
- # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
250
- # @param value [String, Object] Value to assign to option
251
- # @param where [String] Where the value is assigned from
252
- # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
253
- # @return [nil]
254
- def assign_value(value, where:, warn_deprecation: true)
255
- Aspera.assert(!@deprecation, type: :warn) { "Option #{@option} is deprecated: #{@deprecation}" } if warn_deprecation
256
- new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
257
- Log.log.trace1 { "#{where}: #{@option} <- (#{new_value.class})#{new_value}" }
258
- # Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
259
- # Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
260
- case @types
261
- when Type::ENUM
262
- new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
263
- when Type::BOOLEAN
264
- new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
265
- new_value = BoolValue.true?(new_value)
266
- when Type::INTEGER
267
- new_value = Integer(new_value)
268
- when Type::STRING_ARRAY
269
- new_value = [new_value] if new_value.is_a?(String)
270
- when Type::SYMBOL_ARRAY
271
- new_value = [new_value] if new_value.is_a?(String)
272
- Aspera.assert_array_all(new_value, String, type: BadArgument)
273
- new_value = new_value.map { |v| Parser.get_from_list(v, @option, @values) }
274
- else
275
- # nil (setting nil on a Hash/Array option resets to empty container)
276
- new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
277
- new_value = [] if new_value.nil? && @types&.first.eql?(Array)
278
- end
279
- # Skip type validation for the special 'help' value on Hash options: store it as-is
280
- # so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
281
- # Note: set_option already raises SchemaRequest when @schema is set, so this path is
282
- # only reached when @schema is nil (e.g. --query=help before schema is known).
283
- if new_value.eql?(Parser::HELP) && @types&.include?(Hash)
284
- store(new_value)
285
- return
286
- end
287
- Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
288
- if new_value.is_a?(Hash) || new_value.is_a?(Array)
289
- current_value = value(log: false)
290
- new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
291
- new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
292
- end
293
- store(new_value)
294
- Log.log.trace1 { v = value(log: false); "#{@option} <- (#{v.class})#{v}" } # rubocop:disable Style/Semicolon
295
- nil
296
- end
297
-
298
- private
299
-
300
- # Store value according to access mode
301
- # @param new_value [Object] value to store
302
- # @return [Object] stored value
303
- def store(new_value)
304
- case @access
305
- when :local then @object = new_value
306
- when :attr then @object.send(@write_method, new_value)
307
- when :custom then @object.send(@read_method, @option, :set, new_value)
308
- end
309
- end
310
- end
311
-
312
- # Represents a positional (non-option) CLI argument token.
313
- class Argument
314
- # @return [String] the raw argument value
315
- attr_reader :value
316
-
317
- def initialize(value)
318
- @value = value
319
- end
320
-
321
- def to_s = @value
322
- end
323
-
324
- # Represents a parsed CLI option token (long or short form).
325
- # Pre-computed at argv-scan time; resolution against @declared_options happens later in parse_options!
326
- class Option
327
- # @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
328
- attr_reader :raw
329
- # @return [String, nil] option name with underscores (e.g. "log_level", "custom"); nil for short options
330
- attr_reader :name
331
- # @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
332
- attr_reader :short_char
333
- # @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field),
334
- # nil when name is the full option name (no dot)
335
- attr_reader :dot_path
336
- # @return [String, nil] inline value string, or nil if no value was provided inline
337
- attr_reader :value
338
- # @return [Boolean] true if `=` (long) or glued value (short) was present in the raw token;
339
- # false means no inline separator — value may come from the next argv token
340
- attr_reader :has_value
341
-
342
- # @param raw [String] full raw token
343
- # @param name [String, nil] option name (underscored, no prefix)
344
- # @param short_char [String, nil] single-char short letter
345
- # @param dot_path [Array<String>, nil] dot-path sub-keys, or nil
346
- # @param value [String, nil] inline value
347
- # @param has_value [Boolean] whether an inline value separator was present
348
- def initialize(raw:, name:, short_char:, dot_path:, value:, has_value:)
349
- @raw = raw
350
- @name = name
351
- @short_char = short_char
352
- @dot_path = dot_path
353
- @value = value
354
- @has_value = has_value
355
- end
356
-
357
- def to_s = @raw
358
-
359
- class << self
360
- # Build an Option from a raw long-option token (starts with `--`)
361
- # @param raw [String] e.g. "--log-level=debug" or "--custom.field" or "--log-level"
362
- def from_long(raw)
363
- without_prefix = raw.delete_prefix(PREFIX)
364
- eq_idx = without_prefix.index(VALUE_SEP)
365
- if eq_idx
366
- name_raw = without_prefix[0, eq_idx]
367
- value = without_prefix[eq_idx + 1..]
368
- has_value = true
369
- else
370
- name_raw = without_prefix
371
- value = nil
372
- has_value = false
373
- end
374
- parts = name_raw.split(DotContainer::SEPARATOR)
375
- root = parts.shift.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL)
376
- dot_path = parts.empty? ? nil : parts
377
- new(raw: raw, name: root, short_char: nil, dot_path: dot_path, value: value, has_value: has_value)
378
- end
379
-
380
- # Build an Option from a raw short-option token (starts with `-` but not `--`)
381
- # @param raw [String] e.g. "-P", "-Pval", "-h"
382
- def from_short(raw)
383
- short_char = raw[1]
384
- if raw.length > 2
385
- new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: raw[2..], has_value: true)
386
- else
387
- new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: nil, has_value: false)
388
- end
389
- end
390
- end
391
-
392
- # Option name separator on command line (e.g. `--option-name`, the `-` between words)
393
- NAME_SEP_LINE = '-'
394
- # Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
395
- NAME_SEP_SYMBOL = '_'
396
- # Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
397
- VALUE_SEP = '='
398
- # Long-option prefix (e.g. `--opt`)
399
- PREFIX = '--'
400
- # (public: shared with Parser)
401
- end
402
-
403
- # parse command line options
404
- # arguments options start with '-', others are commands
405
- # resolves on extended value syntax
22
+ # Parse command line options and positional arguments.
23
+ #
24
+ # Options are declared incrementally (global, then plugin options).
25
+ # Command line options are applied when their option is declared: explicitly with `parse_options!`,
26
+ # or automatically on next read of an option or argument. Unknown options are kept for later.
27
+ #
28
+ # Option values come from several sources, see `OptionSource`: highest priority wins, whatever the order.
406
29
  class Parser
30
+ include Prompt
31
+
407
32
  class << self
408
33
  # Find shortened string value in allowed symbol list
409
- # @param short_value [String] value or prefix to find
410
- # @param descr [String] description for error messages
411
- # @param allowed_values [Array] list of allowed values
34
+ # @param short_value [String] Value or prefix to find
35
+ # @param descr [String] Description for error messages
36
+ # @param allowed_values [Array] List of allowed values
412
37
  # @return [Symbol, Boolean] matched symbol or boolean value
413
38
  def get_from_list(short_value, descr, allowed_values)
414
39
  Aspera.assert_type(short_value, String)
415
40
  # we accept shortcuts
416
- matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
417
- return matching_exact.first if matching_exact.length == 1
418
- matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
41
+ matching = allowed_values.select { |i| i.to_s.eql?(short_value) }
42
+ matching = allowed_values.select { |i| i.to_s.start_with?(short_value) } unless matching.length.eql?(1)
419
43
  raise BadArgument, "Identifier '#{short_value}' used where a #{descr} is expected: place the identifier after the command" if matching.empty? && short_value.match?(REGEX_LOOKUP_ID_BY_FIELD)
420
44
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
421
45
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
@@ -423,14 +47,6 @@ module Aspera
423
47
  matching.first
424
48
  end
425
49
 
426
- # Find a key in a list by exact match or unique prefix match
427
- # @return [Object, nil] the matching key, or nil if none or ambiguous
428
- def match_prefix(short_value, allowed_values)
429
- return short_value if allowed_values.include?(short_value)
430
- matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
431
- matches.length == 1 ? matches.first : nil
432
- end
433
-
434
50
  # Generates error message with list of allowed values
435
51
  # @param error_msg [String] Error message
436
52
  # @param accept_list [Array<Symbol>] List of allowed values
@@ -473,90 +89,47 @@ module Aspera
473
89
  end
474
90
 
475
91
  # Using dotted hash notation, convert value to bool, int, float or extended value
92
+ # `true` and `yes` are converted to `true`, `false` and `no` to `false`
476
93
  # @param value [String] The value to convert to appropriate type
477
94
  # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
478
95
  def smart_convert(value)
479
96
  case value
480
- when 'true' then true
481
- when 'false' then false
97
+ when 'true', BoolValue::YES_SYM.to_s then true
98
+ when 'false', BoolValue::NO_SYM.to_s then false
482
99
  else
483
- Integer(value, exception: false) ||
100
+ Integer(value, 10, exception: false) ||
484
101
  Float(value, exception: false) ||
485
102
  ExtendedValue.instance.evaluate(value, context: 'dotted expression')
486
103
  end
487
104
  end
488
105
  end
489
106
 
490
- attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
107
+ attr_accessor :ask_missing_mandatory, :ask_missing_optional
491
108
  attr_writer :fail_on_missing_mandatory
492
109
 
493
110
  # @param program_name [String] Name of the program
494
111
  # @param argv [Array<String>, nil] Command line arguments to parse
495
112
  def initialize(program_name, argv = nil)
496
- # Option descriptions: maps option symbol to its OptionValue descriptor
497
- # @type [Hash{Symbol => OptionValue}]
498
- @declared_options = {}
113
+ @registry = OptionRegistry.new
499
114
  # do we ask missing options and arguments to user ?
500
115
  @ask_missing_mandatory = false # STDIN.isatty
501
116
  # ask optional options if not provided and in interactive
502
117
  @ask_missing_optional = false
503
118
  # get_option fails if a mandatory parameter is asked
504
119
  @fail_on_missing_mandatory = true
505
- # set to true when --help / -h is parsed
506
- @help_requested = false
507
- # options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
508
- @explicitly_cleared = {}
120
+ # Values from presets and env vars, for options not declared yet: option symbol -> [{value:, source:}]
121
+ @pending_values = {}
122
+ # `true` when options were declared or presets added since last parse
123
+ @parse_needed = true
509
124
  # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
510
- @option_pairs_batch = {}
511
- @option_pairs_env = {}
512
- # Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
513
- @short_options = {}
514
- # Current help section group name, set by #group
515
- @current_group = 'global'
516
125
  env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
517
126
  ENV.each do |k, v|
518
- @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
519
- end
520
- Log.dump(:env, @option_pairs_env)
521
- # Ordered list of all CLI tokens after `--` splitting.
522
- # Single source of truth for CLI parsing — pending_arguments and pending_options are derived views.
523
- # @type [Array<Option, Argument>]
524
- @argv_tokens = []
525
- # Frozen snapshot of @argv_tokens used by unprocessed_options_with_value.
526
- @initial_argv_tokens = [].freeze
527
- # Number of original positional args before the option currently being parsed (nil = positional context).
528
- @current_option_args_offset = nil
529
- # Current index in @argv_tokens during parse_options! loop (used by shift_next_argument_token).
530
- @current_parse_idx = nil
531
- return if argv.nil?
532
- # true until `--` is found (stop options)
533
- process_options = true
534
- argv.each do |value|
535
- if process_options && value.start_with?('-')
536
- Log.log.trace1 { "opt: #{value}" }
537
- if value.eql?(OPTIONS_STOP)
538
- process_options = false
539
- else
540
- @argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
541
- end
542
- else
543
- Log.log.trace1 { "arg: #{value}" }
544
- @argv_tokens.push(Argument.new(value))
545
- end
127
+ add_pending_value(k.delete_prefix(env_prefix).downcase.to_sym, v, :env, replace: true) if k.start_with?(env_prefix)
546
128
  end
547
- @initial_argv_tokens = @argv_tokens.dup.freeze
548
- Log.log.trace1 { "add_cmd_line_options:arguments=#{pending_arguments},options=#{pending_options}".red }
549
- declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
550
- declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_optional})
551
- # do not parse options yet, let's wait for option `-h` to be overridden
552
- end
553
-
554
- # Add a type to the message if not special types
555
- # @param types [Array<Class>] types to add
556
- # @return [String] Types if relevant
557
- def add_types_info(types)
558
- return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
559
- " (#{types.map(&:name).join(', ')})"
129
+ Log.dump(:env, @pending_values)
130
+ @command_line = CommandLine.new(argv || [])
131
+ declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_mandatory=))
132
+ declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_optional=))
560
133
  end
561
134
 
562
135
  # Declare an option
@@ -570,70 +143,71 @@ module Aspera
570
143
  # - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
571
144
  # the schema then documents the Hash form and `=help` still shows it.
572
145
  # @param default [Object] default value
573
- # @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
574
- # @param deprecation [String] deprecation
146
+ # @param on_set [#call] Called with the new value each time the value is set (e.g. a `Method`, or a lambda).
147
+ # For a flag (`Type::NONE`): called without argument when the flag is found
148
+ # @param shorthand [String] For a `Hash` option: a `String` value is stored as `{shorthand => value}`
149
+ # @param deprecation [Hash, nil] deprecation: `{last:, message:}`, see `Deprecation`
575
150
  # @param schema [String] schema path documenting the Hash form of this option
576
151
  # @param block [Proc] Block to execute when option is found
577
- def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
152
+ def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, on_set: nil, shorthand: nil, deprecation: nil, schema: nil, &block)
578
153
  Aspera.assert_type(option_symbol, Symbol)
579
- Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
580
- Aspera.assert_type(handler, Hash) if handler
581
- Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
582
- option_attrs = @declared_options[option_symbol] = OptionValue.new(
583
- option: option_symbol,
584
- description: description,
585
- allowed: allowed,
586
- handler: handler,
587
- deprecation: deprecation,
588
- schema: schema
154
+ Aspera.assert(!@registry.declared?(option_symbol)) { "#{option_symbol} already declared" }
155
+ if on_set && allowed.eql?(Type::NONE)
156
+ Aspera.assert(block.nil?) { "#{option_symbol}: flag with both on_set and block" }
157
+ block = on_set
158
+ on_set = nil
159
+ end
160
+ # An abbreviation already used on command line must stay unambiguous
161
+ @command_line.abbreviated_option_tokens.each do |tok|
162
+ Aspera.assert(!option_symbol.to_s.start_with?(tok.name), type: BadArgument) do
163
+ "Ambiguous option #{tok.raw}: used as #{self.class.option_name_to_line(tok.abbreviation_of)}, but also matches #{self.class.option_name_to_line(option_symbol)}"
164
+ end
165
+ end
166
+ option = @registry.add(
167
+ OptionValue.new(
168
+ option: option_symbol,
169
+ description: description,
170
+ allowed: allowed,
171
+ on_set: on_set,
172
+ shorthand: shorthand,
173
+ deprecation: deprecation,
174
+ schema: schema
175
+ ),
176
+ short: short
589
177
  )
590
- option_attrs.group = @current_group
591
- description = option_attrs.description
178
+ description = option.description
592
179
  Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
593
180
  Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
594
181
  Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
595
182
  Aspera.assert(!['hash', 'extended value'].any? { |s| description.downcase.include?(s) }) { "#{option_symbol} shall use :allowed instead of hash/extended value in option description" }
596
- set_option(option_symbol, default, where: 'default', warn_deprecation: false) unless default.nil?
597
- case option_attrs.types
598
- when Type::ENUM, Type::BOOLEAN
599
- # This option value must be a symbol (or array of symbols)
600
- set_option(option_symbol, BoolValue.true?(default), where: 'default', warn_deprecation: false) if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
601
- when Type::NONE
602
- Aspera.assert_type(block, Proc) { "missing execution block for #{option_symbol}" }
603
- option_attrs.block = block
183
+ set_option(option_symbol, default, source: :default, warn_deprecation: false) unless default.nil?
184
+ if option.flag?
185
+ Aspera.assert(block.respond_to?(:call)) { "missing execution block for #{option_symbol}" }
186
+ option.block = block
604
187
  end
605
- @short_options[short] = option_symbol unless short.nil?
606
- Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
188
+ @parse_needed = true
189
+ Log.log.trace1 { "declare: #{option_symbol}, group: #{option.group}, short: #{short}" }
607
190
  end
608
191
 
609
192
  # Set the current help section group name for subsequent declarations
610
193
  # @param name [String] group name, shown as section header in help text
611
194
  def group(name)
612
- @current_group = name
195
+ @registry.group = name
613
196
  end
614
197
 
615
- # Rename all options currently tagged with @current_group to a new name,
616
- # then update @current_group. Used by add_manual_header when a plugin
617
- # declares its options before its group name is known (e.g. Plugins::Config).
618
- # @param name [String] new group name
619
- def rename_current_group(name)
620
- @declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
621
- @current_group = name
622
- end
623
-
624
- # Low-level positional argument reader. Prefer +Base#resolve_argument+ from action methods.
625
- # Direct calls from outside +Parser+ are legacy exceptions documented in ST12/ST13
626
- # (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).
627
- # @api private
628
- # @param descr [String] description for help
198
+ # Low-level positional argument reader.
199
+ # Prefer `Base#resolve_argument` from action methods.
200
+ # The only direct call from outside `Cli::Parser` and `Cli::Plugins::Base` is to retrieve the list of files.
201
+ # @param descr [String] Description for help
629
202
  # @param mandatory [Boolean] `true`: raise error no more argument
630
203
  # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
631
- # @param accept_list [Array<Symbol>, NilClass] list of allowed values
632
- # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
204
+ # @param accept_list [Array<Symbol>, nil] list of allowed values
205
+ # @param validation [Class, Array, nil] Accepted value type(s) or list of Symbols
633
206
  # @param aliases [Hash] map of aliases: key = alias, value = real value
634
207
  # @param default [Object] default value
635
208
  # @return [Object, Array, nil] one value, list or nil (if optional and no default)
636
209
  def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
210
+ ensure_parsed
637
211
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
638
212
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
639
213
  validation = Symbol unless accept_list.nil?
@@ -641,47 +215,28 @@ module Aspera
641
215
  Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
642
216
  descr = "#{descr}#{add_types_info(validation)}"
643
217
  result =
644
- if !pending_arguments.empty?
645
- values = extract_argument_tokens(multiple)
646
- values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
647
- # If expecting list and only one arg of type array : it is the list
648
- values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
649
- if accept_list
650
- allowed_values = [].concat(accept_list)
651
- allowed_values.concat(aliases.keys) unless aliases.nil?
652
- values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
653
- end
654
- multiple ? values : values.first
218
+ if !@command_line.pending_arguments.empty? then read_arguments(descr, multiple: multiple, validation: validation, accept_list: accept_list, aliases: aliases)
655
219
  elsif !default.nil? then default
656
220
  elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
657
221
  end
658
222
  Log.log.trace1 { "#{descr}=#{result}" }
659
223
  result = aliases[result] if aliases&.key?(result)
660
- # if value comes from JSON/YAML, it may come as Integer
661
- result = result.to_s if result.is_a?(Integer) && validation&.eql?(Type::STRING)
662
- # Integer coercion: a String argument for an INTEGER option must be parseable
663
- if result.is_a?(String) && validation&.eql?(Type::INTEGER)
664
- int_result = Integer(result, exception: false)
665
- raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
666
- result = int_result
667
- end
668
- if validation && (mandatory || !result.nil?)
669
- value_list = multiple ? result : [result]
670
- value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
671
- end
224
+ result = convert_argument(result, validation)
225
+ (multiple ? result : [result]).each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) } if validation && (mandatory || !result.nil?)
672
226
  result
673
227
  end
674
228
 
675
229
  # Resource identifier as positional parameter
676
230
  #
677
- # @param description [String] description of the identifier
678
- # @param block [Proc] block to search for identifier based on attribute value
679
- # @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
231
+ # @param description [String] description of the identifier
232
+ # @param multiple [Boolean] `true`: read a list of IDs (bulk operation)
233
+ # @param block [Proc] block to search for identifier based on attribute value
234
+ # @return [String, Array<String>] identifier or list of IDs (if `multiple`)
680
235
  # @yieldparam field [String] The field name from percent selector
681
236
  # @yieldparam value [String] The value from percent selector
682
237
  # @yieldreturn [String] Resolved identifier
683
- def instance_identifier(description: 'identifier', &block)
684
- res_id = get_next_argument(description, multiple: get_option(:bulk))
238
+ def instance_identifier(description: 'identifier', multiple: false, &block)
239
+ res_id = get_next_argument(description, multiple: multiple)
685
240
  # Can be an Array
686
241
  if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
687
242
  Aspera.assert(block_given?, type: Cli::BadArgument) { "Percent syntax for #{description} not supported in this context" }
@@ -700,23 +255,21 @@ module Aspera
700
255
  # @param option_symbol [Symbol] name of the option
701
256
  # @return [Boolean]
702
257
  def option_declared?(option_symbol)
703
- @declared_options.key?(option_symbol)
258
+ @registry.declared?(option_symbol)
704
259
  end
705
260
 
706
261
  # @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
707
- attr_reader :declared_options
262
+ def declared_options = @registry.options
708
263
 
709
264
  # Get an option definition by name
710
265
  # @param option_symbol [Symbol] name of the option
711
266
  # @return [OptionValue] Option definition
712
267
  # @raise [Cli::BadArgument] if option not found
713
268
  def option_def(option_symbol)
714
- Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
715
- @declared_options[option_symbol]
269
+ @registry.fetch(option_symbol)
716
270
  end
717
271
 
718
- # Get an option value by name
719
- # either return value or calls handler, can return nil
272
+ # Get an option value by name, can return nil
720
273
  # ask interactively if requested/required
721
274
  # @param option_symbol [Symbol] name of the option to retrieve
722
275
  # @param mandatory [Boolean] if true, raise error if option not set
@@ -724,37 +277,32 @@ module Aspera
724
277
  # if the option value is 'help' (used for --query whose schema depends on the current command)
725
278
  def get_option(option_symbol, mandatory: false, schema: nil)
726
279
  Aspera.assert_type(option_symbol, Symbol)
727
- option_attrs = option_def(option_symbol)
728
- result = option_attrs.value
280
+ ensure_parsed
281
+ option = option_def(option_symbol)
282
+ result = option.value
729
283
  # Contextual schema: raise SchemaRequest when value is 'help'
730
- raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
284
+ raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(SchemaRequest::KEYWORD)
731
285
  # Do not fail for manual generation if option mandatory but not set
732
286
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
733
287
  if result.nil?
734
288
  if !@ask_missing_mandatory
735
289
  Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
736
290
  elsif @ask_missing_optional || mandatory
737
- # ask_missing_mandatory
738
- result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
739
- set_option(option_symbol, result, where: 'interactive')
291
+ result = get_interactive(option_symbol.to_s, accept_list: option.values, schema: option.schema)
292
+ set_option(option_symbol, result, source: :interactive)
740
293
  end
741
294
  end
742
295
  result
743
296
  end
744
297
 
745
- # Set an option value by name, either store value or call handler
298
+ # Set an option value by name: store value and call the `on_set` callback
746
299
  # String is given to extended value
747
300
  # @param option_symbol [Symbol] option name
748
- # @param value [String] Value to set
749
- # @param where [String] Where the value comes from
750
- def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
301
+ # @param value [String] Value to set
302
+ # @param source [Symbol] `OptionSource` of value
303
+ def set_option(option_symbol, value, source: :code, warn_deprecation: true)
751
304
  Aspera.assert_type(option_symbol, Symbol)
752
- option = option_def(option_symbol)
753
- # Raise immediately only when the option has a static schema: the schema is known at parse time.
754
- # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
755
- # in get_option() with the contextual schema provided by the calling command.
756
- raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
757
- option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
305
+ option_def(option_symbol).assign_value(value, source: source, warn_deprecation: warn_deprecation)
758
306
  end
759
307
 
760
308
  # Set option to `nil`
@@ -765,72 +313,71 @@ module Aspera
765
313
  option_def(option_symbol).clear
766
314
  end
767
315
 
768
- # Bind (or re-bind) a runtime handler to an already-declared option.
769
- # Called from plugin initialize() for Category C handlers whose target object
770
- # (e.g. @gen_options) is created after class-load time.
771
- # @param option_symbol [Symbol] name of the already-declared option
772
- # @param object [Object] the target object for get/set delegation
773
- # @param method [Symbol] accessor method name on object
774
- # @return [nil]
775
- def set_handler(option_symbol, object:, method:)
776
- Aspera.assert_type(option_symbol, Symbol)
777
- option_def(option_symbol).bind_handler(o: object, m: method)
778
- end
779
-
780
- # Adds each of the keys of specified hash as an option
316
+ # Adds each of the keys of specified hash as an option.
317
+ # Values are applied by the next parse, and never override a value from env or command line.
781
318
  # @param preset_hash [Hash] Options to add
782
- # @param where [String] Where the value comes from
783
- # @param override [Boolean] Override if already present
319
+ # @param where [String] Where the value comes from (for logs)
320
+ # @param override [Boolean] `false` for plugin default presets: lower priority than other presets
784
321
  def add_option_preset(preset_hash, where, override: true)
785
322
  Aspera.assert_type(preset_hash, Hash)
786
323
  Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
324
+ source = override ? :preset : :plugin_preset
787
325
  preset_hash.each do |k, v|
788
326
  # Ignore comment/meta keys (e.g. _comment, _description)
789
327
  next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
790
- option_symbol = k.to_sym
791
- # Never restore an option that was explicitly cleared from the CLI (e.g. --opt=@none:)
792
- next if @explicitly_cleared.key?(option_symbol)
793
- @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
328
+ add_pending_value(k.to_sym, v, source, replace: override)
794
329
  end
330
+ @parse_needed = true
795
331
  end
796
332
 
797
333
  # Allows a plugin to add an argument as next argument to process
798
334
  # @param argument [String] argument value to prepend
799
- # @return [Array<Option, Argument>] updated tokens list
335
+ # @return [nil]
800
336
  def unshift_next_argument(argument)
801
- @argv_tokens.unshift(Argument.new(argument))
337
+ @command_line.unshift_argument(argument)
338
+ nil
802
339
  end
803
340
 
804
341
  # Check if there are no pending positional arguments
805
342
  # @return [Boolean] true if no pending positional arguments
806
343
  def command_or_arg_empty?
807
- pending_arguments.empty?
344
+ ensure_parsed
345
+ @command_line.pending_arguments.empty?
346
+ end
347
+
348
+ # Check the next pending positional argument, without consuming it
349
+ # @param values [Array<String>] values to compare with
350
+ # @return [Boolean] true if the next pending positional argument is one of values
351
+ def next_argument_in?(values)
352
+ ensure_parsed
353
+ values.include?(@command_line.pending_arguments.first)
808
354
  end
809
355
 
810
356
  # Check for unprocessed options or arguments error messages
811
357
  # @return [Array<String>] list of error messages for unprocessed tokens
812
358
  def final_errors
359
+ begin
360
+ ensure_parsed
361
+ rescue StandardError => e
362
+ # already in error processing: report only unprocessed tokens
363
+ Log.log.debug { "final parse: #{e}" }
364
+ end
813
365
  result = []
814
- result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
815
- result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
366
+ result.push("unprocessed options: #{@command_line.pending_options}") unless @command_line.pending_options.empty?
367
+ result.push("unprocessed values: #{@command_line.pending_arguments}") unless @command_line.pending_arguments.empty?
816
368
  result
817
369
  end
818
370
 
819
- # Get all original options on command line used to generate a config in config file
820
- # @return [Hash] options as taken from config file and command line just before command execution
371
+ # Get all long options with a value from command line, used to generate a config in config file.
372
+ # They are marked as processed.
373
+ # @return [Hash] options with value, dotted notation expanded
821
374
  def unprocessed_options_with_value
375
+ ensure_parsed
822
376
  result = {}
823
- @initial_argv_tokens.each_with_index do |tok, idx|
824
- next unless tok.is_a?(Option) && tok.short_char.nil?
825
- # For space-separated form: value is the immediately following :argument token (if any)
826
- value = tok.value || @initial_argv_tokens[idx + 1]&.then { |t| t.value if t.is_a?(Argument) }
827
- # ignore options without value
828
- next if value.nil?
829
- name = tok.dot_path ? [tok.name, *tok.dot_path].join(DotContainer::SEPARATOR) : tok.name
830
- Log.log.debug { "option #{name}=#{value}" }
831
- path = [tok.name, *(tok.dot_path || [])]
832
- DotContainer.dotted_to_container(path, Parser.smart_convert(value), result)
833
- @argv_tokens.reject! { |t| t.is_a?(Option) && t.raw.eql?(tok.raw) }
377
+ @command_line.each_long_option_with_value do |tok|
378
+ path = [tok.name, *tok.dot_path]
379
+ Log.log.debug { "option #{path.join(DotContainer::SEPARATOR)}=#{tok.value}" }
380
+ DotContainer.dotted_to_container(path, Parser.smart_convert(tok.value), result)
834
381
  end
835
382
  result
836
383
  end
@@ -839,7 +386,7 @@ module Aspera
839
386
  # @return [Hash] options as taken from config file and command line just before command execution
840
387
  def known_options(only_defined: false)
841
388
  result = {}
842
- @declared_options.each_key do |option_symbol|
389
+ @registry.options.each_key do |option_symbol|
843
390
  v = get_option(option_symbol)
844
391
  result[option_symbol] = v unless only_defined && v.nil?
845
392
  rescue => e
@@ -848,186 +395,55 @@ module Aspera
848
395
  result
849
396
  end
850
397
 
851
- # Removes already known options from the list
398
+ # Apply values of options declared so far: from presets, env vars and command line.
399
+ # Can be called any number of times: tokens of options not declared yet are kept for a later call.
400
+ # Called automatically on read of option or argument, but must be called explicitly
401
+ # when values set by `on_set` callbacks are used.
852
402
  def parse_options!
853
403
  Log.log.trace1('parse_options!'.red)
854
- # First options from conf file
855
- @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
856
- # Then, env var (to override)
857
- @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
858
- # Then, command line override.
859
- # Iterate @argv_tokens in order so that --opt val and -s val can consume the next argument
860
- # token directly, without any secondary index.
861
- # Process one option at a time so that @current_option_args_offset can be set before each
862
- # option is evaluated (used by `@:` extended value).
863
- deferred_tokens = []
864
- Log.log.trace1('Before parse')
865
- Log.dump(:argv_tokens, @argv_tokens, level: :trace1)
866
- # Iterate with an index so that Argument tokens stay in @argv_tokens (not shifted).
867
- # Only Option tokens are removed; Arguments remain until consumed by extract_argument_tokens.
868
- @current_parse_idx = 0
869
- while @current_parse_idx < @argv_tokens.length
870
- tok = @argv_tokens[@current_parse_idx]
871
- if tok.is_a?(Argument)
872
- @current_parse_idx += 1
873
- next
874
- end
875
- # tok is an Option — remove it from @argv_tokens
876
- @argv_tokens.delete_at(@current_parse_idx)
877
- # @current_option_args_offset = number of positional Argument tokens that appear BEFORE
878
- # this option in @argv_tokens (i.e. at indices 0...@current_parse_idx after the delete).
879
- # Used by args_as_extended to skip those leading args when collecting @: values.
880
- @current_option_args_offset = @argv_tokens[0...@current_parse_idx].count { |t| t.is_a?(Argument) }
881
- if tok.short_char
882
- # Short option: -X or -Xvalue
883
- option_sym = @short_options[tok.short_char]
884
- if option_sym
885
- raw_value = tok.value
886
- # No inline value and option expects a value: consume the next :argument token
887
- raw_value = shift_next_argument_token \
888
- if !tok.has_value && !@declared_options[option_sym].types.eql?(Type::NONE)
889
- dispatch_option(option_sym, raw_value)
890
- else
891
- deferred_tokens.push(tok)
892
- end
893
- elsif tok.dot_path
894
- # Dotted notation: --a.b.c=val or --a.b.c val (always takes priority over plain option lookup)
895
- Log.log.trace1 { "Dotted option: #{tok.raw}".red }
896
- if tok.has_value
897
- raw_value = tok.value
898
- value_from_next_token = false
899
- else
900
- raw_value = shift_next_argument_token
901
- value_from_next_token = true
902
- end
903
- if @declared_options.key?(tok.name.to_sym)
904
- set_option(tok.name.to_sym, DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(raw_value), get_option(tok.name.to_sym)), where: 'dotted')
905
- else
906
- # Only re-inject if value was space-separated (consumed from next token); inline values stay in tok.value
907
- @argv_tokens.unshift(Argument.new(raw_value)) if raw_value && value_from_next_token
908
- deferred_tokens.push(tok)
909
- end
910
- elsif (resolved_sym = self.class.match_prefix(tok.name.to_sym, @declared_options.keys))
911
- # Known long option (plain, no dot-path)
912
- raw_value = tok.value
913
- # No inline `=` and option expects a value: consume the next :argument token
914
- raw_value = shift_next_argument_token \
915
- if !tok.has_value && !@declared_options[resolved_sym].types.eql?(Type::NONE)
916
- dispatch_option(resolved_sym, raw_value)
917
- else
918
- Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
919
- deferred_tokens.push(tok)
920
- end
921
- end
922
- @current_option_args_offset = nil
923
- @current_parse_idx = nil
924
- Log.log.trace1('After parse')
925
- Log.log.trace1 { "deferred: #{deferred_tokens}" }
926
- # @argv_tokens now contains only: remaining Arguments + deferred Options (already in correct order).
927
- # Re-insert deferred Options at their original positions by rebuilding from @initial_argv_tokens.
928
- deferred_ids = deferred_tokens.map(&:object_id).to_set
929
- remaining_arg_ids = @argv_tokens.grep(Argument).map(&:object_id).to_set
930
- injected = @argv_tokens.grep(Argument).reject { |t| @initial_argv_tokens.include?(t) }
931
- @argv_tokens = @initial_argv_tokens.select do |t|
932
- (t.is_a?(Option) && deferred_ids.include?(t.object_id)) ||
933
- (t.is_a?(Argument) && remaining_arg_ids.include?(t.object_id))
934
- end
935
- # Prepend arguments injected at runtime (e.g. via unshift_next_argument) not in @initial_argv_tokens.
936
- @argv_tokens.unshift(*injected)
937
- end
938
-
939
- # Extract raw string tokens from @argv_tokens (Argument objects) according to `multiple`.
940
- # Consumed Argument tokens are removed from @argv_tokens.
941
- # @param multiple [false, true, String] consumption mode:
942
- # false — consume exactly one token
943
- # true — consume all remaining tokens
944
- # String — consume up to (but not including) the marker token, or all if absent
945
- # @return [Array<String>] consumed raw token strings
946
- def extract_argument_tokens(multiple)
947
- arg_tokens = @argv_tokens.grep(Argument)
948
- case multiple
949
- when false
950
- tok = arg_tokens.first
951
- @argv_tokens.delete(tok)
952
- [tok.value]
953
- when true
954
- arg_tokens.each { |t| @argv_tokens.delete(t) }
955
- arg_tokens.map(&:value)
956
- when String
957
- idx = arg_tokens.index { |t| t.value.eql?(multiple) }
958
- consumed = idx ? arg_tokens[0, idx] : arg_tokens
959
- marker = idx ? arg_tokens[idx] : nil
960
- (consumed + [marker].compact).each { |t| @argv_tokens.delete(t) }
961
- consumed.map(&:value)
962
- else Aspera.error_unexpected_value(multiple) { 'multiple' }
963
- end
964
- end
965
-
966
- # Validate and coerce a single argument value.
967
- # Raises SchemaRequest when the value is 'help' and validation includes Hash.
968
- # Raises BadArgument when the value is an Integer on a STRING validation (coerced upstream).
969
- # Raises BadArgument when the value's type is not in the validation list.
970
- # @param value [Object] the value to validate
971
- # @param validation [Array<Class>] accepted types
972
- # @param descr [String] argument description (for error messages)
973
- # @param schema [String, nil] schema path for SchemaRequest
974
- def validate_argument(value, validation:, descr:, schema:)
975
- raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
976
- raise Cli::BadArgument,
977
- "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
978
- unless validation.any? { |t| value.is_a?(t) }
979
- end
980
-
981
- # Prompt user for console input
982
- # @param prompt [String] prompt string to display
983
- # @param sensitive [Boolean] whether to hide typed input
984
- # @return [String] user input stripped of trailing newline
985
- def prompt_user_input(prompt, sensitive: false)
986
- return $stdin.getpass("#{prompt}> ") if sensitive
987
- print("#{prompt}> ")
988
- line = $stdin.gets
989
- Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
990
- line.chomp
991
- end
992
-
993
- # prompt user for input in a list of symbols
994
- # @param prompt [String] prompt to display
995
- # @param sym_list [Array] list of symbols to select from
996
- # @return [Symbol] selected symbol
997
- def prompt_user_input_in_list(prompt, sym_list)
998
- loop do
999
- input = prompt_user_input(prompt).to_sym
1000
- if sym_list.any? { |a| a.eql?(input) }
1001
- return input
1002
- else
1003
- $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
1004
- end
1005
- end
1006
- end
1007
-
1008
- # Prompt user for input in a list of symbols
1009
- # @param descr [String] description for help
1010
- # @param check_option [Boolean] Check attributes of option with name=descr
404
+ @parse_needed = false
405
+ apply_pending_values
406
+ @command_line.pending_option_tokens.each { |tok| apply_option_token(tok) }
407
+ # Presets loaded by a command line option (e.g. `-P`)
408
+ apply_pending_values
409
+ Log.log.trace1 { "unprocessed options: #{@command_line.pending_options}" }
410
+ end
411
+
412
+ # Execute the block with interactive input of missing mandatory options and arguments, as with `--interactive=yes`.
413
+ # The previous state is restored afterwards, so that interactive input does not leak to the rest of the command.
414
+ # @param enabled [Boolean] `false`: execute the block with current state
415
+ # @return [Object] result of the block
416
+ def with_interactive(enabled: true)
417
+ previous = @ask_missing_mandatory
418
+ @ask_missing_mandatory = true if enabled
419
+ yield
420
+ ensure
421
+ @ask_missing_mandatory = previous
422
+ end
423
+
424
+ # Prompt user for missing option or argument, or raise if not interactive
425
+ # @param descr [String] option name, or argument description
1011
426
  # @param multiple [Boolean, String] `true` if multiple values expected
1012
- # @param accept_list [Array<Symbol>,NilClass] List of expected values
427
+ # @param accept_list [Array<Symbol>, nil] List of expected values
428
+ # @param aliases [Hash, nil] aliases of values, for error message
429
+ # @param schema [String, nil] schema of value, for error message
1013
430
  # @return [String] user input
1014
- def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
1015
- option_attrs = @declared_options[descr.to_sym]
1016
- what = option_attrs ? 'option' : 'argument'
1017
- default_prompt = "#{what}: #{descr}"
431
+ def get_interactive(descr, multiple: false, accept_list: nil, aliases: nil, schema: nil)
432
+ option = @registry.options[descr.to_sym]
433
+ default_prompt = "#{option ? 'option' : 'argument'}: #{descr}"
1018
434
  if !@ask_missing_mandatory
1019
435
  message = "Missing #{default_prompt}"
1020
436
  message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
1021
- message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
437
+ message += "\n#{TerminalFormatter.hint}Give `#{SchemaRequest::KEYWORD}` as argument to retrieve the schema of the missing argument." if schema
1022
438
  raise Cli::MissingArgument, message
1023
439
  end
1024
- # ask interactively
440
+ # Ask interactively
1025
441
  result = []
1026
- puts(' (one per line, end with empty line)') if multiple
442
+ $stderr.puts(' (one per line, end with empty line)') if multiple # rubocop:disable Style/StderrPuts
1027
443
  loop do
1028
444
  prompt = default_prompt
1029
445
  prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
1030
- entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
446
+ entry = prompt_user_input(prompt, sensitive: option&.sensitive)
1031
447
  break if entry.empty? && multiple
1032
448
  entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
1033
449
  entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
@@ -1038,30 +454,19 @@ module Aspera
1038
454
  end
1039
455
 
1040
456
  # Read remaining args and build an `Array` or `Hash`
1041
- # @param value [String] Argument to `@:` extended value
457
+ # When used in an option value, only positional arguments after the option are used.
458
+ # @param end_marker [String] Argument to `@:` extended value
1042
459
  # @return [Hash, Array] Object representing dot-path values
1043
460
  def args_as_extended(end_marker)
1044
- # This extended value does not take args (`@:`)
1045
- # ExtendedValue.assert_no_value(end_marker, :p)
1046
461
  end_marker = SpecialValues::EOA if end_marker.empty?
1047
- # When called from an option value, skip positional args that appear before the option in argv.
1048
- # @current_option_args_offset is set by parse_options! to the number of args in
1049
- # @unprocessed_cmd_line_arguments that preceded this option; nil when called from a positional context.
1050
- skip_count = @current_option_args_offset || 0
1051
- # Skip leading positional args that precede this option in original argv order.
1052
- # Grab the first `skip_count` Argument tokens and temporarily remove them.
1053
- arg_tokens = @argv_tokens.grep(Argument)
1054
- skipped_tokens = skip_count.positive? ? arg_tokens.first(skip_count) : []
1055
- skipped_tokens.each { |t| @argv_tokens.delete(t) }
1056
- Log.log.trace1 { "args_as_extended: skipping #{skipped_tokens.length} args before option: #{skipped_tokens.map(&:value)}" } unless skipped_tokens.empty?
1057
462
  result = nil
1058
- get_next_argument('args', multiple: end_marker).each do |argument|
1059
- Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
1060
- path, value = argument.split(Option::VALUE_SEP, 2)
1061
- result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
463
+ @command_line.with_arguments_after_current_option do
464
+ get_next_argument('args', multiple: end_marker).each do |argument|
465
+ Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
466
+ path, value = argument.split(Option::VALUE_SEP, 2)
467
+ result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
468
+ end
1062
469
  end
1063
- # Restore skipped tokens so they remain available for command dispatching
1064
- skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
1065
470
  result
1066
471
  end
1067
472
 
@@ -1071,15 +476,14 @@ module Aspera
1071
476
  def help_text(banner: nil)
1072
477
  rows = []
1073
478
  current_group = nil
1074
- @declared_options.each do |sym, opt|
479
+ @registry.options.each do |sym, opt|
1075
480
  if opt.group != current_group
1076
481
  current_group = opt.group
1077
482
  rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
1078
483
  end
1079
- short_char = @short_options.key(sym)
1080
- short_part = short_char ? "-#{short_char}, " : ' '
484
+ short_part = opt.short ? "-#{opt.short}, " : ' '
1081
485
  flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
1082
- desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
486
+ desc = opt.deprecation ? "#{opt.description} (#{opt.deprecation})" : opt.description
1083
487
  rows << [flag, desc]
1084
488
  end
1085
489
  table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
@@ -1099,14 +503,70 @@ module Aspera
1099
503
  b.remove_horizontals
1100
504
  end.freeze
1101
505
 
506
+ # Parse if options were declared or presets added since last parse
507
+ def ensure_parsed
508
+ parse_options! if @parse_needed
509
+ end
510
+
511
+ # Add a type to the message if not special types
512
+ # @param types [Array<Class>] types to add
513
+ # @return [String] Types if relevant
514
+ def add_types_info(types)
515
+ return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
516
+ " (#{types.map(&:name).join(', ')})"
517
+ end
518
+
519
+ # Consume and evaluate positional arguments
520
+ # @return [Object, Array] one value, or list if `multiple`
521
+ def read_arguments(descr, multiple:, validation:, accept_list:, aliases:)
522
+ values = @command_line.shift_arguments(multiple)
523
+ values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
524
+ # If expecting list and only one arg of type array : it is the list
525
+ values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
526
+ if accept_list
527
+ allowed_values = accept_list + (aliases&.keys || [])
528
+ values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
529
+ end
530
+ multiple ? values : values.first
531
+ end
532
+
533
+ # Convert argument to expected type, when unambiguous
534
+ # @param value [Object] argument value
535
+ # @param validation [Array<Class>, nil] accepted types
536
+ # @return [Object] converted value
537
+ def convert_argument(value, validation)
538
+ # if value comes from JSON/YAML, it may come as Integer
539
+ return value.to_s if value.is_a?(Integer) && validation.eql?(Type::STRING)
540
+ return value unless value.is_a?(String) && validation.eql?(Type::INTEGER)
541
+ Integer(value, 10, exception: false).tap { |i| raise Cli::BadArgument, "Invalid integer: #{value}" if i.nil? }
542
+ end
543
+
544
+ # Validate a single argument value.
545
+ # @param value [Object] the value to validate
546
+ # @param validation [Array<Class>] accepted types
547
+ # @param descr [String] argument description (for error messages)
548
+ # @param schema [String, nil] schema path for SchemaRequest and validation
549
+ # @raise [SchemaRequest] when the value is 'help' and validation includes Hash.
550
+ # @raise [BadArgument] when the value's type is not in the validation list.
551
+ # @raise [BadArgument] when the value does not match its schema.
552
+ def validate_argument(value, validation:, descr:, schema:)
553
+ raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(SchemaRequest::KEYWORD)
554
+ raise BadArgument,
555
+ "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
556
+ unless validation.any? { |t| value.is_a?(t) }
557
+ errors = Schema::Validator.instance.errors(value, schema) if schema && (value.is_a?(Hash) || value.is_a?(Array))
558
+ raise BadArgument, "Argument #{descr}: #{errors.join('; ')} (give `#{SchemaRequest::KEYWORD}` as argument for schema)" unless errors.nil? || errors.empty?
559
+ end
560
+
1102
561
  # @param opt [OptionValue] option descriptor
1103
- # @return [String, nil] placeholder shown in flag column: 'ENUM', 'HASH', 'INT', 'LIST', 'VALUE', or nil for flag switches
562
+ # @return [String, nil] placeholder shown in flag column, or nil for flags
1104
563
  def option_display_value(opt)
1105
- case opt.types
1106
- when Type::NONE then nil
1107
- when Type::BOOLEAN then 'yes|no'
1108
- when Type::INTEGER then 'INT'
1109
- when Type::ENUM
564
+ case opt.kind
565
+ when :flag then nil
566
+ when :boolean then 'yes|no'
567
+ when :integer then 'INT'
568
+ when :float then 'FLOAT'
569
+ when :enum
1110
570
  opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
1111
571
  else
1112
572
  if opt.types&.include?(Hash) || !opt.schema.nil?
@@ -1119,22 +579,69 @@ module Aspera
1119
579
  end
1120
580
  end
1121
581
 
1122
- # Dispatch a parsed CLI option to its handler.
1123
- # @param sym [Symbol] option symbol
1124
- # @param raw_value [String, nil] raw string value from command line, or nil for flag switches
1125
- def dispatch_option(sym, raw_value)
1126
- opt = @declared_options[sym]
1127
- if opt.types.eql?(Type::NONE)
1128
- opt.block.call
582
+ # Apply a command line option token if its option is declared, else keep it for later.
583
+ # @param tok [Option] option token
584
+ def apply_option_token(tok)
585
+ return if tok.consumed
586
+ option = tok.short_char ? @registry.by_short(tok.short_char) : resolve_long_name(tok)
587
+ return if option.nil?
588
+ if option.flag?
589
+ flags = flag_options(tok, option)
590
+ return if flags.nil?
591
+ @command_line.consume(tok, takes_value: false)
592
+ flags.each { |f| f.block.call }
1129
593
  else
1130
- set_option(sym, raw_value, where: SOURCE_USER)
1131
- # Track options explicitly cleared from CLI (e.g. --opt=@none:) so that
1132
- # subsequent preset injection does not silently restore the value.
1133
- # @none: always evaluates to nil; detect it by checking the raw token directly
1134
- # to avoid re-evaluating side-effectful extended values (e.g. @: consumes positional args).
1135
- cleared = get_option(sym).nil? || raw_value.eql?('@none:')
1136
- @explicitly_cleared[sym] = true if cleared
594
+ value = @command_line.consume(tok, takes_value: true)
595
+ @command_line.with_current_option(tok) do
596
+ if tok.dot_path.nil?
597
+ option.assign_value(value, source: :cmdline)
598
+ else
599
+ # Fill a copy of the current value: the current value is not modified in place (`on_set` callback already received it),
600
+ # and the result is complete (e.g. `--opt.0=a --opt.1=b`): not merged again
601
+ current = copy_containers(option.value(log: false))
602
+ value = DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(value), current)
603
+ option.assign_value(value, source: :cmdline, merge: false)
604
+ end
605
+ end
606
+ end
607
+ end
608
+
609
+ # Copy nested `Hash` and `Array` containers, keep other values as-is (e.g. a `Proc` cannot be marshalled)
610
+ # @param value [Object] value to copy
611
+ # @return [Object] copy
612
+ def copy_containers(value)
613
+ case value
614
+ when Hash then value.transform_values { |v| copy_containers(v) }
615
+ when Array then value.map { |v| copy_containers(v) }
616
+ else value
617
+ end
618
+ end
619
+
620
+ # Flags to execute for a flag token: `--help`, `-h`, or combined `-hN`
621
+ # @param tok [Option] flag token
622
+ # @param option [OptionValue] declared flag option
623
+ # @return [Array<OptionValue>, nil] flags, or `nil` if combined flags are not all declared yet
624
+ # @raise [BadArgument] if a value is given
625
+ def flag_options(tok, option)
626
+ if tok.short_char.nil?
627
+ Aspera.assert(!tok.inline? && tok.dot_path.nil?, type: BadArgument) { "Option #{self.class.option_name_to_line(option.option)} does not take a value" }
628
+ return [option]
1137
629
  end
630
+ flags = [option, *tok.inline_value.to_s.chars.map { |c| @registry.by_short(c) }]
631
+ return if flags.any?(&:nil?)
632
+ Aspera.assert(flags.all?(&:flag?), type: BadArgument) { "Option -#{tok.short_char} does not take a value: #{tok.raw}" }
633
+ flags
634
+ end
635
+
636
+ # Resolve long option name: exact match, or unique abbreviation (recorded on token).
637
+ # @param tok [Option] long option token
638
+ # @return [OptionValue, nil] declared option, or `nil` if not declared yet
639
+ # @raise [BadArgument] if abbreviation is ambiguous
640
+ def resolve_long_name(tok)
641
+ # No abbreviation with dotted notation
642
+ option = @registry.by_long(tok.name, allow_abbreviation: tok.dot_path.nil?)
643
+ tok.abbreviation_of = option.option if !option.nil? && !option.option.to_s.eql?(tok.name)
644
+ option
1138
645
  end
1139
646
 
1140
647
  # Generate command line option string from option symbol
@@ -1146,72 +653,36 @@ module Aspera
1146
653
  opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
1147
654
  end
1148
655
 
1149
- # TODO: use formatter
1150
- # Highlight current value in list
1151
- # @param list [Array<Symbol>] List of possible values
1152
- # @param current [Symbol] Current value
1153
- # @return [String] comma separated sorted list of values, with the current value highlighted
1154
- def highlight_current_in_list(list, current)
1155
- list.sort.map do |i|
1156
- if i.eql?(current)
1157
- $stdout.isatty ? i.to_s.red.bold : "[#{i}]"
1158
- else
1159
- i
1160
- end
1161
- end.join(', ')
1162
- end
1163
-
1164
- # Try to evaluate options set in batch
1165
- # @param unprocessed_options [Array] list of options to apply (key_sym,value)
1166
- # @param where [String] where the options come from
1167
- # Apply all known options from the given pairs hash and return the remaining (unknown) pairs.
1168
- # Pure: does not mutate the argument; the caller is responsible for storing the returned value.
1169
- # @param option_pairs [Hash{Symbol => Object}] candidate key/value pairs
1170
- # @param where [String] label used in log messages and error context
1171
- # @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
1172
- def consume_option_pairs(option_pairs, where)
1173
- Log.log.trace1 { "consume_option_pairs: #{where}" }
1174
- remaining = {}
1175
- option_pairs.each do |k, v|
1176
- if @declared_options.key?(k)
1177
- set_option(k, v, where: where)
1178
- else
1179
- Log.log.trace1 { "unprocessed: #{k}: #{v}" }
1180
- remaining[k] = v
656
+ # Keep value from preset or env until option is declared
657
+ # @param option_symbol [Symbol] option name
658
+ # @param value [Object] value
659
+ # @param source [Symbol] `OptionSource`
660
+ # @param replace [Boolean] replace a pending value from same source
661
+ def add_pending_value(option_symbol, value, source, replace:)
662
+ entries = (@pending_values[option_symbol] ||= [])
663
+ index = entries.index { |e| e[:source].eql?(source) }
664
+ if index.nil?
665
+ entries.push({value: value, source: source})
666
+ elsif replace
667
+ entries[index] = {value: value, source: source}
668
+ end
669
+ end
670
+
671
+ # Apply pending values (presets, env) of declared options, lowest priority first
672
+ def apply_pending_values
673
+ ready = @pending_values.select { |k, _| @registry.declared?(k) }
674
+ ready.each_key { |k| @pending_values.delete(k) }
675
+ ready.each do |option_symbol, entries|
676
+ entries.sort_by { |e| OptionSource.priority(e[:source]) }.each do |e|
677
+ set_option(option_symbol, e[:value], source: e[:source])
1181
678
  end
1182
679
  end
1183
- remaining
1184
- end
1185
-
1186
- # Consume the Argument token immediately following the current option in @argv_tokens.
1187
- # After delete_at(@current_parse_idx), the next token is at @argv_tokens[@current_parse_idx].
1188
- # @return [String, nil] the consumed argument value, or nil if the next token is not an Argument
1189
- def shift_next_argument_token
1190
- pos = @current_parse_idx || 0
1191
- return unless @argv_tokens[pos]&.is_a?(Argument)
1192
-
1193
- @argv_tokens.delete_at(pos).value
1194
- end
1195
-
1196
- # @return [Array<String>] values of all pending positional Argument tokens
1197
- def pending_arguments
1198
- @argv_tokens.grep(Argument).map(&:value)
1199
- end
1200
-
1201
- # @return [Array<String>] raw strings of all pending Option tokens
1202
- def pending_options
1203
- @argv_tokens.grep(Option).map(&:raw)
1204
680
  end
1205
681
 
1206
- # when this is alone, this stops option processing (same characters as Option::PREFIX but distinct semantics)
1207
- OPTIONS_STOP = '--'
1208
- SOURCE_USER = 'cmdline' # cspell:disable-line
1209
682
  # Percent selector: select by this field for this value
1210
683
  REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
1211
- # Ask for schema of Extended value
1212
- HELP = 'help'
1213
684
 
1214
- private_constant :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
685
+ private_constant :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
1215
686
  end
1216
687
  end
1217
688
  end