csvops 0.8.0.alpha → 0.9.0.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -3
  3. data/docs/architecture.md +3 -0
  4. data/docs/cli-output-conventions.md +49 -0
  5. data/docs/release-v0.9.0-alpha.md +80 -0
  6. data/lib/csvtool/cli.rb +132 -12
  7. data/lib/csvtool/interface/cli/menu_loop.rb +6 -5
  8. data/lib/csvtool/interface/cli/output/color_policy.rb +25 -0
  9. data/lib/csvtool/interface/cli/output/colorizer.rb +27 -0
  10. data/lib/csvtool/interface/cli/output/formatters/csv_row_formatter.rb +19 -0
  11. data/lib/csvtool/interface/cli/output/formatters/stats_formatter.rb +57 -0
  12. data/lib/csvtool/interface/cli/output/streams.rb +22 -0
  13. data/lib/csvtool/interface/cli/output/table_renderer.rb +70 -0
  14. data/lib/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter.rb +17 -5
  15. data/lib/csvtool/interface/cli/workflows/presenters/csv_parity_presenter.rb +15 -4
  16. data/lib/csvtool/interface/cli/workflows/presenters/csv_split_presenter.rb +15 -6
  17. data/lib/csvtool/interface/cli/workflows/presenters/csv_stats_presenter.rb +18 -9
  18. data/lib/csvtool/interface/cli/workflows/presenters/row_extraction_presenter.rb +5 -4
  19. data/lib/csvtool/interface/cli/workflows/presenters/row_randomization_presenter.rb +5 -4
  20. data/lib/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow.rb +9 -8
  21. data/lib/csvtool/interface/cli/workflows/run_csv_parity_workflow.rb +6 -5
  22. data/lib/csvtool/interface/cli/workflows/run_csv_split_workflow.rb +11 -10
  23. data/lib/csvtool/interface/cli/workflows/run_csv_stats_workflow.rb +7 -6
  24. data/lib/csvtool/interface/cli/workflows/run_extraction_workflow.rb +9 -8
  25. data/lib/csvtool/interface/cli/workflows/run_row_extraction_workflow.rb +7 -6
  26. data/lib/csvtool/interface/cli/workflows/run_row_randomization_workflow.rb +8 -7
  27. data/lib/csvtool/version.rb +1 -1
  28. data/test/csvtool/cli_test.rb +289 -44
  29. data/test/csvtool/cli_unit_test.rb +5 -5
  30. data/test/csvtool/interface/cli/output/color_policy_test.rb +40 -0
  31. data/test/csvtool/interface/cli/output/colorizer_test.rb +28 -0
  32. data/test/csvtool/interface/cli/output/formatters/csv_row_formatter_test.rb +22 -0
  33. data/test/csvtool/interface/cli/output/formatters/stats_formatter_test.rb +51 -0
  34. data/test/csvtool/interface/cli/output/streams_test.rb +25 -0
  35. data/test/csvtool/interface/cli/output/table_renderer_test.rb +36 -0
  36. data/test/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter_test.rb +4 -1
  37. data/test/csvtool/interface/cli/workflows/presenters/csv_parity_presenter_test.rb +5 -1
  38. data/test/csvtool/interface/cli/workflows/presenters/csv_split_presenter_test.rb +22 -4
  39. data/test/csvtool/interface/cli/workflows/presenters/csv_stats_presenter_test.rb +7 -5
  40. data/test/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow_test.rb +10 -7
  41. data/test/csvtool/interface/cli/workflows/run_csv_parity_workflow_test.rb +3 -1
  42. data/test/csvtool/interface/cli/workflows/run_csv_split_workflow_test.rb +5 -3
  43. data/test/csvtool/interface/cli/workflows/run_csv_stats_workflow_test.rb +23 -18
  44. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c09a1df68b5bbb8885b254bd7ea1260617495fc042cae43ef7251d9eb66836e
4
- data.tar.gz: a5fbf5098df8007e844c83134a5474f92eafe4866b4d9910519d9a1517675af9
3
+ metadata.gz: 940c2492f5bea33d56ad0a47ebf6933cb2e43817530aa04d4e02950affe9d493
4
+ data.tar.gz: 9c32a162b4393f25e99b55e908df2b9dcb55099ce0d820aa3255c310fc09d983
5
5
  SHA512:
