diff --git a/Cargo.lock b/Cargo.lock index f9b3d1c..e7beb95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,6 +256,7 @@ dependencies = [ "ignore", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "sha2", diff --git a/README.md b/README.md index c4b8ddf..7aad639 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ crash-recoverable and inspectable from the CLI the whole way. | Tool | What it does | |---|---| | `bash` | Shell in the workspace; sandboxed with its children under Seatbelt on macOS | -| `read_file` | One path for files (hashline `line#hash` anchors), directories (sorted listings), and http(s) URLs (streamed cap; refused when the sandbox denies network) | +| `read_file` | One path for files (hashline `line#hash` anchors), directories (sorted listings), SQLite databases (schema view or read-only `query`), and http(s) URLs (streamed cap; refused when the sandbox denies network) | | `write_file` / `edit_file` | Writes under sandbox confinement; edits by exact string or by anchored hashline patch with stale-anchor recovery | | `grep` / `glob` | Regex content search and path patterns, `.gitignore`-aware | | `ast_grep` / `ast_edit` | Structural search and rewrite over the syntax tree via [ast-grep](https://ast-grep.github.io) (when installed); rewrites preview by default and write only on `apply: true` | diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index a40c0cb..60cb2d9 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -11,6 +11,7 @@ async-trait.workspace = true futures.workspace = true globset.workspace = true reqwest.workspace = true +rusqlite.workspace = true ignore.workspace = true regex.workspace = true serde.workspace = true diff --git a/crates/tools/src/fs.rs b/crates/tools/src/fs.rs index 06db3e3..917c160 100644 --- a/crates/tools/src/fs.rs +++ b/crates/tools/src/fs.rs @@ -47,21 +47,25 @@ impl Tool for ReadFile { fn spec(&self) -> ToolSpec { ToolSpec { name: "read_file".into(), - description: "Read through one path: a file, a directory, or an \ - http(s) URL. Files render as 1-indexed \ + description: "Read through one path: a file, a directory, a SQLite \ + database, or an http(s) URL. Files render as 1-indexed \ `line#hashcontent`, capped at 256 KiB — the \ `line#hash` token is an anchor that `edit_file` \ patches accept — with an optional line window. \ - Directories render a sorted listing. URLs are \ - fetched with GET (capped, 30s timeout); a sandbox \ - that denies network refuses them." + Directories render a sorted listing. A SQLite file \ + (detected by content) renders its schema and row \ + counts, or runs a read-only `query` (writes are \ + rejected by the engine). URLs are fetched with GET \ + (capped, 30s timeout); a sandbox that denies network \ + refuses them." .into(), input_schema: json!({ "type": "object", "properties": { - "path": {"type": "string", "description": "File or directory path, or an http(s):// URL"}, + "path": {"type": "string", "description": "File, directory, or SQLite path, or an http(s):// URL"}, "offset": {"type": "integer", "description": "1-indexed first line (files only)"}, - "limit": {"type": "integer", "description": "Max lines to return (files only)"} + "limit": {"type": "integer", "description": "Max lines to return (files only)"}, + "query": {"type": "string", "description": "Read-only SQL to run (SQLite files only)"} }, "required": ["path"] }), @@ -85,6 +89,18 @@ impl Tool for ReadFile { if tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_dir()) { return list_dir(&path).await; } + if crate::sqlite::is_sqlite(&path) { + let query = input + .get("query") + .and_then(Value::as_str) + .map(str::to_string); + let db = path.clone(); + return tokio::task::spawn_blocking(move || { + crate::sqlite::read_sqlite(&db, query.as_deref()) + }) + .await + .map_err(|e| ToolError::Failed(format!("sqlite task failed: {e}")))?; + } let raw = tokio::fs::read(&path) .await .map_err(|e| ToolError::Failed(format!("cannot read {}: {e}", path.display())))?; @@ -823,6 +839,35 @@ mod tests { ); } + #[tokio::test] + async fn a_sqlite_database_reads_as_schema_then_queries() { + let dir = tempfile::tempdir().unwrap(); + let c = ctx(&dir); + let conn = rusqlite::Connection::open(dir.path().join("s.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE parts (id INTEGER PRIMARY KEY, sku TEXT); + INSERT INTO parts (sku) VALUES ('W12x26');", + ) + .unwrap(); + drop(conn); + + let out = ReadFile + .run(&c, "t", json!({"path": "s.db"})) + .await + .unwrap(); + assert!(out.contains("parts (1 rows): id, sku"), "{out}"); + + let out = ReadFile + .run( + &c, + "t", + json!({"path": "s.db", "query": "SELECT sku FROM parts"}), + ) + .await + .unwrap(); + assert!(out.contains("W12x26"), "{out}"); + } + /// One canned HTTP exchange on a local port; returns the URL to hit. fn serve_once(response: &'static str) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 696242c..7f6ef52 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -10,6 +10,7 @@ mod bash; mod fs; mod github; mod search; +mod sqlite; pub use ask::{Ask, Asker}; pub use ast::{AstEdit, AstGrep}; diff --git a/crates/tools/src/sqlite.rs b/crates/tools/src/sqlite.rs new file mode 100644 index 0000000..b345d91 --- /dev/null +++ b/crates/tools/src/sqlite.rs @@ -0,0 +1,232 @@ +//! SQLite databases behind the one read path. +//! +//! A database file (recognized by its magic header, not its extension) +//! renders as a schema overview — tables with their columns and row +//! counts — and an optional `query` runs read-only SQL against it. +//! Read-only is enforced by the engine, not by string inspection: the +//! connection opens `mode=ro` with `query_only` on, so a write fails in +//! SQLite itself no matter how it is spelled. A database another process +//! is writing (WAL) falls back to the `immutable=1` open bullpen's own +//! docs recommend for inspecting its store. + +use std::path::Path; + +use rusqlite::{Connection, OpenFlags}; + +use crate::{ToolError, truncate_middle}; + +const MAX_ROWS: usize = 200; +const MAX_OUTPUT_BYTES: usize = 100_000; + +/// The 16-byte header every SQLite 3 database starts with. +pub(crate) fn is_sqlite(path: &Path) -> bool { + std::fs::File::open(path) + .and_then(|mut f| { + use std::io::Read; + let mut magic = [0u8; 16]; + f.read_exact(&mut magic)?; + Ok(&magic == b"SQLite format 3\0") + }) + .unwrap_or(false) +} + +/// Open read-only; when WAL machinery blocks that (a live writer, a +/// missing shm), reopen immutable — a point-in-time snapshot. +fn open_read_only(path: &Path) -> Result { + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let conn = match Connection::open_with_flags(path, flags) { + Ok(conn) => conn, + Err(_) => Connection::open_with_flags( + format!("file:{}?immutable=1", path.display()), + flags | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e| ToolError::Failed(format!("cannot open {}: {e}", path.display())))?, + }; + // Belt and braces on top of mode=ro; also downgrades any accidental + // write into an immediate engine error rather than a lock attempt. + conn.pragma_update(None, "query_only", "ON") + .map_err(|e| ToolError::Failed(format!("cannot open {}: {e}", path.display())))?; + Ok(conn) +} + +fn db_err(path: &Path, e: rusqlite::Error) -> ToolError { + ToolError::Failed(format!("sqlite {}: {e}", path.display())) +} + +/// The default view: every table with its columns and row count. +fn overview(path: &Path, conn: &Connection) -> Result { + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map_err(|e| db_err(path, e))?; + let tables: Vec = stmt + .query_map([], |r| r.get(0)) + .map_err(|e| db_err(path, e))? + .collect::>() + .map_err(|e| db_err(path, e))?; + + if tables.is_empty() { + return Ok(format!("SQLite database {} (no tables)", path.display())); + } + let mut out = format!( + "SQLite database {} ({} table(s)) — pass `query` to run read-only SQL:\n", + path.display(), + tables.len() + ); + for table in tables { + // The table name comes from sqlite_master itself; quoting guards + // names with spaces or keywords, not injection. + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |r| { + r.get(0) + }) + .unwrap_or(-1); + let mut cols = conn + .prepare(&format!("PRAGMA table_info(\"{table}\")")) + .map_err(|e| db_err(path, e))?; + let columns: Vec = cols + .query_map([], |r| r.get::<_, String>(1)) + .map_err(|e| db_err(path, e))? + .collect::>() + .map_err(|e| db_err(path, e))?; + out.push_str(&format!( + " {table} ({count} rows): {}\n", + columns.join(", ") + )); + } + Ok(truncate_middle(out, MAX_OUTPUT_BYTES)) +} + +/// Run one read-only statement and render rows as TSV under a header. +fn run_query(path: &Path, conn: &Connection, query: &str) -> Result { + let mut stmt = conn.prepare(query).map_err(|e| db_err(path, e))?; + let names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let width = names.len(); + let mut rows = stmt.query([]).map_err(|e| db_err(path, e))?; + + let mut lines = vec![names.join("\t")]; + let mut total = 0usize; + while let Some(row) = rows.next().map_err(|e| db_err(path, e))? { + total += 1; + if total > MAX_ROWS { + continue; // keep counting, stop rendering + } + let mut cells = Vec::with_capacity(width); + for i in 0..width { + use rusqlite::types::ValueRef; + cells.push(match row.get_ref(i).map_err(|e| db_err(path, e))? { + ValueRef::Null => "NULL".to_string(), + ValueRef::Integer(v) => v.to_string(), + ValueRef::Real(v) => v.to_string(), + ValueRef::Text(t) => String::from_utf8_lossy(t).into_owned(), + ValueRef::Blob(b) => format!("", b.len()), + }); + } + lines.push(cells.join("\t")); + } + + let mut out = format!("{total} row(s):\n{}", lines.join("\n")); + if total > MAX_ROWS { + out.push_str(&format!("\n[rendered the first {MAX_ROWS}]")); + } + Ok(truncate_middle(out, MAX_OUTPUT_BYTES)) +} + +/// Entry point from `read_file`: overview without a query, TSV with one. +pub(crate) fn read_sqlite(path: &Path, query: Option<&str>) -> Result { + let conn = open_read_only(path)?; + match query { + None => overview(path, &conn), + Some(query) => run_query(path, &conn, query), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("t.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE jobs (id INTEGER PRIMARY KEY, name TEXT, done INTEGER); + INSERT INTO jobs (name, done) VALUES ('weld', 1), ('paint', 0); + CREATE TABLE empty_one (x TEXT);", + ) + .unwrap(); + path + } + + #[test] + fn detects_sqlite_by_magic_not_extension() { + let dir = tempfile::tempdir().unwrap(); + let db = sample(&dir); + assert!(is_sqlite(&db)); + + let plain = dir.path().join("notes.db"); + std::fs::write(&plain, "just text with a .db name").unwrap(); + assert!(!is_sqlite(&plain)); + assert!(!is_sqlite(&dir.path().join("missing.db"))); + } + + #[test] + fn overview_lists_tables_columns_and_counts() { + let dir = tempfile::tempdir().unwrap(); + let out = read_sqlite(&sample(&dir), None).unwrap(); + assert!(out.contains("2 table(s)"), "{out}"); + assert!(out.contains("jobs (2 rows): id, name, done"), "{out}"); + assert!(out.contains("empty_one (0 rows): x"), "{out}"); + } + + #[test] + fn queries_render_tsv_and_cap_rows() { + let dir = tempfile::tempdir().unwrap(); + let db = sample(&dir); + let out = read_sqlite(&db, Some("SELECT name, done FROM jobs ORDER BY id")).unwrap(); + assert_eq!(out, "2 row(s):\nname\tdone\nweld\t1\npaint\t0"); + + let conn = Connection::open(&db).unwrap(); + for i in 0..250 { + conn.execute( + "INSERT INTO jobs (name, done) VALUES (?1, 0)", + [format!("j{i}")], + ) + .unwrap(); + } + let out = read_sqlite(&db, Some("SELECT id FROM jobs")).unwrap(); + assert!(out.starts_with("252 row(s):"), "{out}"); + assert!(out.contains("[rendered the first 200]"), "{out}"); + } + + #[test] + fn writes_are_rejected_by_the_engine_not_string_matching() { + let dir = tempfile::tempdir().unwrap(); + let db = sample(&dir); + for sql in [ + "INSERT INTO jobs (name) VALUES ('sneak')", + "DELETE FROM jobs", + "DROP TABLE jobs", + // Even spelled unusually, the engine sees a write. + " \n/* c */ UPDATE jobs SET done = 1", + ] { + let err = read_sqlite(&db, Some(sql)).unwrap_err(); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("readonly") || msg.contains("read-only") || msg.contains("query_only"), + "{sql}: {msg}" + ); + } + // Nothing changed. + let out = read_sqlite(&db, Some("SELECT COUNT(*) AS n FROM jobs")).unwrap(); + assert!(out.contains("\n2"), "{out}"); + } + + #[test] + fn bad_sql_is_a_clear_error() { + let dir = tempfile::tempdir().unwrap(); + let err = read_sqlite(&sample(&dir), Some("SELEKT nope")).unwrap_err(); + assert!(err.to_string().contains("sqlite"), "{err}"); + } +} diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 8513673..ce9b453 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -14,7 +14,7 @@ what bullpen ships today, and in what order the rest should land. | Tool | Catalog role | Notes | |---|---|---| | `bash` | runtime shell | Serial, sandboxable (Seatbelt on macOS), timeout-bounded. | -| `read_file` | read | One path for files, directories, and http(s) URLs. Files are hashline output — every line carries a `line#hash` anchor — capped head+tail; directories render sorted listings; URLs fetch with a streamed cap and honor the sandbox's network policy. | +| `read_file` | read | One path for files, directories, SQLite databases, and http(s) URLs. Files are hashline output — every line carries a `line#hash` anchor — capped head+tail; directories render sorted listings; SQLite files (detected by magic, not extension) render schema + row counts or run a read-only `query` (writes rejected by the engine via `mode=ro` + `query_only`, with an `immutable=1` fallback for live-WAL stores); URLs fetch with a streamed cap and honor the sandbox's network policy. | | `write_file` / `edit_file` | write / edit | Sandbox write-confinement applies. `edit_file` takes exact-string replacements or hashline patches — hunks addressed by anchors, spans via `to`, with stale-anchor recovery: a moved line is followed while its content hash is unique, a changed line fails with fresh context instead of misapplying. | | `grep` | content search | Regex over the tree, `.gitignore`-aware. | | `glob` | path find | Pattern lookup; reach for `grep` when you need content. | @@ -39,7 +39,7 @@ names: `find` is `glob`, `search` is `grep`, and `task` is the pen's ## Then: files & search, deepened -- **richer `read`** — directories and URLs are in; archives, SQLite, and +- **richer `read`** — directories, URLs, and SQLite are in; archives and PDFs remain, each an incremental, independently testable decoder behind the existing tool.