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/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" +} 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:?}" ); } 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:?}" + ); +} 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" +}