use crate::db::Value;
use crate::db::debug;
use crate::db::ipc;
use crate::util::TOOL_OUTPUT_BUDGET_BYTES;
use crate::{Tool, Workspace};
use anyhow::anyhow;
pub(crate) struct MahbotDebugTool;
#[async_trait::async_trait]
impl Tool for MahbotDebugTool {
fn name(&self) -> &'static str {
"mahbot_debug"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&serde_json::json!({
"query": {
"type": "string",
"description": "A single read-only SQL statement (with multi-statement input only the first statement runs — the rest is silently ignored). Any mutating statement or non-read-only PRAGMA is rejected before execution — see the tool description for the full blocklist. Introspect table/column names first (sqlite_master / PRAGMA table_info) instead of guessing."
},
"db": {
"type": "string",
"description": format!(
"Target database. One of: {}. Defaults to 'core'.",
crate::db::debug_db_names().join(", ")
)
}
}),
&["query"],
)
}
fn side_effects(&self) -> bool {
false }
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let db = super::get_opt_str(&args, "db").unwrap_or("core");
let query = super::get_str(&args, "query")?;
debug::validate_store_name(db, false)?;
let conn = if db == crate::db::LOG_DB_NAME {
crate::logs::LOG_STORE.get().map(|s| &s.conn)
} else {
crate::db::DOMAIN_CONN.get()
}
.ok_or_else(|| anyhow!("the requested store '{db}' is not initialized"))?;
let output = run_read_only_query(conn, query).await?;
let scrubbed = crate::util::scrub_credentials(&output);
Ok(crate::tools::shell::try_spill_to_file(
scrubbed,
TOOL_OUTPUT_BUDGET_BYTES,
))
}
}
async fn run_read_only_query(conn: &crate::db::Connection, sql: &str) -> anyhow::Result<String> {
debug::validate_read_only(sql)?;
let rows = conn
.query_readonly(sql, (), ipc::IPC_ROW_LIMIT)
.await
.map_err(|e| anyhow!("query failed: {e}"))?;
Ok(format_readonly_rows(&rows))
}
fn format_readonly_rows(rows: &crate::db::ReadonlyRows) -> String {
if rows.columns.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(&rows.columns.join("|"));
out.push('\n');
for row in &rows.rows {
let parts: Vec<String> = row.iter().map(format_value).collect();
out.push_str(&parts.join("|"));
out.push('\n');
}
if rows.truncated {
out.push_str(&debug::format_truncation_row(rows.columns.len()));
out.push('\n');
}
out
}
fn format_value(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::Integer(i) => i.to_string(),
Value::Real(f) => f.to_string(),
Value::Text(s) => s.clone(),
Value::Blob(b) => crate::util::hex_string(b),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ReadonlyRows;
#[test]
fn formats_columns_rows_and_truncation_sentinel() {
let rows = ReadonlyRows {
columns: vec!["id".into(), "note".into()],
rows: vec![
vec![Value::Integer(1), Value::Text("a".into())],
vec![Value::Null, Value::Blob(vec![0xde, 0xad])],
],
truncated: true,
};
let out = format_readonly_rows(&rows);
assert_eq!(out, "id|note\n1|a\n|dead\ntruncated|truncated\n");
}
#[test]
fn description_loads_without_panicking() {
assert!(!MahbotDebugTool.description().is_empty());
}
#[test]
fn schema_db_list_is_accepted_by_the_validator() {
let schema = MahbotDebugTool.parameters_schema();
let db_desc = schema
.get("properties")
.and_then(|v| v.get("db"))
.and_then(|v| v.get("description"))
.and_then(|v| v.as_str())
.expect("`db` parameter must have a string description");
for name in crate::db::debug_db_names() {
assert!(db_desc.contains(name), "schema must advertise '{name}'");
assert!(debug::validate_store_name(name, false).is_ok());
}
assert!(debug::validate_store_name("not_a_store", false).is_err());
}
#[tokio::test]
async fn runs_a_read_only_query_on_a_real_store() {
let (store, _dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let out = run_read_only_query(&store.conn, "SELECT 1 AS n, 'x' AS s")
.await
.expect("read-only query must succeed");
assert_eq!(out, "n|s\n1|x\n");
let rejected = run_read_only_query(&store.conn, "DROP TABLE logs").await;
assert!(rejected.is_err(), "mutating query must be rejected");
}
}