aspera-cli 4.27.0 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +29 -0
  4. data/CONTRIBUTING.md +1 -7
  5. data/TODO.md +50 -0
  6. data/docs/README.md +708 -488
  7. data/lib/aspera/agent/base.rb +10 -2
  8. data/lib/aspera/agent/connect.rb +7 -7
  9. data/lib/aspera/agent/desktop.rb +3 -3
  10. data/lib/aspera/agent/direct.rb +48 -19
  11. data/lib/aspera/agent/factory.rb +3 -3
  12. data/lib/aspera/agent/httpgw.rb +55 -14
  13. data/lib/aspera/agent/node.rb +2 -2
  14. data/lib/aspera/agent/transferd.rb +12 -12
  15. data/lib/aspera/api/aoc.rb +28 -28
  16. data/lib/aspera/api/cos_node.rb +10 -9
  17. data/lib/aspera/api/faspex.rb +2 -2
  18. data/lib/aspera/api/httpgw.rb +20 -20
  19. data/lib/aspera/api/node.rb +31 -53
  20. data/lib/aspera/ascmd.rb +13 -13
  21. data/lib/aspera/ascp/installation.rb +43 -36
  22. data/lib/aspera/ascp/management.rb +6 -6
  23. data/lib/aspera/assert.rb +12 -13
  24. data/lib/aspera/cli/ascp_actions.rb +7 -7
  25. data/lib/aspera/cli/async_transfer_store.rb +49 -5
  26. data/lib/aspera/cli/bootstrapper.rb +9 -9
  27. data/lib/aspera/cli/command_registry.rb +1 -1
  28. data/lib/aspera/cli/command_spec.rb +6 -3
  29. data/lib/aspera/cli/context.rb +3 -3
  30. data/lib/aspera/cli/extended_value.rb +26 -26
  31. data/lib/aspera/cli/formatter.rb +76 -54
  32. data/lib/aspera/cli/gem_checker.rb +1 -1
  33. data/lib/aspera/cli/hints.rb +2 -2
  34. data/lib/aspera/cli/http.rb +22 -29
  35. data/lib/aspera/cli/mailer.rb +7 -7
  36. data/lib/aspera/cli/mcp_tool.rb +31 -4
  37. data/lib/aspera/cli/option_declarator.rb +77 -0
  38. data/lib/aspera/cli/options.schema.yaml +273 -3
  39. data/lib/aspera/cli/parser.rb +539 -280
  40. data/lib/aspera/cli/plugins/alee.rb +1 -1
  41. data/lib/aspera/cli/plugins/aoc.rb +297 -313
  42. data/lib/aspera/cli/plugins/ats.rb +25 -21
  43. data/lib/aspera/cli/plugins/base.rb +357 -186
  44. data/lib/aspera/cli/plugins/basic_auth.rb +0 -12
  45. data/lib/aspera/cli/plugins/config.rb +76 -73
  46. data/lib/aspera/cli/plugins/console.rb +8 -8
  47. data/lib/aspera/cli/plugins/cos.rb +4 -3
  48. data/lib/aspera/cli/plugins/factory.rb +2 -2
  49. data/lib/aspera/cli/plugins/faspex5.rb +259 -248
  50. data/lib/aspera/cli/plugins/faspio.rb +7 -2
  51. data/lib/aspera/cli/plugins/httpgw.rb +1 -1
  52. data/lib/aspera/cli/plugins/mcp.rb +11 -11
  53. data/lib/aspera/cli/plugins/node.rb +227 -283
  54. data/lib/aspera/cli/plugins/orchestrator.rb +23 -23
  55. data/lib/aspera/cli/plugins/preview.rb +61 -64
  56. data/lib/aspera/cli/plugins/server.rb +36 -31
  57. data/lib/aspera/cli/plugins/shares.rb +174 -135
  58. data/lib/aspera/cli/preset_actions.rb +39 -19
  59. data/lib/aspera/cli/preset_manager.rb +74 -32
  60. data/lib/aspera/cli/result.rb +18 -16
  61. data/lib/aspera/cli/runner.rb +75 -39
  62. data/lib/aspera/cli/secret_finder.rb +1 -1
  63. data/lib/aspera/cli/sync_actions.rb +36 -35
  64. data/lib/aspera/cli/terminal_formatter.rb +2 -2
  65. data/lib/aspera/cli/transfer_actions.rb +17 -10
  66. data/lib/aspera/cli/transfer_agent.rb +44 -44
  67. data/lib/aspera/cli/transfer_progress.rb +7 -7
  68. data/lib/aspera/cli/vault_manager.rb +34 -12
  69. data/lib/aspera/cli/version.rb +1 -1
  70. data/lib/aspera/cli/wizard.rb +17 -20
  71. data/lib/aspera/colors.rb +3 -3
  72. data/lib/aspera/command_line_builder.rb +16 -16
  73. data/lib/aspera/command_line_converter.rb +1 -1
  74. data/lib/aspera/coverage.rb +1 -2
  75. data/lib/aspera/data_repository.rb +1 -1
  76. data/lib/aspera/dot_container.rb +8 -8
  77. data/lib/aspera/environment.rb +12 -12
  78. data/lib/aspera/faspex_gw.rb +4 -4
  79. data/lib/aspera/faspex_postproc.rb +14 -9
  80. data/lib/aspera/graphql.rb +4 -2
  81. data/lib/aspera/hash_ext.rb +2 -2
  82. data/lib/aspera/json_rpc/client.rb +1 -1
  83. data/lib/aspera/keychain/base.rb +10 -3
  84. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  85. data/lib/aspera/keychain/factory.rb +17 -5
  86. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  87. data/lib/aspera/keychain/macos_security.rb +23 -25
  88. data/lib/aspera/keychain/one_password_api.rb +86 -0
  89. data/lib/aspera/keychain/one_password_base.rb +34 -0
  90. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  91. data/lib/aspera/link_header.rb +2 -2
  92. data/lib/aspera/log.rb +13 -13
  93. data/lib/aspera/markdown.rb +5 -5
  94. data/lib/aspera/nagios.rb +8 -8
  95. data/lib/aspera/node_simulator.rb +33 -27
  96. data/lib/aspera/oauth/base.rb +4 -5
  97. data/lib/aspera/oauth/boot.rb +3 -3
  98. data/lib/aspera/oauth/factory.rb +5 -5
  99. data/lib/aspera/oauth/jwt.rb +5 -7
  100. data/lib/aspera/oauth/web.rb +4 -4
  101. data/lib/aspera/persistency_action_once.rb +4 -4
  102. data/lib/aspera/persistency_folder.rb +8 -8
  103. data/lib/aspera/preview/file_types.rb +4 -4
  104. data/lib/aspera/preview/generator.rb +49 -46
  105. data/lib/aspera/preview/options.rb +3 -2
  106. data/lib/aspera/preview/terminal.rb +5 -5
  107. data/lib/aspera/preview/utils.rb +65 -18
  108. data/lib/aspera/products/connect.rb +2 -2
  109. data/lib/aspera/products/desktop.rb +1 -1
  110. data/lib/aspera/products/other.rb +2 -2
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -10
  113. data/lib/aspera/rest.rb +149 -115
  114. data/lib/aspera/rest_error_analyzer.rb +3 -3
  115. data/lib/aspera/rest_list.rb +10 -9
  116. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  117. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  118. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  119. data/lib/aspera/schema/documentation.rb +17 -11
  120. data/lib/aspera/schema/reader.rb +78 -4
  121. data/lib/aspera/schema/registry.rb +26 -7
  122. data/lib/aspera/secret_hider.rb +5 -5
  123. data/lib/aspera/ssh.rb +64 -31
  124. data/lib/aspera/ssl.rb +3 -3
  125. data/lib/aspera/sync/database.rb +2 -2
  126. data/lib/aspera/sync/operations.rb +13 -13
  127. data/lib/aspera/temp_file_manager.rb +24 -4
  128. data/lib/aspera/transfer/faux_file.rb +2 -2
  129. data/lib/aspera/transfer/parameters.rb +15 -15
  130. data/lib/aspera/transfer/resumer.rb +9 -9
  131. data/lib/aspera/transfer/spec.rb +1 -1
  132. data/lib/aspera/uri_reader.rb +52 -15
  133. data/lib/aspera/web_auth.rb +6 -5
  134. data/lib/aspera/web_server_simple.rb +9 -9
  135. data/lib/aspera/yaml.rb +4 -4
  136. data.tar.gz.sig +0 -0
  137. metadata +10 -4
  138. metadata.gz.sig +0 -0
  139. data/lib/aspera/cli/plugins/faspex.rb +0 -576
  140. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/cli/preset_manager'
