diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/CliHandles.java b/cli/src/main/java/io/github/dfa1/vortex/cli/CliHandles.java index 4e967251..aa8e2da9 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/CliHandles.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/CliHandles.java @@ -13,11 +13,12 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; -/// Shared handle plumbing for the interactive subcommands (`view`, `tui`). +/// Shared plumbing for the subcommands: local-file / http(s) target resolution ([#openTarget]), +/// cause-chain rendering ([#describe]), and the worker-thread handle round-trip the interactive +/// `view`/`tui` commands need. /// -/// A [VortexReader] uses a confined [java.lang.foreign.Arena], so the file must be -/// opened and closed on the same [IoWorker] thread the TUI later dispatches I/O to. These -/// helpers centralise that worker round-trip plus the local-file / http(s) target resolution. +/// A [VortexReader] uses a confined [java.lang.foreign.Arena], so for the interactive commands the +/// file must be opened and closed on the same [IoWorker] thread the TUI later dispatches I/O to. @SuppressWarnings("java:S106") // CLI tool: user-facing diagnostics go to System.err by design. final class CliHandles { @@ -37,7 +38,7 @@ static Optional openOnWorker(IoWorker worker, String target) AtomicReference failure = new AtomicReference<>(); worker.runAndAwait(() -> { try { - handle.set(open(target)); + handle.set(openTarget(target)); } catch (IOException e) { failure.set(e); } @@ -80,7 +81,13 @@ static String describe(Throwable t) { return sb.toString(); } - private static VortexHandle open(String target) throws IOException { + /// Opens `target` as a local path or `http(s)://` URL, printing a diagnostic and returning + /// `null` when the file is missing or the URL is malformed. + /// + /// @param target a local path or an `http(s)://` URL + /// @return the opened handle, or `null` if the target is missing or malformed + /// @throws IOException if opening the file or URL fails + static VortexHandle openTarget(String target) throws IOException { if (target.startsWith("http://") || target.startsWith("https://")) { try { return VortexHttpReader.open(new URI(target)); diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/ExportCommand.java b/cli/src/main/java/io/github/dfa1/vortex/cli/ExportCommand.java index fcff8f9b..3c3be74d 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/ExportCommand.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/ExportCommand.java @@ -33,20 +33,20 @@ static int run(String[] args) { : deriveOutputPath(inputPath); try { ExportOptions options = ExportOptions.defaults() - .withProgressListener(ExportCommand::renderProgress); + .withProgressListener(ProgressBar::render); if (toStdout) { Writer stdout = new OutputStreamWriter(System.out, StandardCharsets.UTF_8); CsvExporter.exportCsv(inputPath, stdout, options); stdout.flush(); - clearProgress(); + ProgressBar.clear(); } else { CsvExporter.exportCsv(inputPath, outputPath, options); - clearProgress(); + ProgressBar.clear(); printResult(inputPath, outputPath); } return ExitStatus.OK; } catch (IOException e) { - clearProgress(); + ProgressBar.clear(); System.err.println("error: " + e.getMessage()); return ExitStatus.ERROR; } @@ -66,17 +66,4 @@ private static void printResult(Path inputPath, Path outputPath) throws IOExcept System.out.printf("written: %s (%s → %s)%n", outputPath, ByteSize.format(inputBytes), ByteSize.format(outputBytes)); } - - private static void renderProgress(long done, long total) { - int pct = total > 0 ? (int) (done * 100L / total) : 100; - int filled = pct * 30 / 100; - String bar = "=".repeat(filled) + (filled < 30 ? ">" : "") + " ".repeat(Math.max(0, 29 - filled)); - System.err.printf("\r [%s] %3d%% %,d / %,d rows", bar, pct, done, total); - System.err.flush(); - } - - private static void clearProgress() { - System.err.printf("\r%-80s\r", ""); - System.err.flush(); - } } diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java b/cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java index 1dc7d397..3295acef 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java @@ -42,7 +42,7 @@ static int run(String[] args) { return runCsv(inputPath, parsedArgs.outputPath(), parsedArgs.delimiter()); } } catch (IOException e) { - clearProgress(); + ProgressBar.clear(); System.err.println("error: " + e.getMessage()); return ExitStatus.ERROR; } @@ -84,7 +84,7 @@ private static int runCsv(Path csvPath, Path vortexPath, Character delimiter) th options = options.withDelimiter(delimiter); } CsvImporter.importCsv(csvPath, vortexPath, options); - clearProgress(); + ProgressBar.clear(); printResult(csvPath, vortexPath, options.writeOptions().allowedCascading()); return ExitStatus.OK; } @@ -94,7 +94,7 @@ private static int runParquet(Path parquetPath, Path vortexPath) throws IOExcept io.github.dfa1.vortex.parquet.ImportOptions.defaults() .withProgressListener(ImportCommand::renderProgress); ParquetImporter.importParquet(parquetPath, vortexPath, options); - clearProgress(); + ProgressBar.clear(); printResult(parquetPath, vortexPath, options.writeOptions().allowedCascading()); return ExitStatus.OK; } @@ -114,21 +114,15 @@ private static void printResult(Path inputPath, Path vortexPath, int cascadingDe sizeChange, cascadingInfo); } + /// Progress callback for imports. An indeterminate `total` (`< 0`, e.g. a streamed source with + /// no known row count) shows just the running row count; otherwise delegates to the shared bar. private static void renderProgress(long done, long total) { if (total < 0) { System.err.printf("\r imported %,d rows", done); + System.err.flush(); } else { - int pct = total > 0 ? (int) (done * 100L / total) : 100; - int filled = pct * 30 / 100; - String bar = "=".repeat(filled) + (filled < 30 ? ">" : "") + " ".repeat(Math.max(0, 29 - filled)); - System.err.printf("\r [%s] %3d%% %,d / %,d rows", bar, pct, done, total); + ProgressBar.render(done, total); } - System.err.flush(); - } - - private static void clearProgress() { - System.err.printf("\r%-80s\r", ""); - System.err.flush(); } private static Path deriveOutputPath(Path inputPath) { diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/InspectCommand.java b/cli/src/main/java/io/github/dfa1/vortex/cli/InspectCommand.java index 29e3abe0..1f74d254 100644 --- a/cli/src/main/java/io/github/dfa1/vortex/cli/InspectCommand.java +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/InspectCommand.java @@ -2,14 +2,8 @@ import io.github.dfa1.vortex.inspect.VortexInspector; import io.github.dfa1.vortex.reader.VortexHandle; -import io.github.dfa1.vortex.reader.VortexHttpReader; -import io.github.dfa1.vortex.reader.VortexReader; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; @SuppressWarnings("java:S106") // CLI command: stdout is the intended output channel final class InspectCommand { @@ -22,49 +16,16 @@ static int run(String[] args) { System.err.println("usage: inspect "); return ExitStatus.USAGE_ERROR; } - try (VortexHandle handle = open(args[1])) { + try (VortexHandle handle = CliHandles.openTarget(args[1])) { if (handle == null) { return ExitStatus.FILE_NOT_FOUND; } System.out.print(VortexInspector.inspect(handle)); return ExitStatus.OK; } catch (IOException | RuntimeException e) { - System.err.println("error: " + describe(e)); + System.err.println("error: " + CliHandles.describe(e)); e.printStackTrace(System.err); return ExitStatus.ERROR; } } - - private static String describe(Throwable t) { - StringBuilder sb = new StringBuilder(); - Throwable cur = t; - while (cur != null) { - if (!sb.isEmpty()) { - sb.append(" -> "); - } - sb.append(cur.getClass().getSimpleName()); - if (cur.getMessage() != null) { - sb.append(": ").append(cur.getMessage()); - } - cur = cur.getCause(); - } - return sb.toString(); - } - - private static VortexHandle open(String target) throws IOException { - if (target.startsWith("http://") || target.startsWith("https://")) { - try { - return VortexHttpReader.open(new URI(target)); - } catch (URISyntaxException _) { - System.err.println("invalid URL: " + target); - return null; - } - } - Path path = Path.of(target); - if (!Files.exists(path)) { - System.err.println("file not found: " + path); - return null; - } - return VortexReader.open(path); - } } diff --git a/cli/src/main/java/io/github/dfa1/vortex/cli/ProgressBar.java b/cli/src/main/java/io/github/dfa1/vortex/cli/ProgressBar.java new file mode 100644 index 00000000..c7f685f5 --- /dev/null +++ b/cli/src/main/java/io/github/dfa1/vortex/cli/ProgressBar.java @@ -0,0 +1,32 @@ +package io.github.dfa1.vortex.cli; + +/// Renders an in-place (carriage-return overwrite) progress line on stderr, shared by the +/// import/export subcommands. Matches the [io.github.dfa1.vortex.csv.ProgressListener] signature, +/// so it can be passed directly as `ProgressBar::render`. +@SuppressWarnings("java:S106") // progress UI is written to stderr by design +final class ProgressBar { + + private static final int WIDTH = 30; + + private ProgressBar() { + } + + /// Renders a determinate progress bar: `[====> ] NN% done / total rows`. A non-positive + /// `total` reports 100%. + /// + /// @param done rows processed so far + /// @param total total rows expected + static void render(long done, long total) { + int pct = total > 0 ? (int) (done * 100L / total) : 100; + int filled = pct * WIDTH / 100; + String bar = "=".repeat(filled) + (filled < WIDTH ? ">" : "") + " ".repeat(Math.max(0, WIDTH - 1 - filled)); + System.err.printf("\r [%s] %3d%% %,d / %,d rows", bar, pct, done, total); + System.err.flush(); + } + + /// Clears the current progress line. + static void clear() { + System.err.printf("\r%-80s\r", ""); + System.err.flush(); + } +} diff --git a/cli/src/test/java/io/github/dfa1/vortex/cli/ProgressBarTest.java b/cli/src/test/java/io/github/dfa1/vortex/cli/ProgressBarTest.java new file mode 100644 index 00000000..dbfdd5d1 --- /dev/null +++ b/cli/src/test/java/io/github/dfa1/vortex/cli/ProgressBarTest.java @@ -0,0 +1,47 @@ +package io.github.dfa1.vortex.cli; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static io.github.dfa1.vortex.cli.CliTestSupport.capture; +import static org.assertj.core.api.Assertions.assertThat; + +class ProgressBarTest { + + @ParameterizedTest + @CsvSource({ + // done, total, pct, hasMarker ('>' present while the bar is not full) + "0, 100, 0, true", + "50, 100, 50, true", + "100, 100, 100, false", // full bar: all '=', no '>' + "0, 0, 100, false", // non-positive total reports 100% + }) + void render_showsPercentAndRowCounts(long done, long total, int pct, boolean hasMarker) { + // Given / When + CliTestSupport.Captured result = capture(() -> { + ProgressBar.render(done, total); + return 0; + }); + + // Then the line carries the percentage and done/total counts, and a '>' cursor only while + // the bar is partially filled + assertThat(result.stderr()) + .contains(String.format("%3d%% %,d / %,d rows", pct, done, total)); + assertThat(result.stderr().contains(">")).isEqualTo(hasMarker); + } + + @Test + void clear_blanksTheLine() { + // Given / When + CliTestSupport.Captured result = capture(() -> { + ProgressBar.clear(); + return 0; + }); + + // Then the carriage-return + 80-space pad overwrites whatever was on the line + assertThat(result.stderr()) + .startsWith("\r") + .contains(" ".repeat(80)); + } +}