diff --git a/README.md b/README.md index aae91b7..557f418 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,19 @@ $ cat events.csv \ | `-H`, `--header` | Print column names as the first output row | | `--json` | Output results as a JSON array of objects (mutually exclusive with `-H`) | | `--max-rows ` | Stop if more than `n` data rows are read (exit 1) | +| `-v`, `--verbose` | Print `Loaded rows` to stderr after loading (always on TTY; forced with flag) | | `-h`, `--help` | Show usage help and exit | | `-V`, `--version` | Print version and exit | +After loading, `sql-pipe` prints `Loaded rows` to stderr whenever stderr is a TTY (interactive terminal). The message is suppressed in scripts and pipes to keep them noise-free. Use `-v` / `--verbose` to force it regardless of TTY: + +```sh +$ cat sales.csv | sql-pipe --verbose 'SELECT region, SUM(revenue) FROM t GROUP BY region' +# stderr: Loaded 42,317 rows +``` + +The count uses thousands separators (`42,317` not `42317`). It is always written to stderr so stdout remains clean for piping. + ### Exit Codes | Code | Meaning | diff --git a/build.zig b/build.zig index 240af0d..179d80b 100644 --- a/build.zig +++ b/build.zig @@ -317,6 +317,42 @@ pub fn build(b: *std.Build) void { test_csv_row_number.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_csv_row_number.step); + // Integration test 29: --verbose prints "Loaded rows" to stderr + const test_verbose_count = b.addSystemCommand(&.{ + "bash", "-c", + \\printf 'name,age\nAlice,30\nBob,25\nCarol,35\n' | ./zig-out/bin/sql-pipe --verbose 'SELECT COUNT(*) FROM t' 2>&1 >/dev/null | grep -q 'Loaded 3 rows' + }); + test_verbose_count.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_count.step); + + // Integration test 30: --verbose formats thousands separator (e.g. 1,000 not 1000) + const test_verbose_thousands = b.addSystemCommand(&.{ + "bash", "-c", + \\{ printf 'n\n'; seq 1 1000; } | ./zig-out/bin/sql-pipe --verbose 'SELECT COUNT(*) FROM t' 2>&1 >/dev/null | grep -q 'Loaded 1,000 rows' + }); + test_verbose_thousands.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_thousands.step); + + // Integration test 31: without --verbose, row count is NOT printed to stderr (non-TTY) + // Note: `2>&1 >/dev/null` redirects stderr to the subshell's stdout (captured in $out), + // then redirects stdout to /dev/null. The TTY check suppresses the count in this + // non-interactive context, so $out should be empty. + const test_no_verbose_silent = b.addSystemCommand(&.{ + "bash", "-c", + \\out=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe 'SELECT * FROM t' 2>&1 >/dev/null) + \\test -z "$out" + }); + test_no_verbose_silent.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_no_verbose_silent.step); + + // Integration test 32: -v is an alias for --verbose + const test_verbose_short = b.addSystemCommand(&.{ + "bash", "-c", + \\printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -v 'SELECT * FROM t' 2>&1 >/dev/null | grep -q 'Loaded 1 rows' + }); + test_verbose_short.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_verbose_short.step); + // Unit tests for the RFC 4180 CSV parser (src/csv.zig) const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/src/main.zig b/src/main.zig index 9b11a19..7400660 100644 --- a/src/main.zig +++ b/src/main.zig @@ -65,6 +65,9 @@ const ParsedArgs = struct { json: bool, /// Abort with exit 1 when more than this many data rows are read; null = unlimited. max_rows: ?usize, + /// Print "Loaded rows" to stderr after all CSV rows are inserted when true. + /// When false, the message is still shown automatically when stderr is a TTY. + verbose: bool, }; /// Result of argument parsing — either parsed arguments or a special action. @@ -96,6 +99,7 @@ fn printUsage(writer: *std.Io.Writer) !void { \\ -H, --header Print column names as the first output row \\ --json Output results as a JSON array of objects \\ --max-rows Stop if more than data rows are read (exit 1) + \\ -v, --verbose Force row count to stderr (shown automatically on TTY) \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit \\ @@ -143,6 +147,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { var explicit_delimiter = false; var explicit_tsv = false; var max_rows: ?usize = null; + var verbose = false; // Loop invariant I: all args[1..i] have been processed; // query holds the first non-flag argument seen, or null; @@ -187,6 +192,8 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { } else if (std.mem.startsWith(u8, arg, "--max-rows=")) { max_rows = std.fmt.parseUnsigned(usize, arg["--max-rows=".len..], 10) catch return error.InvalidMaxRows; if (max_rows.? == 0) return error.InvalidMaxRows; + } else if (std.mem.eql(u8, arg, "--verbose") or std.mem.eql(u8, arg, "-v")) { + verbose = true; } else { if (query == null) query = arg; } @@ -203,6 +210,7 @@ fn parseArgs(args: []const [:0]const u8) SqlPipeError!ArgsResult { .header = header, .json = json, .max_rows = max_rows, + .verbose = verbose, } }; } @@ -850,6 +858,32 @@ fn printSqlErrorContext( // ─── Entry point ────────────────────────────────────── +/// fmtThousands(buf, n) → []const u8 +/// Pre: buf.len >= 26 (accommodates any usize value with thousands separators) +/// Post: n is formatted as a decimal string with ',' separating each group of +/// three digits from the right (e.g. 42317 → "42,317", 1000 → "1,000") +fn fmtThousands(buf: []u8, n: usize) []const u8 { + var tmp: [32]u8 = undefined; // 20 digits max (u64) + safety margin + const digits = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + const len = digits.len; + const first_group = len % 3; // digits in the leading group (0 means groups of 3 from start) + var out_len: usize = 0; + // Loop invariant I: buf[0..out_len] = formatted prefix of digits[0..i] + // commas inserted before every third digit counted from the right + // Bounding function: len - i + for (digits, 0..) |ch, i| { + if ((i > 0 and i == first_group) or + (i > first_group and (i - first_group) % 3 == 0)) + { + buf[out_len] = ','; + out_len += 1; + } + buf[out_len] = ch; + out_len += 1; + } + return buf[0..out_len]; +} + /// fatal(writer, code, comptime fmt, args) → noreturn /// Pre: writer is stderr, code is non-zero ExitCode /// Post: "error: \n" written to stderr, process exits with code @@ -1114,6 +1148,16 @@ fn run( } // {A9: transaction committed; t holds all input rows, no active transaction} + // Print row count to stderr when stderr is a TTY or --verbose is set. + if (parsed.verbose or (std.Io.File.isTty(std.Io.File.stderr(), io) catch false)) { + var count_buf: [32]u8 = undefined; + const count_str = fmtThousands(&count_buf, rows_inserted); + stderr_writer.print("Loaded {s} rows\n", .{count_str}) catch |err| { + std.log.err("failed to write row count: {}", .{err}); + }; + stderr_writer.flush() catch |err| std.log.err("failed to flush stderr: {}", .{err}); + } + execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.json) catch { fatalSqlWithContext(allocator, db, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer); };