6
- metadata.gz: 3695b9d49a638138d03d69122267a2889ecb6bd33605e9a256a20480ccab869c858f8f08996a213a885c2fb9a08e740712bb1dcc38fef6734d10129b20b3d611
7
- data.tar.gz: df4cf19d31ac3c317ae69552cd3181c48984295268880810adf0d7b46bb606a2fa92646c35c4b1cd3343c04c5d08c734efb6b53b08f44fb3259b3dc3cbaf4509
6
+ metadata.gz: 3babceb657e3e3c366c19daa305fcffe18fca1e145dafa8d8f66f022531e17fc21f0788da7b27a0d76e17e07c88fa098105d20116353cced5e4b207870a0f88e
7
+ data.tar.gz: a8efe4b0a86dd3e303ca8946a0b25b3dbcffb1bbeb76823910d43ab8ff1ba4fc7670f1a2c16e135941caf74a3a216f988010bd0178b892a60eb9bd82963c4596
data/README.md CHANGED
@@ -57,7 +57,10 @@ Typical prompt pattern:
57
57
  - choose action-specific options
58
58
  - choose output destination (console or file)
59
59
 
60
- For architecture and internal design details, see [`docs/architecture.md`](docs/architecture.md).
60
+ For architecture and internal design details, see:
61
+
62
+ - [`docs/architecture.md`](docs/architecture.md)
63
+ - [`docs/cli-output-conventions.md`](docs/cli-output-conventions.md)
61
64
 
62
65
  ### 4. Example interaction (console output)
63
66
 
@@ -118,6 +121,22 @@ With Bundler:
118
121
  bundle exec csvtool column /path/to/file.csv column_name
119
122
  ```
120
123
 
124
+ Get CSV stats directly (default text output):
125
+
126
+ ```bash
127
+ csvtool stats /path/to/file.csv
128
+ ```
129
+
130
+ Optional output format and color mode:
131
+
132
+ ```bash
133
+ csvtool stats /path/to/file.csv --format json
134
+ csvtool stats /path/to/file.csv --format csv
135
+ csvtool stats /path/to/file.csv --color auto
136
+ csvtool stats /path/to/file.csv --color always
137
+ csvtool stats /path/to/file.csv --color never
138
+ ```
139
+
121
140
  ### 7. Dedupe interaction example
122
141
 
123
142
  Legend: ` ` = prompt/menu, `+` = user input, `-` = tool output
@@ -303,7 +322,7 @@ bundle exec rake test
303
322
 
304
323
  ## Alpha release
305
324
 
306
- Current prerelease version: `0.8.0.alpha`
325
+ Current prerelease version: `0.9.0.alpha`
307
326
 
308
327
  Install prerelease from RubyGems:
309
328
 
@@ -313,7 +332,7 @@ gem install csvops --pre
313
332
 
314
333
  Release runbook:
315
334
 
316
- - `docs/release-v0.8.0-alpha.md`
335
+ - `docs/release-v0.9.0-alpha.md`
317
336
 
318
337
 
319
338
  ## Architecture
@@ -321,3 +340,4 @@ Release runbook:
321
340
  Full architecture and domain documentation lives in:
322
341
 
323
342
  - [`docs/architecture.md`](docs/architecture.md)
343
+ - [`docs/cli-output-conventions.md`](docs/cli-output-conventions.md)
data/docs/architecture.md CHANGED
@@ -21,6 +21,9 @@ For all interactive domains (`Column Extraction`, `Row Extraction`, `Row Randomi
21
21
  - `domain/*`: invariants and domain policies.
22
22
  - `infrastructure/*`: CSV mechanics and output adapters.
23
23
 
24
+ Output UI rules:
25
+ - See [`docs/cli-output-conventions.md`](cli-output-conventions.md) for stream, format, color, and table rendering contracts used across workflows.
26
+
24
27
  Write-boundary rule:
25
28
  - Use cases coordinate write paths but do not perform direct file writes.
26
29
  - Direct write APIs (`CSV.open`, writable `File.open`, `File.write`, `IO.write`) are infrastructure-only.
