use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use crate::db as turso_mod;
use crate::db::ipc;
use crate::db::wal_guard;
use crate::util::lock::InstanceLockState;
const ROW_LIMIT: usize = ipc::IPC_ROW_LIMIT;
const BLOCKLIST: &[&str] = &[
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "REPLACE", "BEGIN", "COMMIT",
"ROLLBACK", "VACUUM", "REINDEX", "GRANT", "REVOKE", "ATTACH", "DETACH", "ANALYZE",
];
const SQL_PUNCTUATION: &[char] = &[
'(', ')', ';', ',', '.', '*', '+', '-', '/', '=', '<', '>', '!', '|', '&', '~', '{', '}', ':',
];
const SAFE_PRAGMAS: &[&str] = &[
"quick_check",
"integrity_check",
"table_info",
"table_xinfo",
"index_info",
"index_list",
"index_xinfo",
"foreign_key_check",
"database_list",
"compile_options",
"page_count",
"freelist_count",
"page_size",
"encoding",
"user_version",
"schema_version",
"collation_list",
"function_list",
"module_list",
"pragma_list",
"table_list",
"stats",
];
pub async fn run_debug() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
run_debug_with_args(args, None).await
}
fn resolve_home(home_override: Option<PathBuf>) -> Result<PathBuf> {
match home_override {
Some(home) => Ok(home),
None => crate::config::default_config_dir(),
}
}
fn write_stdout(text: &str) -> Result<()> {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
out.write_all(text.as_bytes())
.and_then(|()| out.flush())
.map_err(|e| anyhow!("{STDOUT_WRITE_ERROR_PREFIX}{e}"))
}
fn print_line(args: std::fmt::Arguments<'_>) -> Result<()> {
write_stdout(&format!("{args}\n"))
}
fn parse_db_flag(args: &[String], subcommand: &str) -> Result<Option<String>> {
match args.get(3).map(String::as_str) {
Some("--db") => match args.get(4) {
Some(name) => Ok(Some(name.clone())),
None => bail!("expected: mahbot debug {subcommand} --db <name>"),
},
Some(other) => bail!("invalid {subcommand} argument '{other}'"),
None => Ok(None),
}
}
pub(crate) fn validate_store_name(name: &str, all_valid: bool) -> Result<()> {
let names = turso_mod::debug_db_names();
if names.contains(&name) {
return Ok(());
}
let hint = names.join(", ") + if all_valid { ", all" } else { "" };
bail!("invalid database name '{name}'. Valid names: {hint}");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum FamilyKind {
Quarantine,
PreReindex,
}
impl FamilyKind {
#[must_use]
fn label(self) -> &'static str {
match self {
Self::Quarantine => "quarantine",
Self::PreReindex => "pre-reindex",
}
}
}
#[derive(Debug)]
struct FamilyInfo {
id: String,
store: String,
kind: FamilyKind,
stamp: String,
size: u64,
class: FamilyClass,
files: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FamilyClass {
Complete,
Partial,
BadHeader,
SidecarOnly,
}
impl FamilyClass {
#[must_use]
fn label(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Partial => "partial",
Self::BadHeader => "bad-header",
Self::SidecarOnly => "sidecar-only",
}
}
}
#[derive(Debug)]
pub(crate) struct FamilyMeta {
pub(crate) store: String,
pub(crate) kind: FamilyKind,
pub(crate) stamp: String,
}
pub(crate) fn parse_family_name(name: &str) -> Option<FamilyMeta> {
let (kind, marker) = if name.contains(".quarantine-") {
(FamilyKind::Quarantine, ".quarantine-")
} else if name.contains(".pre-reindex-") {
(FamilyKind::PreReindex, ".pre-reindex-")
} else {
return None;
};
let (prefix, tail) = name.split_once(marker)?;
let store = prefix.strip_suffix(".db")?;
if store.is_empty() || store.contains('/') || store.contains('\\') {
return None;
}
let (stamp, pid_rest) = tail.split_once('-')?;
if !is_family_stamp(stamp) {
return None;
}
let (pid, seq) = match pid_rest.split_once('-') {
Some((pid, seq)) => (pid, seq),
None => (pid_rest, ""),
};
if pid.is_empty() || pid_rest.ends_with('-') || !pid.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if kind == FamilyKind::PreReindex && !seq.is_empty() {
return None;
}
if !seq.is_empty() && !seq.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(FamilyMeta {
store: store.to_string(),
kind,
stamp: stamp.to_string(),
})
}
fn is_family_stamp(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
&& b[8] == b'T'
&& b[15] == b'Z'
&& b[..8].iter().all(u8::is_ascii_digit)
&& b[9..15].iter().all(u8::is_ascii_digit)
}
fn list_families(root: &Path) -> Result<Vec<FamilyInfo>> {
let db_dir = root.join("db");
let entries = match std::fs::read_dir(&db_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(e)
.with_context(|| format!("failed to read store directory: {}", db_dir.display()));
}
};
let names: Vec<String> = entries
.map(|e| {
e.map(|e| e.file_name().to_string_lossy().into_owned())
.with_context(|| format!("failed to read entry in {}", db_dir.display()))
})
.collect::<Result<_>>()?;
let mut bases: std::collections::BTreeMap<String, FamilyMeta> =
std::collections::BTreeMap::new();
for name in &names {
if let Some(meta) = parse_family_name(name) {
bases.insert(name.clone(), meta);
}
}
for name in &names {
if let Some(base) = name.strip_suffix("-wal")
&& !bases.contains_key(base)
&& let Some(meta) = parse_family_name(base)
{
bases.insert(base.to_string(), meta);
}
}
let mut families: Vec<FamilyInfo> = bases
.into_iter()
.map(|(id, meta)| classify_family(root, id, meta))
.collect();
families.sort_by(|a, b| (&a.store, a.kind, &a.stamp).cmp(&(&b.store, b.kind, &b.stamp)));
Ok(families)
}
const FAMILY_MEMBERS: [(&str, &str); 2] = [("", "db"), ("-wal", "wal")];
fn classify_family(root: &Path, id: String, meta: FamilyMeta) -> FamilyInfo {
let db_path = root.join("db").join(&id);
let mut members: Vec<&'static str> = Vec::new();
let mut size: u64 = 0;
for (suffix, label) in FAMILY_MEMBERS {
let path = db_path.with_file_name(format!("{id}{suffix}"));
if let Ok(md) = std::fs::metadata(&path)
&& md.is_file()
{
members.push(label);
size += md.len();
}
}
let complete = FAMILY_MEMBERS
.iter()
.all(|(_, label)| members.contains(label));
let class = if !members.contains(&"db") {
FamilyClass::SidecarOnly
} else if !db_header_ok(&db_path) {
FamilyClass::BadHeader
} else if complete {
FamilyClass::Complete
} else {
FamilyClass::Partial
};
FamilyInfo {
id,
store: meta.store,
kind: meta.kind,
stamp: meta.stamp,
size,
class,
files: members.join(","),
}
}
fn db_header_ok(db_path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(db_path) else {
return false;
};
if meta.len() < wal_guard::DB_HEADER_MIN_SIZE {
return false; }
wal_guard::read_db_header(db_path).is_some_and(|h| wal_guard::db_header_valid(&h))
}
fn run_debug_families(args: &[String], home_override: Option<PathBuf>) -> Result<()> {
let mahbot_home = resolve_home(home_override)?;
let mut families = list_families(&mahbot_home)?;
let filter = parse_db_flag(args, "families")?;
if let Some(store) = filter {
families.retain(|fam| fam.store == store);
}
for fam in &families {
print_line(format_args!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
fam.id,
fam.store,
fam.kind.label(),
fam.stamp,
fam.size,
fam.class.label(),
fam.files
))?;
}
Ok(())
}
fn execute_family_query(family_id: &str, sql: &str, root: &Path) -> Result<String> {
parse_family_name(family_id).with_context(|| {
format!("invalid family id '{family_id}' — list valid ids with `mahbot debug families`")
})?;
let db_path = root.join("db").join(family_id);
if !db_path.exists() {
if turso_mod::wal_path(&db_path).exists() {
bail!(
"forensic family '{family_id}' has no main database file — \
the family cannot be queried"
);
}
bail!(
"forensic family '{family_id}' not found — list valid ids with \
`mahbot debug families`"
);
}
let md = std::fs::symlink_metadata(&db_path)
.with_context(|| format!("cannot stat forensic family '{family_id}'"))?;
if !md.file_type().is_file() {
bail!("forensic family '{family_id}' main file is not a regular file — refusing to open");
}
let result = guard_panics(|| {
let (io, db) = open_readonly(&db_path, turso_mod::experimental_database_opts())?;
connect_execute(&io, &db, sql, &db_path)
});
match result {
Ok(output) => Ok(output),
Err(e) if is_engine_panic_error(&e) => {
bail!("forensic family '{family_id}' could not be read — {e:#}")
}
Err(e) => {
Err(e).with_context(|| format!("forensic family '{family_id}' could not be read"))
}
}
}
fn query_family(family_id: &str, sql: &str, root: &Path) -> Result<()> {
let output = execute_family_query(family_id, sql, root)?;
write_stdout(&output)
}
async fn run_debug_with_args(args: Vec<String>, home_override: Option<PathBuf>) -> Result<()> {
let tail: Vec<&str> = args.iter().skip(2).map(String::as_str).collect();
if tail.contains(&"--help") || tail.contains(&"-h") {
print_usage();
return Ok(());
}
if args.get(2).is_some_and(|a| a == "families") {
return run_debug_families(&args, home_override);
}
if args.get(2).is_some_and(|a| a == "--family") {
if args.len() < 5 {
print_usage();
bail!("expected: mahbot debug --family <id> \"SQL query\"");
}
let mahbot_home = resolve_home(home_override)?;
let sql = &args[4];
validate_read_only(sql)?;
return query_family(&args[3], sql, &mahbot_home);
}
if args.len() < 4 {
print_usage();
bail!("expected: mahbot debug --db <name> [\"SQL query\"]");
}
if args[2] != "--db" {
eprintln!("Error: expected --db flag, got '{}'", args[2]);
print_usage();
bail!("expected --db flag");
}
let db_name = &args[3];
let sql = args.get(4).map(String::as_str);
let mahbot_home = resolve_home(home_override)?;
if let Some(sql) = sql {
validate_read_only(sql)?;
}
let db_list = resolve_db_list(db_name, &mahbot_home)?;
let route_via_channel = match crate::util::lock::instance_lock_state_settled(&mahbot_home) {
InstanceLockState::Held => true,
InstanceLockState::Free => false,
InstanceLockState::Unknown(e) => bail!(
"{}. Re-run when no instance is running.",
crate::util::lock::lock_state_unknown(&mahbot_home, &e)
),
};
let mut failures = 0usize;
for (label, file_path) in &db_list {
if db_name == "all" {
print_line(format_args!("=== {label} ==="))?;
}
let physical = file_path
.file_stem()
.and_then(|s| s.to_str())
.map_or_else(|| label.clone(), str::to_string);
let result = if route_via_channel {
match sql {
Some(sql) => query_over_ipc(&mahbot_home, &physical, sql).await,
None => dump_over_ipc(&mahbot_home, &physical, label).await,
}
} else {
if !file_path.exists() {
if db_name == "all" {
eprintln!(
"Warning: database not found, skipping: {}",
file_path.display()
);
failures += 1;
continue;
}
bail!("database file not found: {}", file_path.display());
}
match sql {
Some(sql) => query_one_store(file_path, sql),
None => dump_one_store(file_path, label),
}
};
match result {
Ok(()) => {}
Err(e) => {
if db_name == "all" {
eprintln!("Error: {e:#}");
failures += 1;
continue;
}
return Err(e);
}
}
}
if db_name == "all" && failures > 0 {
bail!(
"{failures} of {} store(s) failed — see the per-store errors above",
db_list.len()
);
}
Ok(())
}
fn query_one_store(file_path: &Path, sql: &str) -> Result<()> {
open_and_query_readonly(file_path, sql)
}
fn dump_one_store(file_path: &Path, label: &str) -> Result<()> {
open_and_dump_readonly(file_path, label)
}
async fn ipc_query_readonly(root: &Path, store: &str, sql: &str) -> Result<ipc::QueryResponse> {
let req = ipc::QueryRequest {
store: store.to_string(),
sql: sql.to_string(),
params: Vec::new(),
};
let resp = ipc::ipc_query_with_wait(root, &req).await?;
if let Some(err) = &resp.error {
bail!("{err}");
}
Ok(resp)
}
async fn query_over_ipc(root: &Path, physical: &str, sql: &str) -> Result<()> {
let resp = ipc_query_readonly(root, physical, sql).await?;
let rows = resp
.rows
.iter()
.map(|row| {
row.iter()
.map(ipc::WireValue::format)
.collect::<Vec<_>>()
.join("|")
})
.collect();
let out = render_pipe_table(&resp.columns, rows, resp.truncated);
write_stdout(&out)
}
async fn dump_over_ipc(root: &Path, physical: &str, label: &str) -> Result<()> {
use std::fmt::Write as _;
let tables_sql = USER_TABLES_SQL.replace("{filter}", turso_mod::USER_OBJECT_FILTER);
let resp = ipc_query_readonly(root, physical, &tables_sql).await?;
let mut out = format!("== schema dump: {label} ==\n");
for row in &resp.rows {
let name = row.first().map(ipc::WireValue::format).unwrap_or_default();
let sql = row.get(1).map(ipc::WireValue::format).unwrap_or_default();
if name.is_empty() {
continue;
}
let count_sql = format!("SELECT COUNT(*) FROM {}", quote_ident(&name));
let count_resp = ipc_query_readonly(root, physical, &count_sql).await?;
let count = count_resp
.rows
.first()
.and_then(|r| r.first())
.map(ipc::WireValue::format)
.unwrap_or_default();
writeln!(out, "\n[table] {name}\n{sql}\nrows: {count}\n").expect("writing to a String");
}
write_stdout(&out)
}
const ENGINE_PANIC_PREFIX: &str = "the database engine panicked while reading the store: ";
const STDOUT_WRITE_ERROR_PREFIX: &str = "failed writing to stdout: ";
fn guard_panics<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
match crate::shutdown::contain_panics(f) {
Ok(r) => r,
Err(payload) => Err(anyhow!(
"{ENGINE_PANIC_PREFIX}{}",
crate::util::panic_message(&*payload)
)),
}
}
fn is_engine_panic_error(err: &anyhow::Error) -> bool {
format!("{err:#}").starts_with(ENGINE_PANIC_PREFIX)
}
pub(crate) fn open_readonly(
db_path: &Path,
opts: turso::core::DatabaseOpts,
) -> Result<(
std::sync::Arc<dyn turso::core::IO>,
std::sync::Arc<turso::core::Database>,
)> {
let path_str = db_path
.to_str()
.with_context(|| format!("database path must be UTF-8: {}", db_path.display()))?;
let io: std::sync::Arc<dyn turso::core::IO> =
std::sync::Arc::new(turso::core::PlatformIO::new()?);
let db = turso::core::Database::open_file_with_flags(
io.clone(),
path_str,
turso::core::OpenFlags::ReadOnly | turso::core::OpenFlags::NoLock,
opts,
None,
std::sync::Arc::new(turso::core::SqliteDialect),
)
.map_err(|e| {
anyhow!(
"failed to open database '{}' read-only: {e}",
db_path.display()
)
})?;
Ok((io, db))
}
pub(crate) fn connect_readonly(
db: &std::sync::Arc<turso::core::Database>,
db_path: &Path,
) -> Result<std::sync::Arc<turso::core::Connection>> {
let conn = db
.connect()
.map_err(|e| anyhow!("failed to connect to database '{}': {e}", db_path.display()))?;
conn.execute("PRAGMA temp_store = MEMORY").map_err(|e| {
anyhow!(
"failed to set in-memory temp storage on '{}': {e}",
db_path.display()
)
})?;
Ok(conn)
}
fn connect_execute(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
sql: &str,
db_path: &Path,
) -> Result<String> {
let conn = connect_readonly(db, db_path)?;
execute_query_readonly(io, &conn, sql, db_path)
}
fn connect_execute_print(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
sql: &str,
db_path: &Path,
) -> Result<()> {
let output = connect_execute(io, db, sql, db_path)?;
write_stdout(&output)
}
fn open_and_query_readonly(file_path: &Path, sql: &str) -> Result<()> {
open_and_run_readonly(file_path, |io, db, path| {
connect_execute_print(io, db, sql, path)
})
}
fn open_and_run_readonly<T>(
file_path: &Path,
runner: impl FnOnce(
&std::sync::Arc<dyn turso::core::IO>,
&std::sync::Arc<turso::core::Database>,
&Path,
) -> Result<T>,
) -> Result<T> {
guard_panics(|| {
let (io, db) = open_readonly(file_path, turso_mod::experimental_database_opts())?;
runner(&io, &db, file_path)
})
}
fn open_and_dump_readonly(file_path: &Path, label: &str) -> Result<()> {
open_and_run_readonly(file_path, |io, db, path| {
let dump = dump_schema(io, db, path, label)?;
write_stdout(&dump)
})
}
fn execute_query_readonly(
io: &std::sync::Arc<dyn turso::core::IO>,
conn: &std::sync::Arc<turso::core::Connection>,
sql: &str,
db_path: &Path,
) -> Result<String> {
let mut stmt = prepare_readonly(conn, sql, db_path)?;
let col_count = stmt.num_columns();
if col_count == 0 {
return Ok(String::new());
}
let column_names: Vec<String> = (0..col_count)
.map(|i| stmt.get_column_name(i).into_owned())
.collect();
let (rows, has_more) = step_rows(io, db_path, &mut stmt, Some(ROW_LIMIT), |row| {
format_core_row(row, col_count)
})?;
Ok(render_pipe_table(&column_names, rows, has_more))
}
const USER_TABLES_SQL: &str = "SELECT name, sql FROM sqlite_master \
WHERE type = 'table' AND sql IS NOT NULL AND {filter} \
ORDER BY name";
fn dump_schema(
io: &std::sync::Arc<dyn turso::core::IO>,
db: &std::sync::Arc<turso::core::Database>,
db_path: &Path,
label: &str,
) -> Result<String> {
use std::fmt::Write as _;
let conn = connect_readonly(db, db_path)?;
let tables_sql = USER_TABLES_SQL.replace("{filter}", turso_mod::USER_OBJECT_FILTER);
let tables = collect_rows(io, &conn, &tables_sql, db_path, |row| {
let name = format_core_value(row.get_value(0));
let sql = format_core_value(row.get_value(1));
(name, sql)
})?;
let mut out = format!("== schema dump: {label} ==\n");
for (name, sql) in tables {
if name.is_empty() {
continue;
}
let count_sql = format!("SELECT COUNT(*) FROM {}", quote_ident(&name));
let counts = collect_rows(io, &conn, &count_sql, db_path, |row| {
format_core_value(row.get_value(0))
})?;
let count = counts.first().ok_or_else(|| {
anyhow!(
"row count query returned no rows on '{}'",
db_path.display()
)
})?;
write!(out, "\n[table] {name}\n{sql}\nrows: {count}\n")
.expect("writing to a String cannot fail");
}
Ok(out)
}
fn prepare_readonly(
conn: &std::sync::Arc<turso::core::Connection>,
sql: &str,
db_path: &Path,
) -> Result<turso::core::Statement> {
conn.query(sql)
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
.ok_or_else(|| anyhow!("query produced no statement on '{}'", db_path.display()))
}
fn step_rows<T>(
io: &std::sync::Arc<dyn turso::core::IO>,
db_path: &Path,
stmt: &mut turso::core::Statement,
limit: Option<usize>,
mut collect: impl FnMut(&turso::core::Row) -> T,
) -> Result<(Vec<T>, bool)> {
let mut rows = Vec::new();
let mut has_more = false;
loop {
match stmt
.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?
{
turso::core::StepResult::Done => break,
turso::core::StepResult::IO
| turso::core::StepResult::Yield
| turso::core::StepResult::Sleep { .. } => {
io.step()
.map_err(|e| anyhow!("SQL query failed on '{}': {e}", db_path.display()))?;
}
turso::core::StepResult::Row => {
if limit.is_some_and(|l| rows.len() >= l) {
has_more = true;
break;
}
let row = stmt
.row()
.ok_or_else(|| anyhow!("row missing after StepResult::Row"))?;
rows.push(collect(row));
}
turso::core::StepResult::Interrupt => {
bail!("query interrupted on '{}'", db_path.display())
}
turso::core::StepResult::Busy => {
bail!("database busy on '{}'; try again later", db_path.display())
}
}
}
Ok((rows, has_more))
}
fn collect_rows<T>(
io: &std::sync::Arc<dyn turso::core::IO>,
conn: &std::sync::Arc<turso::core::Connection>,
sql: &str,
db_path: &Path,
collect: impl FnMut(&turso::core::Row) -> T,
) -> Result<Vec<T>> {
let mut stmt = prepare_readonly(conn, sql, db_path)?;
let (rows, _has_more) = step_rows(io, db_path, &mut stmt, None, collect)?;
Ok(rows)
}
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn physical_store_list(root: &Path) -> Vec<(String, PathBuf)> {
turso_mod::iter_checkpoint_stores()
.map(|(name, _)| (name.to_string(), turso_mod::store_db_path(root, name)))
.collect()
}
fn resolve_db_list(name: &str, root: &Path) -> Result<Vec<(String, PathBuf)>> {
if name == "all" {
return Ok(physical_store_list(root));
}
validate_store_name(name, true)?;
Ok(vec![(
name.to_string(),
turso_mod::store_db_path(root, name),
)])
}
pub(crate) fn validate_read_only(sql: &str) -> Result<()> {
let tokens = scan_sql(sql).map_err(|e| anyhow!("query rejected: {e}"))?;
for (idx, token) in tokens.iter().enumerate() {
if !token.quoted {
let upper = token.text.to_uppercase();
if BLOCKLIST.contains(&upper.as_str()) {
bail!("query rejected: contains blocked keyword '{}'", token.text);
}
}
if token.text.eq_ignore_ascii_case("PRAGMA") {
let Some(name) = tokens.get(idx + 1) else {
bail!("query rejected: incomplete PRAGMA statement");
};
if !SAFE_PRAGMAS.contains(&name.text.to_lowercase().as_str()) {
bail!(
"query rejected: PRAGMA '{}' is not on the read-only allowlist \
(mutating PRAGMAs are blocked; the connection is read-only)",
name.text
);
}
}
}
Ok(())
}
struct SqlToken {
text: String,
quoted: bool,
}
fn scan_sql(sql: &str) -> Result<Vec<SqlToken>, String> {
fn flush_word(tokens: &mut Vec<SqlToken>, word: &mut String) {
if !word.is_empty() {
tokens.push(SqlToken {
text: std::mem::take(word),
quoted: false,
});
}
}
let chars = sql.chars().collect::<Vec<_>>();
let mut tokens = Vec::new();
let mut word = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '-' && chars.get(i + 1) == Some(&'-') {
flush_word(&mut tokens, &mut word);
i += 2;
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
continue;
}
if c == '/' && chars.get(i + 1) == Some(&'*') {
flush_word(&mut tokens, &mut word);
i += 2;
let mut closed = false;
while i < chars.len() {
if chars[i] == '*' && chars.get(i + 1) == Some(&'/') {
i += 2;
closed = true;
break;
}
i += 1;
}
if !closed {
return Err("unterminated /* comment".to_string());
}
continue;
}
if c == '\'' {
flush_word(&mut tokens, &mut word);
i += 1;
loop {
if i >= chars.len() {
return Err("unterminated string literal".to_string());
}
if chars[i] == '\'' {
if chars.get(i + 1) == Some(&'\'') {
i += 2; continue;
}
i += 1; break;
}
i += 1;
}
continue;
}
if c == '"' || c == '`' {
flush_word(&mut tokens, &mut word);
let quote = c;
i += 1;
let mut inner = String::new();
loop {
if i >= chars.len() {
return Err("unterminated quoted identifier".to_string());
}
if chars[i] == quote {
if chars.get(i + 1) == Some("e) {
inner.push(quote);
i += 2;
continue;
}
i += 1;
break;
}
inner.push(chars[i]);
i += 1;
}
tokens.push(SqlToken {
text: inner,
quoted: true,
});
continue;
}
if c == '[' {
flush_word(&mut tokens, &mut word);
i += 1;
let mut inner = String::new();
loop {
if i >= chars.len() {
return Err("unterminated [bracket] identifier".to_string());
}
if chars[i] == ']' {
i += 1;
break;
}
inner.push(chars[i]);
i += 1;
}
tokens.push(SqlToken {
text: inner,
quoted: true,
});
continue;
}
if c.is_whitespace() || SQL_PUNCTUATION.contains(&c) {
flush_word(&mut tokens, &mut word);
i += 1;
continue;
}
word.push(c);
i += 1;
}
flush_word(&mut tokens, &mut word);
Ok(tokens)
}
fn format_core_row(row: &turso::core::Row, column_count: usize) -> String {
let parts: Vec<String> = (0..column_count)
.map(|idx| format_core_value(row.get_value(idx)))
.collect();
parts.join("|")
}
fn format_core_value(val: &turso::core::Value) -> String {
match val {
turso::core::Value::Null => String::new(),
turso::core::Value::Numeric(turso::core::Numeric::Integer(i)) => i.to_string(),
turso::core::Value::Numeric(turso::core::Numeric::Float(fl)) => {
let f: f64 = (*fl).into();
f.to_string()
}
turso::core::Value::Text(t) => t.as_str().to_string(),
turso::core::Value::Blob(b) => crate::util::hex_string(b),
}
}
pub(crate) fn render_pipe_table(columns: &[String], rows: Vec<String>, truncated: bool) -> String {
if columns.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(&columns.join("|"));
out.push('\n');
for row in rows {
out.push_str(&row);
out.push('\n');
}
if truncated {
out.push_str(&format_truncation_row(columns.len()));
out.push('\n');
}
out
}
pub(crate) fn format_truncation_row(column_count: usize) -> String {
let parts: Vec<&str> = match column_count {
1 => vec!["truncated"],
2 => vec!["truncated", "truncated"],
_ => {
let mut parts = vec!["..."];
parts.extend(std::iter::repeat_n("truncated", column_count - 2));
parts.push("...");
parts
}
};
parts.join("|")
}
fn print_usage() {
eprintln!("Usage: mahbot debug --db <name> [\"SQL query\"]");
eprintln!(" mahbot debug families [--db <name>]");
eprintln!(" mahbot debug --family <id> \"SQL query\"");
let names = turso_mod::debug_db_names().join(" | ");
eprintln!(" -h, --help print this help and exit 0");
eprintln!(" --db <name> {names} | all");
eprintln!(" with a SQL argument: read-only query, pipe-delimited output");
eprintln!(" without one: schema dump — one block per user table");
eprintln!(" (`[table] <name>` / DDL / `rows: N`); `all` dumps every live");
eprintln!(" database in per-store sections (per-store errors; exit 1 if");
eprintln!(" any store failed)");
eprintln!(" SQL query read-only SQL, quoted as a single argument");
eprintln!(" families list quarantine/pre-reindex forensic families (--db filters by");
eprintln!(" store name; a name matching nothing prints an empty list)");
eprintln!(" --family <id> read-only SQL against one forensic family (id from `families`)");
eprintln!();
eprintln!("Examples:");
eprintln!(" mahbot debug --db board");
eprintln!(" mahbot debug --db all");
eprintln!(" mahbot debug --db board \"SELECT phase, COUNT(*) FROM tickets GROUP BY phase\"");
eprintln!(" mahbot debug --db all \"SELECT name FROM sqlite_master WHERE type='table'\"");
eprintln!(" mahbot debug families");
eprintln!(
" mahbot debug --family board.db.quarantine-20260812T120000Z-1234 \"SELECT COUNT(*) FROM tickets\""
);
}
#[cfg(test)]
mod tests {
use super::*;
fn dir_names(dir: &Path) -> std::collections::BTreeSet<String> {
std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect()
}
#[test]
fn blocklist_rejects_mutation_keywords() {
for sql in [
"DROP TABLE tickets",
"DELETE FROM logs",
"INSERT INTO logs VALUES (1)",
"UPDATE users SET name='x'",
"VACUUM",
"BEGIN",
"ANALYZE",
"analyze",
"PRAGMA wal_checkpoint(TRUNCATE)",
] {
assert!(validate_read_only(sql).is_err(), "should reject: {sql}");
}
}
#[test]
fn safe_queries_pass_validation() {
for sql in [
"SELECT * FROM tickets",
"SELECT created_at FROM logs LIMIT 10",
"PRAGMA quick_check",
"PRAGMA integrity_check(1)",
"PRAGMA table_info(tickets)",
"SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
"SELECT * FROM tool_calls WHERE tool_name = 'analyze'",
"SELECT 'DROP TABLE tickets' AS note",
"-- DROP TABLE tickets\nSELECT 1",
"/* DELETE FROM logs */ SELECT 1",
"SELECT \"UPDATE\" FROM t",
"SELECT [drop] FROM t",
"SELECT 'it''s analyze time' FROM t",
] {
assert!(validate_read_only(sql).is_ok(), "should accept: {sql}");
}
}
#[test]
fn unterminated_literals_and_comments_are_rejected() {
for sql in [
"SELECT 'analyze",
"SELECT 1 /* note",
"SELECT \"drop",
"SELECT [drop",
] {
assert!(validate_read_only(sql).is_err(), "should reject: {sql}");
}
}
#[test]
fn quoted_pragma_names_still_resolve() {
assert!(validate_read_only("PRAGMA [table_info](tickets)").is_ok());
assert!(validate_read_only("PRAGMA [wal_checkpoint]").is_err());
}
#[test]
fn mutating_pragmas_are_rejected() {
for sql in [
"PRAGMA wal_checkpoint",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=OFF",
"PRAGMA auto_vacuum=INCREMENTAL",
] {
assert!(validate_read_only(sql).is_err(), "should reject: {sql}");
}
}
#[test]
fn tokenizer_splits_sql_punctuation() {
let tokens = scan_sql("SELECT a, b FROM t WHERE x='y'").expect("valid SQL");
let unquoted: Vec<&str> = tokens
.iter()
.filter(|t| !t.quoted)
.map(|t| t.text.as_str())
.collect();
for tok in ["SELECT", "a", "b", "FROM", "t", "WHERE", "x"] {
assert!(unquoted.contains(&tok), "missing unquoted token: {tok}");
}
assert!(!unquoted.contains(&"y"), "string literal leaked as token");
assert!(
tokens.iter().all(|t| !t.text.is_empty()),
"tokens must not be empty"
);
}
#[tokio::test]
async fn run_debug_queries_a_real_store_read_only() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let before = dir_names(&db_dir);
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let result = run_debug_with_args(args, Some(dir.path().to_path_buf())).await;
assert!(
result.is_ok(),
"read-only query on a real store must succeed: {result:?}"
);
assert_eq!(
dir_names(&db_dir),
before,
"the read-only open must leave the store directory unchanged"
);
}
#[tokio::test]
async fn schema_dump_prints_user_tables_with_row_counts() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
store
.conn
.execute(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:00Z', 'INFO', 'test', 'hello')",
turso_mod::params![],
)
.await
.expect("insert a log row");
let db_path = dir.path().join("db").join("logs.db");
let dump =
open_and_run_readonly(&db_path, |io, db, path| dump_schema(io, db, path, "logs"))
.expect("dump must succeed on a real store");
assert!(
dump.starts_with("== schema dump: logs ==\n"),
"dump must open with the store header: {dump}"
);
for table in ["logs", "tool_calls", "llm_requests"] {
assert!(
dump.contains(&format!("\n[table] {table}\n")),
"user table block missing for '{table}': {dump}"
);
}
assert!(
dump.contains("CREATE TABLE logs ("),
"table DDL must be included: {dump}"
);
assert!(
dump.contains("\nrows: 1\n"),
"row count must reflect the live row: {dump}"
);
assert!(
!dump.contains("__turso_internal_"),
"internal turso artifacts must be excluded: {dump}"
);
assert!(
!dump.contains("sqlite_sequence"),
"sqlite_sequence must be excluded: {dump}"
);
}
#[tokio::test]
async fn run_debug_dumps_schema_without_sql() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let before = dir_names(&db_dir);
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
];
let result = run_debug_with_args(args, Some(dir.path().to_path_buf())).await;
assert!(
result.is_ok(),
"schema dump without SQL must succeed: {result:?}"
);
assert_eq!(
dir_names(&db_dir),
before,
"the schema dump must leave the store directory unchanged"
);
}
#[tokio::test]
async fn run_debug_dump_all_reports_missing_stores() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"all".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("--db all with missing stores must report a failure summary");
let msg = format!("{err:#}");
let total = physical_store_list(dir.path()).len();
assert!(
msg.contains(&format!("{} of {total} store(s) failed", total - 1)),
"must summarize per-store failures, got: {msg}"
);
}
#[test]
fn family_name_parser_round_trips() {
let q = parse_family_name("board.db.quarantine-20260812T120000Z-1234").unwrap();
assert_eq!(q.store, "board");
assert_eq!(q.kind, FamilyKind::Quarantine);
assert_eq!(q.stamp, "20260812T120000Z");
assert!(parse_family_name("board.db.quarantine-20260812T120000Z-1234-2").is_some());
assert_eq!(
parse_family_name("stats.db.quarantine-20260812T120000Z-7")
.unwrap()
.store,
"stats"
);
let p = parse_family_name("logs.db.pre-reindex-20260812T120000Z-99").unwrap();
assert_eq!(p.kind, FamilyKind::PreReindex);
for bad in [
"board.db.quarantine-20260812T120000Z", "board.db.quarantine-20260812T120000Z-abc", "board.db.quarantine-20260812T120000Z-1234-", "board.db.pre-reindex-20260812T120000Z-99-1", "board.db.quarantine-12T34-1", ] {
assert!(parse_family_name(bad).is_none(), "must reject: {bad}");
}
}
fn valid_db_bytes() -> Vec<u8> {
let mut b = vec![0u8; 128];
b[..16].copy_from_slice(b"SQLite format 3\0");
b[16] = 0x10; b[17] = 0x00;
b
}
#[test]
fn list_families_classifies_file_sets() {
let dir = tempfile::TempDir::new().unwrap();
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let c = "board.db.quarantine-20260812T120000Z-100";
std::fs::write(db_dir.join(c), valid_db_bytes()).unwrap();
std::fs::write(db_dir.join(format!("{c}-wal")), b"x").unwrap();
let p = "logs.db.pre-reindex-20260812T120000Z-200";
std::fs::write(db_dir.join(p), valid_db_bytes()).unwrap();
std::fs::write(db_dir.join(format!("{p}-wal")), b"x").unwrap();
let s = "sessions.db.quarantine-20260812T120000Z-300";
std::fs::write(db_dir.join(format!("{s}-wal")), b"x").unwrap();
let pa = "users.db.quarantine-20260812T120000Z-400";
std::fs::write(db_dir.join(pa), valid_db_bytes()).unwrap();
let bh = "config.db.quarantine-20260812T120000Z-500";
std::fs::write(db_dir.join(bh), vec![b'x'; 128]).unwrap();
std::fs::write(db_dir.join(".DS_Store"), b"").unwrap();
std::fs::write(db_dir.join("board.db"), valid_db_bytes()).unwrap();
let families = list_families(dir.path()).unwrap();
let by_id: std::collections::BTreeMap<&str, &FamilyInfo> =
families.iter().map(|f| (f.id.as_str(), f)).collect();
assert_eq!(by_id.len(), 5, "families: {families:?}");
assert_eq!(by_id[c].class, FamilyClass::Complete);
assert_eq!(by_id[c].files, "db,wal");
assert_eq!(by_id[p].class, FamilyClass::Complete);
assert_eq!(by_id[p].files, "db,wal");
assert_eq!(by_id[s].class, FamilyClass::SidecarOnly);
assert_eq!(by_id[s].files, "wal");
assert_eq!(by_id[pa].class, FamilyClass::Partial);
assert_eq!(by_id[pa].files, "db");
assert_eq!(by_id[bh].class, FamilyClass::BadHeader);
let stores: Vec<&str> = families.iter().map(|f| f.store.as_str()).collect();
assert_eq!(stores, ["board", "config", "logs", "sessions", "users"]);
}
fn move_family_aside(db_dir: &Path, base: &Path, fam: &str) -> Vec<String> {
let mut moved = Vec::new();
for (src, suffix) in [(base, ""), (&turso_mod::wal_path(base), "-wal")] {
if src.exists() {
std::fs::rename(src, db_dir.join(format!("{fam}{suffix}"))).unwrap();
moved.push(format!("{fam}{suffix}"));
}
}
moved
}
fn file_state(path: &Path) -> (u64, std::time::SystemTime) {
let md = std::fs::metadata(path).unwrap();
(md.len(), md.modified().unwrap())
}
#[tokio::test]
async fn run_debug_family_error_paths_report_clear_errors() {
let dir = tempfile::TempDir::new().unwrap();
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let fam = "logs.db.quarantine-20260812T120000Z-4242";
let run = |sql: &str| {
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
fam.to_string(),
sql.to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
};
std::fs::write(db_dir.join(format!("{fam}-wal")), b"x").unwrap();
let err = run("SELECT 1").await.expect_err("sidecar-only must fail");
let msg = format!("{err:#}");
assert!(msg.contains("no main database file"), "got: {msg}");
std::fs::remove_file(db_dir.join(format!("{fam}-wal"))).unwrap();
std::fs::write(db_dir.join(fam), vec![0x42; 4096]).unwrap();
let err = run("SELECT 1").await.expect_err("garbage db must fail");
let msg = format!("{err:#}");
assert!(msg.contains(fam), "error must name the family: {msg}");
}
#[test]
fn guard_panics_converts_panic_to_error() {
let err = guard_panics(|| -> Result<()> { panic!("boom: pager index OOB") })
.expect_err("a panic must surface as an error");
let msg = format!("{err:#}");
assert!(
msg.contains("panicked while reading the store"),
"got: {msg}"
);
assert!(msg.contains("boom: pager index OOB"), "got: {msg}");
assert!(is_engine_panic_error(&err), "panic must be engine-panic");
}
#[tokio::test]
async fn run_debug_queries_a_family_in_place() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let db_dir = dir.path().join("db");
let fam = "logs.db.pre-reindex-20260812T120000Z-4242";
store
.flush_batch(
"j1",
"Engineer",
"ws1",
&[crate::ToolCallRecord {
tool_name: "read".to_string(),
arguments: "{}".to_string(),
duration_ms: 1,
success: true,
error_message: None,
}],
)
.await
.unwrap();
move_family_aside(&db_dir, &db_dir.join("logs.db"), fam);
assert!(
std::fs::metadata(db_dir.join(format!("{fam}-wal")))
.unwrap()
.len()
> 0,
"pre-reindex wal must hold committed frames"
);
let before: Vec<((u64, std::time::SystemTime), String)> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| {
let path = e.unwrap().path();
(
file_state(&path),
path.file_name().unwrap().to_string_lossy().into_owned(),
)
})
.collect();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
fam.to_string(),
"SELECT COUNT(*) FROM tool_calls".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("pre-reindex family must be queryable in place");
let after_names: Vec<String> = std::fs::read_dir(&db_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
after_names.len(),
before.len(),
"in-place family query must not create files beside the family"
);
for (state, name) in &before {
assert_eq!(
file_state(&db_dir.join(name)),
*state,
"family file must be unchanged: {name}"
);
}
let out = execute_family_query(fam, "SELECT COUNT(*) FROM tool_calls", dir.path()).unwrap();
assert_eq!(
out, "COUNT(*)\n1\n",
"in-place query must read the wal-only row"
);
}
#[tokio::test]
async fn run_debug_family_rejects_invalid_id() {
let dir = tempfile::TempDir::new().unwrap();
for bad in [
"../../etc/passwd",
"board.db",
"board.db.quarantine-20260812T120000Z-1-wal",
] {
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
bad.to_string(),
"SELECT 1".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("invalid family id must be rejected");
let msg = format!("{err:#}");
assert!(msg.contains("invalid family id"), "got: {msg}");
}
}
#[tokio::test]
async fn run_debug_help_after_verb_prints_usage() {
let dir = tempfile::TempDir::new().unwrap();
for tail in [
vec!["--help"],
vec!["-h"],
vec!["families", "--help"],
vec!["families", "-h"],
vec!["--family", "--help"],
vec!["--family", "-h"],
vec!["families", "--db", "--help"],
vec!["--db", "--help"],
vec!["--db", "-h"],
vec!["--family", "--help", "extra"],
vec!["--family", "-h", "extra"],
vec![
"--family",
"logs.db.quarantine-20260812T120000Z-4242",
"--help",
],
vec!["--db", "board", "--help"],
vec!["--db", "board", "-h"],
vec!["--db", "board", "--help", "extra"],
vec!["--db", "board", "-h", "extra"],
vec!["families", "--db", "board", "--help"],
] {
let mut args = vec!["mahbot".to_string(), "debug".to_string()];
args.extend(tail.into_iter().map(str::to_owned));
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.unwrap_or_else(|e| panic!("help must print usage and exit 0: {e:#}"));
}
}
#[tokio::test]
async fn run_debug_families_filters_and_reports_missing_family() {
let dir = tempfile::TempDir::new().unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"families".to_string(),
"--db".to_string(),
"nonexistent".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("families --db <matching-nothing> must print nothing and exit 0");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--family".to_string(),
"logs.db.quarantine-20260812T120000Z-4242".to_string(),
"SELECT 1".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("a well-formed but missing family must fail");
let msg = format!("{err:#}");
assert!(msg.contains("not found"), "got: {msg}");
assert!(!msg.contains("sidecar-only"), "got: {msg}");
let db_dir = dir.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
let legacy = "stats.db.quarantine-20260812T120000Z-7";
std::fs::write(db_dir.join(legacy), valid_db_bytes()).unwrap();
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"families".to_string(),
"--db".to_string(),
"stats".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("legacy store family must be filterable by --db");
}
#[tokio::test]
async fn run_debug_without_an_instance_reads_committed_wal() {
let (store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
store
.conn
.execute_batch(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:00Z', 'INFO', 'test', 'wal-row')",
)
.await
.expect("insert a committed row");
drop(store);
assert!(
matches!(
crate::util::lock::instance_lock_state_settled(dir.path()),
InstanceLockState::Free
),
"temp root has no mahbot.lock — no instance must be considered to hold it"
);
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect("direct read of committed WAL rows must succeed");
let db_path = dir.path().join("db").join("logs.db");
let out = open_and_run_readonly(&db_path, |io, db, path| {
connect_execute(io, db, "SELECT COUNT(*) FROM logs", path)
})
.expect("direct read-only open must succeed");
assert_eq!(
out, "COUNT(*)\n1\n",
"WAL-only committed row must be visible"
);
}
fn hold_location(root: &Path) -> std::fs::File {
let lock = std::fs::File::create(crate::util::lock::lock_file_path(root))
.expect("create the lock file");
assert!(
crate::util::lock::try_flock(&lock).expect("lock the location"),
"the test must hold the location"
);
lock
}
#[serial_test::serial(ipc_bound)]
#[tokio::test]
async fn run_debug_reaches_a_held_location_through_the_channel() {
let (store, _store_dir) = crate::open_test_store!(crate::logs::LogStore, "log");
store
.conn
.execute_batch(
"INSERT INTO logs (timestamp, level, target, message) \
VALUES ('2026-01-01T00:00:00Z', 'INFO', 'test', 'ipc-row')",
)
.await
.expect("insert a committed row");
let root = tempfile::TempDir::new().unwrap();
let db_dir = root.path().join("db");
std::fs::create_dir_all(&db_dir).unwrap();
std::fs::write(db_dir.join("logs.db"), b"not a store").unwrap();
let _held = hold_location(root.path());
let listener_root = root.path().to_path_buf();
let listener = tokio::spawn(async move {
crate::db::ipc::run_ipc_listener(&listener_root, std::sync::Arc::new(store)).await;
});
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
run_debug_with_args(args, Some(root.path().to_path_buf()))
.await
.expect("a held location must be queried through the instance's channel");
listener.abort();
}
#[tokio::test]
#[serial_test::serial(ipc_bound)]
async fn run_debug_refuses_a_held_location_without_a_channel() {
let _env = crate::util::test::set_env_var("MAHBOT_IPC_BOUND_TIMEOUT_SECS", Some("0"));
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
let _held = hold_location(dir.path());
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("a held location without a reachable channel must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("instance is running"), "got: {msg}");
assert!(msg.contains("left untouched"), "got: {msg}");
}
#[tokio::test]
async fn run_debug_refuses_when_the_lock_probe_cannot_tell() {
let (_store, dir) = crate::open_test_store!(crate::logs::LogStore, "log");
std::fs::create_dir(crate::util::lock::lock_file_path(dir.path()))
.expect("occupy the lock file path");
let args = vec![
"mahbot".to_string(),
"debug".to_string(),
"--db".to_string(),
"logs".to_string(),
"SELECT COUNT(*) FROM logs".to_string(),
];
let err = run_debug_with_args(args, Some(dir.path().to_path_buf()))
.await
.expect_err("an undecidable lock probe must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("cannot tell whether"), "got: {msg}");
assert!(msg.contains("left untouched"), "got: {msg}");
}
}