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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../test_helper"
4
4
  require "csvtool/cli"
5
+ require "json"
5
6
  require "tmpdir"
6
7
  require "fileutils"
7
8
 
@@ -12,7 +13,7 @@ class TestCli < Minitest::Test
12
13
 
13
14
  def test_menu_can_exit_cleanly
14
15
  output = StringIO.new
15
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("8\n"), stdout: output, stderr: StringIO.new)
16
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("8\n"), stdout: output, stderr: output)
16
17
  assert_equal 0, status
17
18
  assert_includes output.string, "CSV Tool Menu"
18
19
  end
@@ -28,12 +29,13 @@ class TestCli < Minitest::Test
28
29
  "8"
29
30
  ].join("\n") + "\n"
30
31
 
31
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
32
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
32
33
 
33
34
  assert_equal 0, status
34
35
  assert_includes output.string, "CSV Stats Summary"
35
- assert_includes output.string, "Rows: 3"
36
- assert_includes output.string, "Columns: 2"
36
+ assert_includes output.string, "Metric"
37
+ assert_includes output.string, "Rows"
38
+ assert_includes output.string, "Columns"
37
39
  assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
38
40
  end
39
41
 
@@ -48,7 +50,7 @@ class TestCli < Minitest::Test
48
50
  "8"
49
51
  ].join("\n") + "\n"
50
52
 
51
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
53
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
52
54
 
53
55
  assert_equal 0, status
54
56
  assert_includes output.string, "File not found: /tmp/does-not-exist.csv"
@@ -70,7 +72,7 @@ class TestCli < Minitest::Test
70
72
  "8"
71
73
  ].join("\n") + "\n"
72
74
 
73
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
75
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
74
76
 
75
77
  assert_equal 0, status
76
78
  assert_includes output.string, "Wrote output to #{output_path}"
@@ -98,10 +100,10 @@ class TestCli < Minitest::Test
98
100
  "8"
99
101
  ].join("\n") + "\n"
100
102
 
101
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
103
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
102
104
 
103
105
  assert_equal 0, status
104
- assert_includes output.string, "Chunks written: 3"
106
+ assert_includes output.string, "Chunks written"
105
107
  assert File.file?(File.join(dir, "people_part_001.csv"))
106
108
  assert File.file?(File.join(dir, "people_part_002.csv"))
107
109
  assert File.file?(File.join(dir, "people_part_003.csv"))
@@ -119,7 +121,7 @@ class TestCli < Minitest::Test
119
121
  "8"
120
122
  ].join("\n") + "\n"
121
123
 
122
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
124
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
123
125
 
124
126
  assert_equal 0, status
125
127
  assert_includes output.string, "Chunk size must be a positive integer."
@@ -140,7 +142,7 @@ class TestCli < Minitest::Test
140
142
  ].join("\n") + "\n"
141
143
 
142
144
  output = StringIO.new
143
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
145
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
144
146
 
145
147
  assert_equal 0, status
146
148
  assert_match(/\nAlice\nBob\nCara\n/, output.string)
@@ -159,6 +161,244 @@ class TestCli < Minitest::Test
159
161
  assert_equal "Alice\nBob\nCara\n", output.string
160
162
  end
161
163
 
