From e6e25704840b9241721ad4453ce2ff3702bfaeff Mon Sep 17 00:00:00 2001 From: olavostauros Date: Fri, 26 Jun 2026 15:58:12 -0300 Subject: [PATCH 1/2] fix(search): fall back to TSV when gum table rejects input (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sanitize cell values (tabs→spaces, newlines→⏎) and wrap gum table call in try/except to fall back to plain TSV on failure. Apply same fix to notes list. Add test for unusual-character input. --- .mise/tasks/list | 20 +++++++++++++------- .mise/tasks/search | 20 +++++++++++++------- test/search.bats | 30 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/.mise/tasks/list b/.mise/tasks/list index becae19..5ccfef9 100755 --- a/.mise/tasks/list +++ b/.mise/tasks/list @@ -67,13 +67,19 @@ elif not rows: else: print("No notes found.") else: + def _sanitize(val: str) -> str: + return str(val).replace("\t", " ").replace("\n", "⏎") + table = "Title\tType\tStatus\tTags\tUpdated\n" + "\n".join( - f"{row['title']}\t{row['type']}\t{row['status']}\t{', '.join(row['tags'])}\t{row['date']}" for row in rows - ) - subprocess.run( - ["gum", "table", "--print", "--separator", "\t"], - input=table, - text=True, - check=True, + f"{_sanitize(row['title'])}\t{_sanitize(row['type'])}\t{_sanitize(row['status'])}\t{_sanitize(', '.join(row['tags']))}\t{_sanitize(row['date'])}" for row in rows ) + try: + subprocess.run( + ["gum", "table", "--print", "--separator", "\t"], + input=table, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + print(table) PY diff --git a/.mise/tasks/search b/.mise/tasks/search index eb4b2ac..9521344 100755 --- a/.mise/tasks/search +++ b/.mise/tasks/search @@ -94,13 +94,19 @@ elif not rows: suffix = f" with {' '.join(filters)}" if filters else "" print(f"No notes found matching: {query}{suffix}") else: + def _sanitize(val: str) -> str: + return str(val).replace("\t", " ").replace("\n", "⏎") + table = "Title\tType\tStatus\tUpdated\tMatches\n" + "\n".join( - f"{row['title']}\t{row['type']}\t{row['status']}\t{row['date']}\t{' | '.join(row['matches'][:2])}" for row in rows - ) - subprocess.run( - ["gum", "table", "--print", "--separator", "\t"], - input=table, - text=True, - check=True, + f"{_sanitize(row['title'])}\t{_sanitize(row['type'])}\t{_sanitize(row['status'])}\t{_sanitize(row['date'])}\t{_sanitize(' | '.join(row['matches'][:2]))}" for row in rows ) + try: + subprocess.run( + ["gum", "table", "--print", "--separator", "\t"], + input=table, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + print(table) PY diff --git a/test/search.bats b/test/search.bats index 3190a17..053a320 100755 --- a/test/search.bats +++ b/test/search.bats @@ -84,3 +84,33 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"No notes found matching: absent"* ]] } + +@test "search falls back to TSV when gum table rejects unusual characters" { + # Write a note with a literal tab in the title — this breaks gum table's TSV parser + python3 << 'PYEND' +import os +path = os.path.join(os.environ['NOTES_CALLER_PWD'], 'notes', 'unusual.md') +content = ( + '---\n' + 'title: "Tab\tTitle"\n' + 'type: note\n' + 'status: active\n' + 'tags: [test]\n' + 'created: 2026-01-01\n' + 'updated: 2026-01-02\n' + '---\n' + '\n' + '# Tab\tTitle\n' + 'Body with\ta\ttab\ttoo.\n' +) +with open(path, 'w') as f: + f.write(content) +PYEND + + run notes search Tab + [ "$status" -eq 0 ] + # Should get sanitized output (tabs → spaces), not a crash + echo "$output" | grep -q "Tab Title" + # Body match snippet found (matches[:2] limits display, but first body line is enough) + echo "$output" | grep -q "# Tab Title" +} From 533cc0f5f3bf597492274d8fa7a29e71c792fd48 Mon Sep 17 00:00:00 2001 From: junior Date: Fri, 7 Aug 2026 10:07:29 -0400 Subject: [PATCH 2/2] fix(output): share resilient table presentation --- .mise/tasks/list | 28 ++++++++++++-------------- .mise/tasks/search | 28 ++++++++++++-------------- README.md | 2 +- lib/table_output.py | 36 +++++++++++++++++++++++++++++++++ test/list.bats | 23 ++++++++++++++++++++++ test/search.bats | 46 ++++++++++++++++++++++--------------------- test/test_helper.bash | 13 ++++++++++++ 7 files changed, 123 insertions(+), 53 deletions(-) create mode 100644 lib/table_output.py diff --git a/.mise/tasks/list b/.mise/tasks/list index 80f5119..ae6281a 100755 --- a/.mise/tasks/list +++ b/.mise/tasks/list @@ -21,7 +21,6 @@ require_readable_notes_state "$notes_dir" python3 - "$REPO_DIR" "$notes_dir" "${usage_json:-false}" "${usage_recent:-}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" <<'PY' import json -import subprocess import sys from pathlib import Path @@ -35,6 +34,7 @@ status_filter = sys.argv[7] sys.path.insert(0, str(repo_dir / "lib")) from frontmatter import filter_notes, iter_notes # noqa: E402 +from table_output import print_table # noqa: E402 rows = [ note.row() @@ -69,19 +69,17 @@ elif not rows: else: print("No notes found.") else: - def _sanitize(val: str) -> str: - return str(val).replace("\t", " ").replace("\n", "⏎") - - table = "Title\tType\tStatus\tTags\tUpdated\n" + "\n".join( - f"{_sanitize(row['title'])}\t{_sanitize(row['type'])}\t{_sanitize(row['status'])}\t{_sanitize(', '.join(row['tags']))}\t{_sanitize(row['date'])}" for row in rows + print_table( + ["Title", "Type", "Status", "Tags", "Updated"], + [ + [ + row["title"], + row["type"], + row["status"], + ", ".join(row["tags"]), + row["date"], + ] + for row in rows + ], ) - try: - subprocess.run( - ["gum", "table", "--print", "--separator", "\t"], - input=table, - text=True, - check=True, - ) - except (subprocess.CalledProcessError, FileNotFoundError): - print(table) PY diff --git a/.mise/tasks/search b/.mise/tasks/search index c2c77dd..d0313e8 100755 --- a/.mise/tasks/search +++ b/.mise/tasks/search @@ -22,7 +22,6 @@ require_readable_notes_state "$notes_dir" python3 - "$REPO_DIR" "$notes_dir" "${usage_query:-}" "${usage_json:-false}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" "${usage_limit:-50}" <<'PY' import json -import subprocess import sys from pathlib import Path @@ -37,6 +36,7 @@ limit = sys.argv[8] sys.path.insert(0, str(repo_dir / "lib")) from frontmatter import filter_notes, iter_notes # noqa: E402 +from table_output import print_table # noqa: E402 try: limit_count = int(limit) @@ -96,19 +96,17 @@ elif not rows: suffix = f" with {' '.join(filters)}" if filters else "" print(f"No notes found matching: {query}{suffix}") else: - def _sanitize(val: str) -> str: - return str(val).replace("\t", " ").replace("\n", "⏎") - - table = "Title\tType\tStatus\tUpdated\tMatches\n" + "\n".join( - f"{_sanitize(row['title'])}\t{_sanitize(row['type'])}\t{_sanitize(row['status'])}\t{_sanitize(row['date'])}\t{_sanitize(' | '.join(row['matches'][:2]))}" for row in rows + print_table( + ["Title", "Type", "Status", "Updated", "Matches"], + [ + [ + row["title"], + row["type"], + row["status"], + row["date"], + " | ".join(row["matches"][:2]), + ] + for row in rows + ], ) - try: - subprocess.run( - ["gum", "table", "--print", "--separator", "\t"], - input=table, - text=True, - check=True, - ) - except (subprocess.CalledProcessError, FileNotFoundError): - print(table) PY diff --git a/README.md b/README.md index ac1207b..b7c5f35 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 475](https://img.shields.io/badge/tests-475-brightgreen?style=flat)](test/) +[![tests: 478](https://img.shields.io/badge/tests-478-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/lib/table_output.py b/lib/table_output.py new file mode 100644 index 0000000..901e419 --- /dev/null +++ b/lib/table_output.py @@ -0,0 +1,36 @@ +"""Shared table presentation for human-facing Notes commands.""" + +from __future__ import annotations + +import os +import subprocess +from collections.abc import Iterable, Sequence + + +def sanitize_cell(value: object) -> str: + return str(value).replace("\t", " ").replace("\n", "⏎") + + +def format_tsv(headers: Sequence[object], rows: Iterable[Sequence[object]]) -> str: + lines = ["\t".join(sanitize_cell(value) for value in headers)] + lines.extend("\t".join(sanitize_cell(value) for value in row) for row in rows) + return "\n".join(lines) + + +def print_table(headers: Sequence[object], rows: Iterable[Sequence[object]]) -> None: + table = format_tsv(headers, rows) + gum = os.environ.get("GUM", "gum") + + try: + result = subprocess.run( + [gum, "table", "--print", "--separator", "\t"], + input=f"{table}\n", + text=True, + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError): + print(table) + return + + print(result.stdout, end="") diff --git a/test/list.bats b/test/list.bats index 7aff2b9..732b1da 100644 --- a/test/list.bats +++ b/test/list.bats @@ -41,6 +41,29 @@ setup() { echo "$output" | python3 -c "import sys, json; data = json.load(sys.stdin); assert data[0]['tags'] == ['alpha', 'beta', 'gamma']" } +@test "list falls back to TSV when gum is unavailable" { + notes new -- --slug alpha --title "Alpha Note" --tags "testing" --updated "2026-03-14" + export GUM="$BATS_TEST_TMPDIR/missing-gum" + + run notes list + + [ "$status" -eq 0 ] + [[ "$output" == *$'Title\tType\tStatus\tTags\tUpdated'* ]] + [[ "$output" == *$'Alpha Note\t'* ]] + [[ "$output" == *$'testing\t2026-03-14'* ]] +} + +@test "list --json bypasses gum presentation" { + notes new -- --slug beta --title "Beta Note" --tags "testing" --updated "2026-03-15" + make_failing_gum + + run notes list -- --json + + [ "$status" -eq 0 ] + [ ! -e "$GUM_LOG" ] + echo "$output" | python3 -c "import sys, json; assert json.load(sys.stdin)[0]['title'] == 'Beta Note'" +} + @test "list --recent limits output" { notes new -- --slug old --title "Old Note" --tags "test" --updated "2026-03-10" notes new -- --slug mid --title "Mid Note" --tags "test" --updated "2026-03-15" diff --git a/test/search.bats b/test/search.bats index 053a320..6096bae 100755 --- a/test/search.bats +++ b/test/search.bats @@ -85,32 +85,34 @@ EOF [[ "$output" == *"No notes found matching: absent"* ]] } -@test "search falls back to TSV when gum table rejects unusual characters" { - # Write a note with a literal tab in the title — this breaks gum table's TSV parser - python3 << 'PYEND' +@test "search falls back to sanitized TSV when gum table fails" { + python3 <<'PY' import os -path = os.path.join(os.environ['NOTES_CALLER_PWD'], 'notes', 'unusual.md') -content = ( - '---\n' +from pathlib import Path + +notes_dir = Path(os.environ["NOTES_CALLER_PWD"]) / "notes" +(notes_dir / "unusual.md").write_text( + "---\n" 'title: "Tab\tTitle"\n' - 'type: note\n' - 'status: active\n' - 'tags: [test]\n' - 'created: 2026-01-01\n' - 'updated: 2026-01-02\n' - '---\n' - '\n' - '# Tab\tTitle\n' - 'Body with\ta\ttab\ttoo.\n' + "type: note\n" + "status: active\n" + "tags: [test]\n" + "created: 2026-01-01\n" + "updated: 2026-01-02\n" + "---\n\n" + "# Tab\tTitle\n" + "Body with\ta\ttab\ttoo.\n", + encoding="utf-8", ) -with open(path, 'w') as f: - f.write(content) -PYEND +PY + make_failing_gum run notes search Tab + [ "$status" -eq 0 ] - # Should get sanitized output (tabs → spaces), not a crash - echo "$output" | grep -q "Tab Title" - # Body match snippet found (matches[:2] limits display, but first body line is enough) - echo "$output" | grep -q "# Tab Title" + [ -s "$GUM_LOG" ] + [[ "$output" == *$'Title\tType\tStatus\tUpdated\tMatches'* ]] + [[ "$output" == *"Tab Title"* ]] + [[ "$output" == *"# Tab Title"* ]] + [[ "$output" != *"partial gum output"* ]] } diff --git a/test/test_helper.bash b/test/test_helper.bash index 8fb88bf..8e02377 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -46,6 +46,19 @@ SH } export -f make_failing_xargs_overlay +make_failing_gum() { + export GUM="$BATS_TEST_TMPDIR/failing-gum" + export GUM_LOG="$BATS_TEST_TMPDIR/gum.log" + cat > "$GUM" <<'SH' +#!/usr/bin/env bash +printf 'called\n' >> "${GUM_LOG:?}" +printf 'partial gum output\n' +exit 71 +SH + chmod +x "$GUM" +} +export -f make_failing_gum + # rudi() wrapper — calls rudi against the same target repo. rudi() { if [ -z "${NOTES_CALLER_PWD:-}" ]; then