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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +8 -0
  3. data/README.md +209 -0
  4. data/Rakefile +10 -0
  5. data/bin/csvtool +8 -0
  6. data/bin/tool +8 -0
  7. data/csvops.gemspec +25 -0
  8. data/docs/release-v0.1.0-alpha.md +73 -0
  9. data/exe/csvtool +8 -0
  10. data/lib/csvtool/application/use_cases/run_extraction.rb +125 -0
  11. data/lib/csvtool/cli.rb +87 -0
  12. data/lib/csvtool/domain/extraction_session/column_selection.rb +17 -0
  13. data/lib/csvtool/domain/extraction_session/csv_source.rb +18 -0
  14. data/lib/csvtool/domain/extraction_session/extraction_options.rb +22 -0
  15. data/lib/csvtool/domain/extraction_session/extraction_session.rb +61 -0
  16. data/lib/csvtool/domain/extraction_session/extraction_value.rb +15 -0
  17. data/lib/csvtool/domain/extraction_session/output_destination.rb +35 -0
  18. data/lib/csvtool/domain/extraction_session/preview.rb +23 -0
  19. data/lib/csvtool/domain/extraction_session/separator.rb +17 -0
  20. data/lib/csvtool/infrastructure/csv/header_reader.rb +17 -0
  21. data/lib/csvtool/infrastructure/csv/value_streamer.rb +20 -0
  22. data/lib/csvtool/infrastructure/output/console_writer.rb +20 -0
  23. data/lib/csvtool/infrastructure/output/csv_file_writer.rb +30 -0
  24. data/lib/csvtool/interface/cli/errors/presenter.rb +59 -0
  25. data/lib/csvtool/interface/cli/menu_loop.rb +41 -0
  26. data/lib/csvtool/interface/cli/prompts/column_selector_prompt.rb +54 -0
  27. data/lib/csvtool/interface/cli/prompts/confirm_prompt.rb +29 -0
  28. data/lib/csvtool/interface/cli/prompts/file_path_prompt.rb +21 -0
  29. data/lib/csvtool/interface/cli/prompts/output_destination_prompt.rb +40 -0
  30. data/lib/csvtool/interface/cli/prompts/separator_prompt.rb +44 -0
  31. data/lib/csvtool/interface/cli/prompts/skip_blanks_prompt.rb +22 -0
  32. data/lib/csvtool/services/preview_builder.rb +20 -0
  33. data/lib/csvtool/version.rb +5 -0
  34. data/test/csvtool/application/use_cases/run_extraction_test.rb +31 -0
  35. data/test/csvtool/cli_test.rb +134 -0
  36. data/test/csvtool/cli_unit_test.rb +27 -0
  37. data/test/csvtool/domain/extraction_session/column_selection_test.rb +11 -0
  38. data/test/csvtool/domain/extraction_session/csv_source_test.rb +14 -0
  39. data/test/csvtool/domain/extraction_session/extraction_options_test.rb +18 -0
  40. data/test/csvtool/domain/extraction_session/extraction_session_test.rb +35 -0
  41. data/test/csvtool/domain/extraction_session/extraction_value_test.rb +11 -0
  42. data/test/csvtool/domain/extraction_session/output_destination_test.rb +18 -0
  43. data/test/csvtool/domain/extraction_session/preview_test.rb +18 -0
  44. data/test/csvtool/domain/extraction_session/separator_test.rb +15 -0
  45. data/test/csvtool/infrastructure/csv/header_reader_test.rb +16 -0
  46. data/test/csvtool/infrastructure/csv/value_streamer_test.rb +22 -0
  47. data/test/csvtool/infrastructure/output/console_writer_test.rb +19 -0
  48. data/test/csvtool/infrastructure/output/csv_file_writer_test.rb +35 -0
  49. data/test/csvtool/interface/cli/errors/presenter_test.rb +36 -0
  50. data/test/csvtool/interface/cli/menu_loop_test.rb +51 -0
  51. data/test/csvtool/interface/cli/prompts/column_selector_prompt_test.rb +23 -0
  52. data/test/csvtool/interface/cli/prompts/confirm_prompt_test.rb +23 -0
  53. data/test/csvtool/interface/cli/prompts/file_path_prompt_test.rb +11 -0
  54. data/test/csvtool/interface/cli/prompts/output_destination_prompt_test.rb +28 -0
  55. data/test/csvtool/interface/cli/prompts/separator_prompt_test.rb +31 -0
  56. data/test/csvtool/interface/cli/prompts/skip_blanks_prompt_test.rb +13 -0
  57. data/test/csvtool/services/preview_builder_test.rb +22 -0
  58. data/test/fixtures/empty.csv +0 -0
  59. data/test/fixtures/sample_people.csv +4 -0
  60. data/test/fixtures/sample_people.tsv +4 -0
  61. data/test/fixtures/sample_people_blanks.csv +6 -0
  62. data/test/fixtures/sample_people_colon.txt +4 -0
  63. data/test/fixtures/sample_people_many.csv +13 -0
  64. data/test/test_helper.rb +6 -0
  65. metadata +150 -0
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Domain
5
+ module ExtractionSession
6
+ class ExtractionValue
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value.to_s
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Domain
5
+ module ExtractionSession
6
+ class OutputDestination
7
+ attr_reader :mode, :path
8
+
9
+ def self.console
10
+ new(mode: :console)
11
+ end
12
+
13
+ def self.file(path:)
14
+ new(mode: :file, path: path)
15
+ end
16
+
17
+ def initialize(mode:, path: nil)
18
+ raise ArgumentError, "invalid output mode" unless %i[console file].include?(mode)
19
+ raise ArgumentError, "file output path cannot be empty" if mode == :file && path.to_s.empty?
20
+
21
+ @mode = mode
22
+ @path = path
23
+ end
24
+
25
+ def file?
26
+ @mode == :file
27
+ end
28
+
29
+ def console?
30
+ @mode == :console
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Domain
5
+ module ExtractionSession
6
+ class Preview
7
+ attr_reader :values
8
+
9
+ def initialize(values:)
10
+ @values = values
11
+ end
12
+
13
+ def size
14
+ @values.size
15
+ end
16
+
17
+ def to_strings
18
+ @values.map(&:value)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Domain
5
+ module ExtractionSession
6
+ class Separator
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ raise ArgumentError, "separator cannot be empty" if value.to_s.empty?
11
+
12
+ @value = value
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Csvtool
6
+ module Infrastructure
7
+ module CSV
8
+ class HeaderReader
9
+ def call(file_path:, col_sep:)
10
+ first_row = ::CSV.open(file_path, "r", headers: true, col_sep: col_sep, &:first)
11
+ headers = first_row&.headers || []
12
+ headers.compact.reject(&:empty?)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Csvtool
6
+ module Infrastructure
7
+ module CSV
8
+ class ValueStreamer
9
+ def each(file_path:, column_name:, col_sep:, skip_blanks:)
10
+ ::CSV.foreach(file_path, headers: true, col_sep: col_sep) do |row|
11
+ value = row[column_name].to_s
12
+ next if skip_blanks && value.strip.empty?
13
+
14
+ yield value
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Infrastructure
5
+ module Output
6
+ class ConsoleWriter
7
+ def initialize(stdout:, value_streamer:)
8
+ @stdout = stdout
9
+ @value_streamer = value_streamer
10
+ end
11
+
12
+ def call(file_path:, column_name:, col_sep:, skip_blanks:)
13
+ @value_streamer.each(file_path: file_path, column_name: column_name, col_sep: col_sep, skip_blanks: skip_blanks) do |value|
14
+ @stdout.puts value
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Csvtool
6
+ module Infrastructure
7
+ module Output
8
+ class CsvFileWriter
9
+ def initialize(stdout:, errors:, value_streamer:)
10
+ @stdout = stdout
11
+ @errors = errors
12
+ @value_streamer = value_streamer
13
+ end
14
+
15
+ def call(file_path:, column_name:, col_sep:, skip_blanks:, output_path:)
16
+ ::CSV.open(output_path, "w") do |csv|
17
+ csv << [column_name]
18
+ @value_streamer.each(file_path: file_path, column_name: column_name, col_sep: col_sep, skip_blanks: skip_blanks) do |value|
19
+ csv << [value]
20
+ end
21
+ end
22
+
23
+ @stdout.puts "Wrote output to #{output_path}"
24
+ rescue Errno::EACCES, Errno::ENOENT => e
25
+ @errors.cannot_write_output_file(output_path, e.class)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Errors
7
+ class Presenter
8
+ def initialize(stdout:)
9
+ @stdout = stdout
10
+ end
11
+
12
+ def file_not_found(path)
13
+ @stdout.puts "File not found: #{path}"
14
+ end
15
+
16
+ def no_headers
17
+ @stdout.puts "No headers found."
18
+ end
19
+
20
+ def column_not_found
21
+ @stdout.puts "Column not found."
22
+ end
23
+
24
+ def could_not_parse_csv
25
+ @stdout.puts "Could not parse CSV file."
26
+ end
27
+
28
+ def cannot_read_file(path)
29
+ @stdout.puts "Cannot read file: #{path}"
30
+ end
31
+
32
+ def cannot_write_output_file(path, error_class)
33
+ @stdout.puts "Cannot write output file: #{path} (#{error_class})"
34
+ end
35
+
36
+ def empty_output_path
37
+ @stdout.puts "Output file path cannot be empty."
38
+ end
39
+
40
+ def invalid_output_destination
41
+ @stdout.puts "Invalid output destination."
42
+ end
43
+
44
+ def empty_custom_separator
45
+ @stdout.puts "Separator cannot be empty."
46
+ end
47
+
48
+ def invalid_separator_choice
49
+ @stdout.puts "Invalid separator choice."
50
+ end
51
+
52
+ def canceled
53
+ @stdout.puts "Canceled."
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ class MenuLoop
7
+ def initialize(stdin:, stdout:, menu_options:, extract_action:)
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ @menu_options = menu_options
11
+ @extract_action = extract_action
12
+ end
13
+
14
+ def run
15
+ loop do
16
+ print_menu
17
+ @stdout.print "> "
18
+
19
+ case @stdin.gets&.strip
20
+ when "1"
21
+ @extract_action.call
22
+ when "2"
23
+ return 0
24
+ else
25
+ @stdout.puts "Please choose 1 or 2."
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def print_menu
33
+ @stdout.puts "CSV Tool Menu"
34
+ @menu_options.each_with_index do |option, index|
35
+ @stdout.puts "#{index + 1}. #{option}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class ColumnSelectorPrompt
8
+ def initialize(stdin:, stdout:, errors:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ @errors = errors
12
+ end
13
+
14
+ def call(headers)
15
+ @stdout.print "Filter columns (optional): "
16
+ filter = @stdin.gets&.strip.to_s
17
+
18
+ filtered_headers = select_headers(headers, filter)
19
+ return nil if filtered_headers.empty?
20
+
21
+ @stdout.puts "Select column:"
22
+ filtered_headers.each_with_index do |header, index|
23
+ @stdout.puts "#{index + 1}. #{header}"
24
+ end
25
+ @stdout.print "Column number: "
26
+
27
+ selected_header = filtered_headers[@stdin.gets&.strip.to_i - 1]
28
+ return selected_header if selected_header
29
+
30
+ @errors.column_not_found
31
+ nil
32
+ end
33
+
34
+ private
35
+
36
+ def select_headers(headers, filter)
37
+ filtered_headers =
38
+ if filter.empty?
39
+ headers
40
+ else
41
+ headers.select { |header| header.to_s.downcase.include?(filter.downcase) }
42
+ end
43
+
44
+ if filtered_headers.empty?
45
+ @errors.column_not_found
46
+ return []
47
+ end
48
+ filtered_headers
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class ConfirmPrompt
8
+ def initialize(stdin:, stdout:, errors:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ @errors = errors
12
+ end
13
+
14
+ def call(preview_values)
15
+ @stdout.puts "Preview (first #{preview_values.length} values):"
16
+ preview_values.each { |value| @stdout.puts value }
17
+ @stdout.print "Print all values? [y/N]: "
18
+
19
+ answer = @stdin.gets&.strip.to_s.downcase
20
+ return true if %w[y yes].include?(answer)
21
+
22
+ @errors.canceled
23
+ false
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class FilePathPrompt
8
+ def initialize(stdin:, stdout:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ end
12
+
13
+ def call
14
+ @stdout.print "CSV file path: "
15
+ @stdin.gets&.strip.to_s
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class OutputDestinationPrompt
8
+ def initialize(stdin:, stdout:, errors:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ @errors = errors
12
+ end
13
+
14
+ def call
15
+ @stdout.puts "Output destination:"
16
+ @stdout.puts "1. console"
17
+ @stdout.puts "2. file"
18
+ @stdout.print "Output destination [1]: "
19
+ choice = @stdin.gets&.strip.to_s
20
+
21
+ case choice
22
+ when "", "1"
23
+ { mode: :console }
24
+ when "2"
25
+ @stdout.print "Output file path: "
26
+ output_path = @stdin.gets&.strip.to_s
27
+ return { mode: :file, path: output_path } unless output_path.empty?
28
+
29
+ @errors.empty_output_path
30
+ nil
31
+ else
32
+ @errors.invalid_output_destination
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class SeparatorPrompt
8
+ def initialize(stdin:, stdout:, errors:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ @errors = errors
12
+ end
13
+
14
+ def call
15
+ @stdout.puts "Choose separator:"
16
+ @stdout.puts "1. comma (,)"
17
+ @stdout.puts "2. tab (\\t)"
18
+ @stdout.puts "3. semicolon (;)"
19
+ @stdout.puts "4. pipe (|)"
20
+ @stdout.puts "5. custom"
21
+ @stdout.print "Separator choice [1]: "
22
+
23
+ case @stdin.gets&.strip.to_s
24
+ when "", "1" then ","
25
+ when "2" then "\t"
26
+ when "3" then ";"
27
+ when "4" then "|"
28
+ when "5"
29
+ @stdout.print "Custom separator: "
30
+ custom = @stdin.gets&.strip.to_s
31
+ return custom unless custom.empty?
32
+
33
+ @errors.empty_custom_separator
34
+ nil
35
+ else
36
+ @errors.invalid_separator_choice
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Prompts
7
+ class SkipBlanksPrompt
8
+ def initialize(stdin:, stdout:)
9
+ @stdin = stdin
10
+ @stdout = stdout
11
+ end
12
+
13
+ def call
14
+ @stdout.print "Skip blank values? [Y/n]: "
15
+ answer = @stdin.gets&.strip.to_s.downcase
16
+ !%w[n no].include?(answer)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Services
5
+ class PreviewBuilder
6
+ def initialize(value_streamer:)
7
+ @value_streamer = value_streamer
8
+ end
9
+
10
+ def call(file_path:, column_name:, col_sep:, skip_blanks:, limit:)
11
+ preview_values = []
12
+ @value_streamer.each(file_path: file_path, column_name: column_name, col_sep: col_sep, skip_blanks: skip_blanks) do |value|
13
+ preview_values << value
14
+ break if preview_values.length >= limit
15
+ end
16
+ preview_values
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ VERSION = "0.1.0.alpha"
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/application/use_cases/run_extraction"
5
+
6
+ class RunExtractionTest < Minitest::Test
7
+ def test_missing_file_path_reports_error
8
+ out = StringIO.new
9
+ use_case = Csvtool::Application::UseCases::RunExtraction.new(
10
+ stdin: StringIO.new("/tmp/not-present.csv\n"),
11
+ stdout: out
12
+ )
13
+
14
+ use_case.call
15
+
16
+ assert_includes out.string, "File not found: /tmp/not-present.csv"
17
+ end
18
+
19
+ def test_use_case_can_run_console_happy_path
20
+ out = StringIO.new
21
+ fixture = File.expand_path("../../../fixtures/sample_people.csv", __dir__)
22
+ input = ["#{fixture}", "1", "", "1", "", "y", ""].join("\n") + "\n"
23
+
24
+ use_case = Csvtool::Application::UseCases::RunExtraction.new(stdin: StringIO.new(input), stdout: out)
25
+ use_case.call
26
+
27
+ assert_includes out.string, "Alice"
28
+ assert_includes out.string, "Bob"
29
+ assert_includes out.string, "Cara"
30
+ end
31
+ end