3
4
  require 'aspera/cli/extended_value'
4
5
  require 'aspera/cli/error'
6
+ require 'aspera/cli/special_values'
5
7
  require 'aspera/cli/terminal_formatter'
6
8
  require 'aspera/colors'
7
9
  require 'aspera/secret_hider'
@@ -43,15 +45,12 @@ module Aspera
43
45
  SYMBOLS = [NO_SYM, YES_SYM].freeze
44
46
  # @return [Boolean] `true` if value is a value for `true` in ALL
45
47
  def true?(enum)
46
- Aspera.assert_values(enum, ALL){'boolean'}
48
+ Aspera.assert_values(enum, ALL) { 'boolean' }
47
49
  TRUE_VALUES.include?(enum)
48
50
  end
49
51
 
50
52
  # @return [:yes, :no]
51
- def to_sym(enum)
52
- Aspera.assert_values(enum, ALL){'boolean'}
53
- TRUE_VALUES.include?(enum) ? YES_SYM : NO_SYM
54
- end
53
+ def to_sym(enum) = true?(enum) ? YES_SYM : NO_SYM
55
54
 
56
55
  # @return [Boolean] `true` if value is a value for `true` or `false` in ALL
57
56
  def symbol?(sym)
@@ -60,25 +59,26 @@ module Aspera
60
59
  module_function :true?, :to_sym, :symbol?
61
60
  end
62
61
 
63
- # Constants to be used as parameter `allowed:` for `OptionValue`.
64
- # Public API: TYPES_STRING_ARRAY, TYPES_SYMBOL_ARRAY, TYPES_INTEGER, TYPES_BOOLEAN, TYPES_NONE.
62
+ # Type specifiers for the `allowed:` parameter of option declarations.
63
+ # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
65
64
  # Internal (do not pass as `allowed:`):
66
- # TYPES_ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
67
- # TYPES_STRING - the implicit default; equivalent to omitting `allowed:` entirely
68
- module Allowed
69
- # This option can be set to a single string or array, multiple times, and gives Array of String
70
- TYPES_STRING_ARRAY = [Array, String].freeze
71
- # A list of symbols with constrained values; use as prefix: TYPES_SYMBOL_ARRAY + [:val1, :val2]
72
- TYPES_SYMBOL_ARRAY = [Array, Symbol].freeze
73
- # Value will be coerced to int
74
- TYPES_INTEGER = [Integer].freeze
75
- TYPES_BOOLEAN = BoolValue::TYPES
76
- # No value at all for the option, it's a switch, like `-N`
77
- TYPES_NONE = [].freeze
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
78
  # Internal: derived when allowed: is an Array<Symbol>; do not pass directly
79
- TYPES_ENUM = [Symbol].freeze
79
+ ENUM = [Symbol].freeze
80
80
  # Internal: implicit default (String); equivalent to omitting allowed: entirely
81
- TYPES_STRING = [String].freeze
81
+ STRING = [String].freeze
82
82
  end
83
83
 
84
84
  # Description of option, how to manage
@@ -103,8 +103,8 @@ module Aspera
103
103
  # - `Class` The single allowed Class
104
104
  # - `Array<Class>` Multiple allowed classes
105
105
  # - `Array<Symbol>` List of allowed values
106
- def initialize(option:, description: nil, allowed: Allowed::TYPES_STRING, handler: nil, deprecation: nil, schema: nil)
107
- Log.log.trace1{"option: #{option}, allowed: #{allowed}"}
106
+ def initialize(option:, description: nil, allowed: Type::STRING, handler: nil, deprecation: nil, schema: nil)
107
+ Log.log.trace1 { "option: #{option}, allowed: #{allowed}" }
108
108
  @option = option
109
109
  @description = description
110
110
  @group = nil
@@ -121,51 +121,83 @@ module Aspera
121
121
  bind_handler(handler) unless handler.nil?
122
122
  @types = nil
123
123
  @values = nil
124
- # Derive allowed type from schema when not explicitly provided
125
- if (allowed.nil? || allowed.eql?(Allowed::TYPES_STRING)) && schema
126
- schema_node = Schema::Registry.instance.reader(schema).current rescue nil
127
- if schema_node
128
- case schema_node['type']
129
- when 'object' then allowed = Hash
130
- when 'array' then allowed = Array
131
- end
132
- end
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
133
146
  end
