diff --git a/README.md b/README.md index c74a17e..8887210 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,20 @@ The `validate` command: - with `--table`, checks that all rows have equal length - Exits with 0 on success, 1 on warnings or errors +### Transpose + +Transpose a table. +Both `t` and `transpose` are valid, prefer the latter for long-living scripts. + +```sh +nsv t table.nsv +cat table.nsv | nsv t +``` + +The `transpose` command: +- Requires table input (all rows must have equal arity), errors on ragged data +- Allocates full input in memory (once, but still) + ### Stats Structural overview of an NSV file. diff --git a/src/main.rs b/src/main.rs index 66dc367..8628110 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,15 @@ enum Commands { #[arg(value_name = "FILE")] file: Option, }, + + /// Transpose rows and columns of NSV data + #[command(alias = "t")] + Transpose { + /// Input file (reads from stdin if omitted or "-") + #[arg(value_name = "FILE")] + file: Option, + }, + } fn main() { @@ -63,6 +72,12 @@ fn main() { Commands::Stats { file } => { stats(file); } + Commands::Transpose { file } => { + if let Err(e) = transpose(file) { + eprintln!("error: {}", e); + std::process::exit(1); + } + } } } @@ -295,3 +310,52 @@ fn validate(file: Option, table: bool) -> i32 { exit_code } + +/// Transpose rows and columns of NSV data. +/// +/// Requires table input (all rows must have equal arity). +/// Errors on ragged data. +fn transpose(file: Option) -> Result<(), String> { + let raw = read_input(&file); + + // Split into raw lines (already-escaped cell bytes). + // Group by blank lines into rows of raw cell slices. + let mut rows: Vec> = Vec::new(); + let mut current_row: Vec<&[u8]> = Vec::new(); + for line in raw.split(|&b| b == b'\n') { + if line.is_empty() { + if !current_row.is_empty() { + rows.push(current_row); + current_row = Vec::new(); + } + } else { + current_row.push(line); + } + } + if !current_row.is_empty() { + rows.push(current_row); + } + + if rows.is_empty() { + return Ok(()); + } + + let arity = rows[0].len(); + if rows.iter().any(|r| r.len() != arity) { + return Err("not a table — row arities differ".to_string()); + } + if arity == 0 { + return Ok(()); + } + + let mut out = io::BufWriter::new(io::stdout().lock()); + for col in 0..arity { + for row in &rows { + out.write_all(row[col]).map_err(|e| e.to_string())?; + out.write_all(b"\n").map_err(|e| e.to_string())?; + } + out.write_all(b"\n").map_err(|e| e.to_string())?; + } + Ok(()) +} + diff --git a/tests/test_transpose.sh b/tests/test_transpose.sh new file mode 100755 index 0000000..cbbf8b4 --- /dev/null +++ b/tests/test_transpose.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +cd "$(dirname "$0")/.." +cargo build --quiet || exit 1 + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +PASS=0 +FAIL=0 + +# run_test NAME STDIN_OR_FILE EXPECTED_STDOUT [USE_STDIN] +# Runs transpose, checks exit 0, compares stdout exactly. +run_test() { + local name="$1" + local input="$2" + local expected="$3" + local use_stdin="$4" + + local stdout stderr exit_code + + if [[ "$use_stdin" == "stdin" ]]; then + stdout=$(printf '%s' "$input" | cargo run --quiet -- transpose 2>$TMPDIR/stderr) && exit_code=0 || exit_code=$? + else + stdout=$(cargo run --quiet -- transpose "$input" 2>$TMPDIR/stderr) && exit_code=0 || exit_code=$? + fi + stderr=$(cat $TMPDIR/stderr) + rm -f $TMPDIR/stderr + + local failed=0 + + if [[ "$exit_code" -ne 0 ]]; then + echo "FAIL: $name - expected exit 0, got $exit_code" + failed=1 + fi + + if [[ -n "$stderr" ]]; then + echo "FAIL: $name - unexpected stderr: $stderr" + failed=1 + fi + + if [[ "$stdout" != "$expected" ]]; then + echo "FAIL: $name - stdout mismatch" + diff <(echo "$stdout") <(echo "$expected") + failed=1 + fi + + if [[ "$failed" -eq 0 ]]; then + echo "PASS: $name" + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + fi +} + +echo "Running transpose tests..." +echo + +F=tests/fixtures + +# ── Empty input ── + +run_test "empty file" $F/empty.nsv "" + +# ── 2×2 table → 2×2 table ── + +run_test "2x2 table" $F/table.nsv "$(printf 'a\nc\n\nb\nd\n\n')" + +# ── 3×4 table → 4×3 table ── + +run_test "3x4 table" $F/table_3x4.nsv "$(printf 'a\ne\ni\n\nb\nf\nj\n\nc\ng\nk\n\nd\nh\nl\n\n')" + +# ── Ragged rows rejected ── + +cargo run --quiet -- transpose $F/ragged.nsv >/dev/null 2>$TMPDIR/stderr && exit_code=0 || exit_code=$? +stderr=$(cat $TMPDIR/stderr) +rm -f $TMPDIR/stderr +if [[ "$exit_code" -eq 1 ]] && [[ "$stderr" == *"not a table"* ]]; then + echo "PASS: ragged rows rejected" + PASS=$((PASS + 1)) +else + echo "FAIL: ragged rows rejected - exit=$exit_code stderr=$stderr" + FAIL=$((FAIL + 1)) +fi + +# ── Single row → single column ── +# Input: one row with 3 cells [x,y,z] + +run_test "single row to column" "$(printf 'x\ny\nz\n\n')" "$(printf 'x\n\ny\n\nz\n\n')" "stdin" + +# ── Single column → single row ── +# Input: two rows each with one cell +run_test "single column to row" "$(printf 'p\n\nq\n\n')" "$(printf 'p\nq\n\n')" "stdin" + +# ── stdin input ── + +run_test "stdin" "$(printf 'a\nb\n\nc\nd\n\n')" "$(printf 'a\nc\n\nb\nd\n\n')" "stdin" + +# ── Roundtrip: transpose(transpose(x)) == x ── + +# Use table.nsv: transpose twice should recover original +first=$(cargo run --quiet -- transpose $F/table.nsv 2>/dev/null) +second=$(printf '%s' "$first" | cargo run --quiet -- transpose 2>/dev/null) +original=$(cat $F/table.nsv) +if [[ "$second" == "$original" ]]; then + echo "PASS: roundtrip transpose(transpose(x)) == x" + PASS=$((PASS + 1)) +else + echo "FAIL: roundtrip transpose(transpose(x)) == x" + diff <(echo "$second") <(echo "$original") + FAIL=$((FAIL + 1)) +fi + +# ── Roundtrip with 3×4 table ── + +first=$(cargo run --quiet -- transpose $F/table_3x4.nsv 2>/dev/null) +second=$(printf '%s' "$first" | cargo run --quiet -- transpose 2>/dev/null) +original=$(cat $F/table_3x4.nsv) +if [[ "$second" == "$original" ]]; then + echo "PASS: roundtrip 3x4" + PASS=$((PASS + 1)) +else + echo "FAIL: roundtrip 3x4" + diff <(echo "$second") <(echo "$original") + FAIL=$((FAIL + 1)) +fi + +# ── Multiline cell (escaped content preserved) ── + +stdout=$(cargo run --quiet -- transpose $F/multiline_cell.nsv 2>/dev/null) +exit_code=$? +if [[ "$exit_code" -eq 0 ]] && [[ -n "$stdout" ]]; then + # Validate the transposed output is valid NSV + echo "$stdout" | cargo run --quiet -- validate 2>/dev/null && validate_exit=0 || validate_exit=$? + if [[ "$validate_exit" -eq 0 ]]; then + echo "PASS: multiline cell transposed is valid NSV" + PASS=$((PASS + 1)) + else + echo "FAIL: multiline cell transposed is not valid NSV" + FAIL=$((FAIL + 1)) + fi +else + echo "FAIL: multiline cell transpose failed" + FAIL=$((FAIL + 1)) +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]]