164
+ def test_stats_command_writes_summary_to_stdout_only
165
+ stdout = StringIO.new
166
+ stderr = StringIO.new
167
+
168
+ status = Csvtool::CLI.start(
169
+ ["stats", fixture_path("sample_people.csv")],
170
+ stdin: StringIO.new,
171
+ stdout: stdout,
172
+ stderr: stderr
173
+ )
174
+
175
+ assert_equal 0, status
176
+ assert_includes stdout.string, "CSV Stats Summary"
177
+ assert_includes stdout.string, "Metric"
178
+ assert_includes stdout.string, "Rows"
179
+ assert_includes stdout.string, "Columns"
180
+ assert_includes stdout.string, "Headers"
181
+ assert_equal "", stderr.string
182
+ end
183
+
184
+ def test_stats_command_writes_errors_to_stderr_only
185
+ stdout = StringIO.new
186
+ stderr = StringIO.new
187
+
188
+ status = Csvtool::CLI.start(
189
+ ["stats", "/tmp/not-there.csv"],
190
+ stdin: StringIO.new,
191
+ stdout: stdout,
192
+ stderr: stderr
193
+ )
194
+
195
+ assert_equal 1, status
196
+ assert_equal "", stdout.string
197
+ assert_includes stderr.string, "File not found: /tmp/not-there.csv"
198
+ end
199
+
200
+ def test_stats_command_supports_json_format
201
+ stdout = StringIO.new
202
+ stderr = StringIO.new
203
+
204
+ status = Csvtool::CLI.start(
205
+ ["stats", fixture_path("sample_people.csv"), "--format", "json"],
206
+ stdin: StringIO.new,
207
+ stdout: stdout,
208
+ stderr: stderr
209
+ )
210
+
211
+ assert_equal 0, status
212
+ data = JSON.parse(stdout.string, symbolize_names: true)
213
+ assert_equal 3, data[:row_count]
214
+ assert_equal 2, data[:column_count]
215
+ assert_equal ["name", "city"], data[:headers]
216
+ assert_equal "", stderr.string
217
+ end
218
+
219
+ def test_stats_command_supports_csv_format
220
+ stdout = StringIO.new
221
+ stderr = StringIO.new
222
+
223
+ status = Csvtool::CLI.start(
224
+ ["stats", fixture_path("sample_people.csv"), "--format=csv"],
225
+ stdin: StringIO.new,
226
+ stdout: stdout,
227
+ stderr: stderr
228
+ )
229
+
230
+ assert_equal 0, status
231
+ lines = stdout.string.lines.map(&:strip)
232
+ assert_equal "metric,value", lines[0]
233
+ assert_includes lines, "row_count,3"
234
+ assert_includes lines, "column_count,2"
235
+ assert_includes lines, "headers,name|city"
236
+ assert_equal "", stderr.string
237
+ end
238
+
239
+ def test_stats_command_rejects_unknown_format
240
+ stdout = StringIO.new
241
+ stderr = StringIO.new
242
+
243
+ status = Csvtool::CLI.start(
244
+ ["stats", fixture_path("sample_people.csv"), "--format", "yaml"],
245
+ stdin: StringIO.new,
246
+ stdout: stdout,
247
+ stderr: stderr
248
+ )
249
+
250
+ assert_equal 1, status
251
+ assert_equal "", stdout.string
252
+ assert_includes stderr.string, "Invalid format: yaml"
253
+ end
254
+
255
+ def test_stats_command_color_auto_without_tty_has_no_ansi
256
+ stdout = StringIO.new
257
+ stderr = StringIO.new
258
+
259
+ status = Csvtool::CLI.start(
260
+ ["stats", fixture_path("sample_people.csv"), "--color", "auto"],
261
+ stdin: StringIO.new,
262
+ stdout: stdout,
263
+ stderr: stderr
264
+ )
265
+
266
+ assert_equal 0, status
267
+ refute_match(/\e\[[0-9;]*m/, stdout.string)
268
+ assert_equal "", stderr.string
269
+ end
270
+
271
+ def test_stats_command_color_always_adds_ansi
272
+ stdout = StringIO.new
273
+ stderr = StringIO.new
274
+
275
+ status = Csvtool::CLI.start(
276
+ ["stats", fixture_path("sample_people.csv"), "--color=always"],
277
+ stdin: StringIO.new,
278
+ stdout: stdout,
279
+ stderr: stderr
280
+ )
281
+
282
+ assert_equal 0, status
283
+ assert_match(/\e\[[0-9;]*m/, stdout.string)
284
+ assert_equal "", stderr.string
285
+ end
286
+
287
+ def test_stats_command_color_never_disables_ansi
288
+ stdout = StringIO.new
289
+ stderr = StringIO.new
290
+
291
+ status = Csvtool::CLI.start(
292
+ ["stats", fixture_path("sample_people.csv"), "--color", "never"],
293
+ stdin: StringIO.new,
294
+ stdout: stdout,
295
+ stderr: stderr
296
+ )
297
+
298
+ assert_equal 0, status
299
+ refute_match(/\e\[[0-9;]*m/, stdout.string)
300
+ assert_equal "", stderr.string
301
+ end
302
+
303
+ def test_stats_command_no_color_env_disables_auto
304
+ stdout = StringIO.new
305
+ stderr = StringIO.new
306
+
307
+ status = Csvtool::CLI.start(
308
+ ["stats", fixture_path("sample_people.csv"), "--color", "auto"],
309
+ stdin: StringIO.new,
310
+ stdout: stdout,
311
+ stderr: stderr,
312
+ env: { "NO_COLOR" => "1" }
313
+ )
314
+
315
+ assert_equal 0, status
316
+ refute_match(/\e\[[0-9;]*m/, stdout.string)
317
+ end
318
+
319
+ def test_stats_command_color_always_overrides_no_color_env
320
+ stdout = StringIO.new
321
+ stderr = StringIO.new
322
+
323
+ status = Csvtool::CLI.start(
324
+ ["stats", fixture_path("sample_people.csv"), "--color", "always"],
325
+ stdin: StringIO.new,
326
+ stdout: stdout,
327
+ stderr: stderr,
328
+ env: { "NO_COLOR" => "1" }
329
+ )
330
+
331
+ assert_equal 0, status
332
+ assert_match(/\e\[[0-9;]*m/, stdout.string)
333
+ end
334
+
335
+ def test_stats_command_rejects_unknown_color_mode
336
+ stdout = StringIO.new
337
+ stderr = StringIO.new
338
+
339
+ status = Csvtool::CLI.start(
340
+ ["stats", fixture_path("sample_people.csv"), "--color", "sometimes"],
341
+ stdin: StringIO.new,
342
+ stdout: stdout,
343
+ stderr: stderr
344
+ )
345
+
346
+ assert_equal 1, status
347
+ assert_equal "", stdout.string
348
+ assert_includes stderr.string, "Invalid color mode: sometimes"
349
+ end
350
+
351
+ def test_menu_workflow_routes_ui_to_stderr_and_data_to_stdout
352
+ stdout = StringIO.new
353
+ stderr = StringIO.new
354
+ input = [
355
+ "7",
356
+ fixture_path("sample_people.csv"),
357
+ "",
358
+ "",
359
+ "",
360
+ "8"
361
+ ].join("\n") + "\n"
362
+
363
+ status = Csvtool::CLI.start(
364
+ ["menu"],
365
+ stdin: StringIO.new(input),
366
+ stdout: stdout,
367
+ stderr: stderr
368
+ )
369
+
370
+ assert_equal 0, status
371
+ assert_includes stderr.string, "CSV Tool Menu"
372
+ assert_includes stderr.string, "CSV file path:"
373
+ assert_includes stdout.string, "CSV Stats Summary"
374
+ refute_includes stdout.string, "CSV Tool Menu"
375
+ refute_includes stdout.string, "CSV file path:"
376
+ end
377
+
378
+ def test_stats_command_table_respects_narrow_terminal_width
379
+ stdout = StringIO.new
380
+ stderr = StringIO.new
381
+
382
+ Dir.mktmpdir do |dir|
383
+ path = File.join(dir, "long_headers.csv")
384
+ File.write(path, "very_long_column_name,another_really_long_column_name\nvalue_a,value_b\n")
385
+
386
+ status = Csvtool::CLI.start(
387
+ ["stats", path],
388
+ stdin: StringIO.new,
389
+ stdout: stdout,
390
+ stderr: stderr,
391
+ env: { "COLUMNS" => "32" }
392
+ )
393
+
394
+ assert_equal 0, status
395
+ lines = stdout.string.lines.map(&:chomp)
396
+ assert lines.all? { |line| line.length <= 32 }
397
+ assert_includes stdout.string, "..."
398
+ assert_equal "", stderr.string
399
+ end
400
+ end
401
+
162
402
  def test_row_range_workflow_prints_selected_rows
163
403
  output = StringIO.new
164
404
  input = [
@@ -171,7 +411,7 @@ class TestCli < Minitest::Test
171
411
  "8"
172
412
  ].join("\n") + "\n"
173
413
 
174
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
414
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
175
415
 
176
416
  assert_equal 0, status
177
417
  assert_includes output.string, "name,city"
@@ -192,7 +432,7 @@ class TestCli < Minitest::Test
192
432
  "8"
193
433
  ].join("\n") + "\n"
194
434
 
195
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
435
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
196
436
 
197
437
  assert_equal 0, status
198
438
  assert_includes output.string, "Start row must be a positive integer."
@@ -211,7 +451,7 @@ class TestCli < Minitest::Test
211
451
  "8"
212
452
  ].join("\n") + "\n"
213
453
 
214
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
454
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
215
455
 
216
456
  assert_equal 0, status
217
457
  assert_includes output.string, "name,city"
@@ -232,7 +472,7 @@ class TestCli < Minitest::Test
232
472
  "8"
233
473
  ].join("\n") + "\n"
234
474
 
235
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
475
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
236
476
 
237
477
  assert_equal 0, status
238
478
  assert_includes output.string, "name,city"
@@ -257,7 +497,7 @@ class TestCli < Minitest::Test
257
497
  "8"
258
498
  ].join("\n") + "\n"
259
499
 
260
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
500
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
261
501
  assert_equal 0, status
262
502
  assert_equal "name,city\nBob,Paris\nCara,Berlin\n", File.read(output_path)
263
503
  end
@@ -277,7 +517,7 @@ class TestCli < Minitest::Test
277
517
  "8"
278
518
  ].join("\n") + "\n"