134
- if !allowed.nil?
135
- allowed = [allowed] if allowed.is_a?(Class)
136
- Aspera.assert_type(allowed, Array)
137
- if allowed.take(Allowed::TYPES_SYMBOL_ARRAY.length) == Allowed::TYPES_SYMBOL_ARRAY
138
- # Special case: array of defined symbol values
139
- @types = Allowed::TYPES_SYMBOL_ARRAY
140
- @values = allowed[Allowed::TYPES_SYMBOL_ARRAY.length..]
141
- # Default value for symbol array when no value has been set yet
142
- assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
143
- elsif allowed.all?(Class)
144
- @types = allowed
145
- @values = BoolValue::ALL if allowed.eql?(Allowed::TYPES_BOOLEAN)
146
- # Default value for array/hash when no value has been set yet
147
- if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
148
- assign_value([], where: 'array default', warn_deprecation: false)
149
- elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
150
- assign_value({}, where: 'hash default', warn_deprecation: false)
151
- end
152
- elsif allowed.all?(Symbol)
153
- @types = Allowed::TYPES_ENUM
154
- @values = allowed
155
- else
156
- Aspera.error_unexpected_value(allowed)
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)
157
183
  end
184
+ elsif allowed.all?(Symbol)
185
+ @types = Type::ENUM
186
+ @values = allowed
187
+ else
188
+ Aspera.error_unexpected_value(allowed)
158
189
  end
159
- Log.log.trace1{"declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
160
190
  end
161
191
 
192
+ public
193
+
162
194
  # Wire (or re-wire) the getter/setter delegation for this option.
163
195
  # Safe to call after construction - used by Parser#set_handler to bind a composed
164
196
  # instance variable that did not exist at class-load time (Category C handlers).
165
197
  # @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
166
198
  # @return [nil]
167
199
  def bind_handler(handler)
168
- Aspera.assert_type(handler, Hash){'handler'}
200
+ Aspera.assert_type(handler, Hash) { 'handler' }
169
201
  # Capture any value already stored locally before switching to delegated storage.
170
202
  # This transfers defaults (and any preset values already applied) to the new target.
171
203
  pending_value = @access.eql?(:local) ? @object : nil
@@ -173,12 +205,12 @@ module Aspera
173
205
  @read_method = handler[:m]
174
206
  @write_method = "#{@read_method}=".to_sym
175
207
  @access = if @object.respond_to?(@write_method)
176
- :write
208
+ :attr # delegation via attr_accessor-style m / m=
177
209
  else
178
- :setter
210
+ :custom # delegation via generic 3-arg method m(sym, :get/:set, val)
179
211
  end
180
- Aspera.assert(@object.respond_to?(@read_method)){"#{@object} does not respond to #{@read_method}"}
181
- Log.log.trace1{"bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
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 }
182
214
  # Push the pending local value to the new target if one was stored
183
215
  assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
184
216
  nil
@@ -193,18 +225,23 @@ module Aspera
193
225
  first_line.end_with?('.') ? first_line[0..-2] : first_line
194
226
  end
195
227
 
228
+ # Reset stored value to nil
229
+ # @return [nil]
196
230
  def clear
197
231
  @object = nil
198
232
  end
199
233
 
234
+ # Get current option value
235
+ # @param log [Boolean] whether to log the value retrieval
236
+ # @return [Object] current value
200
237
  def value(log: true)
201
238
  current_value =
202
239
  case @access
203
- when :local then @object
204
- when :write then @object.send(@read_method)
205
- when :setter then @object.send(@read_method, @option, :get)
240
+ when :local then @object
241
+ when :attr then @object.send(@read_method)
242
+ when :custom then @object.send(@read_method, @option, :get)
206
243
  end
207
- Log.log.trace1{"#{@option} -> (#{current_value.class})#{current_value}"} if log
244
+ Log.log.trace1 { "#{@option} -> (#{current_value.class})#{current_value}" } if log
208
245
  current_value
209
246
  end
210
247
 
@@ -215,25 +252,25 @@ module Aspera
215
252
  # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
216
253
  # @return [nil]
217
254
  def assign_value(value, where:, warn_deprecation: true)
218
- Aspera.assert(!@deprecation, type: :warn){"Option #{@option} is deprecated: #{@deprecation}"} if warn_deprecation
255
+ Aspera.assert(!@deprecation, type: :warn) { "Option #{@option} is deprecated: #{@deprecation}" } if warn_deprecation
219
256
  new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
220
- Log.log.trace1{"#{where}: #{@option} <- (#{new_value.class})#{new_value}"}
257
+ Log.log.trace1 { "#{where}: #{@option} <- (#{new_value.class})#{new_value}" }
221
258
  # Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
222
259
  # Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
223
260
  case @types
224
- when Allowed::TYPES_ENUM
261
+ when Type::ENUM
225
262
  new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
226
- when Allowed::TYPES_BOOLEAN
263
+ when Type::BOOLEAN
227
264
  new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
228
265
  new_value = BoolValue.true?(new_value)
229
- when Allowed::TYPES_INTEGER
266
+ when Type::INTEGER
230
267
  new_value = Integer(new_value)
231
- when Allowed::TYPES_STRING_ARRAY
268
+ when Type::STRING_ARRAY
232
269
  new_value = [new_value] if new_value.is_a?(String)
233
- when Allowed::TYPES_SYMBOL_ARRAY
270
+ when Type::SYMBOL_ARRAY
234
271
  new_value = [new_value] if new_value.is_a?(String)
235
272
  Aspera.assert_array_all(new_value, String, type: BadArgument)
236
- new_value = new_value.map{ |v| Parser.get_from_list(v, @option, @values)}
273
+ new_value = new_value.map { |v| Parser.get_from_list(v, @option, @values) }
237
274
  else
238
275
  # nil (setting nil on a Hash/Array option resets to empty container)
239
276
  new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
@@ -241,42 +278,144 @@ module Aspera
241
278
  end
242
279
  # Skip type validation for the special 'help' value on Hash options: store it as-is
243
280
  # so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
244
- if new_value.eql?(Parser::HELP) && @types&.include?(Hash) && !@schema
245
- case @access
246
- when :local then @object = new_value
247
- when :write then @object.send(@write_method, new_value)
248
- when :setter then @object.send(@read_method, @option, :set, new_value)
249
- end
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)
250
285
  return
