csvops 0.1.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 +7 -0
- data/Gemfile +8 -0
- data/README.md +209 -0
- data/Rakefile +10 -0
- data/bin/csvtool +8 -0
- data/bin/tool +8 -0
- data/csvops.gemspec +25 -0
- data/docs/release-v0.1.0-alpha.md +73 -0
- data/exe/csvtool +8 -0
- data/lib/csvtool/application/use_cases/run_extraction.rb +125 -0
- data/lib/csvtool/cli.rb +87 -0
- data/lib/csvtool/domain/extraction_session/column_selection.rb +17 -0
- data/lib/csvtool/domain/extraction_session/csv_source.rb +18 -0
- data/lib/csvtool/domain/extraction_session/extraction_options.rb +22 -0
- data/lib/csvtool/domain/extraction_session/extraction_session.rb +61 -0
- data/lib/csvtool/domain/extraction_session/extraction_value.rb +15 -0
- data/lib/csvtool/domain/extraction_session/output_destination.rb +35 -0
- data/lib/csvtool/domain/extraction_session/preview.rb +23 -0
- data/lib/csvtool/domain/extraction_session/separator.rb +17 -0
- data/lib/csvtool/infrastructure/csv/header_reader.rb +17 -0
- data/lib/csvtool/infrastructure/csv/value_streamer.rb +20 -0
- data/lib/csvtool/infrastructure/output/console_writer.rb +20 -0
- data/lib/csvtool/infrastructure/output/csv_file_writer.rb +30 -0
- data/lib/csvtool/interface/cli/errors/presenter.rb +59 -0
- data/lib/csvtool/interface/cli/menu_loop.rb +41 -0
- data/lib/csvtool/interface/cli/prompts/column_selector_prompt.rb +54 -0
- data/lib/csvtool/interface/cli/prompts/confirm_prompt.rb +29 -0
- data/lib/csvtool/interface/cli/prompts/file_path_prompt.rb +21 -0
- data/lib/csvtool/interface/cli/prompts/output_destination_prompt.rb +40 -0
- data/lib/csvtool/interface/cli/prompts/separator_prompt.rb +44 -0
- data/lib/csvtool/interface/cli/prompts/skip_blanks_prompt.rb +22 -0
- data/lib/csvtool/services/preview_builder.rb +20 -0
- data/lib/csvtool/version.rb +5 -0
- data/test/csvtool/application/use_cases/run_extraction_test.rb +31 -0
- data/test/csvtool/cli_test.rb +134 -0
- data/test/csvtool/cli_unit_test.rb +27 -0
- data/test/csvtool/domain/extraction_session/column_selection_test.rb +11 -0
- data/test/csvtool/domain/extraction_session/csv_source_test.rb +14 -0
- data/test/csvtool/domain/extraction_session/extraction_options_test.rb +18 -0
- data/test/csvtool/domain/extraction_session/extraction_session_test.rb +35 -0
- data/test/csvtool/domain/extraction_session/extraction_value_test.rb +11 -0
- data/test/csvtool/domain/extraction_session/output_destination_test.rb +18 -0
- data/test/csvtool/domain/extraction_session/preview_test.rb +18 -0
- data/test/csvtool/domain/extraction_session/separator_test.rb +15 -0
- data/test/csvtool/infrastructure/csv/header_reader_test.rb +16 -0
- data/test/csvtool/infrastructure/csv/value_streamer_test.rb +22 -0
- data/test/csvtool/infrastructure/output/console_writer_test.rb +19 -0
- data/test/csvtool/infrastructure/output/csv_file_writer_test.rb +35 -0
- data/test/csvtool/interface/cli/errors/presenter_test.rb +36 -0
- data/test/csvtool/interface/cli/menu_loop_test.rb +51 -0
- data/test/csvtool/interface/cli/prompts/column_selector_prompt_test.rb +23 -0
- data/test/csvtool/interface/cli/prompts/confirm_prompt_test.rb +23 -0
- data/test/csvtool/interface/cli/prompts/file_path_prompt_test.rb +11 -0
- data/test/csvtool/interface/cli/prompts/output_destination_prompt_test.rb +28 -0
- data/test/csvtool/interface/cli/prompts/separator_prompt_test.rb +31 -0
- data/test/csvtool/interface/cli/prompts/skip_blanks_prompt_test.rb +13 -0
- data/test/csvtool/services/preview_builder_test.rb +22 -0
- data/test/fixtures/empty.csv +0 -0
- data/test/fixtures/sample_people.csv +4 -0
- data/test/fixtures/sample_people.tsv +4 -0
- data/test/fixtures/sample_people_blanks.csv +6 -0
- data/test/fixtures/sample_people_colon.txt +4 -0
- data/test/fixtures/sample_people_many.csv +13 -0
- data/test/test_helper.rb +6 -0
- metadata +150 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8621d11595ca64afe19513bbeb253a9568ef78566aff8eec116a2b169e899fce
|
|
4
|
+
data.tar.gz: '009a655572331c7699ca8c6034d9bcf45db517e90da4ae4317d3b42c6b4aa7dd'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 542f540de68616558cfe40e39fecbb4a3b91fa7a2bf8fd59397e5a9aad855503795e928c1ddae18c3577e6e3558b34166e3c240e6b7744e11d0c6b8a11297aa6
|
|
7
|
+
data.tar.gz: 04a5beb85795c5b10984a8a12980a1079a8a4f07f447d6621d6e6961ded35ee18edb5834e5e8fbad99b0f7fd0c76c2aae31f7d2ccc48f3b54a520d7bcbf91931
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# CSV Ops CLI
|
|
2
|
+
|
|
3
|
+
`csvops` is a small Ruby CLI for interactive CSV workflows.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby 3.3.0
|
|
8
|
+
- Bundler
|
|
9
|
+
- `rake`
|
|
10
|
+
- `minitest`
|
|
11
|
+
|
|
12
|
+
Install dependencies:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### 1. Start the CLI
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
csvtool menu
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
With Bundler:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle exec csvtool menu
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Choose an action
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
CSV Tool Menu
|
|
36
|
+
1. Extract column
|
|
37
|
+
2. Exit
|
|
38
|
+
>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Select `1` to run extraction.
|
|
42
|
+
|
|
43
|
+
### 3. Follow prompts
|
|
44
|
+
|
|
45
|
+
Prompt flow:
|
|
46
|
+
|
|
47
|
+
- CSV file path
|
|
48
|
+
- Separator (`comma`, `tab`, `semicolon`, `pipe`, or `custom`)
|
|
49
|
+
- Optional header filter + column selection
|
|
50
|
+
- Skip blanks (`Y/n`, default `Y`)
|
|
51
|
+
- Preview + confirmation
|
|
52
|
+
- Output destination (`console` or `file`)
|
|
53
|
+
|
|
54
|
+
### 4. Example interaction (console output)
|
|
55
|
+
|
|
56
|
+
Legend: ` ` = prompt/menu, `+` = user input, `-` = tool output
|
|
57
|
+
|
|
58
|
+
```diff
|
|
59
|
+
CSV file path: /path/to/file.csv
|
|
60
|
+
Choose separator:
|
|
61
|
+
1. comma (,)
|
|
62
|
+
2. tab (\t)
|
|
63
|
+
3. semicolon (;)
|
|
64
|
+
4. pipe (|)
|
|
65
|
+
5. custom
|
|
66
|
+
+Separator choice [1]: 1
|
|
67
|
+
Filter columns (optional):
|
|
68
|
+
Select column:
|
|
69
|
+
1. name
|
|
70
|
+
2. city
|
|
71
|
+
+Column number: 1
|
|
72
|
+
Skip blank values? [Y/n]:
|
|
73
|
+
Preview (first 3 values):
|
|
74
|
+
-Alice
|
|
75
|
+
-Bob
|
|
76
|
+
-Cara
|
|
77
|
+
Print all values? [y/N]:
|
|
78
|
+
+y
|
|
79
|
+
Output destination:
|
|
80
|
+
1. console
|
|
81
|
+
2. file
|
|
82
|
+
+Output destination [1]: 1
|
|
83
|
+
-Alice
|
|
84
|
+
-Bob
|
|
85
|
+
-Cara
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Example interaction (file output)
|
|
89
|
+
|
|
90
|
+
```diff
|
|
91
|
+
Output destination:
|
|
92
|
+
1. console
|
|
93
|
+
2. file
|
|
94
|
+
+Output destination [1]: 2
|
|
95
|
+
+Output file path: /tmp/names.csv
|
|
96
|
+
-Wrote output to /tmp/names.csv
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 6. Direct command mode
|
|
100
|
+
|
|
101
|
+
Extract a column without using the interactive menu:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
csvtool column /path/to/file.csv column_name
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
With Bundler:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bundle exec csvtool column /path/to/file.csv column_name
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Testing
|
|
114
|
+
|
|
115
|
+
Run tests:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
rake test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bundle exec rake test
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Alpha release
|
|
128
|
+
|
|
129
|
+
Current prerelease version: `0.1.0.alpha`
|
|
130
|
+
|
|
131
|
+
Install prerelease from RubyGems:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
gem install csvops --pre
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Release runbook:
|
|
138
|
+
|
|
139
|
+
- `/Users/roberthall/Projects/csvops/docs/release-v0.1.0-alpha.md`
|
|
140
|
+
|
|
141
|
+
## Architecture
|
|
142
|
+
|
|
143
|
+
The codebase follows a DDD-lite layered structure:
|
|
144
|
+
|
|
145
|
+
- `domain/`: core domain model and invariants (`ExtractionSession` aggregate + value objects/entities).
|
|
146
|
+
- `application/`: use-case orchestration (`RunExtraction`).
|
|
147
|
+
- `infrastructure/`: CSV reading/streaming and output adapters (console/file).
|
|
148
|
+
- `interface/cli/`: menu, prompts, and user-facing error presentation.
|
|
149
|
+
- `Csvtool::CLI`: entrypoint wiring from command args to interface/application flow.
|
|
150
|
+
|
|
151
|
+
## Domain model
|
|
152
|
+
|
|
153
|
+
Bounded context: `Column Extraction`.
|
|
154
|
+
|
|
155
|
+
Core DDD structure:
|
|
156
|
+
|
|
157
|
+
- Aggregate root: `ExtractionSession`
|
|
158
|
+
- Controls extraction state transitions (`start`, `with_preview`, `confirm!`, `with_output_destination`).
|
|
159
|
+
- Enforces session-level invariants.
|
|
160
|
+
- Entities:
|
|
161
|
+
- `CsvSource` (file path + `Separator`)
|
|
162
|
+
- `ColumnSelection` (chosen header)
|
|
163
|
+
- Value objects:
|
|
164
|
+
- `Separator`
|
|
165
|
+
- `ExtractionOptions` (`skip_blanks`, `preview_limit`)
|
|
166
|
+
- `Preview` (list of `ExtractionValue`)
|
|
167
|
+
- `ExtractionValue`
|
|
168
|
+
- `OutputDestination` (`console` or `file(path)`)
|
|
169
|
+
- Application service:
|
|
170
|
+
- `Application::UseCases::RunExtraction` orchestrates one extraction request.
|
|
171
|
+
- Infrastructure adapters:
|
|
172
|
+
- `Infrastructure::CSV::HeaderReader`
|
|
173
|
+
- `Infrastructure::CSV::ValueStreamer`
|
|
174
|
+
- `Infrastructure::Output::ConsoleWriter`
|
|
175
|
+
- `Infrastructure::Output::CsvFileWriter`
|
|
176
|
+
- Interface adapters:
|
|
177
|
+
- `Interface::CLI::MenuLoop`
|
|
178
|
+
- `Interface::CLI::Prompts::*`
|
|
179
|
+
- `Interface::CLI::Errors::Presenter`
|
|
180
|
+
|
|
181
|
+
```mermaid
|
|
182
|
+
flowchart LR
|
|
183
|
+
UI["Interface CLI\n(Menu + Prompts + Errors)"] --> APP["Application Use Case\nRunExtraction"]
|
|
184
|
+
APP --> AGG["Domain Aggregate\nExtractionSession"]
|
|
185
|
+
|
|
186
|
+
AGG --> E1["Entity\nCsvSource"]
|
|
187
|
+
AGG --> E2["Entity\nColumnSelection"]
|
|
188
|
+
AGG --> V1["Value Objects\nSeparator / ExtractionOptions / Preview / OutputDestination / ExtractionValue"]
|
|
189
|
+
|
|
190
|
+
APP --> INFCSV["Infrastructure CSV\nHeaderReader + ValueStreamer"]
|
|
191
|
+
APP --> INFOUT["Infrastructure Output\nConsoleWriter + CsvFileWriter"]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Project layout
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
bin/tool # CLI entrypoint
|
|
198
|
+
lib/csvtool/cli.rb
|
|
199
|
+
lib/csvtool/domain/extraction_session/*
|
|
200
|
+
lib/csvtool/application/use_cases/run_extraction.rb
|
|
201
|
+
lib/csvtool/infrastructure/csv/*
|
|
202
|
+
lib/csvtool/infrastructure/output/*
|
|
203
|
+
lib/csvtool/interface/cli/menu_loop.rb
|
|
204
|
+
lib/csvtool/interface/cli/prompts/*
|
|
205
|
+
lib/csvtool/interface/cli/errors/presenter.rb
|
|
206
|
+
test/csvtool/cli_test.rb # end-to-end workflow tests
|
|
207
|
+
test/csvtool/**/*_test.rb # focused unit tests by component folder
|
|
208
|
+
test/test_helper.rb
|
|
209
|
+
```
|
data/Rakefile
ADDED
data/bin/csvtool
ADDED
data/bin/tool
ADDED
data/csvops.gemspec
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/csvtool/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "csvops"
|
|
7
|
+
spec.version = Csvtool::VERSION
|
|
8
|
+
spec.authors = ["Robert Hall"]
|
|
9
|
+
spec.email = [""]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Interactive CSV column extraction CLI"
|
|
12
|
+
spec.description = "A small Ruby CLI for extracting CSV columns interactively or via direct command."
|
|
13
|
+
spec.homepage = "https://github.com/RobertAndrewHall/csvops"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.3"
|
|
16
|
+
|
|
17
|
+
spec.files = Dir.glob("{lib,exe,bin,test,docs}/**/*") + %w[README.md Gemfile Rakefile csvops.gemspec]
|
|
18
|
+
spec.bindir = "exe"
|
|
19
|
+
spec.executables = ["csvtool"]
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
|
|
22
|
+
spec.add_dependency "csv", "~> 3.3"
|
|
23
|
+
spec.add_development_dependency "minitest", "~> 6.0"
|
|
24
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
25
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Release Checklist: v0.1.0-alpha
|
|
2
|
+
|
|
3
|
+
## 1. Verify environment
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
ruby -v
|
|
7
|
+
bundle -v
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Expected:
|
|
11
|
+
- Ruby `3.3.0`
|
|
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
|
+
printf '2\n' | bundle exec csvtool menu
|
|
29
|
+
bundle exec csvtool column test/fixtures/sample_people.csv name
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Expected output for `column` command:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
Alice
|
|
36
|
+
Bob
|
|
37
|
+
Cara
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 5. Build and validate gem package
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
gem build csvops.gemspec
|
|
44
|
+
gem install ./csvops-0.1.0.alpha.gem
|
|
45
|
+
csvtool column test/fixtures/sample_people.csv name
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 6. Commit release prep
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git add -A
|
|
52
|
+
git commit -m "chore(release): prepare v0.1.0-alpha"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 7. Tag release
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git tag -a v0.1.0-alpha -m "v0.1.0-alpha"
|
|
59
|
+
git push origin main --tags
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 8. Publish gem (optional for alpha)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
gem push csvops-0.1.0.alpha.gem
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 9. Create GitHub release
|
|
69
|
+
|
|
70
|
+
Create release `v0.1.0-alpha` with:
|
|
71
|
+
- Summary of supported commands (`menu`, `column`)
|
|
72
|
+
- Known limitations
|
|
73
|
+
- Install instructions (`gem install csvops --pre`)
|
data/exe/csvtool
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "csvtool/interface/cli/errors/presenter"
|
|
5
|
+
require "csvtool/interface/cli/prompts/file_path_prompt"
|
|
6
|
+
require "csvtool/interface/cli/prompts/separator_prompt"
|
|
7
|
+
require "csvtool/interface/cli/prompts/column_selector_prompt"
|
|
8
|
+
require "csvtool/interface/cli/prompts/skip_blanks_prompt"
|
|
9
|
+
require "csvtool/interface/cli/prompts/confirm_prompt"
|
|
10
|
+
require "csvtool/interface/cli/prompts/output_destination_prompt"
|
|
11
|
+
require "csvtool/infrastructure/csv/header_reader"
|
|
12
|
+
require "csvtool/infrastructure/csv/value_streamer"
|
|
13
|
+
require "csvtool/services/preview_builder"
|
|
14
|
+
require "csvtool/infrastructure/output/console_writer"
|
|
15
|
+
require "csvtool/infrastructure/output/csv_file_writer"
|
|
16
|
+
require "csvtool/domain/extraction_session/separator"
|
|
17
|
+
require "csvtool/domain/extraction_session/csv_source"
|
|
18
|
+
require "csvtool/domain/extraction_session/column_selection"
|
|
19
|
+
require "csvtool/domain/extraction_session/extraction_options"
|
|
20
|
+
require "csvtool/domain/extraction_session/extraction_value"
|
|
21
|
+
require "csvtool/domain/extraction_session/preview"
|
|
22
|
+
require "csvtool/domain/extraction_session/output_destination"
|
|
23
|
+
require "csvtool/domain/extraction_session/extraction_session"
|
|
24
|
+
|
|
25
|
+
module Csvtool
|
|
26
|
+
module Application
|
|
27
|
+
module UseCases
|
|
28
|
+
class RunExtraction
|
|
29
|
+
def initialize(stdin:, stdout:)
|
|
30
|
+
@stdin = stdin
|
|
31
|
+
@stdout = stdout
|
|
32
|
+
@errors = Interface::CLI::Errors::Presenter.new(stdout: stdout)
|
|
33
|
+
@header_reader = Infrastructure::CSV::HeaderReader.new
|
|
34
|
+
@value_streamer = Infrastructure::CSV::ValueStreamer.new
|
|
35
|
+
@preview_builder = Services::PreviewBuilder.new(value_streamer: @value_streamer)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
file_path = Interface::CLI::Prompts::FilePathPrompt.new(stdin: @stdin, stdout: @stdout).call
|
|
40
|
+
return @errors.file_not_found(file_path) unless File.file?(file_path)
|
|
41
|
+
|
|
42
|
+
col_sep = Interface::CLI::Prompts::SeparatorPrompt.new(stdin: @stdin, stdout: @stdout, errors: @errors).call
|
|
43
|
+
return if col_sep.nil?
|
|
44
|
+
separator = Domain::ExtractionSession::Separator.new(col_sep)
|
|
45
|
+
|
|
46
|
+
source = Domain::ExtractionSession::CsvSource.new(path: file_path, separator: separator)
|
|
47
|
+
headers = @header_reader.call(file_path: source.path, col_sep: source.separator.value)
|
|
48
|
+
return @errors.no_headers if headers.empty?
|
|
49
|
+
|
|
50
|
+
column_name = Interface::CLI::Prompts::ColumnSelectorPrompt.new(stdin: @stdin, stdout: @stdout, errors: @errors).call(headers)
|
|
51
|
+
return if column_name.nil?
|
|
52
|
+
column_selection = Domain::ExtractionSession::ColumnSelection.new(name: column_name)
|
|
53
|
+
|
|
54
|
+
skip_blanks = Interface::CLI::Prompts::SkipBlanksPrompt.new(stdin: @stdin, stdout: @stdout).call
|
|
55
|
+
options = Domain::ExtractionSession::ExtractionOptions.new(skip_blanks: skip_blanks, preview_limit: 10)
|
|
56
|
+
session = Domain::ExtractionSession::ExtractionSession.start(
|
|
57
|
+
source: source,
|
|
58
|
+
column_selection: column_selection,
|
|
59
|
+
options: options
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
preview_values = @preview_builder.call(
|
|
63
|
+
file_path: session.source.path,
|
|
64
|
+
column_name: session.column_selection.name,
|
|
65
|
+
col_sep: session.source.separator.value,
|
|
66
|
+
skip_blanks: session.options.skip_blanks?,
|
|
67
|
+
limit: session.options.preview_limit
|
|
68
|
+
)
|
|
69
|
+
preview = Domain::ExtractionSession::Preview.new(
|
|
70
|
+
values: preview_values.map { |value| Domain::ExtractionSession::ExtractionValue.new(value) }
|
|
71
|
+
)
|
|
72
|
+
session = session.with_preview(preview)
|
|
73
|
+
|
|
74
|
+
confirmed = Interface::CLI::Prompts::ConfirmPrompt.new(stdin: @stdin, stdout: @stdout, errors: @errors).call(session.preview.to_strings)
|
|
75
|
+
return unless confirmed
|
|
76
|
+
session = session.confirm!
|
|
77
|
+
|
|
78
|
+
output_destination = Interface::CLI::Prompts::OutputDestinationPrompt.new(stdin: @stdin, stdout: @stdout, errors: @errors).call
|
|
79
|
+
return if output_destination.nil?
|
|
80
|
+
domain_destination =
|
|
81
|
+
if output_destination[:mode] == :file
|
|
82
|
+
Domain::ExtractionSession::OutputDestination.file(path: output_destination[:path])
|
|
83
|
+
else
|
|
84
|
+
Domain::ExtractionSession::OutputDestination.console
|
|
85
|
+
end
|
|
86
|
+
session = session.with_output_destination(domain_destination)
|
|
87
|
+
|
|
88
|
+
write_output(
|
|
89
|
+
session.output_destination,
|
|
90
|
+
file_path: session.source.path,
|
|
91
|
+
column_name: session.column_selection.name,
|
|
92
|
+
col_sep: session.source.separator.value,
|
|
93
|
+
skip_blanks: session.options.skip_blanks?
|
|
94
|
+
)
|
|
95
|
+
rescue CSV::MalformedCSVError
|
|
96
|
+
@errors.could_not_parse_csv
|
|
97
|
+
rescue Errno::EACCES
|
|
98
|
+
@errors.cannot_read_file(file_path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def writer_for(output_destination)
|
|
104
|
+
if output_destination.file?
|
|
105
|
+
Infrastructure::Output::CsvFileWriter.new(stdout: @stdout, errors: @errors, value_streamer: @value_streamer)
|
|
106
|
+
else
|
|
107
|
+
Infrastructure::Output::ConsoleWriter.new(stdout: @stdout, value_streamer: @value_streamer)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_output(output_destination, file_path:, column_name:, col_sep:, skip_blanks:)
|
|
112
|
+
writer = writer_for(output_destination)
|
|
113
|
+
args = {
|
|
114
|
+
file_path: file_path,
|
|
115
|
+
column_name: column_name,
|
|
116
|
+
col_sep: col_sep,
|
|
117
|
+
skip_blanks: skip_blanks
|
|
118
|
+
}
|
|
119
|
+
args[:output_path] = output_destination.path if output_destination.file?
|
|
120
|
+
writer.call(**args)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/csvtool/cli.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "csvtool/interface/cli/menu_loop"
|
|
5
|
+
require "csvtool/application/use_cases/run_extraction"
|
|
6
|
+
require "csvtool/interface/cli/errors/presenter"
|
|
7
|
+
require "csvtool/infrastructure/csv/header_reader"
|
|
8
|
+
require "csvtool/infrastructure/csv/value_streamer"
|
|
9
|
+
require "csvtool/infrastructure/output/console_writer"
|
|
10
|
+
|
|
11
|
+
module Csvtool
|
|
12
|
+
class CLI
|
|
13
|
+
MENU_OPTIONS = [
|
|
14
|
+
"Extract column",
|
|
15
|
+
"Exit"
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.start(argv, stdin:, stdout:, stderr:)
|
|
19
|
+
new(argv, stdin: stdin, stdout: stdout, stderr: stderr).run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(argv, stdin:, stdout:, stderr:)
|
|
23
|
+
@argv = argv
|
|
24
|
+
@stdin = stdin
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
case @argv.first
|
|
31
|
+
when "menu"
|
|
32
|
+
run_menu_loop
|
|
33
|
+
when "column"
|
|
34
|
+
run_column_command
|
|
35
|
+
else
|
|
36
|
+
print_usage
|
|
37
|
+
1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def run_menu_loop
|
|
44
|
+
extract_action = -> { Application::UseCases::RunExtraction.new(stdin: @stdin, stdout: @stdout).call }
|
|
45
|
+
Interface::CLI::MenuLoop.new(
|
|
46
|
+
stdin: @stdin,
|
|
47
|
+
stdout: @stdout,
|
|
48
|
+
menu_options: MENU_OPTIONS,
|
|
49
|
+
extract_action: extract_action
|
|
50
|
+
).run
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_usage
|
|
54
|
+
@stderr.puts "Usage:"
|
|
55
|
+
@stderr.puts " csvtool menu"
|
|
56
|
+
@stderr.puts " csvtool column <file> <column>"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_column_command
|
|
60
|
+
file_path = @argv[1]
|
|
61
|
+
column_name = @argv[2]
|
|
62
|
+
unless file_path && column_name
|
|
63
|
+
print_usage
|
|
64
|
+
return 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
errors = Interface::CLI::Errors::Presenter.new(stdout: @stdout)
|
|
68
|
+
return errors.file_not_found(file_path) || 1 unless File.file?(file_path)
|
|
69
|
+
|
|
70
|
+
header_reader = Infrastructure::CSV::HeaderReader.new
|
|
71
|
+
headers = header_reader.call(file_path: file_path, col_sep: ",")
|
|
72
|
+
return errors.no_headers || 1 if headers.empty?
|
|
73
|
+
return errors.column_not_found || 1 unless headers.include?(column_name)
|
|
74
|
+
|
|
75
|
+
value_streamer = Infrastructure::CSV::ValueStreamer.new
|
|
76
|
+
writer = Infrastructure::Output::ConsoleWriter.new(stdout: @stdout, value_streamer: value_streamer)
|
|
77
|
+
writer.call(file_path: file_path, column_name: column_name, col_sep: ",", skip_blanks: true)
|
|
78
|
+
0
|
|
79
|
+
rescue CSV::MalformedCSVError
|
|
80
|
+
errors.could_not_parse_csv
|
|
81
|
+
1
|
|
82
|
+
rescue Errno::EACCES
|
|
83
|
+
errors.cannot_read_file(file_path)
|
|
84
|
+
1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Csvtool
|
|
4
|
+
module Domain
|
|
5
|
+
module ExtractionSession
|
|
6
|
+
class ColumnSelection
|
|
7
|
+
attr_reader :name
|
|
8
|
+
|
|
9
|
+
def initialize(name:)
|
|
10
|
+
raise ArgumentError, "column name cannot be empty" if name.to_s.empty?
|
|
11
|
+
|
|
12
|
+
@name = name
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Csvtool
|
|
4
|
+
module Domain
|
|
5
|
+
module ExtractionSession
|
|
6
|
+
class CsvSource
|
|
7
|
+
attr_reader :path, :separator
|
|
8
|
+
|
|
9
|
+
def initialize(path:, separator:)
|
|
10
|
+
raise ArgumentError, "path cannot be empty" if path.to_s.empty?
|
|
11
|
+
|
|
12
|
+
@path = path
|
|
13
|
+
@separator = separator
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Csvtool
|
|
4
|
+
module Domain
|
|
5
|
+
module ExtractionSession
|
|
6
|
+
class ExtractionOptions
|
|
7
|
+
attr_reader :skip_blanks, :preview_limit
|
|
8
|
+
|
|
9
|
+
def initialize(skip_blanks:, preview_limit:)
|
|
10
|
+
raise ArgumentError, "preview_limit must be positive" unless preview_limit.to_i.positive?
|
|
11
|
+
|
|
12
|
+
@skip_blanks = !!skip_blanks
|
|
13
|
+
@preview_limit = preview_limit
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def skip_blanks?
|
|
17
|
+
@skip_blanks
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Csvtool
|
|
4
|
+
module Domain
|
|
5
|
+
module ExtractionSession
|
|
6
|
+
class ExtractionSession
|
|
7
|
+
attr_reader :source, :column_selection, :options, :preview, :output_destination
|
|
8
|
+
|
|
9
|
+
def self.start(source:, column_selection:, options:)
|
|
10
|
+
new(source: source, column_selection: column_selection, options: options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(source:, column_selection:, options:, preview: nil, output_destination: nil, confirmed: false)
|
|
14
|
+
@source = source
|
|
15
|
+
@column_selection = column_selection
|
|
16
|
+
@options = options
|
|
17
|
+
@preview = preview
|
|
18
|
+
@output_destination = output_destination
|
|
19
|
+
@confirmed = confirmed
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_preview(preview)
|
|
23
|
+
self.class.new(
|
|
24
|
+
source: @source,
|
|
25
|
+
column_selection: @column_selection,
|
|
26
|
+
options: @options,
|
|
27
|
+
preview: preview,
|
|
28
|
+
output_destination: @output_destination,
|
|
29
|
+
confirmed: @confirmed
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def confirm!
|
|
34
|
+
self.class.new(
|
|
35
|
+
source: @source,
|
|
36
|
+
column_selection: @column_selection,
|
|
37
|
+
options: @options,
|
|
38
|
+
preview: @preview,
|
|
39
|
+
output_destination: @output_destination,
|
|
40
|
+
confirmed: true
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def with_output_destination(destination)
|
|
45
|
+
self.class.new(
|
|
46
|
+
source: @source,
|
|
47
|
+
column_selection: @column_selection,
|
|
48
|
+
options: @options,
|
|
49
|
+
preview: @preview,
|
|
50
|
+
output_destination: destination,
|
|
51
|
+
confirmed: @confirmed
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def confirmed?
|
|
56
|
+
@confirmed
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|