Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ dirsql
# `exit`, `quit`, or Ctrl-D to leave.
#
# dirsql> SELECT count(*) AS files FROM './';
# [{"files":128}]
# files
# -----
# 128
#
# 1 row
# dirsql>
```

Expand All @@ -79,6 +83,51 @@ rather than re-walking the directory each time, and the live watcher keeps it
fresh between them. Files the scan had to skip are named on stderr once, up
front.

### Output format

Rows go where they are useful: a **table** when stdout is a terminal, the
**JSON array** when it is piped or redirected. `SELECT * FROM './'` in a
5000-file tree should not put a 5000-element JSON array in front of a person,
and `dirsql "…" | jq` should not have to parse a table.

`--format` overrides that, in both directions, and is valid in the REPL and in
[`dirsql query`](#dirsql-query) alike:

| Value | Renders |
|---|---|
| `auto` (default) | Table if stdout is a terminal, JSON otherwise. |
| `table` | Always a table — including into a pipe or a file. |
| `json` | Always the JSON array — including at a terminal. |

```bash
dirsql "SELECT basename, size FROM './' ORDER BY basename" --format table
# basename size
# -------- ----
# a.md 6
# bb.md 10
#
# 2 rows
```

There is no `.mode`: dirsql has no dot-commands to extend (see
[Leaving](#leaving)), and a flag serves the one-shot query too. `dirsql server`
does not take `--format` — its transport is JSON over HTTP.

**`auto` keys on stdout, not stdin.** `dirsql > rows.json` typed at a terminal
is still headed for a file, and the file gets JSON.

Table rendering is deliberately plain: aligned columns, a rule under the
header, a row count, and `NULL` spelled out so it cannot be confused with an
empty string. Two things happen to a value on its way into a cell, both
because a `content` column holds a whole file: **newlines, tabs and other
control characters are escaped** (`\n`, `\t`, `\u{…}`) so one row cannot span
several lines, and **anything longer than 60 characters is truncated with
`…`** so one column cannot set the width of every row. `--format json`
returns the values unaltered.

Laying the table out to the terminal's width, and paging a long result, are
both out of scope; pipe to `less` for the latter.

### Where a statement ends

At its semicolon — the same rule `sqlite3` uses, and **SQLite's own tokenizer**
Expand Down Expand Up @@ -157,7 +206,11 @@ failure:
dirsql> SELECT nope FROM missing;
dirsql: SQLite error: no such table: missing
dirsql> SELECT 1 AS n;
[{"n":1}]
n
-
1

1 row
```

A config that cannot be loaded is different in kind: it fails identically for
Expand Down Expand Up @@ -331,6 +384,12 @@ Errors print the same diagnostic the HTTP `{"error": …}` body carries —
config failures, SQL errors, rejected reads, hook failures, timeouts — to
stderr, with exit code `1`.

#### `--format {auto,table,json}`

How to render the result rows — the same flag [the REPL](#output-format)
takes, with the same `auto` default. A one-shot query is usually piped, so
`auto` usually means JSON; `--format table` is there for the times it is not.

### Exit codes

| Code | Meaning |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/ -x -q",
"ran_at": 1787265623,
"exit_code": 0,
"commit": "060576f12a666b12e3256dccfbc17257870ae52e",
"branch": "claude/epic-953-slice3-format"
}
3 changes: 3 additions & 0 deletions packages/rust/changelog.d/2026-08-20-format-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Added**