251
286
  end
252
- Aspera.assert_type(new_value, *@types, type: BadArgument){"Option #{@option}"} if @types
287
+ Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
253
288
  if new_value.is_a?(Hash) || new_value.is_a?(Array)
254
289
  current_value = value(log: false)
255
290
  new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
256
291
  new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
257
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)
258
304
  case @access
259
- when :local then @object = new_value
260
- when :write then @object.send(@write_method, new_value)
261
- when :setter then @object.send(@read_method, @option, :set, new_value)
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)
262
308
  end
263
- Log.log.trace1{v = value(log: false); "#{@option} <- (#{v.class})#{v}"} # rubocop:disable Style/Semicolon
264
- nil
265
309
  end
266
310
  end
267
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
+
268
403
  # parse command line options
269
404
  # arguments options start with '-', others are commands
270
405
  # resolves on extended value syntax
271
406
  class Parser
272
407
  class << self
273
408
  # 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
412
+ # @return [Symbol, Boolean] matched symbol or boolean value
274
413
  def get_from_list(short_value, descr, allowed_values)
275
414
  Aspera.assert_type(short_value, String)
276
415
  # we accept shortcuts
277
- matching_exact = allowed_values.select{ |i| i.to_s.eql?(short_value)}
416
+ matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
278
417
  return matching_exact.first if matching_exact.length == 1
279
- matching = allowed_values.select{ |i| i.to_s.start_with?(short_value)}
418
+ matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
280
419
  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)
281
420
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
282
421
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
@@ -288,28 +427,42 @@ module Aspera
288
427
  # @return [Object, nil] the matching key, or nil if none or ambiguous
289
428
  def match_prefix(short_value, allowed_values)
290
429
  return short_value if allowed_values.include?(short_value)
291
- matches = allowed_values.select{ |k| k.to_s.start_with?(short_value.to_s)}
430
+ matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
292
431
  matches.length == 1 ? matches.first : nil
293
432
  end
294
433
 
295
434
  # Generates error message with list of allowed values
296
- # @param error_msg [String] Error message
435
+ # @param error_msg [String] Error message
297
436
  # @param accept_list [Array<Symbol>] List of allowed values
298
- def multi_choice_assert_msg(error_msg, accept_list)
299
- [error_msg, 'Use:', *accept_list.map{ |choice| "- #{choice}"}.sort].join("\n")
437
+ # @param aliases [Hash{Symbol=>Symbol}, nil] alias→id map; used to annotate entries with their aliases
438
+ def multi_choice_assert_msg(error_msg, accept_list, aliases: nil)
439
+ # Build reverse map: id → [alias, ...] for annotation
440
+ reverse = aliases&.each_with_object({}) do |(ali, id), h|
441
+ (h[id] ||= []) << ali
442
+ end
443
+ lines = accept_list.map do |choice|
444
+ suffix = reverse&.key?(choice) ? " (alias: #{reverse[choice].join(', ')})" : ''
445
+ "- #{choice}#{suffix}"
446
+ end
447
+ [error_msg, 'Use:', *lines.sort].join("\n")
300
448
  end
301
449
 
302
450
  # Change option name with dash to name with underscore
303
451
  # @param name [String] option name with dash separators
304
452
  # @return [String] option name with underscore separators
305
453
  def option_line_to_name(name)
306
- name.gsub(OPTION_SEP_LINE, OPTION_SEP_SYMBOL)
454
+ name.gsub(Option::NAME_SEP_LINE, Option::NAME_SEP_SYMBOL)
307
455
  end
308
456
 
457
+ # Convert option symbol to CLI line flag format
458
+ # @param name [Symbol, String] option name
459
+ # @return [String] option flag (e.g. "--option-name")
309
460
  def option_name_to_line(name)
310
- "#{OPTION_PREFIX}#{name.to_s.gsub(OPTION_SEP_SYMBOL, OPTION_SEP_LINE)}"
461
+ "#{Option::PREFIX}#{name.to_s.gsub(Option::NAME_SEP_SYMBOL, Option::NAME_SEP_LINE)}"
311
462
  end
312
463
 
464
+ # Parse percent-selector string into field name and value (extended value is parsed in value)
465
+ # @param identifier [String] identifier to parse
313
466
  # @return [Hash{Symbol => String}, nil] `{field:,value:}` if identifier is a percent selector, else `nil`
314
467
  def percent_selector(identifier)
315
468
  Aspera.assert_type(identifier, String)
@@ -318,6 +471,20 @@ module Aspera
318
471
  end
319
472
  nil
320
473
  end
474
+
475
+ # Using dotted hash notation, convert value to bool, int, float or extended value
476
+ # @param value [String] The value to convert to appropriate type
477
+ # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
478
+ def smart_convert(value)
479
+ case value
480
+ when 'true' then true
481
+ when 'false' then false
482
+ else
483
+ Integer(value, exception: false) ||
484
+ Float(value, exception: false) ||
485
+ ExtendedValue.instance.evaluate(value, context: 'dotted expression')
486
+ end
487
+ end
321
488
  end
322
489
 
323
490
  attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
@@ -337,6 +504,8 @@ module Aspera
337
504
  @fail_on_missing_mandatory = true
338
505
  # set to true when --help / -h is parsed
339
506
  @help_requested = false
507
+ # options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
508
+ @explicitly_cleared = {}
340
509
  # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
341
510
  @option_pairs_batch = {}
342
511
  @option_pairs_env = {}
@@ -344,48 +513,41 @@ module Aspera
344
513
  @short_options = {}
345
514
  # Current help section group name, set by #group
346
515
  @current_group = 'global'
347
- env_prefix = program_name.upcase + OPTION_SEP_SYMBOL
516
+ env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
348
517
  ENV.each do |k, v|
349
518
  @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
350
519
  end
351
- Log.log.debug{"env=#{@option_pairs_env}".red}
352
- # command line values starting with at least one '-'
353
- @unprocessed_cmd_line_options = []
354
- # command line values *not* starting with '-'
355
- @unprocessed_cmd_line_arguments = []
356
- # a copy of all initial options
357
- @initial_cli_options = []
358
- # For each option string: list (one entry per occurrence) of the number of positional args
359
- # that appear before it in original argv. Used by `@:` in option values to skip preceding args.
360
- # @type [Hash{String => Array<Integer>}]
361
- @args_before_option = {}
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
362
531
  return if argv.nil?
