From 390264e5fd868786ce8a5bc4d6b08e9e54fb676b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:08:28 +0000 Subject: [PATCH 1/4] test: pin the piped REPL contract for bare `dirsql` (RED) Bare `dirsql` currently prints a usage error and exits 2. These tests assert the contract #987 specifies instead: one statement per line from a pipe, no prompt or banner, a failing statement reported and the session continued, clean EOF exiting 0, and an unusable config failing once rather than per line. Pushed on its own so CI is observed red before any implementation lands. Refs #987 skip-changelog: test-only commit; the fragment lands with the implementation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- packages/rust/tests/repl_pipe_e2e.rs | 178 +++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/rust/tests/repl_pipe_e2e.rs diff --git a/packages/rust/tests/repl_pipe_e2e.rs b/packages/rust/tests/repl_pipe_e2e.rs new file mode 100644 index 000000000..77623315e --- /dev/null +++ b/packages/rust/tests/repl_pipe_e2e.rs @@ -0,0 +1,178 @@ +//! End-to-end tests for the piped (non-TTY) half of bare `dirsql` (#987). +//! +//! These spawn the real compiled `dirsql` binary with **no subcommand and no +//! SQL**, feed statements on stdin, and assert what only the process boundary +//! can show: the exit code, what reaches stdout, and what reaches stderr. +//! Nothing is mocked (real process, real filesystem, real SQLite). +//! +//! The contract under test: bare `dirsql` is a REPL. Reading its stdin from a +//! pipe runs one statement per line with no prompt and no banner, a failing +//! statement is reported and the session **continues**, and a clean EOF exits +//! `0` regardless of per-statement failures. The interactive half (prompt, +//! banner, TTY detection) needs a PTY and is covered by the python e2e suite. +//! +//! Gated behind `--features cli`: the `dirsql` bin target itself is +//! `required-features = ["cli"]`, so without the feature there is no binary +//! for `assert_cmd::cargo_bin` to find. + +#![cfg(feature = "cli")] + +use std::io::Write; +use std::process::{Output, Stdio}; + +use assert_cmd::prelude::*; +use tempfile::TempDir; + +/// Run bare `dirsql` (plus any extra argv) over a fresh empty directory, +/// feeding `stdin` through a pipe and closing it to signal EOF. +fn repl(stdin: &str, args: &[&str]) -> (TempDir, Output) { + let root = TempDir::new().unwrap(); + let mut child = std::process::Command::cargo_bin("dirsql") + .expect("binary must exist") + .args(args) + .current_dir(root.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawning `dirsql` failed"); + child + .stdin + .take() + .expect("stdin was piped") + .write_all(stdin.as_bytes()) + .expect("writing to the REPL's stdin failed"); + let out = child + .wait_with_output() + .expect("waiting on `dirsql` failed"); + (root, out) +} + +fn stdout_of(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn stderr_of(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +#[test] +fn a_piped_statement_runs_and_exits_zero() { + // The headline behavior change: `echo "SELECT 1" | dirsql` used to be an + // exit-2 usage error and now runs the statement. + let (_root, out) = repl("SELECT 1 AS n\n", &[]); + + assert_eq!( + out.status.code(), + Some(0), + "a clean session exits 0, got {out:?}" + ); + assert_eq!(stdout_of(&out).trim(), r#"[{"n":1}]"#); +} + +#[test] +fn every_statement_prints_its_own_result() { + // A REPL is a loop, not a one-shot: each line is executed and rendered in + // turn rather than the first line winning and the rest being dropped. + let (_root, out) = repl("SELECT 1 AS n\nSELECT 2 AS n\n", &[]); + + let stdout = stdout_of(&out); + let lines: Vec<&str> = stdout.lines().map(str::trim).collect(); + assert_eq!(lines, vec![r#"[{"n":1}]"#, r#"[{"n":2}]"#], "{out:?}"); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn a_failing_statement_does_not_end_the_session() { + // The one real behavioral difference from `dirsql query`, which exits 1 on + // the first failure. A REPL that dies on a typo is unusable. + let (_root, out) = repl("SELECT nope FROM nowhere\nSELECT 1 AS n\n", &[]); + + assert!( + stderr_of(&out).contains("nowhere"), + "the failure is named on stderr, got {out:?}" + ); + assert_eq!( + stdout_of(&out).trim(), + r#"[{"n":1}]"#, + "the statement after the failure still runs, got {out:?}" + ); +} + +#[test] +fn a_clean_eof_exits_zero_even_after_a_failure() { + // Matches interactive `sqlite3`: per-statement failures are reported as + // they happen and do not colour the session's exit code. + let (_root, out) = repl("SELECT nope FROM nowhere\n", &[]); + + assert_eq!( + out.status.code(), + Some(0), + "EOF after a failed statement still exits 0, got {out:?}" + ); +} + +#[test] +fn blank_lines_produce_no_output() { + // Pressing enter at a prompt must not emit an empty result or an error. + let (_root, out) = repl("\n \n\t\nSELECT 1 AS n\n", &[]); + + assert_eq!(stdout_of(&out).trim(), r#"[{"n":1}]"#, "{out:?}"); + assert_eq!(stderr_of(&out), "", "{out:?}"); +} + +#[test] +fn exit_stops_reading_the_rest_of_stdin() { + // `exit` is a REPL word, not SQL: it terminates the session rather than + // being handed to SQLite as a statement. + let (_root, out) = repl("SELECT 1 AS n\nexit\nSELECT 2 AS n\n", &[]); + + assert_eq!(stdout_of(&out).trim(), r#"[{"n":1}]"#, "{out:?}"); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn quit_stops_reading_the_rest_of_stdin() { + let (_root, out) = repl("SELECT 1 AS n\nquit\nSELECT 2 AS n\n", &[]); + + assert_eq!(stdout_of(&out).trim(), r#"[{"n":1}]"#, "{out:?}"); + assert_eq!(out.status.code(), Some(0)); +} + +#[test] +fn the_piped_path_prints_no_prompt_and_no_banner() { + // The whole point of the TTY/pipe split: `dirsql < script.sql > out` must + // yield results alone, with no interactive furniture interleaved. + let (_root, out) = repl("SELECT 1 AS n\n", &[]); + + let stdout = stdout_of(&out); + assert!( + !stdout.contains("dirsql>"), + "no prompt in a pipe, got {out:?}" + ); + assert!( + !stdout.contains("Ctrl-D"), + "no banner in a pipe, got {out:?}" + ); +} + +#[test] +fn an_unusable_config_fails_once_instead_of_looping() { + // `AppState::Unavailable` fails identically on every statement, so + // readiness is checked once before the loop rather than reported per line. + let (_root, out) = repl("SELECT 1 AS n\nSELECT 2 AS n\n", &["-c", "missing.toml"]); + + assert_eq!( + out.status.code(), + Some(1), + "a bad config exits 1 without entering the loop, got {out:?}" + ); + assert_eq!( + stderr_of(&out) + .lines() + .filter(|l| l.contains("missing.toml")) + .count(), + 1, + "the config failure is reported exactly once, got {out:?}" + ); +} From 5eea7dd799ad6dc8ec81e2895baa86aad3f78006 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:22:50 +0000 Subject: [PATCH 2/4] feat(cli): bare `dirsql` opens a REPL over the current directory `dirsql` with no subcommand and no SQL printed a usage error and exited 2. It now reads statements until EOF: a banner and a `dirsql> ` prompt when stdin is a terminal, one statement per line and nothing else when it is a pipe. `sqlite3` sets the expectation -- run the binary bare, get a shell. The loop is a wrapper around the existing pipeline, not a second one: `load_state` and `execute_query` are unchanged, and statements reach `execute_query` through the same `query_body` intake a one-shot query and a `POST /query` do, so a statement typed at the prompt cannot drift from the same statement passed on the command line. Three behaviors are deliberately not `run_query`'s: - a failing statement prints its diagnostic and the session continues, where `dirsql query` exits 1. A REPL that dies on a typo is unusable. - a clean EOF exits 0 regardless of per-statement failures, matching interactive `sqlite3`. `PARTIAL_SCAN_EXIT` (23) has no session-level meaning, so skipped files are named once before the first prompt and the code is not produced. - a degraded index is reported once and exits 1 before the loop, rather than failing identically on every line the user types. The index is built once, ahead of the loop, so statements share one scan and the live watcher keeps it fresh between them. No new dependency (`std::io::IsTerminal` is std) and no new flag: `ConfigArgs` is already flattened onto the top-level `Cli`, so `-c`, `--persist`, `--no-ignore` and `--on-file` apply to the REPL with no parser work. The read runs on a blocking thread: `run_cli` drives everything inside `runtime.block_on`, so an inline read would park the thread the runtime is driven on. `BufReader` rather than `StdinLock`, which is not `Send`. `cli_e2e`'s `no_subcommand_and_no_sql_errors_pointing_at_dirsql_server` did assert the old runtime error, contrary to #987's survey, which found only the parse-level test. Its #662 intent -- bare `dirsql` must not silently start the server -- outlives the usage error, so it is rewritten to assert that invariant against the REPL rather than deleted. The REPL loop takes its statement sink, reader and both output sinks as parameters so `unit lint`'s isolation rule is satisfiable without a TTY or an index; that is why `run_repl` carries an `err` sink #987's sketch did not. Fixes #987 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD --- docs/reference/cli.md | 89 ++- .../2026-08-20-bare-dirsql-repl.md | 3 + .../2026-08-20-bare-dirsql-repl.md | 65 +++ packages/rust/src/cli/mod.rs | 2 + packages/rust/src/cli/repl.rs | 547 ++++++++++++++++++ packages/rust/src/cli/run.rs | 66 ++- packages/rust/tests/cli_e2e.rs | 23 +- 7 files changed, 766 insertions(+), 29 deletions(-) create mode 100644 packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md create mode 100644 packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md create mode 100644 packages/rust/src/cli/repl.rs diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 942ac21d6..e9dc30e24 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -9,9 +9,9 @@ The `dirsql` binary has these modes: | `dirsql query ""` | Explicit synonym for the default one-shot query. | | `dirsql server` | Start a long-lived HTTP server exposing a SQL view of a directory. See [HTTP API](./http-api.md). | | `dirsql init` | Generate a `.dirsql.toml`. | +| `dirsql` (bare) | Open a [REPL](#the-repl) over the current directory, reading statements until EOF. | -Bare `dirsql` with no SQL is a usage error pointing at `dirsql server` — it -does **not** start the server. +Bare `dirsql` does **not** start the server — that is `dirsql server`. ## Installation @@ -47,6 +47,91 @@ dirsql "SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5" pipeline, same flags, same output. See that section for config discovery, `--persist`, `--on-file`, hooks, and exit codes. +## The REPL + +`dirsql` with no subcommand and no SQL reads statements until EOF: + +```bash +dirsql +# dirsql 0.2.7 — this directory is a database. +# +# SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5 +# SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%' +# +# `exit`, `quit`, or Ctrl-D to leave. +# +# dirsql> SELECT count(*) AS files FROM './' +# [{"files":128}] +# dirsql> +``` + +Statements go through the same pipeline as [`dirsql query`](#dirsql-query) and +`POST /query`, so a statement typed at the prompt and one passed on the command +line return identical rows. Every config flag the default mode takes — `-c`, +`--persist`, `--no-ignore`, `--on-file` — applies unchanged: + +```bash +dirsql -c .dirsql.toml --persist +``` + +The index is built **once**, before the first prompt: statements share one scan +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. + +### Terminal vs. pipe + +The prompt and banner appear only when **stdin is a terminal**. From a pipe or +a redirect there is neither, so results arrive alone: + +```bash +printf "SELECT 1 AS n\nSELECT 2 AS n\n" | dirsql +# [{"n":1}] +# [{"n":2}] + +dirsql < queries.sql > rows.jsonl +``` + +One statement per line, in both modes. Blank lines do nothing. + +### Leaving + +`exit`, `quit` (either case), or Ctrl-D. There are no dot-commands: the `.` +prefix exists in `sqlite3` to namespace meta-commands against SQL, and with no +meta-commands there is nothing to namespace. Schema questions are ordinary SQL: + +```sql +SELECT name FROM sqlite_master WHERE type = 'table'; +``` + +### Errors + +A statement that fails prints its diagnostic — the same string the HTTP +`{"error": …}` body carries — to stderr, and **the session continues**. This is +the one behavioral difference from `dirsql query`, which exits `1` on the first +failure: + +``` +dirsql> SELECT nope FROM missing +dirsql: SQLite error: no such table: missing +dirsql> SELECT 1 AS n +[{"n":1}] +``` + +A config that cannot be loaded is different in kind: it fails identically for +every statement, so it is reported once and exits `1` before the first prompt. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Clean EOF (Ctrl-D, `exit`, `quit`, or the end of a piped script) — **including when statements failed**. Matches interactive `sqlite3`; use [`dirsql query`](#dirsql-query) when a script needs a statement's exit status. | +| `1` | The index could not be built (a bad `-c`, an unresolvable `--on-file`), or stdin could not be read. Nothing was executed. | + +`23` (partial scan) is not produced here: skipped files are reported before the +first prompt, and a session's exit code describes the session rather than one +scan. + ## `dirsql server` ```bash diff --git a/packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md b/packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md new file mode 100644 index 000000000..0876d6bb6 --- /dev/null +++ b/packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md @@ -0,0 +1,3 @@ +**Added** + +- **Bare `dirsql` opens a REPL over the current directory.** With no subcommand and no SQL, dirsql reads statements until EOF instead of printing a usage error: `dirsql> ` prompts and a sample-query banner when stdin is a terminal, one statement per line and no interactive furniture when it is a pipe (`dirsql < script.sql > out.json`). Statements go through the same `execute_query` pipeline `dirsql query` and `POST /query` use, so results are identical to the one-shot form. A failing statement prints its diagnostic to stderr and the session **continues** — unlike `dirsql query`, which exits 1 — and a clean EOF exits 0 regardless of per-statement failures, matching interactive `sqlite3`. Leave with `exit`, `quit`, or Ctrl-D; there are no dot-commands. The index is built once before the loop, so statements share one scan and the live watcher keeps it fresh between them. Every config flag bare `dirsql` already accepted (`-c`, `--persist`, `--no-ignore`, `--on-file`) applies unchanged, and an unusable config is reported once up front rather than on every statement. (#987) diff --git a/packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md b/packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md new file mode 100644 index 000000000..3b89bf570 --- /dev/null +++ b/packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md @@ -0,0 +1,65 @@ +### Bare `dirsql` opens a REPL instead of exiting 2 (#987) + +#### Summary + +`dirsql` with no subcommand and no SQL printed a usage error pointing at +`dirsql server` and exited `2`. It now reads SQL statements from stdin until +EOF — a prompted REPL on a terminal, one statement per line from a pipe. No +API changed and no flag was added or removed; the runtime behavior of one +invocation did. + +#### Required changes + +| Before | After | Fix | +| ------ | ----- | --- | +| `dirsql` (bare, no stdin redirect) — exit 2, usage error on stderr | Opens an interactive REPL and blocks until EOF | Nothing, if a human ran it. A **script** that ran bare `dirsql` expecting an immediate exit-2 must redirect stdin (`dirsql < /dev/null`, which now exits 0) or call the mode it meant (`dirsql query ""`, `dirsql server`). | +| `echo "SELECT 1" \| dirsql` — exit 2, the SQL ignored | Runs the statement and prints its rows | Nothing. If the exit-2 was being relied on as "this invocation is invalid", pass the SQL as an argument instead: `dirsql "SELECT 1"`. | +| `dirsql --help` / `dirsql --version` | Unchanged | — | + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- Bare `dirsql` no longer exits at all until stdin reaches EOF. In a + non-interactive context with an open stdin (a CI step, a supervisor that + leaves stdin attached to a pipe nothing writes to), the process now waits + where it used to fail fast. +- The exit code for bare `dirsql` changes from `2` to `0` on a clean EOF — + **including when statements failed**. Per-statement failures are reported on + stderr as they happen and do not colour the session's exit code, matching + interactive `sqlite3`. A script that needs a statement's exit status must use + `dirsql query ""`, which still exits 1 on the first failure. +- `PARTIAL_SCAN_EXIT` (23) is not produced by the REPL. Skipped files are still + named on stderr, once, before the first prompt; the code has no meaning for a + session whose exit describes the session rather than one scan. `dirsql query` + keeps it. +- `exit` and `quit` are consumed by the REPL rather than being handed to SQLite. + Neither was a valid statement before, so nothing that used to run stops + running. +- A config that fails to load (`-c missing.toml`) exits 1 before the first + prompt rather than being reported once per statement. + +#### Verification + +```bash +# Previously: exit 2, "dirsql: no query given…" on stderr. +echo "SELECT 1 AS n" | dirsql +# -> [{"n":1}] +# -> exit 0 + +# A failure no longer ends the session, and does not colour the exit code. +printf 'SELECT nope FROM missing\nSELECT 2 AS n\n' | dirsql +# -> dirsql: SQLite error: no such table: missing (stderr) +# -> [{"n":2}] +# -> exit 0 + +# One-shot mode is unchanged: still exits 1 on the first failure. +dirsql "SELECT nope FROM missing"; echo $? +# -> 1 + +# Fast-fail for a script that relied on bare `dirsql` returning immediately. +dirsql < /dev/null; echo $? +# -> 0 +``` diff --git a/packages/rust/src/cli/mod.rs b/packages/rust/src/cli/mod.rs index 84255abc8..a1e465973 100644 --- a/packages/rust/src/cli/mod.rs +++ b/packages/rust/src/cli/mod.rs @@ -18,6 +18,7 @@ //! - [`router`] — axum routes + request handlers (thin HTTP adapters). //! - [`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`. //! - [`serialize`] — row + event → JSON. use std::net::SocketAddr; @@ -31,6 +32,7 @@ use crate::DirSQL; pub mod execute; pub mod init; +pub mod repl; pub mod router; pub mod run; pub mod serialize; diff --git a/packages/rust/src/cli/repl.rs b/packages/rust/src/cli/repl.rs new file mode 100644 index 000000000..1998a1799 --- /dev/null +++ b/packages/rust/src/cli/repl.rs @@ -0,0 +1,547 @@ +//! The read-eval-print loop behind bare `dirsql`. +//! +//! `dirsql` with no subcommand and no SQL reads statements until EOF instead +//! of printing a usage error. The loop is a thin wrapper over the same +//! [`execute_query`] pipeline `dirsql query` and `POST /query` use, so a +//! statement typed at the prompt and one passed on the command line cannot +//! drift. +//! +//! Two halves, selected by one boolean the caller derives from +//! `stdin().is_terminal()`: +//! +//! - **interactive** — a banner, a prompt before each read, and a trailing +//! newline on exit so the shell prompt starts on its own line. +//! - **piped** — none of that furniture, so `dirsql < script.sql > out.json` +//! yields results alone. +//! +//! Both halves share the loop, and the differences from one-shot +//! [`run_query`](super::run) are deliberate: a failing statement is reported +//! and the session **continues**, and a clean EOF exits `0` regardless of +//! per-statement failures (matching interactive `sqlite3`). + +use std::io::{BufRead, Write}; + +use serde_json::Value; + +use super::AppState; +use super::execute::{QueryFailure, execute_query}; +use super::run::query_body; + +/// Printed before each read on the interactive path. +const PROMPT: &str = "dirsql> "; + +/// Where the REPL sends a statement. The real implementation is the shared +/// [`execute_query`] pipeline; the loop is generic over this so its control +/// flow — continue-on-error, exit words, blank lines — is unit-testable +/// without an index behind it. +trait Statements { + async fn run(&self, sql: &str) -> Result; +} + +/// [`Statements`] backed by the real index. +struct StateStatements<'a>(&'a AppState); + +impl Statements for StateStatements<'_> { + async fn run(&self, sql: &str) -> Result { + // Unbounded, exactly as the one-shot CLI is: only the long-lived + // server enforces `query_timeout`. + execute_query(self.0, query_body(sql), None).await + } +} + +/// Run the REPL over `input`, rendering results to `out` and diagnostics to +/// `err`, and return the process exit code. +/// +/// A degraded index fails identically for every statement, so readiness is +/// checked **once** here and reported once, rather than printing the same +/// line for every statement the user types. +pub(super) async fn run_repl( + state: &AppState, + input: R, + out: &mut W, + err: &mut E, + interactive: bool, +) -> u8 +where + R: BufRead + Send + 'static, + W: Write, + E: Write, +{ + if let AppState::Unavailable(reason) = state { + let _ = writeln!(err, "dirsql: {reason}"); + return 1; + } + repl_loop(&StateStatements(state), input, out, err, interactive).await +} + +/// The loop itself, generic over its statement sink so unit tests can drive +/// it with a double. +async fn repl_loop( + statements: &S, + mut input: R, + out: &mut W, + err: &mut E, + interactive: bool, +) -> u8 +where + S: Statements, + R: BufRead + Send + 'static, + W: Write, + E: Write, +{ + if interactive && write!(out, "{}", banner()).is_err() { + return 1; + } + loop { + if interactive && (write!(out, "{PROMPT}").is_err() || out.flush().is_err()) { + return 1; + } + let line = match next_line(input).await { + Ok((reader, Some(line))) => { + input = reader; + line + } + Ok((_, None)) => break, + Err(read_err) => { + let _ = writeln!(err, "dirsql: failed to read input: {read_err}"); + return 1; + } + }; + + let sql = line.trim(); + if sql.is_empty() { + continue; + } + if is_exit_word(sql) { + break; + } + + match statements.run(sql).await { + Ok(value) => { + // 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() { + return 0; + } + } + Err(failure) => { + let _ = writeln!(err, "dirsql: {}", failure.message()); + } + } + } + if interactive { + let _ = writeln!(out); + } + 0 +} + +/// Read one line off `reader`, handing the reader back. `None` is EOF. +/// +/// The read happens on a blocking thread: `run_cli` drives the whole CLI +/// inside `runtime.block_on`, so reading inline would park the thread the +/// runtime is driven on while the live watcher still needs it. +async fn next_line(mut reader: R) -> std::io::Result<(R, Option)> +where + R: BufRead + Send + 'static, +{ + tokio::task::spawn_blocking(move || { + let mut line = String::new(); + // A count of 0 is EOF and only EOF: a real line always carries at + // least its terminator, so an empty `line` cannot mean anything else. + match reader.read_line(&mut line)? { + 0 => Ok((reader, None)), + _ => Ok((reader, Some(line))), + } + }) + .await + .map_err(std::io::Error::other)? +} + +/// `exit` and `quit` end the session. Neither is a valid SQL statement, so +/// there is nothing to namespace them against — which is why dirsql has no +/// dot-commands (#953). +fn is_exit_word(line: &str) -> bool { + line.eq_ignore_ascii_case("exit") || line.eq_ignore_ascii_case("quit") +} + +/// The interactive greeting. Static text only: no scan and no live rows, so +/// the first prompt appears immediately even in a large tree (#986). +fn banner() -> String { + format!( + "dirsql {version} — this directory is a database.\n\ + \n \ + SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5\n \ + SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%'\n\ + \n\ + `exit`, `quit`, or Ctrl-D to leave.\n\n", + version = env!("CARGO_PKG_VERSION") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::io::Cursor; + + /// A [`Statements`] double: records every statement it is handed and + /// replies from a scripted queue, so the loop is exercised with no index. + #[derive(Default)] + struct FakeStatements { + seen: RefCell>, + replies: RefCell>>, + } + + impl FakeStatements { + fn failing(message: &str) -> Self { + let fake = Self::default(); + fake.replies + .borrow_mut() + .push(Err(QueryFailure::BadRequest(message.to_string()))); + fake + } + + fn seen(&self) -> Vec { + self.seen.borrow().clone() + } + } + + impl Statements for FakeStatements { + async fn run(&self, sql: &str) -> Result { + self.seen.borrow_mut().push(sql.to_string()); + self.replies + .borrow_mut() + .pop() + .unwrap_or_else(|| Ok(Value::Array(vec![]))) + } + } + + /// A sink whose every write fails, standing in for a closed pipe. + struct BrokenPipe; + + impl Write for BrokenPipe { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other("broken pipe")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("broken pipe")) + } + } + + /// A sink that accepts writes but cannot flush -- a prompt sitting in a + /// buffer whose drain has gone away. + struct FlushFails; + + impl Write for FlushFails { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other("cannot flush")) + } + } + + /// A reader that fails instead of yielding a line, standing in for a + /// mid-session I/O error on stdin. + struct BrokenReader; + + impl std::io::Read for BrokenReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("input went away")) + } + } + + impl BufRead for BrokenReader { + fn fill_buf(&mut self) -> std::io::Result<&[u8]> { + Err(std::io::Error::other("input went away")) + } + + fn consume(&mut self, _amt: usize) {} + } + + fn input(script: &str) -> Cursor> { + Cursor::new(script.as_bytes().to_vec()) + } + + fn text(sink: &[u8]) -> String { + String::from_utf8(sink.to_vec()).expect("output must be UTF-8") + } + + async fn drive(script: &str, interactive: bool) -> (FakeStatements, String, String, u8) { + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = repl_loop(&fake, input(script), &mut out, &mut err, interactive).await; + (fake, text(&out), text(&err), code) + } + + #[tokio::test] + async fn every_non_blank_line_is_executed_in_order() { + let (fake, _, _, code) = drive("SELECT 1\nSELECT 2\n", false).await; + + assert_eq!(fake.seen(), vec!["SELECT 1", "SELECT 2"]); + assert_eq!(code, 0); + } + + #[tokio::test] + async fn each_result_is_written_on_its_own_line() { + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + repl_loop( + &fake, + input("SELECT 1\nSELECT 2\n"), + &mut out, + &mut err, + false, + ) + .await; + + assert_eq!(text(&out), "[]\n[]\n"); + } + + #[tokio::test] + async fn statements_are_trimmed_before_execution() { + let (fake, _, _, _) = drive(" SELECT 1 \n", false).await; + + assert_eq!(fake.seen(), vec!["SELECT 1"]); + } + + #[tokio::test] + async fn blank_and_whitespace_lines_are_skipped() { + let (fake, out, err, code) = drive("\n \n\t\n", false).await; + + assert!(fake.seen().is_empty(), "nothing was executed"); + assert_eq!(out, "", "a blank line renders nothing"); + assert_eq!(err, "", "a blank line is not an error"); + assert_eq!(code, 0); + } + + #[tokio::test] + async fn a_final_line_without_a_newline_still_runs() { + // The last line of a heredoc or `printf` often arrives unterminated. + let (fake, _, _, _) = drive("SELECT 1", false).await; + + assert_eq!(fake.seen(), vec!["SELECT 1"]); + } + + #[tokio::test] + async fn exit_ends_the_session_before_the_rest_of_the_input() { + let (fake, _, _, code) = drive("SELECT 1\nexit\nSELECT 2\n", false).await; + + assert_eq!(fake.seen(), vec!["SELECT 1"]); + assert_eq!(code, 0); + } + + #[tokio::test] + async fn quit_ends_the_session_before_the_rest_of_the_input() { + let (fake, _, _, code) = drive("quit\nSELECT 2\n", false).await; + + assert!(fake.seen().is_empty()); + assert_eq!(code, 0); + } + + #[tokio::test] + async fn a_failure_is_reported_and_the_session_continues() { + // The one real behavioral difference from `dirsql query`, which exits + // 1 on the first failure. + let fake = FakeStatements::failing("no such table: nowhere"); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + let code = repl_loop( + &fake, + input("SELECT nope\nSELECT 1\n"), + &mut out, + &mut err, + false, + ) + .await; + + assert_eq!(text(&err), "dirsql: no such table: nowhere\n"); + assert_eq!(fake.seen(), vec!["SELECT nope", "SELECT 1"]); + assert_eq!(code, 0, "a clean EOF exits 0 even after a failure"); + } + + #[tokio::test] + async fn a_failure_renders_nothing_on_the_result_sink() { + let fake = FakeStatements::failing("boom"); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + repl_loop(&fake, input("SELECT nope\n"), &mut out, &mut err, false).await; + + assert_eq!(text(&out), "", "errors never reach the result sink"); + } + + #[tokio::test] + async fn the_piped_path_writes_no_prompt_and_no_banner() { + let (_, out, _, _) = drive("SELECT 1\n", false).await; + + assert_eq!(out, "[]\n", "results alone, no interactive furniture"); + } + + #[tokio::test] + async fn the_interactive_path_opens_with_the_banner() { + let (_, out, _, _) = drive("", true).await; + + assert!( + out.starts_with(&banner()), + "banner comes first, got {out:?}" + ); + } + + #[tokio::test] + async fn the_interactive_path_prompts_before_every_read() { + let (_, out, _, _) = drive("SELECT 1\nSELECT 2\n", true).await; + + // Two statements plus the read that hits EOF. + assert_eq!(out.matches(PROMPT).count(), 3, "got {out:?}"); + } + + #[tokio::test] + async fn the_interactive_path_closes_with_a_newline() { + // Ctrl-D leaves the cursor mid-line; without this the shell prompt + // returns on top of the dirsql prompt. + let (_, out, _, _) = drive("", true).await; + + assert!(out.ends_with("\n"), "got {out:?}"); + } + + #[tokio::test] + async fn a_read_failure_ends_the_session_with_an_error() { + let fake = FakeStatements::default(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + let code = repl_loop(&fake, BrokenReader, &mut out, &mut err, false).await; + + assert_eq!(code, 1); + assert_eq!( + text(&err), + "dirsql: failed to read input: input went away\n" + ); + } + + #[tokio::test] + async fn a_closed_result_sink_stops_the_loop_cleanly() { + // `dirsql < script.sql | head -1`: nothing downstream is listening, so + // executing the rest of the script is wasted work. + let fake = FakeStatements::default(); + let mut err = Vec::new(); + + let code = repl_loop( + &fake, + input("SELECT 1\nSELECT 2\n"), + &mut BrokenPipe, + &mut err, + false, + ) + .await; + + assert_eq!(code, 0); + assert_eq!(fake.seen(), vec!["SELECT 1"], "the second never runs"); + } + + #[tokio::test] + async fn a_closed_sink_abandons_the_banner_rather_than_looping() { + let fake = FakeStatements::default(); + let mut err = Vec::new(); + + let code = repl_loop(&fake, input("SELECT 1\n"), &mut BrokenPipe, &mut err, true).await; + + assert_eq!(code, 1); + assert!(fake.seen().is_empty()); + } + + #[tokio::test] + async fn an_unflushable_prompt_stops_the_loop_too() { + // A prompt that reaches the buffer but never the terminal leaves the + // user typing blind, so it is as fatal as one that cannot be written + // at all -- both halves of the prompt write have to be checked. + let fake = FakeStatements::default(); + let mut err = Vec::new(); + + let code = repl_loop(&fake, input("SELECT 1\n"), &mut FlushFails, &mut err, true).await; + + assert_eq!(code, 1); + assert!(fake.seen().is_empty(), "nothing runs behind a dead prompt"); + } + + #[tokio::test] + async fn a_degraded_index_is_reported_once_and_never_loops() { + // `AppState::Unavailable` fails identically for every statement, so + // repeating it per line is noise rather than information. + let state = AppState::Unavailable("failed to resolve missing.toml".to_string()); + let (mut out, mut err) = (Vec::new(), Vec::new()); + + let code = run_repl( + &state, + input("SELECT 1\nSELECT 2\n"), + &mut out, + &mut err, + false, + ) + .await; + + assert_eq!(code, 1); + assert_eq!(text(&err), "dirsql: failed to resolve missing.toml\n"); + assert_eq!(text(&out), ""); + } + + #[tokio::test] + async fn the_real_statement_sink_answers_from_the_shared_pipeline() { + // `StateStatements` is the loop's only route to `execute_query`. One + // that answered without consulting the index would make every result + // the REPL prints fiction, so pin that it reports the index's state. + let state = AppState::Unavailable("failed to load config".to_string()); + + let failure = StateStatements(&state) + .run("SELECT 1") + .await + .expect_err("a degraded index cannot answer a statement"); + + assert_eq!(failure.message(), "failed to load config"); + } + + #[test] + fn exit_words_are_recognized_regardless_of_case() { + for word in ["exit", "quit", "EXIT", "Quit", "eXiT"] { + assert!(is_exit_word(word), "{word} ends the session"); + } + } + + #[test] + fn nothing_else_is_an_exit_word() { + // A prefix match would swallow `SELECT quitters` and `exit_code`. + for word in ["exits", "quitting", "exit;", ".exit", "SELECT 1", ""] { + assert!(!is_exit_word(word), "{word} is not an exit word"); + } + } + + #[test] + fn the_banner_names_the_version_and_how_to_leave() { + let banner = banner(); + + assert!(banner.contains(env!("CARGO_PKG_VERSION")), "{banner}"); + assert!(banner.contains("Ctrl-D"), "{banner}"); + assert!(banner.contains("exit"), "{banner}"); + } + + #[test] + fn the_banner_shows_runnable_sample_queries() { + // The greeting is the whole onboarding surface: a user who has never + // seen a path-table needs one here. + let banner = banner(); + + assert!( + banner.contains("SELECT basename, size FROM './'"), + "{banner}" + ); + assert!(banner.contains("FROM './**/*.md'"), "{banner}"); + } + + #[test] + fn the_banner_ends_with_a_blank_line_before_the_prompt() { + assert!(banner().ends_with("\n\n"), "{}", banner()); + } +} diff --git a/packages/rust/src/cli/run.rs b/packages/rust/src/cli/run.rs index 38d23c58e..6adc96c2d 100644 --- a/packages/rust/src/cli/run.rs +++ b/packages/rust/src/cli/run.rs @@ -6,16 +6,21 @@ //! `docs/reference/cli.md`. //! - `server`: the HTTP server documented in `docs/reference/cli.md`. //! - `init`: writes a fixed starter `.dirsql.toml`; see `docs/reference/cli.md`. -//! - No subcommand and no SQL: a usage error pointing at `dirsql server`. +//! - No subcommand and no SQL (bare `dirsql`): an interactive REPL over the +//! current directory; see [`repl`](super::repl). //! //! Only compiled with `--features cli`. //! //! The `dirsql` binary is a shim over [`run_cli`], so `cargo install dirsql //! --features cli` and every other entry path run the same code. +use std::io::IsTerminal; use std::path::PathBuf; -use super::{AppState, ServerConfig, execute::execute_query, init::InitOptions, serve_with_state}; +use super::{ + AppState, ServerConfig, execute::execute_query, init::InitOptions, repl::run_repl, + serve_with_state, +}; use crate::{DirSQL, Extension, Row, Table}; use clap::{Args, Parser, Subcommand}; @@ -24,10 +29,14 @@ use clap::{Args, Parser, Subcommand}; name = "dirsql", version, about = "Query a local directory as SQL. `dirsql \"\"` runs one query; \ - `dirsql server` starts the HTTP server.", + bare `dirsql` opens a REPL; `dirsql server` starts the HTTP \ + server.", long_about = "Runs one SQL query over a local directory and prints the \ result rows as JSON. `dirsql \"SELECT * FROM './'\"` is the \ default; `dirsql query \"\"` is an explicit synonym. \ + With no subcommand and no SQL, dirsql reads statements until \ + EOF instead: a prompted REPL on a terminal, one statement \ + per line from a pipe. \ Tables are defined by a `.dirsql.toml` config file passed \ with `-c`; with no `-c` there are no named tables and \ filesystem queries go through path-tables \ @@ -46,8 +55,8 @@ struct Cli { command: Option, /// The SQL to run in the default (query) mode: `dirsql ""`. With no - /// subcommand and no SQL, dirsql prints a usage error pointing at - /// `dirsql server`. Identical to `dirsql query ""`. + /// subcommand and no SQL, dirsql opens a REPL over the current directory + /// instead. Identical to `dirsql query ""`. sql: Option, /// Attach a parser to every path-table in the default-mode query — the @@ -277,11 +286,14 @@ pub fn run_cli(argv: Vec) -> i32 { i32::from(code) } -/// The default (no-subcommand) behavior: run the positional SQL as a one-shot -/// query, exactly as `dirsql query ""` does. With no SQL there is nothing -/// to run and no mode selected, so print a usage error pointing at -/// `dirsql server` — silently starting the server here would re-invert the -/// default this design deliberately fixed (#662). +/// The default (no-subcommand) behavior. With SQL, run it as a one-shot query, +/// exactly as `dirsql query ""` does. With no SQL, read statements until +/// EOF instead: bare `dirsql` is a REPL over the current directory, prompted +/// on a terminal and silent from a pipe (#987). +/// +/// The index is built **once**, before the loop, rather than per statement: +/// no directory re-scan between statements, and the live watcher keeps it +/// fresh across them. async fn run_default(cli: Cli) -> u8 { match cli.sql { Some(sql) => { @@ -293,11 +305,28 @@ async fn run_default(cli: Cli) -> u8 { .await } None => { - eprintln!( - "dirsql: no query given. Run a query with `dirsql \"SELECT * FROM './'\"`, \ - or start the HTTP server with `dirsql server`. See `dirsql --help`." - ); - 2 + let parser = match resolve_on_file(&cli.on_file) { + Ok(parser) => parser, + Err(message) => { + eprintln!("dirsql: {message}"); + return 1; + } + }; + let state = load_state(&cli.common, parser); + // Skipped files are named once here rather than per statement, and + // `PARTIAL_SCAN_EXIT` has no REPL meaning: the session's exit code + // describes the session, not one scan. + report_scan_failures(&state); + run_repl( + &state, + // `StdinLock` is not `Send`, so it cannot cross into the + // blocking read; `BufReader` locks per call and can. + std::io::BufReader::new(std::io::stdin()), + &mut std::io::stdout(), + &mut std::io::stderr(), + std::io::stdin().is_terminal(), + ) + .await } } } @@ -371,8 +400,9 @@ fn report_scan_failures(state: &AppState) -> bool { /// Synthesize the exact `POST /query` body for a positional SQL argument, /// so the shared pipeline's intake validation sees byte-for-byte what an -/// HTTP client would send. -fn query_body(sql: &str) -> String { +/// HTTP client would send. Shared with the REPL, whose statements must reach +/// the pipeline through the same intake as a one-shot query. +pub(super) fn query_body(sql: &str) -> String { serde_json::json!({ "sql": sql }).to_string() } @@ -668,7 +698,7 @@ mod tests { #[test] fn no_subcommand_and_no_sql_selects_neither() { // #662: bare `dirsql` with nothing else parses cleanly (no command, no - // SQL); `run_default` turns that into the usage error at runtime. + // SQL); `run_default` turns that into the REPL at runtime (#987). let cli = Cli::parse_from(["dirsql"]); assert!(cli.command.is_none()); assert!(cli.sql.is_none()); diff --git a/packages/rust/tests/cli_e2e.rs b/packages/rust/tests/cli_e2e.rs index 2f5a8bc39..5649cb633 100644 --- a/packages/rust/tests/cli_e2e.rs +++ b/packages/rust/tests/cli_e2e.rs @@ -714,23 +714,28 @@ fn bare_dirsql_default_query_honors_config_flag() { } #[test] -fn no_subcommand_and_no_sql_errors_pointing_at_dirsql_server() { - // #662: bare `dirsql` with no SQL must NOT silently start the server (that - // would re-invert the default). It errors and points at `dirsql server`. +fn no_subcommand_and_no_sql_reads_stdin_instead_of_serving() { + // #662 established that bare `dirsql` must NOT silently start the server + // (that would re-invert the default); #987 replaced the usage error it + // used to print with a REPL. The invariant that survives both: bare + // `dirsql` binds no port and returns on its own once stdin is exhausted. + // `output()` hands the child an empty stdin, so the session sees EOF at + // once -- if it were serving, this would block until killed. let dir = TempDir::new().unwrap(); let out = std::process::Command::cargo_bin("dirsql") .expect("binary must exist") .current_dir(dir.path()) .output() .expect("spawning bare `dirsql` failed"); - assert!( - !out.status.success(), - "bare `dirsql` with no query must be a non-zero exit, got {out:?}" + assert_eq!( + out.status.code(), + Some(0), + "an immediate EOF is a clean session, got {out:?}" ); - let stderr = String::from_utf8(out.stderr).unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); assert!( - stderr.contains("dirsql server"), - "the no-args error must point at `dirsql server`, got {stderr:?}" + !stdout.contains("Running at"), + "bare `dirsql` must not start the server, got {stdout:?}" ); } From fe7ec0de4fd2170913c0a2dc3cfa4a5edf445360 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:48:03 +0000 Subject: [PATCH 3/4] e2e attestation for claude/epic-953-stacked-prs-x59wm3 --- .../claude-epic-953-stacked-prs-x59wm3.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json diff --git a/packages/python/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json b/packages/python/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json new file mode 100644 index 000000000..d0be4d38d --- /dev/null +++ b/packages/python/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/ -x -q", + "ran_at": 1787258883, + "exit_code": 0, + "commit": "5eea7dd799ad6dc8ec81e2895baa86aad3f78006", + "branch": "claude/epic-953-stacked-prs-x59wm3" +} From a2793fab0e19373780abf115a3ead5d18e1d88fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 20:49:25 +0000 Subject: [PATCH 4/4] e2e attestation for claude/epic-953-stacked-prs-x59wm3 --- .../claude-epic-953-stacked-prs-x59wm3.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json diff --git a/packages/ts/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json b/packages/ts/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json new file mode 100644 index 000000000..7e9b20bcb --- /dev/null +++ b/packages/ts/e2e-attestations/claude-epic-953-stacked-prs-x59wm3.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1787258965, + "exit_code": 0, + "commit": "fe7ec0de4fd2170913c0a2dc3cfa4a5edf445360", + "branch": "claude/epic-953-stacked-prs-x59wm3" +}