1use std::path::Path;
14use std::sync::Arc;
15
16use anyhow::{Context, Result};
17use perspt_sdk::{AccessMode, EffectKind, FootprintSpec, ResourceSelector, ToolEntry};
18
19use super::{family_entry, object_schema};
20use crate::candidate::CandidateWorkspace;
21use crate::toolloop::EffectOutcome;
22use crate::tools::handlers::{CandidateHandlerRegistry, CandidateToolHandler};
23
24const MAX_ROWS: usize = 200;
25const MAX_BYTES: usize = 32 * 1024;
26const DATA_EXTENSIONS: &[&str] = &["csv", "tsv", "parquet", "json", "jsonl", "ndjson", "duckdb"];
27
28fn scoped_path_footprint() -> FootprintSpec {
29 FootprintSpec::new(vec![ResourceSelector::ScopedArgument {
30 family: "db".into(),
31 field: "path".into(),
32 access: AccessMode::Read,
33 }])
34}
35
36pub fn entries() -> Vec<ToolEntry> {
37 vec![
38 family_entry(
39 "db_list",
40 "List data files in the workspace (CSV, TSV, Parquet, JSON, DuckDB)",
41 EffectKind::DataRead,
42 object_schema(&[]),
43 FootprintSpec::new(vec![ResourceSelector::Literal {
44 resource: perspt_sdk::Resource::Scoped {
45 family: "db".into(),
46 key: "list".into(),
47 },
48 access: AccessMode::Read,
49 }]),
50 ),
51 family_entry(
52 "db_schema",
53 "Describe the columns and types of a workspace data file",
54 EffectKind::DataRead,
55 object_schema(&[("path", "string", "Workspace-relative data file", true)]),
56 scoped_path_footprint(),
57 ),
58 family_entry(
59 "db_query",
60 "Run one SELECT-only SQL statement against a workspace data file; \
61 the file is exposed as the view `data`; results are row- and \
62 byte-capped",
63 EffectKind::DataRead,
64 object_schema(&[
65 ("path", "string", "Workspace-relative data file", true),
66 (
67 "sql",
68 "string",
69 "A single SELECT/WITH statement over `data`",
70 true,
71 ),
72 ]),
73 scoped_path_footprint(),
74 ),
75 ]
76}
77
78pub fn register(registry: &mut CandidateHandlerRegistry) -> Result<()> {
79 registry.register("db_list", Arc::new(DbList))?;
80 registry.register("db_schema", Arc::new(DbSchema))?;
81 registry.register("db_query", Arc::new(DbQuery))?;
82 Ok(())
83}
84
85fn is_data_file(path: &Path) -> bool {
86 path.extension()
87 .and_then(|ext| ext.to_str())
88 .map(|ext| DATA_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
89 .unwrap_or(false)
90}
91
92fn data_file(
94 workspace: &CandidateWorkspace,
95 call: &perspt_sdk::ProviderToolCall,
96) -> Result<std::path::PathBuf> {
97 let relative = call
98 .arguments
99 .get("path")
100 .and_then(|v| v.as_str())
101 .context("a workspace-relative `path` argument is required")?;
102 let relative = workspace.validate_relative(relative)?;
103 let path = workspace.overlay_root().join(&relative);
104 anyhow::ensure!(
105 is_data_file(&path),
106 "not a recognized data file (csv, tsv, parquet, json, jsonl, ndjson, duckdb): {relative}"
107 );
108 anyhow::ensure!(path.is_file(), "data file not found: {relative}");
109 Ok(path)
110}
111
112fn open_data_view(path: &Path) -> Result<duckdb::Connection> {
115 let connection = duckdb::Connection::open_in_memory()?;
116 let literal = path
117 .to_str()
118 .context("data file path is not valid UTF-8")?
119 .replace('\'', "''");
120 let extension = path
121 .extension()
122 .and_then(|ext| ext.to_str())
123 .unwrap_or_default()
124 .to_ascii_lowercase();
125 let view = match extension.as_str() {
126 "csv" | "tsv" => format!("CREATE VIEW data AS SELECT * FROM read_csv_auto('{literal}')"),
127 "parquet" => format!("CREATE VIEW data AS SELECT * FROM read_parquet('{literal}')"),
128 "json" | "jsonl" | "ndjson" => {
129 format!("CREATE VIEW data AS SELECT * FROM read_json_auto('{literal}')")
130 }
131 "duckdb" => format!("ATTACH '{literal}' AS src (READ_ONLY)"),
132 other => anyhow::bail!("unsupported data file extension {other:?}"),
133 };
134 connection.execute_batch(&view)?;
135 Ok(connection)
136}
137
138fn validate_select_only(sql: &str) -> Result<()> {
141 let trimmed = sql.trim().trim_end_matches(';');
142 anyhow::ensure!(
143 !trimmed.contains(';'),
144 "db_query admits exactly one statement"
145 );
146 let lowered = trimmed.to_ascii_lowercase();
147 anyhow::ensure!(
148 lowered.starts_with("select") || lowered.starts_with("with"),
149 "db_query admits only SELECT/WITH statements"
150 );
151 const FORBIDDEN: &[&str] = &[
152 "attach",
153 "copy",
154 "install",
155 "load",
156 "pragma",
157 "export",
158 "import",
159 "create",
160 "insert",
161 "update ",
162 "delete",
163 "drop",
164 "alter",
165 "set ",
166 "call",
167 "read_csv",
168 "read_parquet",
169 "read_json",
170 "read_text",
171 "read_blob",
172 "glob",
173 "getenv",
174 "sniff_csv",
175 ];
176 for keyword in FORBIDDEN {
177 anyhow::ensure!(
178 !lowered.contains(keyword),
179 "db_query rejects {} in SQL (read-only, pre-registered view only)",
180 keyword.trim()
181 );
182 }
183 Ok(())
184}
185
186fn render_rows(statement: &mut duckdb::Statement<'_>) -> Result<String> {
188 let mut rows = statement.query([])?;
189 let mut lines = Vec::new();
190 let mut names_written = false;
191 let mut row_count = 0usize;
192 let mut bytes = 0usize;
193 while let Some(row) = rows.next()? {
194 let stmt = row.as_ref();
195 if !names_written {
196 lines.push(stmt.column_names().join(" | "));
197 names_written = true;
198 }
199 let width = stmt.column_count();
200 let mut cells = Vec::with_capacity(width);
201 for index in 0..width {
202 let value: duckdb::types::Value = row.get(index)?;
203 cells.push(render_value(&value));
204 }
205 let line = cells.join(" | ");
206 bytes += line.len();
207 lines.push(line);
208 row_count += 1;
209 if row_count >= MAX_ROWS || bytes >= MAX_BYTES {
210 lines.push(format!("… (capped at {row_count} rows)"));
211 break;
212 }
213 }
214 if lines.is_empty() {
215 lines.push("(no rows)".into());
216 }
217 Ok(lines.join("\n"))
218}
219
220fn render_value(value: &duckdb::types::Value) -> String {
221 match value {
222 duckdb::types::Value::Null => "NULL".into(),
223 duckdb::types::Value::Text(text) => text.clone(),
224 other => format!("{other:?}"),
225 }
226}
227
228struct DbList;
229
230#[async_trait::async_trait]
231impl CandidateToolHandler for DbList {
232 async fn apply(
233 &self,
234 workspace: &CandidateWorkspace,
235 _call: &perspt_sdk::ProviderToolCall,
236 _entry: &ToolEntry,
237 ) -> Result<EffectOutcome> {
238 let root = workspace.overlay_root().to_path_buf();
239 let mut files = Vec::new();
240 for entry in ignore::WalkBuilder::new(&root).build().flatten() {
241 let path = entry.path();
242 if path.is_file() && is_data_file(path) {
243 if let Ok(relative) = path.strip_prefix(&root) {
244 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
245 files.push(format!("{} ({size} bytes)", relative.display()));
246 }
247 }
248 }
249 files.sort();
250 Ok(EffectOutcome {
251 output: if files.is_empty() {
252 "no data files found".into()
253 } else {
254 files.join("\n")
255 },
256 mutated: false,
257 completed: true,
258 })
259 }
260}
261
262struct DbSchema;
263
264#[async_trait::async_trait]
265impl CandidateToolHandler for DbSchema {
266 async fn apply(
267 &self,
268 workspace: &CandidateWorkspace,
269 call: &perspt_sdk::ProviderToolCall,
270 _entry: &ToolEntry,
271 ) -> Result<EffectOutcome> {
272 let path = data_file(workspace, call)?;
273 let connection = open_data_view(&path)?;
274 let sql = if path.extension().and_then(|e| e.to_str()) == Some("duckdb") {
275 "SELECT table_name, column_name, data_type FROM information_schema.columns \
276 ORDER BY table_name, ordinal_position"
277 } else {
278 "SELECT 'data' AS table_name, column_name, data_type \
279 FROM information_schema.columns WHERE table_name = 'data' \
280 ORDER BY ordinal_position"
281 };
282 let mut statement = connection.prepare(sql)?;
283 let output = render_rows(&mut statement)?;
284 Ok(EffectOutcome {
285 output,
286 mutated: false,
287 completed: true,
288 })
289 }
290}
291
292struct DbQuery;
293
294#[async_trait::async_trait]
295impl CandidateToolHandler for DbQuery {
296 async fn apply(
297 &self,
298 workspace: &CandidateWorkspace,
299 call: &perspt_sdk::ProviderToolCall,
300 _entry: &ToolEntry,
301 ) -> Result<EffectOutcome> {
302 let sql = call
303 .arguments
304 .get("sql")
305 .and_then(|v| v.as_str())
306 .context("db_query requires a `sql` argument")?;
307 validate_select_only(sql)?;
308 let path = data_file(workspace, call)?;
309 let connection = open_data_view(&path)?;
310 let mut statement = connection.prepare(sql.trim().trim_end_matches(';'))?;
311 let output = render_rows(&mut statement)?;
312 Ok(EffectOutcome {
313 output,
314 mutated: false,
315 completed: true,
316 })
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn probe_call(name: &str, arguments: serde_json::Value) -> perspt_sdk::ProviderToolCall {
325 perspt_sdk::ProviderToolCall {
326 call_id: "c1".into(),
327 name: name.into(),
328 arguments,
329 }
330 }
331
332 #[test]
333 fn select_only_allowlist_rejects_escapes() {
334 assert!(validate_select_only("SELECT * FROM data").is_ok());
335 assert!(validate_select_only("WITH t AS (SELECT 1) SELECT * FROM t").is_ok());
336 assert!(validate_select_only("SELECT * FROM read_csv_auto('/etc/passwd')").is_err());
337 assert!(validate_select_only("INSTALL httpfs").is_err());
338 assert!(validate_select_only("SELECT 1; DROP TABLE data").is_err());
339 assert!(validate_select_only("PRAGMA database_list").is_err());
340 assert!(validate_select_only("SELECT getenv('HOME')").is_err());
341 assert!(validate_select_only("COPY data TO '/tmp/out.csv'").is_err());
342 }
343
344 #[tokio::test]
345 async fn db_query_reads_workspace_csv_only() {
346 let dir = tempfile::tempdir().unwrap();
347 std::fs::write(
348 dir.path().join("people.csv"),
349 "name,age\nada,36\ngrace,45\n",
350 )
351 .unwrap();
352 let workspace = CandidateWorkspace::create(dir.path(), "n1", 0, "r1").unwrap();
353
354 let outcome = DbQuery
355 .apply(
356 &workspace,
357 &probe_call(
358 "db_query",
359 serde_json::json!({
360 "path": "people.csv",
361 "sql": "SELECT name FROM data ORDER BY age DESC"
362 }),
363 ),
364 &entries()[2],
365 )
366 .await
367 .unwrap();
368 assert!(outcome.output.contains("grace"));
369 assert!(!outcome.mutated);
370
371 let escape = DbQuery
372 .apply(
373 &workspace,
374 &probe_call(
375 "db_query",
376 serde_json::json!({
377 "path": "../outside.csv",
378 "sql": "SELECT * FROM data"
379 }),
380 ),
381 &entries()[2],
382 )
383 .await;
384 assert!(escape.is_err());
385 }
386
387 #[tokio::test]
388 async fn db_schema_describes_csv_columns() {
389 let dir = tempfile::tempdir().unwrap();
390 std::fs::write(dir.path().join("people.csv"), "name,age\nada,36\n").unwrap();
391 let workspace = CandidateWorkspace::create(dir.path(), "n1", 0, "r1").unwrap();
392 let outcome = DbSchema
393 .apply(
394 &workspace,
395 &probe_call("db_schema", serde_json::json!({"path": "people.csv"})),
396 &entries()[1],
397 )
398 .await
399 .unwrap();
400 assert!(outcome.output.contains("name"));
401 assert!(outcome.output.contains("age"));
402 }
403}