slipway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. metadata +173 -0
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+ require 'time'
5
+ require_relative 'error'
6
+ require_relative 'git/branch_name'
7
+ require_relative 'git/url'
8
+ require_relative 'names'
9
+ require_relative 'labels'
10
+ require_relative 'resources'
11
+ require_relative 'schema'
12
+ require_relative 'yaml'
13
+
14
+ module Slipway
15
+ module Manifest
16
+ class Invalid < Error
17
+ attr_reader :source, :problem
18
+
19
+ def initialize(source, problem)
20
+ @source = source
21
+ @problem = problem
22
+ super("#{source}: #{problem}")
23
+ end
24
+ end
25
+
26
+ # Turns one parsed document into a Project or a Group, or raises Invalid with the source and
27
+ # the first field that breaks its rule. Which fields exist, which are required, their defaults
28
+ # and the words of each refusal come from Schema, so a field Schema does not name is refused.
29
+ # Every field that can reach git or a command slipway prints is checked here, so a resource
30
+ # read from the store or applied from a file can be handed to Git::Repository as it is.
31
+ class Reader
32
+ TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
33
+ # SHA-1 or SHA-256; an abbreviation can become ambiguous as the repository grows.
34
+ REVISION = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
35
+
36
+ def initialize(document, source, default_group)
37
+ @document = document
38
+ @source = source
39
+ @default_group = default_group
40
+ end
41
+
42
+ def resource
43
+ invalid('document is not a mapping') unless @document.is_a?(Hash)
44
+ @schema = Schema::KINDS.fetch(kind)
45
+ reject_unknown(@document, @schema, nil)
46
+ @metadata = mapping('metadata')
47
+ @spec = mapping('spec')
48
+ kind == 'Project' ? project : group
49
+ end
50
+
51
+ private
52
+
53
+ def kind
54
+ case @document['kind']
55
+ in String => kind if Schema::KINDS.key?(kind) then kind
56
+ in nil then required('kind')
57
+ in String => other then invalid("\"kind\" must be #{Schema::KINDS.keys.join(' or ')}, not #{other.inspect}")
58
+ else mistyped('kind', Schema::STRING)
59
+ end
60
+ end
61
+
62
+ def project
63
+ Project.new(name:, group: group_name, labels:, created_at:, path:, description:, remote:, branch:, revision:,
64
+ sync_policy:, paused:)
65
+ end
66
+
67
+ def group = Group.new(name:, labels:, created_at:, description:)
68
+
69
+ def name
70
+ checked { Names.validate!(string(@metadata, 'metadata', 'name'), what: "#{kind.downcase} name") }
71
+ end
72
+
73
+ def group_name
74
+ checked { Names.validate!(string(@metadata, 'metadata', 'group') || @default_group, what: 'group name') }
75
+ end
76
+
77
+ # An empty path would be resolved against whatever directory the reader happens to be in.
78
+ def path
79
+ value = string(@spec, 'spec', 'path')
80
+ broken('spec', 'path') if value.strip.empty?
81
+ value
82
+ end
83
+
84
+ def description = string(@spec, 'spec', 'description')
85
+
86
+ def remote
87
+ url = string(@spec, 'spec', 'remote')
88
+ url && checked { Git::Url.validate!(url, field: '"spec.remote"') }
89
+ end
90
+
91
+ def branch
92
+ name = string(@spec, 'spec', 'branch')
93
+ name && checked { Git::BranchName.validate!(name) }
94
+ end
95
+
96
+ def revision
97
+ case @spec['revision']
98
+ in nil then nil
99
+ in String => sha if REVISION.match?(sha) then sha
100
+ else broken('spec', 'revision')
101
+ end
102
+ end
103
+
104
+ def sync_policy
105
+ field = @schema.dig('spec', 'syncPolicy')
106
+ case @spec['syncPolicy']
107
+ in nil then field.default
108
+ in String => policy if field.enum.include?(policy) then policy
109
+ in String => other then invalid("\"spec.syncPolicy\" #{field.rule}, not #{other.inspect}")
110
+ else mistyped('spec.syncPolicy', Schema::STRING)
111
+ end
112
+ end
113
+
114
+ def paused
115
+ case @spec['paused']
116
+ in nil then @schema.dig('spec', 'paused').default
117
+ in true | false => paused then paused
118
+ else mistyped('spec.paused', Schema::BOOLEAN)
119
+ end
120
+ end
121
+
122
+ def labels
123
+ case @metadata['labels']
124
+ in nil then {}
125
+ in Hash => labels then checked { Labels.validate!(string_pairs(labels)) }
126
+ else mistyped('metadata.labels', Schema::LABEL_MAP)
127
+ end
128
+ end
129
+
130
+ def string_pairs(labels)
131
+ labels.each do |key, value|
132
+ invalid('"metadata.labels" keys must be strings') unless key.is_a?(String)
133
+ mistyped("metadata.labels.#{key}", Schema::STRING) unless value.is_a?(String)
134
+ end
135
+ end
136
+
137
+ def created_at
138
+ case @metadata['creationTimestamp']
139
+ in nil then nil
140
+ in String => text if TIMESTAMP.match?(text) then time(text)
141
+ else broken('metadata', 'creationTimestamp')
142
+ end
143
+ end
144
+
145
+ # Second precision, so the value read back equals the value written.
146
+ def time(text)
147
+ Time.iso8601(text).getutc.floor
148
+ rescue ArgumentError
149
+ broken('metadata', 'creationTimestamp')
150
+ end
151
+
152
+ def mapping(key)
153
+ case @document[key]
154
+ in nil then {}
155
+ in Hash => section then reject_unknown(section, @schema.field(key), key)
156
+ else mistyped(key, Schema::OBJECT)
157
+ end
158
+ end
159
+
160
+ def reject_unknown(section, schema, prefix)
161
+ section.each_key do |key|
162
+ next if schema.field(key)
163
+
164
+ invalid("unknown field #{[prefix, key].compact.join('.').inspect}")
165
+ end
166
+ end
167
+
168
+ def string(section, prefix, key)
169
+ case section[key]
170
+ in String => value then value
171
+ in nil if @schema.dig(prefix, key).required then required("#{prefix}.#{key}")
172
+ in nil then nil
173
+ else mistyped("#{prefix}.#{key}", Schema::STRING)
174
+ end
175
+ end
176
+
177
+ def checked
178
+ yield
179
+ rescue Names::Invalid, Labels::Invalid, Git::Url::Invalid, Git::BranchName::Invalid => e
180
+ invalid(e.message)
181
+ end
182
+
183
+ def required(path) = invalid("\"#{path}\" is required")
184
+
185
+ def mistyped(path, type) = invalid("\"#{path}\" must be #{Schema::NOUNS.fetch(type)}")
186
+
187
+ # For the fields whose rule this class checks itself rather than a module such as Names.
188
+ def broken(prefix, key) = invalid("\"#{prefix}.#{key}\" #{@schema.dig(prefix, key).rule}")
189
+
190
+ def invalid(problem)
191
+ raise Invalid.new(@source, problem)
192
+ end
193
+ end
194
+
195
+ private_constant :Reader
196
+
197
+ LIST_KIND = 'List'
198
+ LIST_FIELDS = %w[kind items].freeze
199
+
200
+ # `hash` must have string keys.
201
+ def self.parse(hash, source:, default_group: 'default') = Reader.new(hash, source, default_group).resource
202
+
203
+ def self.load_documents(text, source:)
204
+ documents(text, source).each_with_index.filter_map do |document, index|
205
+ case document
206
+ in nil then nil
207
+ in Hash then document
208
+ else raise Invalid.new(source, "document #{index + 1} is not a mapping")
209
+ end
210
+ end
211
+ rescue Psych::SyntaxError => e
212
+ raise Invalid.new(source, "#{e.problem} at line #{e.line}, column #{e.column}")
213
+ rescue Psych::DisallowedClass => e
214
+ raise Invalid.new(source, disallowed(e))
215
+ rescue Psych::BadAlias => e
216
+ raise Invalid.new(source, e.message)
217
+ end
218
+
219
+ # kubectl prints several objects as one List and applies such a List back item by item, so
220
+ # each item stands for a document of its own.
221
+ def self.load_objects(text, source:)
222
+ load_documents(text, source:).flat_map { it['kind'] == LIST_KIND ? list_items(it, source) : [it] }
223
+ end
224
+
225
+ def self.list_items(list, source)
226
+ unknown = list.keys - LIST_FIELDS
227
+ raise Invalid.new(source, "unknown field #{unknown.first.inspect} in a List") unless unknown.empty?
228
+
229
+ case list['items']
230
+ in nil then []
231
+ in Array => items if items.all?(Hash) then items
232
+ else raise Invalid.new(source, '"items" of a List must be a sequence of mappings')
233
+ end
234
+ end
235
+ private_class_method :list_items
236
+
237
+ def self.parse_yaml(text, source:, default_group: 'default')
238
+ case load_documents(text, source:)
239
+ in [document] then parse(document, source:, default_group:)
240
+ in [] then raise Invalid.new(source, 'expected one document, found none')
241
+ in documents then raise Invalid.new(source, "expected one document, found #{documents.size}")
242
+ end
243
+ end
244
+
245
+ # Psych.safe_load_stream only exists from psych 5.3 and the gem supports Ruby 3.4, so the
246
+ # stream is parsed first and every document re-emitted alone for Psych.safe_load.
247
+ def self.documents(text, source)
248
+ Psych.parse_stream(text, filename: source).children.map do |document|
249
+ stream = Psych::Nodes::Stream.new
250
+ stream.children << document
251
+ Psych.safe_load(stream.to_yaml, filename: source)
252
+ end
253
+ end
254
+ private_class_method :documents
255
+
256
+ # YAML types an unquoted 2026-09-29T00:12:33Z as a Time, which the safe loader refuses.
257
+ def self.disallowed(error)
258
+ klass = error.message.delete_prefix('Tried to load unspecified class: ')
259
+ "#{klass} values are not accepted; timestamps, dates and symbols must be quoted strings"
260
+ end
261
+ private_class_method :disallowed
262
+
263
+ def self.dump(resource) = Yaml.dump(resource.to_manifest)
264
+ end
265
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+
5
+ module Slipway
6
+ # The RFC 1123 label rule kubectl applies to object names.
7
+ module Names
8
+ # Callers decide whether the name came from the command line or a file.
9
+ class Invalid < Error; end
10
+
11
+ PATTERN = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/
12
+ RULE = 'lowercase letters, digits and dashes, starting and ending with a letter or digit, at most 63 characters'
13
+
14
+ def self.valid?(name) = name.is_a?(String) && PATTERN.match?(name)
15
+
16
+ def self.validate!(name, what: 'name')
17
+ return name if valid?(name)
18
+
19
+ raise Invalid, "#{name.inspect} is not a valid #{what}: #{RULE}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git'
4
+ require_relative 'state'
5
+
6
+ module Slipway
7
+ # One project's result line in a verb that acts on each selected repository: `word` starts it,
8
+ # `reason` follows in parentheses and `details` are printed under it as they are; the printer
9
+ # redacts them and makes them plain.
10
+ Outcome = Data.define(:project, :word, :reason, :details)
11
+
12
+ class Outcome
13
+ # The words more than one verb prints. A word only one verb prints stays with that verb.
14
+ FETCHED = 'fetched'
15
+ UNCHANGED = 'unchanged'
16
+ SKIPPED = 'skipped'
17
+ PAUSED = 'paused'
18
+ DENIED = 'denied'
19
+ FAILED = 'failed'
20
+ ERROR_WORDS = { Git::AuthRequired => DENIED, Git::LocalUpstream => SKIPPED }.freeze
21
+ REASONS = { Git::AuthRequired => 'AuthRequired', Git::LocalUpstream => 'LocalUpstream',
22
+ Git::Timeout => 'Timeout', Git::WriteTimeout => 'Timeout',
23
+ Git::ProtocolNotAllowed => 'ProtocolNotAllowed' }.freeze
24
+
25
+ def initialize(project:, word:, reason: nil, details: []) = super
26
+
27
+ # The line above already names the project, so the path is cut from the message.
28
+ def self.failure(project, error)
29
+ reason = REASONS.fetch(error.class) { State.for_error(error) }
30
+ new(project:, word: ERROR_WORDS.fetch(error.class, FAILED), reason:,
31
+ details: [detail(error), error.hint].compact)
32
+ end
33
+
34
+ # Nothing else in the result says which directory could not be read, so it leads the detail,
35
+ # as the manifest writes it.
36
+ def self.unreadable(project, inspection)
37
+ error = inspection.error
38
+ new(project:, word: SKIPPED, reason: inspection.state,
39
+ details: ["#{project.path}: #{detail(error)}", error.hint].compact)
40
+ end
41
+
42
+ def self.detail(error) = error.message.delete_prefix("#{error.path}: ")
43
+ private_class_method :detail
44
+ end
45
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Output
5
+ # Formats an elapsed time the way kubectl's AGE column does: two units at most, the
6
+ # precision dropping as the duration grows, and integer truncation at every step.
7
+ module Age
8
+ INVALID = '<invalid>'
9
+ MINUTE = 60
10
+ HOUR = 60
11
+ DAY = 24
12
+ YEAR = 365
13
+
14
+ def self.humanize(from, to)
15
+ return nil if from.nil?
16
+
17
+ format(to - from)
18
+ end
19
+
20
+ # Two or more seconds in the future is invalid; a little clock skew rounds to zero.
21
+ def self.format(seconds)
22
+ seconds = seconds.to_i
23
+ return INVALID if seconds < -1
24
+ return '0s' if seconds.negative?
25
+ return "#{seconds}s" if seconds < 2 * MINUTE
26
+
27
+ from_minutes(seconds)
28
+ end
29
+
30
+ def self.from_minutes(seconds)
31
+ minutes = seconds / MINUTE
32
+ return pair(minutes, 'm', seconds % MINUTE, 's') if minutes < 10
33
+ return "#{minutes}m" if minutes < 3 * HOUR
34
+
35
+ from_hours(minutes)
36
+ end
37
+
38
+ def self.from_hours(minutes)
39
+ hours = minutes / HOUR
40
+ return pair(hours, 'h', minutes % HOUR, 'm') if hours < 8
41
+ return "#{hours}h" if hours < 2 * DAY
42
+
43
+ from_days(hours)
44
+ end
45
+
46
+ def self.from_days(hours)
47
+ days = hours / DAY
48
+ return pair(days, 'd', hours % DAY, 'h') if days < 8
49
+ return "#{days}d" if days < 2 * YEAR
50
+
51
+ from_years(days)
52
+ end
53
+
54
+ def self.from_years(days)
55
+ years = days / YEAR
56
+ return pair(years, 'y', days % YEAR, 'd') if years < 8
57
+
58
+ "#{years}y"
59
+ end
60
+
61
+ def self.pair(major, major_unit, minor, minor_unit)
62
+ return "#{major}#{major_unit}" if minor.zero?
63
+
64
+ "#{major}#{major_unit}#{minor}#{minor_unit}"
65
+ end
66
+
67
+ private_class_method :from_minutes, :from_hours, :from_days, :from_years, :pair
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Output
5
+ # A value whose color the caller has already chosen, such as a STATUS word.
6
+ Painted = Data.define(:role, :text)
7
+
8
+ class Describe
9
+ NONE = '<none>'
10
+ INDENT = ' '
11
+ GAP = 2
12
+ TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
13
+
14
+ def initialize(context)
15
+ @context = context
16
+ end
17
+
18
+ # A value that is a non-empty list of [key, value] pairs renders as a nested block; any
19
+ # other list prints one item per line.
20
+ def render(entries)
21
+ return '' if entries.empty?
22
+
23
+ "#{block(entries, 0).join("\n")}\n"
24
+ end
25
+
26
+ def print(entries) = @context.print(render(entries))
27
+
28
+ private
29
+
30
+ def block(entries, depth)
31
+ width = entries.map { |key, _| key.to_s.size + 1 }.max + GAP
32
+ entries.flat_map { |key, value| entry(key.to_s, value, depth, width) }
33
+ end
34
+
35
+ def entry(key, value, depth, width)
36
+ indent = INDENT * depth
37
+ label = "#{indent}#{@context.style.paint_cycle(:describe_keys, depth, key)}:"
38
+ return [label, *block(value, depth + 1)] if section?(value)
39
+
40
+ first, *rest = values(value)
41
+ column = ' ' * (indent.size + width)
42
+ [label + (' ' * (width - key.size - 1)) + first, *rest.map { column + it }]
43
+ end
44
+
45
+ def section?(value)
46
+ value.is_a?(Array) && !value.empty? && value.all? { it.is_a?(Array) && it.size == 2 }
47
+ end
48
+
49
+ # Never empty: #entry puts the first line beside the key.
50
+ def values(value)
51
+ case value
52
+ when nil, [], {} then [@context.paint(:none, NONE)]
53
+ when Hash then value.sort.map { |key, item| Output.plain("#{key}=#{item}") }
54
+ when Array then value.map { Output.plain(it) }
55
+ else [scalar(value)]
56
+ end
57
+ end
58
+
59
+ def scalar(value)
60
+ case value
61
+ when Painted then @context.paint(value.role, Output.plain(value.text))
62
+ when Numeric then @context.paint(:number, value)
63
+ when true then @context.paint(:boolean_true, value)
64
+ when false then @context.paint(:boolean_false, value)
65
+ when Time then value.utc.strftime(TIME_FORMAT)
66
+ else Output.plain(value)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Output
5
+ # The plaintext layout of kubectl explain: the KIND line, the FIELD line when a field was named,
6
+ # its DESCRIPTION wrapped at 80 columns and the FIELDS under it, each with its description or,
7
+ # with `recursive`, as a tree of names and types. The types line up in one column, where
8
+ # kubectl puts a tab. Colors follow kubecolor: the labels, the field names and the -required-
9
+ # mark.
10
+ class Explain
11
+ # `description` is the text to print whole; `fields` are the fields under this one.
12
+ Field = Data.define(:name, :type, :required, :description, :fields)
13
+
14
+ WIDTH = 80
15
+ INDENT = ' '
16
+ GAP = 3
17
+ REQUIRED = '-required-'
18
+
19
+ def initialize(context)
20
+ @context = context
21
+ end
22
+
23
+ # `named` prints the FIELD line, for a field below the resource type itself.
24
+ def render(kind:, field:, named: false, recursive: false)
25
+ sections = ["#{label('KIND')} #{kind}"]
26
+ sections << "#{label('FIELD')} #{field.name} #{type(field)}" if named
27
+ sections << "#{label('DESCRIPTION')}\n#{paragraph(field.description, INDENT * 2)}"
28
+ sections << fields(field, recursive) unless field.fields.empty?
29
+ "#{sections.join("\n\n")}\n"
30
+ end
31
+
32
+ def print(**) = @context.print(render(**))
33
+
34
+ private
35
+
36
+ def label(word) = "#{@context.style.paint_cycle(:describe_keys, 0, word)}:"
37
+
38
+ def type(field)
39
+ mark = " #{@context.paint(:explain_required, REQUIRED)}" if field.required
40
+ "<#{field.type}>#{mark}"
41
+ end
42
+
43
+ # Each row is [depth, field], with depth 1 for the fields right under the one explained.
44
+ def fields(field, recursive)
45
+ rows = recursive ? tree(field.fields, 1) : field.fields.map { [1, it] }
46
+ width = rows.map { |depth, child| (INDENT * depth).size + child.name.size }.max + GAP
47
+ entries = rows.map { |depth, child| entry(child, depth, width, recursive) }
48
+ "#{label('FIELDS')}\n#{entries.join(recursive ? "\n" : "\n\n")}"
49
+ end
50
+
51
+ def tree(fields, depth) = fields.flat_map { [[depth, it], *tree(it.fields, depth + 1)] }
52
+
53
+ # A recursive listing cycles the name colors by depth, as kubecolor does.
54
+ def entry(field, depth, width, recursive)
55
+ indent = INDENT * depth
56
+ name = @context.style.paint_cycle(:describe_keys, recursive ? depth - 1 : 0, field.name)
57
+ line = "#{indent}#{name}#{' ' * (width - indent.size - field.name.size)}#{type(field)}"
58
+ recursive ? line : "#{line}\n#{paragraph(field.description, indent + INDENT)}"
59
+ end
60
+
61
+ def paragraph(text, indent) = wrap(text, WIDTH - indent.size).map { "#{indent}#{it}" }.join("\n")
62
+
63
+ # A word longer than the width gets a line of its own rather than being cut.
64
+ def wrap(text, width)
65
+ text.split.each_with_object([]) do |word, lines|
66
+ if lines.empty? || lines.last.size + word.size >= width
67
+ lines << word
68
+ else
69
+ lines[-1] = "#{lines.last} #{word}"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../yaml'
4
+
5
+ module Slipway
6
+ module Output
7
+ # Callers pass Hashes with string keys and strings for timestamps, because the YAML dump
8
+ # admits only the core scalar types.
9
+ module Serializer
10
+ STRUCTURED = %w[json yaml].freeze
11
+ LIST_KIND = 'List'
12
+
13
+ def self.render(format, items, single:)
14
+ document = single ? items.first : { 'kind' => LIST_KIND, 'items' => items }
15
+ case format
16
+ when 'json' then json(document)
17
+ when 'yaml' then Yaml.dump(document)
18
+ else raise ArgumentError, unknown_format(format)
19
+ end
20
+ end
21
+
22
+ # Required lazily: most runs print a table and never need json.
23
+ def self.json(document)
24
+ require 'json'
25
+ "#{JSON.pretty_generate(document)}\n"
26
+ end
27
+
28
+ def self.unknown_format(format)
29
+ "unknown structured format #{format.inspect} (known formats: #{STRUCTURED.join(', ')})"
30
+ end
31
+
32
+ private_class_method :json, :unknown_format
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Output
5
+ # Widths come from the plain text, so color never shifts a column.
6
+ class Table
7
+ NONE = '<none>'
8
+ GAP = ' '
9
+
10
+ # `roles` is called with the header and the plain cell text and may return a theme role
11
+ # that replaces the column color. `color_offset` leading columns stay out of the color cycle.
12
+ def initialize(context, headers:, show_headers: true, roles: nil, color_offset: 0)
13
+ @context = context
14
+ @headers = headers
15
+ @show_headers = show_headers
16
+ @roles = roles
17
+ @color_offset = color_offset
18
+ end
19
+
20
+ def render(rows)
21
+ cells = rows.map { |row| @headers.each_index.map { text(row[it]) } }
22
+ widths = column_widths(cells)
23
+ lines = cells.map { row_line(it, widths) }
24
+ lines.unshift(header_line(widths)) if @show_headers
25
+ lines.map { "#{it}\n" }.join
26
+ end
27
+
28
+ def print(rows) = @context.print(render(rows))
29
+
30
+ private
31
+
32
+ def text(value)
33
+ value = Output.plain(value)
34
+ value.empty? ? NONE : value
35
+ end
36
+
37
+ def column_widths(cells)
38
+ @headers.each_index.map do |index|
39
+ sizes = cells.map { it[index].size }
40
+ sizes << @headers[index].to_s.size if @show_headers
41
+ sizes.max
42
+ end
43
+ end
44
+
45
+ def header_line(widths)
46
+ titles = @headers.each_with_index.map { |header, index| header.to_s.upcase.ljust(widths[index]) }
47
+ @context.paint(:table_header, titles.join(GAP).rstrip)
48
+ end
49
+
50
+ def row_line(cells, widths)
51
+ painted = cells.each_with_index.map do |cell, index|
52
+ paint_cell(cell, index) + (' ' * (widths[index] - cell.size))
53
+ end
54
+ painted.join(GAP).rstrip
55
+ end
56
+
57
+ def paint_cell(cell, index)
58
+ return @context.paint(:none, cell) if cell == NONE
59
+
60
+ role = @roles&.call(@headers[index], cell)
61
+ return @context.paint(role, cell) if role
62
+
63
+ @context.style.paint_cycle(:table_columns, index - @color_offset, cell)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli/style'
4
+ require_relative 'output/age'
5
+ require_relative 'output/table'
6
+ require_relative 'output/describe'
7
+ require_relative 'output/explain'
8
+ require_relative 'output/serializer'
9
+
10
+ module Slipway
11
+ module Output
12
+ TABLE = 'table'
13
+ WIDE = 'wide'
14
+ NAME = 'name'
15
+ # Help lists the formats in this order.
16
+ FORMATS = [TABLE, WIDE, *Serializer::STRUCTURED, NAME].freeze
17
+
18
+ # The rule lives in the command layer, whose runner prints the error lines and cannot
19
+ # require Output.
20
+ def self.plain(text) = CLI::Style.plain(text)
21
+
22
+ # A warning can quote a manifest path or git's stderr. Every warning line is built here, and
23
+ # the conventions test refuses the prefix anywhere else, so none reaches stderr raw.
24
+ def self.warning(context, message)
25
+ context.warn("#{context.paint_err(:warning, 'warning:')} #{plain(message)}")
26
+ end
27
+ end
28
+ end