279
519
 
280
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
520
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
281
521
 
282
522
  assert_equal 0, status
283
523
  assert_includes output.string, "Alice,London"
@@ -297,7 +537,7 @@ class TestCli < Minitest::Test
297
537
  "8"
298
538
  ].join("\n") + "\n"
299
539
 
300
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
540
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
301
541
 
302
542
  assert_equal 0, status
303
543
  assert_includes output.string, "name,city"
@@ -322,7 +562,7 @@ class TestCli < Minitest::Test
322
562
  "8"
323
563
  ].join("\n") + "\n"
324
564
 
325
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
565
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
326
566
 
327
567
  assert_equal 0, status
328
568
  assert_includes output.string, "Wrote output to #{output_path}"
@@ -344,7 +584,7 @@ class TestCli < Minitest::Test
344
584
  "8"
345
585
  ].join("\n") + "\n"
346
586
 
347
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
587
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
348
588
 
349
589
  assert_equal 0, status
350
590
  assert_includes output.string, "name\tcity"
@@ -363,7 +603,7 @@ class TestCli < Minitest::Test
363
603
  "8"
364
604
  ].join("\n") + "\n"
365
605
 
366
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
606
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
367
607
 
368
608
  assert_equal 0, status
369
609
  refute_includes output.string, "name,city"