363
532
  # true until `--` is found (stop options)
364
533
  process_options = true
365
- arg_count = 0
366
534
  argv.each do |value|
367
535
  if process_options && value.start_with?('-')
368
- Log.log.trace1{"opt: #{value}"}
536
+ Log.log.trace1 { "opt: #{value}" }
369
537
  if value.eql?(OPTIONS_STOP)
370
538
  process_options = false
371
539
  else
372
- @unprocessed_cmd_line_options.push(value)
373
- (@args_before_option[value] ||= []).push(arg_count)
540
+ @argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
374
541
  end
375
542
  else
376
- Log.log.trace1{"arg: #{value}"}
377
- @unprocessed_cmd_line_arguments.push(value)
378
- arg_count += 1
543
+ Log.log.trace1 { "arg: #{value}" }
544
+ @argv_tokens.push(Argument.new(value))
379
545
  end
380
546
  end
381
- # Total positional args at parse time - used in args_as_extended to compute how many to skip.
382
- @arg_total_count = @unprocessed_cmd_line_arguments.length
383
- # Number of original positional args before the option currently being parsed (nil = positional context).
384
- @current_option_args_offset = nil
385
- @initial_cli_options = @unprocessed_cmd_line_options.dup.freeze
386
- Log.log.trace1{"add_cmd_line_options:commands/arguments=#{@unprocessed_cmd_line_arguments},options=#{@unprocessed_cmd_line_options}".red}
387
- declare(:interactive, description: 'Use interactive input of missing params', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
388
- declare(:ask_options, description: 'Ask even optional options', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_optional})
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})
389
551
  # do not parse options yet, let's wait for option `-h` to be overridden
390
552
  end
391
553
 
@@ -393,7 +555,7 @@ module Aspera
393
555
  # @param types [Array<Class>] types to add
394
556
  # @return [String] Types if relevant
395
557
  def add_types_info(types)
396
- return '' if !types || types.empty? || types.eql?(Allowed::TYPES_ENUM) || types.eql?(Allowed::TYPES_BOOLEAN) || types.eql?(Allowed::TYPES_STRING)
558
+ return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
397
559
  " (#{types.map(&:name).join(', ')})"
398
560
  end
399
561
 
@@ -401,15 +563,20 @@ module Aspera
401
563
  # @param option_symbol [Symbol] option name
402
564
  # @param description [String, nil] description for help; if nil, derived from schema
403
565
  # @param short [String] short option name
404
- # @param allowed [Object] Allowed values, see `OptionValue`
566
+ # @param allowed [Object] Allowed values, see `OptionValue`.
567
+ # When `schema:` is provided:
568
+ # - Omit `allowed:` when the schema has a single type (`type: object/array`): it is inferred automatically.
569
+ # - Omit `allowed:` when the schema uses `oneOf`/`anyOf` and all branches are `object`: `Hash` is inferred.
570
+ # - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
571
+ # the schema then documents the Hash form and `=help` still shows it.
405
572
  # @param default [Object] default value
406
573
  # @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
407
574
  # @param deprecation [String] deprecation
408
- # @param schema [String] Definition of schema for Hash parameters
575
+ # @param schema [String] schema path documenting the Hash form of this option
409
576
  # @param block [Proc] Block to execute when option is found
410
577
  def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
411
578
  Aspera.assert_type(option_symbol, Symbol)
412
- Aspera.assert(!@declared_options.key?(option_symbol)){"#{option_symbol} already declared"}
579
+ Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
413
580
  Aspera.assert_type(handler, Hash) if handler
414
581
  Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
415
582
  option_attrs = @declared_options[option_symbol] = OptionValue.new(
@@ -422,21 +589,21 @@ module Aspera
422
589
  )
423
590
  option_attrs.group = @current_group
424
591
  description = option_attrs.description
425
- Aspera.assert(!description.nil?){"#{option_symbol}: no description and no schema to derive one from"}
426
- Aspera.assert(description[-1] != '.'){"#{option_symbol} ends with dot"}
427
- Aspera.assert(description[0] == description[0].upcase){"#{option_symbol} description does not start with an uppercase"}
428
- Aspera.assert(!['hash', 'extended value'].any?{ |s| description.downcase.include?(s)}){"#{option_symbol} shall use :allowed instead of hash/extended value in option description"}
429
- set_option(option_symbol, default, where: 'default') unless default.nil?
592
+ Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
593
+ Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
594
+ Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
595
+ 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?
430
597
  case option_attrs.types
431
- when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN
598
+ when Type::ENUM, Type::BOOLEAN
432
599
  # This option value must be a symbol (or array of symbols)
433
- set_option(option_symbol, BoolValue.true?(default), where: 'default') if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
434
- when Allowed::TYPES_NONE
435
- Aspera.assert_type(block, Proc){"missing execution block for #{option_symbol}"}
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}" }
436
603
  option_attrs.block = block
437
604
  end
438
605
  @short_options[short] = option_symbol unless short.nil?
439
- Log.log.trace1{"declare: #{option_symbol}, group: #{@current_group}, short: #{short}"}
606
+ Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
440
607
  end
441
608
 
442
609
  # Set the current help section group name for subsequent declarations
@@ -450,7 +617,7 @@ module Aspera
450
617
  # declares its options before its group name is known (e.g. Plugins::Config).
451
618
  # @param name [String] new group name
452
619
  def rename_current_group(name)
453
- @declared_options.each_value{ |opt| opt.group = name if opt.group.eql?(@current_group)}
620
+ @declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
454
621
  @current_group = name
455
622
  end
456
623
 
@@ -466,59 +633,41 @@ module Aspera
466
633
  # @param aliases [Hash] map of aliases: key = alias, value = real value
467
634
  # @param default [Object] default value
468
635
  # @return [Object, Array, nil] one value, list or nil (if optional and no default)
469
- def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Allowed::TYPES_STRING, aliases: nil, default: nil, schema: nil)
636
+ def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
470
637
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
471
638
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
472
639
  validation = Symbol unless accept_list.nil?
473
640
  validation = [validation] unless validation.is_a?(Array) || validation.nil?
474
- Aspera.assert_array_all(validation, Class){'validation'} unless validation.nil?
641
+ Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
475
642
  descr = "#{descr}#{add_types_info(validation)}"
476
643
  result =