- **`--format {auto,table,json}` decides how result rows are rendered**, in the REPL and `dirsql query` alike. `auto` (the default) reads the **destination**: a table when stdout is a terminal, the JSON array when it is piped or redirected — so `SELECT * FROM './'` in a large tree lays itself out to be read instead of putting a few thousand JSON objects in front of a person, while `dirsql "…" | jq` keeps receiving byte-identical JSON. It keys on stdout, not stdin, so `dirsql > rows.json` typed at a terminal still writes JSON. `--format table` and `--format json` override in either direction, including into a pipe. There is no `.mode`: dirsql has no dot-commands to extend, and a flag serves the one-shot query too; `dirsql server` does not accept it, since its transport is JSON over HTTP. Tables are aligned columns with a rule under the header, a row count, `NULL` spelled out so it cannot be read as an empty string, control characters escaped (`\n`, `\t`, `\u{…}`) so one row cannot span several lines, and cells truncated past 60 characters so one `content` column cannot set the width of every row. `--format json` returns values unaltered. (#989)
63 changes: 63 additions & 0 deletions packages/rust/migrations.d/2026-08-20-format-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
### Result rows render as a table at a terminal (#989)

#### Summary

Every surface printed a JSON array. Rows now follow their **destination**: a
table when stdout is a terminal, the same JSON array when it is piped or
redirected. A new `--format {auto,table,json}` overrides that in either
direction, defaulting to `auto`.

**Piped output is unchanged, byte for byte.** `dirsql "<sql>" | jq` and
`dirsql "<sql>" > rows.json` produce exactly what they produced before.

#### Required changes

| Before | After | Fix |
| ------ | ----- | --- |
| `dirsql "<sql>"` at a terminal printed JSON you could copy | Prints a table | `dirsql "<sql>" --format json`, or redirect it — `auto` already picks JSON for any non-terminal destination. |
| A script capturing output **through a pty** (`script`, `expect`, a CI terminal emulator) parsed JSON | Sees a table | Pass `--format json` explicitly. A pty is a terminal, so `auto` cannot tell it from a human. |
| `dirsql server --format …` | Not applicable | The flag was never accepted there and still is not: the server speaks JSON over HTTP. |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- Rows printed to a terminal are a table rather than a JSON array. Nothing
changes for a pipe, a file, or any other non-terminal destination.
- The decision keys on **stdout**, not stdin. `dirsql > rows.json` typed
interactively writes JSON; `dirsql < script.sql` with stdout on a terminal
writes a table.
- Table cells are altered to fit a grid: newlines, tabs and other control
characters are escaped (`\n`, `\t`, `\u{…}`), and anything longer than 60
characters is truncated with `…`. A `content` column is the reason for both;
use `--format json` when you need the value unaltered.
- `NULL` is spelled out in a table, so it is distinguishable from an empty
string. JSON is unaffected — it still carries `null`.

#### Verification

```bash
# A pipe is not a terminal, so nothing changes there:
dirsql "SELECT basename FROM './'" | jq -r '.[].basename'
# a.md
# bb.md

# The same query at a terminal:
dirsql "SELECT basename, size FROM './' ORDER BY basename" --format table
# basename size
# -------- ----
# a.md 6
# bb.md 10
#
# 2 rows

# Forcing JSON at a terminal:
dirsql "SELECT basename FROM './'" --format json
# [{"basename":"a.md"},{"basename":"bb.md"}]

# An unknown value is a usage error rather than a silent fallback:
dirsql "SELECT 1" --format yaml; echo $?
# 2
```
2 changes: 2 additions & 0 deletions packages/rust/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
//! - [`execute`] — the transport-agnostic query pipeline shared by the
//! HTTP handler and the one-shot `dirsql query` subcommand.
//! - [`repl`] — the read-eval-print loop behind bare `dirsql`.
//! - [`table`] — result rows rendered for reading rather than piping.
//! - [`serialize`] — row + event → JSON.

use std::net::SocketAddr;
Expand All @@ -37,6 +38,7 @@ pub mod router;
pub mod run;
pub mod serialize;
pub mod server;
pub mod table;

pub use run::run_cli;
pub use server::{serve, serve_with_state};
Expand Down
68 changes: 60 additions & 8 deletions packages/rust/src/cli/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use serde_json::Value;

use super::AppState;
use super::execute::{QueryFailure, execute_query};
use super::run::query_body;
use super::run::{Format, query_body, render_rows};

/// The interactive prompt, split as reedline renders it: the left half, then
/// the indicator that follows it.
Expand Down Expand Up @@ -97,6 +97,7 @@ trait Lines {
/// line for every statement the user types.
pub(super) async fn run_repl<R, W, E>(
state: &AppState,
format: Format,
input: R,
out: &mut W,
err: &mut E,
Expand All @@ -114,9 +115,17 @@ where
let statements = StateStatements(state);

if interactive {
repl_loop(&statements, EditorLines::new(), out, err, true).await
repl_loop(&statements, EditorLines::new(), out, err, format, true).await
} else {
repl_loop(&statements, PipedLines(Some(input)), out, err, false).await
repl_loop(
&statements,
PipedLines(Some(input)),
out,
err,
format,
false,
)
.await
}
}

Expand All @@ -127,6 +136,7 @@ async fn repl_loop<S, L, W, E>(
mut lines: L,
out: &mut W,
err: &mut E,
format: Format,
interactive: bool,
) -> u8
where
Expand Down Expand Up @@ -166,7 +176,7 @@ where
// A closed stdout (`dirsql < script.sql | head -1`) means
// nothing downstream is listening; keep reading and there is
// a whole script left to execute into a broken pipe.
if writeln!(out, "{value}").is_err() {
if write!(out, "{}", render_rows(&value, format)).is_err() {
return 0;
}
}
Expand Down Expand Up @@ -524,7 +534,15 @@ mod tests {
async fn drive(entries: Vec<Entry>) -> (FakeStatements, String, String, u8) {
let fake = FakeStatements::default();
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = repl_loop(&fake, FakeLines::of(entries), &mut out, &mut err, false).await;
let code = repl_loop(
&fake,
FakeLines::of(entries),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
(fake, text(&out), text(&err), code)
}

Expand All @@ -538,6 +556,7 @@ mod tests {
FakeLines::statements(&["SELECT 1", "SELECT 2"]),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand All @@ -557,6 +576,7 @@ mod tests {
FakeLines::statements(&[" SELECT 1 \n"]),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand Down Expand Up @@ -633,6 +653,7 @@ mod tests {
FakeLines::statements(&["SELECT nope", "SELECT 1"]),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand All @@ -657,7 +678,15 @@ mod tests {
let fake = FakeStatements::default();
let (mut out, mut err) = (Vec::new(), Vec::new());

repl_loop(&fake, FakeLines::of(vec![]), &mut out, &mut err, true).await;
repl_loop(
&fake,
FakeLines::of(vec![]),
&mut out,
&mut err,
Format::Json,
true,
)
.await;

assert!(text(&out).ends_with("\n\n\n"), "{:?}", text(&out));
}
Expand All @@ -667,7 +696,15 @@ mod tests {
let fake = FakeStatements::default();
let (mut out, mut err) = (Vec::new(), Vec::new());

repl_loop(&fake, FakeLines::of(vec![]), &mut out, &mut err, true).await;
repl_loop(
&fake,
FakeLines::of(vec![]),
&mut out,
&mut err,
Format::Json,
true,
)
.await;

assert!(text(&out).starts_with(&banner()), "{:?}", text(&out));
}
Expand All @@ -682,6 +719,7 @@ mod tests {
FakeLines::statements(&["SELECT 1"]),
&mut BrokenPipe,
&mut err,
Format::Json,
true,
)
.await;
Expand All @@ -703,6 +741,7 @@ mod tests {
FakeLines::statements(&["SELECT 1"]),
&mut FlushFails,
&mut err,
Format::Json,
true,
)
.await;
Expand All @@ -716,7 +755,15 @@ mod tests {
let fake = FakeStatements::default();
let (mut out, mut err) = (Vec::new(), Vec::new());

let code = repl_loop(&fake, FakeLines::failing(), &mut out, &mut err, false).await;
let code = repl_loop(
&fake,
FakeLines::failing(),
&mut out,
&mut err,
Format::Json,
false,
)
.await;

assert_eq!(code, 1);
assert_eq!(
Expand All @@ -737,6 +784,7 @@ mod tests {
FakeLines::statements(&["SELECT 1", "SELECT 2"]),
&mut BrokenPipe,
&mut err,
Format::Json,
false,
)
.await;
Expand All @@ -757,6 +805,7 @@ mod tests {
PipedLines(Some(input("SELECT 1\nSELECT 2\n"))),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand All @@ -775,6 +824,7 @@ mod tests {
PipedLines(Some(input("SELECT 1"))),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand All @@ -792,6 +842,7 @@ mod tests {
PipedLines(Some(BrokenReader)),
&mut out,
&mut err,
Format::Json,
false,
)
.await;
Expand Down Expand Up @@ -821,6 +872,7 @@ mod tests {

let code = run_repl(
&state,
Format::Json,
input("SELECT 1\nSELECT 2\n"),
&mut out,
&mut err,
Expand Down
Loading
Loading