@@ -383,7 +623,7 @@ class TestCli < Minitest::Test
383
623
  "8"
384
624
  ].join("\n") + "\n"
385
625
 
386
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
626
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
387
627
 
388
628
  assert_equal 0, status
389
629
  assert_includes output.string, "Seed must be an integer."
@@ -408,7 +648,7 @@ class TestCli < Minitest::Test
408
648
  "8"
409
649
  ].join("\n") + "\n"
410
650
 
411
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
651
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
412
652
 
413
653
  assert_equal 0, status
414
654
  assert_includes output.string, "Reference CSV file path:"
@@ -417,7 +657,7 @@ class TestCli < Minitest::Test
417
657
  assert_includes output.string, "customer_id,name"
418
658
  assert_includes output.string, "1,Alice"
419
659
  assert_includes output.string, "3,Cara"
420
- assert_includes output.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
660
+ assert_includes output.string, "Summary"
421
661
  end
422
662
 
423
663
  def test_dedupe_workflow_can_write_to_file
@@ -442,12 +682,12 @@ class TestCli < Minitest::Test
442
682
  "8"
443
683
  ].join("\n") + "\n"
444
684
 
445
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
685
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
446
686
 
447
687
  assert_equal 0, status
448
688
  assert_includes output.string, "Wrote output to #{output_path}"
449
689
  assert_equal "customer_id,name\n1,Alice\n3,Cara\n", File.read(output_path)
450
- assert_includes output.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
690
+ assert_includes output.string, "Summary"
451
691
  end
452
692
  end
453
693
 
@@ -469,7 +709,7 @@ class TestCli < Minitest::Test
469
709
  "8"
470
710
  ].join("\n") + "\n"
471
711
 
472
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
712
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
473
713
 
474
714
  assert_equal 0, status
475
715
  assert_includes output.string, "customer_id\tname"
@@ -495,13 +735,13 @@ class TestCli < Minitest::Test
495
735
  "8"
496
736
  ].join("\n") + "\n"
497
737
 
498
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
738
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
499
739
 
500
740
  assert_equal 0, status
501
741
  refute_includes output.string, "customer_id,name"
502
742
  assert_includes output.string, "1,Alice"
503
743
  assert_includes output.string, "3,Cara"