477
- if !@unprocessed_cmd_line_arguments.empty?
478
- case multiple
479
- when true
480
- values = @unprocessed_cmd_line_arguments.shift(@unprocessed_cmd_line_arguments.length)
481
- when false
482
- values = [@unprocessed_cmd_line_arguments.shift]
483
- when String
484
- index = @unprocessed_cmd_line_arguments.index(multiple)
485
- if index
486
- values = @unprocessed_cmd_line_arguments.shift(index)
487
- @unprocessed_cmd_line_arguments.shift # remove end marker
488
- else
489
- values = @unprocessed_cmd_line_arguments.shift(@unprocessed_cmd_line_arguments.length)
490
- end
491
- else Aspera.error_unexpected_value(multiple){'multiple'}
492
- end
493
- values = values.map{ |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation)}
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) }
494
647
  # If expecting list and only one arg of type array : it is the list
495
648
  values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
496
649
  if accept_list
497
650
  allowed_values = [].concat(accept_list)
498
651
  allowed_values.concat(aliases.keys) unless aliases.nil?
499
- values = values.map{ |v| self.class.get_from_list(v, descr, allowed_values)}
652
+ values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
500
653
  end
501
654
  multiple ? values : values.first
502
655
  elsif !default.nil? then default
503
- # no value provided, either get value interactively, or exception
504
- elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, schema: schema)
656
+ elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
505
657
  end
506
- if result.is_a?(String) && validation&.eql?(Allowed::TYPES_INTEGER)
658
+ Log.log.trace1 { "#{descr}=#{result}" }
659
+ 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)
507
664
  int_result = Integer(result, exception: false)
508
665
  raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
509
666
  result = int_result
510
667
  end
511
- Log.log.trace1{"#{descr}=#{result}"}
512
- result = aliases[result] if aliases&.key?(result)
513
- # if value comes from JSON/YAML, it may come as Integer
514
- result = result.to_s if result.is_a?(Integer) && validation&.eql?(Allowed::TYPES_STRING)
515
668
  if validation && (mandatory || !result.nil?)
516
669
  value_list = multiple ? result : [result]
517
- value_list.each do |value|
518
- raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
519
- raise Cli::BadArgument,
520
- "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" unless validation.any?{ |t| value.is_a?(t)}
521
- end
670
+ value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
522
671
  end
523
672
  result
524
673
  end
@@ -535,12 +684,16 @@ module Aspera
535
684
  res_id = get_next_argument(description, multiple: get_option(:bulk))
536
685
  # Can be an Array
537
686
  if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
538
- Aspera.assert(block_given?, type: Cli::BadArgument){"Percent syntax for #{description} not supported in this context"}
687
+ Aspera.assert(block_given?, type: Cli::BadArgument) { "Percent syntax for #{description} not supported in this context" }
539
688
  res_id = yield(m[:field], m[:value])
540
689
  end
541
690
  res_id
542
691
  end
543
692
 
693
+ # Get next positional command argument from accepted list
694
+ # @param command_list [Array<Symbol>] accepted command names
695
+ # @param aliases [Hash, nil] command aliases
696
+ # @return [Symbol] selected command
544
697
  def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
545
698
 
546
699
  # Check whether an option has already been declared in this manager
@@ -558,7 +711,7 @@ module Aspera
558
711
  # @return [OptionValue] Option definition
559
712
  # @raise [Cli::BadArgument] if option not found
560
713
  def option_def(option_symbol)
561
- Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument){"Unknown option: #{option_symbol}"}
714
+ Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
562
715
  @declared_options[option_symbol]
563
716
  end
564
717
 
@@ -579,7 +732,7 @@ module Aspera
579
732
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
580
733
  if result.nil?
581
734
  if !@ask_missing_mandatory
582
- Aspera.assert(!mandatory, type: Cli::BadArgument){"Missing mandatory option: #{option_symbol}"}
735
+ Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
583
736
  elsif @ask_missing_optional || mandatory
584
737
  # ask_missing_mandatory
585
738
  result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
@@ -594,17 +747,19 @@ module Aspera
594
747
  # @param option_symbol [Symbol] option name
595
748
  # @param value [String] Value to set
596
749
  # @param where [String] Where the value comes from
597
- def set_option(option_symbol, value, where: 'code override')
750
+ def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
598
751
  Aspera.assert_type(option_symbol, Symbol)
599
752
  option = option_def(option_symbol)
600
753
  # Raise immediately only when the option has a static schema: the schema is known at parse time.
601
754
  # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
602
755
  # in get_option() with the contextual schema provided by the calling command.
603
756
  raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
604
- option.assign_value(value, where: where)
757
+ option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
605
758
  end
606
759
 
607
760
  # Set option to `nil`
761
+ # @param option_symbol [Symbol] option name
762
+ # @return [nil]
608
763
  def clear_option(option_symbol)
609
764
  Aspera.assert_type(option_symbol, Symbol)
610
765
  option_def(option_symbol).clear
@@ -628,28 +783,36 @@ module Aspera
628
783
  # @param override [Boolean] Override if already present
629
784
  def add_option_preset(preset_hash, where, override: true)
630
785
  Aspera.assert_type(preset_hash, Hash)
631
- Log.log.debug{"add_option_preset: #{preset_hash}, #{where}, #{override}"}
786
+ Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
632
787
  preset_hash.each do |k, v|
788
+ # Ignore comment/meta keys (e.g. _comment, _description)
789
+ next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
633
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)
634
793
  @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
635
794
  end
636
795
  end
637
796
 
638
797
  # Allows a plugin to add an argument as next argument to process
798
+ # @param argument [String] argument value to prepend
799
+ # @return [Array<Option, Argument>] updated tokens list
639
800
  def unshift_next_argument(argument)
640
- @unprocessed_cmd_line_arguments.unshift(argument)
801
+ @argv_tokens.unshift(Argument.new(argument))
641
802
  end
642
803
 
643
- # Check if there were unprocessed values to generate error
804
+ # Check if there are no pending positional arguments
805
+ # @return [Boolean] true if no pending positional arguments
644
806
  def command_or_arg_empty?
645
- @unprocessed_cmd_line_arguments.empty?
807
+ pending_arguments.empty?
646
808
  end
647
809
 
648
- # Unprocessed options or arguments ?
810
+ # Check for unprocessed options or arguments error messages
811
+ # @return [Array<String>] list of error messages for unprocessed tokens
649
812
  def final_errors
650
813
  result = []