@@ -0,0 +1,49 @@
1
+ # CLI Output Conventions
2
+
3
+ This document defines the output contract for all CLI workflows.
4
+
5
+ ## 1. Stream contract
6
+
7
+ - `stdout` is for data output only.
8
+ - `stderr` is for prompts, menu UI, status, and errors.
9
+ - Commands should be pipe-safe: redirecting `stdout` must not capture prompts/errors.
10
+
11
+ ## 2. Format contract
12
+
13
+ - Supported formats: `text`, `json`, `csv`.
14
+ - `text` is human-readable and may include tables/colors.
15
+ - `json` and `csv` are machine-readable and should remain stable over time.
16
+ - Structured formats must avoid decorative output.
17
+
18
+ ## 3. Color policy
19
+
20
+ - Supported modes: `auto`, `always`, `never`.
21
+ - `auto` colors only when output target is a TTY.
22
+ - `NO_COLOR` disables color in `auto` mode.
23
+ - `always` overrides `NO_COLOR`.
24
+ - Structured formats (`json`, `csv`) are not colorized.
25
+
26
+ ## 4. Table rendering rules
27
+
28
+ - Use shared table renderer for summary-style text output.
29
+ - Render within terminal width constraints.
30
+ - Truncate long cells with ellipsis when necessary.
31
+ - Avoid broken/overlapping columns in narrow terminals.
32
+
33
+ ## 5. Shared services usage
34
+
35
+ All workflows should use shared output services under:
36
+
37
+ - `lib/csvtool/interface/cli/output/streams.rb`
38
+ - `lib/csvtool/interface/cli/output/formatters/*`
39
+ - `lib/csvtool/interface/cli/output/color_policy.rb`
40
+ - `lib/csvtool/interface/cli/output/colorizer.rb`
41
+ - `lib/csvtool/interface/cli/output/table_renderer.rb`
42
+
43
+ Prefer these services over ad-hoc formatting in presenters/workflows.
44
+
45
+ ## 6. Testing expectations
46
+
47
+ - Add focused unit tests for each output service.
48
+ - Add workflow/CLI tests for stream separation and representative formatting behavior.
49
+ - Keep acceptance assertions centered on contract semantics rather than fragile spacing where possible.
@@ -0,0 +1,80 @@
1
+ # Release Checklist: v0.9.0-alpha
2
+
3
+ ## 1. Verify environment
4
+
5
+ ```bash
6
+ ruby -v
7
+ bundle -v
8
+ ```
9
+
10
+ Expected:
11
+ - Ruby `3.3.x`
12
+
13
+ ## 2. Install dependencies
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## 3. Run quality checks
20
+
21
+ ```bash
22
+ bundle exec rake test
23
+ ```
24
+
25
+ ## 4. Smoke test CLI commands
26
+
27
+ ```bash
28
+ bundle exec csvtool menu
29
+ bundle exec csvtool stats test/fixtures/sample_people.csv --format text
30
+ bundle exec csvtool stats test/fixtures/sample_people.csv --format json
31
+ bundle exec csvtool stats test/fixtures/sample_people.csv --format csv
32
+ ```
33
+
34
+ ## 5. Smoke test output conventions across workflows
35
+
36
+ Verify in menu-driven workflows:
37
+ - prompts/menu/errors are on `stderr`
38
+ - data output is on `stdout`
39
+
40
+ Verify shared output behavior:
41
+ - formatter consistency (`text|json|csv`)
42
+ - color policy (`auto|always|never`, `NO_COLOR`)
43
+ - width-aware summary tables in stats/parity/split/dedupe
44
+
45
+ ## 6. Build and validate gem package
46
+
47
+ ```bash
48
+ gem build csvops.gemspec
49
+ gem install ./csvops-0.9.0.alpha.gem
50
+ csvtool menu
51
+ ```
52
+
53
+ ## 7. Commit release prep
54
+
55
+ ```bash
56
+ git add -A
57
+ git commit -m "chore(release): prepare v0.9.0-alpha"
58
+ ```
59
+
60
+ ## 8. Tag release
61
+
62
+ ```bash
63
+ git tag -a v0.9.0-alpha -m "v0.9.0-alpha"
64
+ git push origin main --tags
65
+ ```
66
+
67
+ ## 9. Publish gem
68
+
69
+ ```bash
70
+ gem push csvops-0.9.0.alpha.gem
71
+ ```
72
+
73
+ ## 10. Create GitHub release
74
+
75
+ Create release `v0.9.0-alpha` with:
76
+ - Shared output stream services across workflows (`stdout` data, `stderr` UI/errors)
77
+ - Shared formatter services and migrated presenters
78
+ - Shared color policy + colorizer across workflows
79
+ - Shared width-aware table rendering across summary presenters
80
+ - New output conventions documentation (`docs/cli-output-conventions.md`)
data/lib/csvtool/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "csv"
4
+ require "json"
4
5
  require "csvtool/interface/cli/menu_loop"