504
- assert_includes output.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
744
+ assert_includes output.string, "Summary"
505
745
  end
506
746
 
507
747
  def test_parity_workflow_reports_match_and_returns_to_menu
@@ -515,13 +755,15 @@ class TestCli < Minitest::Test
515
755
  "8"
516
756
  ].join("\n") + "\n"
517
757
 
518
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
758
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
519
759
 
520
760
  assert_equal 0, status
521
761
  assert_includes output.string, "Left CSV file path:"
522
762
  assert_includes output.string, "Right CSV file path:"
523
763
  assert_includes output.string, "MATCH"
524
- assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
764
+ assert_includes output.string, "Metric"
765
+ assert_includes output.string, "Left rows"
766
+ assert_includes output.string, "Right rows"
525
767
  assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
526
768
  end
527
769
 
@@ -536,11 +778,12 @@ class TestCli < Minitest::Test
536
778
  "8"
537
779
  ].join("\n") + "\n"
538
780
 
539
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
781
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
540
782
 
541
783
  assert_equal 0, status
542
784
  assert_includes output.string, "MATCH"
543
- assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
785
+ assert_includes output.string, "Left rows"
786
+ assert_includes output.string, "Right rows"
544
787
  end
545
788
 
546
789
  def test_parity_workflow_headerless_mode_compares_all_rows
@@ -554,11 +797,12 @@ class TestCli < Minitest::Test
554
797
  "8"
555
798
  ].join("\n") + "\n"
556
799
 
557
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
800
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
558
801
 
559
802
  assert_equal 0, status
560
803
  assert_includes output.string, "MATCH"
561
- assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
804
+ assert_includes output.string, "Left rows"
805
+ assert_includes output.string, "Right rows"
562
806
  end
563
807
 
564
808
  def test_parity_workflow_reports_header_mismatch_in_headered_mode
@@ -572,7 +816,7 @@ class TestCli < Minitest::Test
572
816
  "8"
573
817
  ].join("\n") + "\n"
574
818
 
575
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
819
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
576
820
 
577
821
  assert_equal 0, status
578
822
  assert_includes output.string, "CSV headers do not match."
@@ -590,11 +834,12 @@ class TestCli < Minitest::Test
590
834
  "8"
591
835
  ].join("\n") + "\n"
592
836
 
593
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
837
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
594
838
 
595
839
  assert_equal 0, status
596
840
  assert_includes output.string, "MISMATCH"
597
- assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=1 right_only=1"
841
+ assert_includes output.string, "Left only"
842
+ assert_includes output.string, "Right only"
598
843
  assert_includes output.string, "Left-only examples:"
599
844
  assert_includes output.string, "Cara,Berlin (count +1)"
600
845
  assert_includes output.string, "Right-only examples:"
@@ -612,7 +857,7 @@ class TestCli < Minitest::Test
612
857
  "8"
613
858
  ].join("\n") + "\n"
614
859
 
615
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
860
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
616
861
 
617
862
  assert_equal 0, status
618
863
  assert_includes output.string, "File not found: /tmp/not-there-left.csv"
@@ -631,7 +876,7 @@ class TestCli < Minitest::Test
631
876
  "8"
632
877
  ].join("\n") + "\n"
633
878
 
634
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
879
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
635
880
 
636
881
  assert_equal 0, status
637
882
  assert_includes output.string, "File not found: /tmp/not-there-right.csv"
@@ -650,7 +895,7 @@ class TestCli < Minitest::Test
650
895
  "8"
651
896
  ].join("\n") + "\n"
652
897
 
653
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
898
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
654
899
 
655
900
  assert_equal 0, status
656
901
  assert_includes output.string, "Could not parse CSV file."
@@ -677,7 +922,7 @@ class TestCli < Minitest::Test
677
922
  "8"
678
923
  ].join("\n") + "\n"
679
924
 
680
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
925
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
681
926
  assert_equal 0, status
682
927
  assert_equal "name\nAlice\nBob\nCara\n", File.read(output_path)
683
928
  end
@@ -698,7 +943,7 @@ class TestCli < Minitest::Test
698
943
  ].join("\n") + "\n"
699
944
 
700
945
  output = StringIO.new
701
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
946
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
702
947
 
703
948
  assert_equal 0, status
704
949
  assert_includes output.string, "Canceled."
@@ -711,7 +956,7 @@ class TestCli < Minitest::Test
711
956
  ["menu"],