651
- result.push("unprocessed options: #{@unprocessed_cmd_line_options}") unless @unprocessed_cmd_line_options.empty?
652
- result.push("unprocessed values: #{@unprocessed_cmd_line_arguments}") unless @unprocessed_cmd_line_arguments.empty?
814
+ result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
815
+ result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
653
816
  result
654
817
  end
655
818
 
@@ -657,17 +820,17 @@ module Aspera
657
820
  # @return [Hash] options as taken from config file and command line just before command execution
658
821
  def unprocessed_options_with_value
659
822
  result = {}
660
- @initial_cli_options.each do |option_argument|
661
- # ignore short options
662
- next unless option_argument.start_with?(OPTION_PREFIX)
663
- name, value = option_argument.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
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) }
664
827
  # ignore options without value
665
828
  next if value.nil?
666
- Log.log.debug{"option #{name}=#{value}"}
667
- path = name.split(DotContainer::SEPARATOR)
668
- path[0] = self.class.option_line_to_name(path[0])
669
- DotContainer.dotted_to_container(path, smart_convert(value), result)
670
- @unprocessed_cmd_line_options.delete(option_argument)
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) }
671
834
  end
672
835
  result
673
836
  end
@@ -692,59 +855,138 @@ module Aspera
692
855
  @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
693
856
  # Then, env var (to override)
694
857
  @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
695
- # Then, command line override: process one option at a time so that @current_option_args_offset
696
- # can be set before each option is evaluated (used by `@:` extended value in option values).
697
- unknown_options = []
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 = []
698
864
  Log.log.trace1('Before parse')
699
- Log.dump(:unprocessed_cmd_line_options, @unprocessed_cmd_line_options, level: :trace1)
700
- until @unprocessed_cmd_line_options.empty?
701
- opt = @unprocessed_cmd_line_options.shift
702
- # Expose args_before for this option so `args_as_extended` can skip args preceding it.
703
- # Peek (first) without consuming - consumed only if this option is processed (not deferred).
704
- @current_option_args_offset = @args_before_option[opt]&.first
705
- if opt.start_with?(OPTION_PREFIX)
706
- # Long option: --name or --name=value
707
- name_raw, raw_value = opt.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
708
- option_sym = self.class.option_line_to_name(name_raw).to_sym
709
- resolved_sym = self.class.match_prefix(option_sym, @declared_options.keys)
710
- if resolved_sym
711
- dispatch_option(resolved_sym, raw_value)
712
- @args_before_option[opt]&.shift # consumed: advance to next occurrence
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')
713
905
  else
714
- # Dotted notation: --a.b.c=d does: a={"b":{"c":ext_val(d)}}
715
- Log.log.trace1{"Unknown long option: #{opt}".red}
716
- if !raw_value.nil?
717
- path = name_raw.split(DotContainer::SEPARATOR)
718
- root_sym = self.class.option_line_to_name(path.shift).to_sym
719
- if @declared_options.key?(root_sym)
720
- set_option(root_sym, DotContainer.dotted_to_container(path, smart_convert(raw_value), get_option(root_sym)), where: 'dotted')
721
- @args_before_option[opt]&.shift # consumed: advance to next occurrence
722
- next
723
- end
724
- end
725
- # Unknown option: defer to next parse_options! round, do not consume the recorded offset
726
- unknown_options.push(opt)
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)
727
909
  end
728
- elsif opt.start_with?('-') && (option_sym = @short_options[opt[1]])
729
- # Short option: -h, -v, or -Pvalue (value glued to flag)
730
- dispatch_option(option_sym, opt.length > 2 ? opt[2..] : nil)
731
- @args_before_option[opt]&.shift # consumed: advance to next occurrence
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)
732
917
  else
733
- unknown_options.push(opt)
918
+ Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
919
+ deferred_tokens.push(tok)
734
920
  end
735
921
  end
736
922
  @current_option_args_offset = nil
923
+ @current_parse_idx = nil
737
924
  Log.log.trace1('After parse')
738
- Log.log.trace1{"remains: #{unknown_options}"}
739
- # Set unprocessed options for next time
740
- @unprocessed_cmd_line_options = unknown_options
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) }
741
979
  end
742
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
743
985
  def prompt_user_input(prompt, sensitive: false)
744
986
  return $stdin.getpass("#{prompt}> ") if sensitive
745
987
  print("#{prompt}> ")
746
988
  line = $stdin.gets
747
- Aspera.assert_type(line, String){'Unexpected end of standard input'}
989
+ Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
748
990
  line.chomp
749
991
  end
750
992
 
@@ -755,7 +997,7 @@ module Aspera
755
997
  def prompt_user_input_in_list(prompt, sym_list)
756
998
  loop do
757
999
  input = prompt_user_input(prompt).to_sym
758
- if sym_list.any?{ |a| a.eql?(input)}
1000
+ if sym_list.any? { |a| a.eql?(input) }
759
1001
  return input
760
1002
  else
761
1003
  $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
@@ -769,13 +1011,13 @@ module Aspera
769
1011
  # @param multiple [Boolean, String] `true` if multiple values expected
770
1012
  # @param accept_list [Array<Symbol>,NilClass] List of expected values
771
1013
  # @return [String] user input
772
- def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, schema: nil)
1014
+ def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
773
1015
  option_attrs = @declared_options[descr.to_sym]
774
1016
  what = option_attrs ? 'option' : 'argument'
775
1017
  default_prompt = "#{what}: #{descr}"
776
1018
  if !@ask_missing_mandatory
777
1019
  message = "Missing #{default_prompt}"
778
- message = self.class.multi_choice_assert_msg(message, accept_list) if accept_list
1020
+ message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
779
1021
  message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
780
1022
  raise Cli::MissingArgument, message
781
1023
  end
@@ -803,23 +1045,23 @@ module Aspera
803
1045
  # ExtendedValue.assert_no_value(end_marker, :p)
804
1046
  end_marker = SpecialValues::EOA if end_marker.empty?
805
1047
  # When called from an option value, skip positional args that appear before the option in argv.
806
- # @current_option_args_offset holds the number of original args before the option (nil = positional context).
807
- # The number to actually skip = args_before_option - args_already_consumed (clamped to 0).
808
- skip_count = if @current_option_args_offset
809
- [@current_option_args_offset - (@arg_total_count - @unprocessed_cmd_line_arguments.length), 0].max
810
- else
811
- 0
812
- end
813
- skipped = skip_count.positive? ? @unprocessed_cmd_line_arguments.shift(skip_count) : []
814
- Log.log.trace1{"args_as_extended: skipping #{skipped.length} args before option: #{skipped}"} unless skipped.empty?
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?
815
1057
  result = nil