5
6
  require "csvtool/interface/cli/workflows/run_extraction_workflow"
6
7
  require "csvtool/interface/cli/workflows/run_row_extraction_workflow"
@@ -10,9 +11,19 @@ require "csvtool/interface/cli/workflows/run_csv_parity_workflow"
10
11
  require "csvtool/interface/cli/workflows/run_csv_split_workflow"
11
12
  require "csvtool/interface/cli/workflows/run_csv_stats_workflow"
12
13
  require "csvtool/interface/cli/errors/presenter"
14
+ require "csvtool/interface/cli/output/table_renderer"
15
+ require "csvtool/interface/cli/output/streams"
16
+ require "csvtool/interface/cli/output/color_policy"
17
+ require "csvtool/interface/cli/output/colorizer"
18
+ require "csvtool/interface/cli/output/formatters/stats_formatter"
13
19
  require "csvtool/infrastructure/csv/header_reader"
14
20
  require "csvtool/infrastructure/csv/value_streamer"
15
21
  require "csvtool/infrastructure/output/console_writer"
22
+ require "csvtool/application/use_cases/run_csv_stats"
23
+ require "csvtool/domain/csv_stats_session/stats_source"
24
+ require "csvtool/domain/csv_stats_session/stats_options"
25
+ require "csvtool/domain/csv_stats_session/stats_session"
26
+ require "csvtool/domain/shared/output_destination"
16
27
 
17
28
  module Csvtool
18
29
  class CLI
@@ -27,15 +38,16 @@ module Csvtool
27
38
  "Exit"
28
39
  ].freeze
29
40
 
30
- def self.start(argv, stdin:, stdout:, stderr:)
31
- new(argv, stdin: stdin, stdout: stdout, stderr: stderr).run
41
+ def self.start(argv, stdin:, stdout:, stderr:, env: ENV)
42
+ new(argv, stdin: stdin, stdout: stdout, stderr: stderr, env: env).run
32
43
  end
33
44
 
34
- def initialize(argv, stdin:, stdout:, stderr:)
45
+ def initialize(argv, stdin:, stdout:, stderr:, env: ENV)
35
46
  @argv = argv
36
47
  @stdin = stdin
37
48
  @stdout = stdout
38
49
  @stderr = stderr
50
+ @env = env
39
51
  end
40
52
 
41
53
  def run
@@ -44,6 +56,8 @@ module Csvtool
44
56
  run_menu_loop
45
57
  when "column"
46
58
  run_column_command
59
+ when "stats"
60
+ run_stats_command
47
61
  else
48
62
  print_usage
49
63
  1
@@ -53,16 +67,18 @@ module Csvtool
53
67
  private
54
68
 
55
69
  def run_menu_loop
