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.
- checksums.yaml +4 -4
- data/README.md +23 -3
- data/docs/architecture.md +3 -0
- data/docs/cli-output-conventions.md +49 -0
- data/docs/release-v0.9.0-alpha.md +80 -0
- data/lib/csvtool/cli.rb +132 -12
- data/lib/csvtool/interface/cli/menu_loop.rb +6 -5
- data/lib/csvtool/interface/cli/output/color_policy.rb +25 -0
- data/lib/csvtool/interface/cli/output/colorizer.rb +27 -0
- data/lib/csvtool/interface/cli/output/formatters/csv_row_formatter.rb +19 -0
- data/lib/csvtool/interface/cli/output/formatters/stats_formatter.rb +57 -0
- data/lib/csvtool/interface/cli/output/streams.rb +22 -0
- data/lib/csvtool/interface/cli/output/table_renderer.rb +70 -0
- data/lib/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter.rb +17 -5
- data/lib/csvtool/interface/cli/workflows/presenters/csv_parity_presenter.rb +15 -4
- data/lib/csvtool/interface/cli/workflows/presenters/csv_split_presenter.rb +15 -6
- data/lib/csvtool/interface/cli/workflows/presenters/csv_stats_presenter.rb +18 -9
- data/lib/csvtool/interface/cli/workflows/presenters/row_extraction_presenter.rb +5 -4
- data/lib/csvtool/interface/cli/workflows/presenters/row_randomization_presenter.rb +5 -4
- data/lib/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow.rb +9 -8
- data/lib/csvtool/interface/cli/workflows/run_csv_parity_workflow.rb +6 -5
- data/lib/csvtool/interface/cli/workflows/run_csv_split_workflow.rb +11 -10
- data/lib/csvtool/interface/cli/workflows/run_csv_stats_workflow.rb +7 -6
- data/lib/csvtool/interface/cli/workflows/run_extraction_workflow.rb +9 -8
- data/lib/csvtool/interface/cli/workflows/run_row_extraction_workflow.rb +7 -6
- data/lib/csvtool/interface/cli/workflows/run_row_randomization_workflow.rb +8 -7
- data/lib/csvtool/version.rb +1 -1
- data/test/csvtool/cli_test.rb +289 -44
- data/test/csvtool/cli_unit_test.rb +5 -5
- data/test/csvtool/interface/cli/output/color_policy_test.rb +40 -0
- data/test/csvtool/interface/cli/output/colorizer_test.rb +28 -0
- data/test/csvtool/interface/cli/output/formatters/csv_row_formatter_test.rb +22 -0
- data/test/csvtool/interface/cli/output/formatters/stats_formatter_test.rb +51 -0
- data/test/csvtool/interface/cli/output/streams_test.rb +25 -0
- data/test/csvtool/interface/cli/output/table_renderer_test.rb +36 -0
- data/test/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter_test.rb +4 -1
- data/test/csvtool/interface/cli/workflows/presenters/csv_parity_presenter_test.rb +5 -1
- data/test/csvtool/interface/cli/workflows/presenters/csv_split_presenter_test.rb +22 -4
- data/test/csvtool/interface/cli/workflows/presenters/csv_stats_presenter_test.rb +7 -5
- data/test/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow_test.rb +10 -7
- data/test/csvtool/interface/cli/workflows/run_csv_parity_workflow_test.rb +3 -1
- data/test/csvtool/interface/cli/workflows/run_csv_split_workflow_test.rb +5 -3
- data/test/csvtool/interface/cli/workflows/run_csv_stats_workflow_test.rb +23 -18
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 940c2492f5bea33d56ad0a47ebf6933cb2e43817530aa04d4e02950affe9d493
|
|
4
|
+
data.tar.gz: 9c32a162b4393f25e99b55e908df2b9dcb55099ce0d820aa3255c310fc09d983
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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: @
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
54
|
+
@stderr.puts "CSV Tool Menu"
|
|
54
55
|
@menu_options.each_with_index do |option, index|
|
|
55
|
-
@
|
|
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
|