816
1058
  get_next_argument('args', multiple: end_marker).each do |argument|
817
- Aspera.assert(argument.include?(OPTION_VALUE_SEPARATOR)){"Positional argument: #{argument} does not include #{OPTION_VALUE_SEPARATOR}"}
818
- path, value = argument.split(OPTION_VALUE_SEPARATOR, 2)
819
- result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), smart_convert(value), result)
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)
820
1062
  end
821
- # Restore skipped args so they remain available for command dispatching
822
- @unprocessed_cmd_line_arguments.unshift(*skipped) unless skipped.empty?
1063
+ # Restore skipped tokens so they remain available for command dispatching
1064
+ skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
823
1065
  result
824
1066
  end
825
1067
 
@@ -837,7 +1079,8 @@ module Aspera
837
1079
  short_char = @short_options.key(sym)
838
1080
  short_part = short_char ? "-#{short_char}, " : ' '
839
1081
  flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
840
- rows << [flag, opt.description]
1082
+ desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
1083
+ rows << [flag, desc]
841
1084
  end
842
1085
  table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
843
1086
  banner.nil? ? table.to_s : "#{banner}\n#{table}"
@@ -857,12 +1100,22 @@ module Aspera
857
1100
  end.freeze
858
1101
 
859
1102
  # @param opt [OptionValue] option descriptor
860
- # @return [String, nil] placeholder shown in flag column: 'ENUM', 'VALUE', or nil for flag switches
1103
+ # @return [String, nil] placeholder shown in flag column: 'ENUM', 'HASH', 'INT', 'LIST', 'VALUE', or nil for flag switches
861
1104
  def option_display_value(opt)
862
1105
  case opt.types
863
- when Allowed::TYPES_NONE then nil
864
- when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN then 'ENUM'
865
- else 'VALUE'
1106
+ when Type::NONE then nil
1107
+ when Type::BOOLEAN then 'yes|no'
1108
+ when Type::INTEGER then 'INT'
1109
+ when Type::ENUM
1110
+ opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
1111
+ else
1112
+ if opt.types&.include?(Hash) || !opt.schema.nil?
1113
+ 'HASH'
1114
+ elsif opt.types&.include?(Array)
1115
+ 'LIST'
1116
+ else
1117
+ 'VALUE'
1118
+ end
866
1119
  end
867
1120
  end
868
1121
 
@@ -871,32 +1124,26 @@ module Aspera
871
1124
  # @param raw_value [String, nil] raw string value from command line, or nil for flag switches
872
1125
  def dispatch_option(sym, raw_value)
873
1126
  opt = @declared_options[sym]
874
- if opt.types.eql?(Allowed::TYPES_NONE)
1127
+ if opt.types.eql?(Type::NONE)
875
1128
  opt.block.call
876
1129
  else
877
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
878
1137
  end
879
1138
  end
880
1139
 
881
- # Using dotted hash notation, convert value to bool, int, float or extended value
882
- # @param value [String] The value to convert to appropriate type
883
- # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
884
- def smart_convert(value)
885
- case value
886
- when 'true' then true
887
- when 'false' then false
888
- else
889
- Integer(value, exception: false) ||
890
- Float(value, exception: false) ||
891
- ExtendedValue.instance.evaluate(value, context: 'dotted expression')
892
- end
893
- end
894
-
895
- # generate command line option from option symbol
1140
+ # Generate command line option string from option symbol
1141
+ # @param symbol [Symbol] option name
1142
+ # @param opt_val [String, nil] optional value placeholder
1143
+ # @return [String] formatted option string (e.g. "--option=VALUE")
896
1144
  def symbol_to_option(symbol, opt_val = nil)
897
- result = [OPTION_PREFIX, symbol.to_s.gsub(OPTION_SEP_SYMBOL, OPTION_SEP_LINE)].join
898
- result = [result, OPTION_VALUE_SEPARATOR, opt_val].join unless opt_val.nil?
899
- result
1145
+ result = self.class.option_name_to_line(symbol)
1146
+ opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
900
1147
  end
901
1148
 
902
1149
  # TODO: use formatter
@@ -923,28 +1170,40 @@ module Aspera
923
1170
  # @param where [String] label used in log messages and error context
924
1171
  # @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
925
1172
  def consume_option_pairs(option_pairs, where)
926
- Log.log.trace1{"consume_option_pairs: #{where}"}
1173
+ Log.log.trace1 { "consume_option_pairs: #{where}" }
927
1174
  remaining = {}
928
1175
  option_pairs.each do |k, v|
929
1176
  if @declared_options.key?(k)
930
1177
  set_option(k, v, where: where)
931
1178
  else
932
- Log.log.trace1{"unprocessed: #{k}: #{v}"}
1179
+ Log.log.trace1 { "unprocessed: #{k}: #{v}" }
933
1180
  remaining[k] = v
934
1181
  end
935
1182
  end
936
1183
  remaining
937
1184
  end
938
1185
 
939
- # Option name separator on command line, e.g. in --option-blah, third "-"
940
- OPTION_SEP_LINE = '-'
941
- # Option name separator in code (symbol), e.g. in :option_blah, the "_"
942
- OPTION_SEP_SYMBOL = '_'
943
- # Option value separator on command line, e.g. in --option-blah=foo, the "="
944
- OPTION_VALUE_SEPARATOR = '='
945
- # Starts an option, e.g. in --option-blah, the two first "--"
946
- OPTION_PREFIX = '--'
947
- # when this is alone, this stops option processing
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
+ end
1205
+
1206
+ # when this is alone, this stops option processing (same characters as Option::PREFIX but distinct semantics)
948
1207
  OPTIONS_STOP = '--'
949
1208
  SOURCE_USER = 'cmdline' # cspell:disable-line
950
1209
  # Percent selector: select by this field for this value
@@ -952,7 +1211,7 @@ module Aspera
952
1211
  # Ask for schema of Extended value
953
1212
  HELP = 'help'
954
1213
 
955
- private_constant :OPTION_SEP_LINE, :OPTION_SEP_SYMBOL, :OPTION_VALUE_SEPARATOR, :OPTION_PREFIX, :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
1214
+ private_constant :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
956
1215
  end
957
1216
  end
958
1217
  end