56
- extract_column_action = -> { Interface::CLI::Workflows::RunExtractionWorkflow.new(stdin: @stdin, stdout: @stdout).call }
57
- extract_rows_action = -> { Interface::CLI::Workflows::RunRowExtractionWorkflow.new(stdin: @stdin, stdout: @stdout).call }
58
- randomize_rows_action = -> { Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: @stdin, stdout: @stdout).call }
59
- dedupe_action = -> { Interface::CLI::Workflows::RunCrossCsvDedupeWorkflow.new(stdin: @stdin, stdout: @stdout).call }
60
- parity_action = -> { Interface::CLI::Workflows::RunCsvParityWorkflow.new(stdin: @stdin, stdout: @stdout).call }
61
- split_action = -> { Interface::CLI::Workflows::RunCsvSplitWorkflow.new(stdin: @stdin, stdout: @stdout).call }
62
- stats_action = -> { Interface::CLI::Workflows::RunCsvStatsWorkflow.new(stdin: @stdin, stdout: @stdout).call }
70
+ streams = Interface::CLI::Output::Streams.build(data: @stdout, ui: @stderr)
71
+ extract_column_action = -> { Interface::CLI::Workflows::RunExtractionWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
72
+ extract_rows_action = -> { Interface::CLI::Workflows::RunRowExtractionWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
73
+ randomize_rows_action = -> { Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
74
+ dedupe_action = -> { Interface::CLI::Workflows::RunCrossCsvDedupeWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
75
+ parity_action = -> { Interface::CLI::Workflows::RunCsvParityWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
76
+ split_action = -> { Interface::CLI::Workflows::RunCsvSplitWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
77
+ stats_action = -> { Interface::CLI::Workflows::RunCsvStatsWorkflow.new(stdin: @stdin, stdout: streams.data, stderr: streams.ui).call }
63
78
  Interface::CLI::MenuLoop.new(
64
79
  stdin: @stdin,
65
- stdout: @stdout,
80
+ stdout: streams.data,
81
+ stderr: streams.ui,
66
82
  menu_options: MENU_OPTIONS,
67
83
  extract_column_action: extract_column_action,
68
84
  extract_rows_action: extract_rows_action,
@@ -78,6 +94,7 @@ module Csvtool
78
94
  @stderr.puts "Usage:"
79
95
  @stderr.puts " csvtool menu"
80
96
  @stderr.puts " csvtool column <file> <column>"
97
+ @stderr.puts " csvtool stats <file> [--format text|json|csv] [--color auto|always|never]"
81
98
  end
82
99
 
83
100
  def run_column_command
@@ -88,7 +105,7 @@ module Csvtool
88
105
  return 1
89
106
  end
90
107
 
91
- errors = Interface::CLI::Errors::Presenter.new(stdout: @stdout)
108
+ errors = Interface::CLI::Errors::Presenter.new(stdout: @stderr)
92
109
  return errors.file_not_found(file_path) || 1 unless File.file?(file_path)
93
110
 
94
111
  header_reader = Infrastructure::CSV::HeaderReader.new
@@ -107,5 +124,108 @@ module Csvtool
107
124
  errors.cannot_read_file(file_path)
108
125
  1
109
126
  end
127
+
128
+ def run_stats_command
129
+ file_path, format, color_mode = parse_stats_args(@argv[1..])
130
+ unless file_path
131
+ print_usage
132
+ return 1
133
+ end
134
+
135
+ errors = Interface::CLI::Errors::Presenter.new(stdout: @stderr)
136
+ unless %w[text json csv].include?(format)
137
+ @stderr.puts "Invalid format: #{format}"
138
+ return 1
139
+ end
140
+ unless %w[auto always never].include?(color_mode)
141
+ @stderr.puts "Invalid color mode: #{color_mode}"
142
+ return 1
143
+ end
144
+ source = Domain::CsvStatsSession::StatsSource.new(path: file_path, separator: ",", headers_present: true)
145
+ options = Domain::CsvStatsSession::StatsOptions.new
146
+ destination = Domain::Shared::OutputDestination.console
147
+ session = Domain::CsvStatsSession::StatsSession.start(source: source, options: options).with_output_destination(destination)
148
+ result = Application::UseCases::RunCsvStats.new.call(session: session)
149
+
150
+ unless result.ok?
151
+ case result.error
152
+ when :file_not_found
153
+ errors.file_not_found(result.data[:path])
154
+ when :could_not_parse_csv
155
+ errors.could_not_parse_csv
156
+ when :cannot_read_file
157
+ errors.cannot_read_file(result.data[:path])
158
+ else
159
+ @stderr.puts "Unknown error."
160
+ end
161
+ return 1
162
+ end
163
+
164
+ formatter = Interface::CLI::Output::Formatters::StatsFormatter.new(
165
+ table_renderer: Interface::CLI::Output::TableRenderer.new
166
+ )
167
+ output = formatter.call(data: result.data, format: format, max_width: terminal_width)
168
+ print_stats_output(output, format: format, color_mode: color_mode)
169
+
170
+ 0
171
+ end
172
+
173
+ def parse_stats_args(args)
174
+ file_path = args[0]
175
+ format = "text"
176
+ color_mode = "auto"
177
+ index = 1
178
+ while index < args.length
179
+ arg = args[index]
180
+ if arg.start_with?("--format=")
181
+ format = arg.split("=", 2)[1]
182
+ elsif arg == "--format"
183
+ format = args[index + 1].to_s
184
+ index += 1
185
+ elsif arg.start_with?("--color=")
186
+ color_mode = arg.split("=", 2)[1]
187
+ elsif arg == "--color"
188
+ color_mode = args[index + 1].to_s
189
+ index += 1
190
+ end
191
+ index += 1
192
+ end
193
+ [file_path, format, color_mode]
194
+ end
195
+
196
+ def print_stats_output(output, format:, color_mode:)
197
+ if format == "text"
198
+ policy = Interface::CLI::Output::ColorPolicy.new(mode: color_mode, io: @stdout, env: @env)
199
+ colorizer = Interface::CLI::Output::Colorizer.new(policy: policy)
200
+ text = apply_text_color(output, colorizer: colorizer)
201
+ @stdout.puts text
202
+ else
203
+ @stdout.puts output
204
+ end
205
+ end
206
+
207
+ def apply_text_color(text, colorizer:)
208
+ text.lines.map do |line|
209
+ line = line.chomp
210
+ case line
211
+ when "CSV Stats Summary"
212
+ colorizer.call(line, code: "1;36")
213
+ when "Column completeness:"
214
+ colorizer.call(line, code: "1")
215
+ when /\A(Metric|Value|Column|Non-blank|Blank)(\s+\|.*)?\z/
216
+ colorizer.call(line, code: "1")
217
+ else
218
+ line
219
+ end
220
+ end.join("\n")
221
+ end
222
+
223
+ def terminal_width
224
+ columns = @env["COLUMNS"].to_i
225
+ return columns if columns.positive?
226
+ return @stdout.winsize[1] if @stdout.respond_to?(:winsize)
227
+
228
+ 80
229
+ end
110
230
  end
111
231
  end
@@ -4,9 +4,10 @@ module Csvtool
4
4
  module Interface
5
5
  module CLI
6
6
  class MenuLoop
7
- def initialize(stdin:, stdout:, menu_options:, extract_column_action:, extract_rows_action:, randomize_rows_action:, dedupe_action:, parity_action:, split_action:, stats_action:)
7
+ def initialize(stdin:, stdout:, stderr: stdout, menu_options:, extract_column_action:, extract_rows_action:, randomize_rows_action:, dedupe_action:, parity_action:, split_action:, stats_action:)
8
8
  @stdin = stdin
9
9
  @stdout = stdout
10
+ @stderr = stderr
10
11
  @menu_options = menu_options
11
12
  @extract_column_action = extract_column_action
12
13
  @extract_rows_action = extract_rows_action
@@ -20,7 +21,7 @@ module Csvtool
20
21
  def run
21
22
  loop do
22
23
  print_menu
23
- @stdout.print "> "
24
+ @stderr.print "> "
24
25
  choice = @stdin.gets
25
26
  return 0 if choice.nil?
26
27
 
@@ -42,7 +43,7 @@ module Csvtool
42
43
  when "8"
43
44
  return 0
44
45
  else
45
- @stdout.puts "Please choose 1, 2, 3, 4, 5, 6, 7, or 8."
46
+ @stderr.puts "Please choose 1, 2, 3, 4, 5, 6, 7, or 8."
46
47
  end
47
48
  end
48
49
  end
@@ -50,9 +51,9 @@ module Csvtool
50
51
  private
51
52
 
52
53
  def print_menu
53
- @stdout.puts "CSV Tool Menu"
54
+ @stderr.puts "CSV Tool Menu"
54
55
  @menu_options.each_with_index do |option, index|
55
- @stdout.puts "#{index + 1}. #{option}"
56
+ @stderr.puts "#{index + 1}. #{option}"
56
57
  end
57
58
  end
58
59
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Output
7
+ class ColorPolicy
8
+ def initialize(mode:, io:, env: ENV)
9
+ @mode = mode
10
+ @io = io
11
+ @env = env
12
+ end
13
+
14
+ def enabled?
15
+ return true if @mode == "always"
16
+ return false if @mode == "never"
17
+ return false if @env["NO_COLOR"]
18
+
19
+ @io.respond_to?(:tty?) && @io.tty?
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csvtool/interface/cli/output/color_policy"
4
+
5
+ module Csvtool
6
+ module Interface
7
+ module CLI
8
+ module Output
9
+ class Colorizer
10
+ def initialize(policy:)
11
+ @policy = policy
12
+ end
13
+
14
+ def call(text, code:)
15
+ return text unless @policy.enabled?
16
+
17
+ "\e[#{code}m#{text}\e[0m"
18
+ end
19
+
20
+ def self.auto(io:, env: ENV)
21
+ new(policy: ColorPolicy.new(mode: "auto", io: io, env: env))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Csvtool
6
+ module Interface
7
+ module CLI
8
+ module Output
9
+ module Formatters
10
+ class CsvRowFormatter
11
+ def call(fields:, col_sep:)
12
+ ::CSV.generate_line(fields, row_sep: "", col_sep: col_sep).chomp
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Csvtool
6
+ module Interface
7
+ module CLI
8
+ module Output
9
+ module Formatters
10
+ class StatsFormatter
11
+ def initialize(table_renderer:)
12
+ @table_renderer = table_renderer
13
+ end
14
+
15
+ def call(data:, format:, max_width: 80)
16
+ case format
17
+ when "json"
18
+ JSON.generate(data)
19
+ when "csv"
20
+ csv_lines(data).join("\n")
21
+ else
22
+ text_lines(data, max_width: max_width).join("\n")
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def csv_lines(data)
29
+ lines = ["metric,value", "row_count,#{data[:row_count]}", "column_count,#{data[:column_count]}"]
30
+ lines << "headers,#{data[:headers].join('|')}" unless data[:headers].nil? || data[:headers].empty?
31
+ data.fetch(:column_stats, []).each do |stats|
32
+ lines << "column.#{stats[:name]}.non_blank,#{stats[:non_blank_count]}"
33
+ lines << "column.#{stats[:name]}.blank,#{stats[:blank_count]}"
34
+ end
35
+ lines
36
+ end
37
+
38
+ def text_lines(data, max_width:)
39
+ lines = ["CSV Stats Summary"]
40
+ summary_rows = [["Rows", data[:row_count].to_s], ["Columns", data[:column_count].to_s]]
41
+ summary_rows << ["Headers", data[:headers].join(", ")] unless data[:headers].nil? || data[:headers].empty?
42
+ lines << @table_renderer.render(headers: ["Metric", "Value"], rows: summary_rows, max_width: max_width)
43
+
44
+ return lines if data[:column_stats].nil? || data[:column_stats].empty?
45
+
46
+ lines << ""
47
+ lines << "Column completeness:"
48
+ rows = data[:column_stats].map { |stats| [stats[:name], stats[:non_blank_count].to_s, stats[:blank_count].to_s] }
49
+ lines << @table_renderer.render(headers: ["Column", "Non-blank", "Blank"], rows: rows, max_width: max_width)
50
+ lines
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Output
7
+ class Streams
8
+ attr_reader :data, :ui
9
+
10
+ def self.build(data:, ui: data)
11
+ new(data: data, ui: ui)
12
+ end
13
+
14
+ def initialize(data:, ui:)
15
+ @data = data
16
+ @ui = ui
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Output
7
+ class TableRenderer
8
+ MIN_COLUMN_WIDTH = 4
9
+
10
+ def render(headers:, rows:, max_width: 80)
11
+ widths = compute_widths(headers, rows)
12
+ widths = fit_widths(widths, max_width)
13
+
14
+ lines = []
15
+ lines << format_row(headers, widths)
16
+ lines << separator(widths)
17
+ rows.each { |row| lines << format_row(row, widths) }
18
+ lines.join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ def compute_widths(headers, rows)
24
+ widths = headers.map { |header| header.to_s.length }
25
+ rows.each do |row|
26
+ row.each_with_index do |cell, index|
27
+ widths[index] = [widths[index], cell.to_s.length].max
28
+ end
29
+ end
30
+ widths
31
+ end
32
+
33
+ def fit_widths(widths, max_width)
34
+ return widths if total_width(widths) <= max_width
35
+
36
+ adjusted = widths.dup
37
+ while total_width(adjusted) > max_width
38
+ index = adjusted.each_with_index.max_by { |width, _i| width }[1]
39
+ break if adjusted[index] <= MIN_COLUMN_WIDTH
40
+
41
+ adjusted[index] -= 1
42
+ end
43
+ adjusted
44
+ end
45
+
46
+ def total_width(widths)
47
+ widths.sum + (3 * (widths.length - 1))
48
+ end
49
+
50
+ def separator(widths)
51
+ widths.map { |width| "-" * width }.join("-+-")
52
+ end
53
+
54
+ def format_row(row, widths)
55
+ row.each_with_index.map do |cell, index|
56
+ truncate(cell.to_s, widths[index]).ljust(widths[index])
57
+ end.join(" | ")
58
+ end
59
+
60
+ def truncate(text, width)
61
+ return text if text.length <= width
62
+ return text[0, width] if width < MIN_COLUMN_WIDTH
63
+
64
+ "#{text[0, width - 3]}..."
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end