712
957
  stdin: StringIO.new("1\n/tmp/does-not-exist.csv\n4\n7\n"),
713
958
  stdout: output,
714
- stderr: StringIO.new
959
+ stderr: output
715
960
  )
716
961
 
717
962
  assert_equal 0, status
@@ -734,7 +979,7 @@ class TestCli < Minitest::Test
734
979
  ].join("\n") + "\n"
735
980
 
736
981
  output = StringIO.new
737
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
982
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: output)
738
983
 
739
984
  assert_equal 0, status
740
985
  assert_includes output.string, "Cannot write output file: /tmp/not-a-dir/out.csv"
@@ -16,7 +16,7 @@ class CliUnitTest < Minitest::Test
16
16
  end
17
17
 
18
18
  def test_menu_command_can_exit_zero
19
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("6\n"), stdout: StringIO.new, stderr: StringIO.new)
19
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("8\n"), stdout: StringIO.new, stderr: StringIO.new)
20
20
  assert_equal 0, status
21
21
  end
22
22
 
@@ -28,7 +28,7 @@ class CliUnitTest < Minitest::Test
28
28
  def test_menu_routes_to_row_range_shell
29
29
  stdout = StringIO.new
30
30
  fixture = File.expand_path("../fixtures/sample_people.csv", __dir__)
31
- input = ["2", fixture, "", "2", "3", "", "6"].join("\n") + "\n"
31
+ input = ["2", fixture, "", "2", "3", "", "8"].join("\n") + "\n"
32
32
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
33
33
  assert_equal 0, status
34
34
  assert_includes stdout.string, "name,city"
@@ -39,7 +39,7 @@ class CliUnitTest < Minitest::Test
39
39
  def test_menu_routes_to_randomize_rows_shell
40
40
  stdout = StringIO.new
41
41
  fixture = File.expand_path("../fixtures/sample_people.csv", __dir__)
42
- input = ["3", fixture, "", "", "", "", "6"].join("\n") + "\n"
42
+ input = ["3", fixture, "", "", "", "", "8"].join("\n") + "\n"
43
43
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
44
44
  assert_equal 0, status
45
45
  assert_includes stdout.string, "name,city"
@@ -52,12 +52,12 @@ class CliUnitTest < Minitest::Test
52
52
  stdout = StringIO.new
53
53
  source_fixture = File.expand_path("../fixtures/dedupe_source.csv", __dir__)
54
54
  reference_fixture = File.expand_path("../fixtures/dedupe_reference.csv", __dir__)
55
- input = ["4", source_fixture, "", "", reference_fixture, "", "", "customer_id", "external_id", "", "", "", "6"].join("\n") + "\n"
55
+ input = ["4", source_fixture, "", "", reference_fixture, "", "", "customer_id", "external_id", "", "", "", "8"].join("\n") + "\n"
56
56
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
57
57
  assert_equal 0, status
58
58
  assert_includes stdout.string, "customer_id,name"
59
59
  assert_includes stdout.string, "1,Alice"
60
60
  assert_includes stdout.string, "3,Cara"
61
- assert_includes stdout.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
61
+ assert_includes stdout.string, "Summary"
62
62
  end
63
63
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/output/color_policy"
5
+
6
+ class ColorPolicyTest < Minitest::Test
7
+ class TtyIO
8
+ def tty? = true
9
+ end
10
+
11
+ class NonTtyIO
12
+ def tty? = false
13
+ end
14
+
15
+ def test_auto_uses_tty
16
+ enabled = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: TtyIO.new, env: {}).enabled?
17
+ disabled = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: NonTtyIO.new, env: {}).enabled?
18
+
19
+ assert_equal true, enabled
20
+ assert_equal false, disabled
21
+ end
22
+
23
+ def test_never_disables_color
24
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "never", io: TtyIO.new, env: {})
25
+
26
+ assert_equal false, policy.enabled?
27
+ end
28
+
29
+ def test_always_enables_color_even_with_no_color
30
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "always", io: NonTtyIO.new, env: { "NO_COLOR" => "1" })
31
+
32
+ assert_equal true, policy.enabled?
33
+ end
34
+
35
+ def test_no_color_disables_auto
36
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: TtyIO.new, env: { "NO_COLOR" => "1" })
37
+
38
+ assert_equal false, policy.enabled?
39
+ end
40
+ end