use crate::cli::InfoArgs;
use crate::config;
use crate::error::Result;
use crate::output::{OutputContext, OutputMode};
use crate::util::parse_id;
use fsqlite::Connection;
use fsqlite_types::SqliteValue;
use rich_rust::prelude::*;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Serialize)]
struct SchemaInfo {
tables: Vec<String>,
schema_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
config: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
sample_issue_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
detected_prefix: Option<String>,
}
#[derive(Serialize)]
struct InfoOutput {
database_path: String,
beads_dir: String,
mode: String,
daemon_connected: bool,
#[serde(skip)]
resolved_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
daemon_fallback_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
daemon_detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
issue_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
config: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<SchemaInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
db_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
jsonl_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
jsonl_size: Option<u64>,
}
#[derive(Default)]
struct InfoSnapshot {
issue_count: Option<usize>,
config_map: Option<HashMap<String, String>>,
detected_prefix: Option<String>,
schema: Option<SchemaInfo>,
}
pub fn execute(args: &InfoArgs, cli: &config::CliOverrides, ctx: &OutputContext) -> Result<()> {
if args.whats_new {
return print_message(ctx, "No whats-new data available for br.", "whats_new");
}
if args.thanks {
return print_message(
ctx,
"Thanks for using br. See README for project acknowledgements.",
"thanks",
);
}
let output = collect_info_output(args, cli)?;
if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
}
if ctx.is_json() {
ctx.json_pretty(&output);
return Ok(());
}
if ctx.is_toon() {
ctx.toon(&output);
return Ok(());
}
if ctx.is_rich() {
render_info_rich(&output, ctx);
} else {
print_human(&output);
}
Ok(())
}
fn collect_info_output(args: &InfoArgs, cli: &config::CliOverrides) -> Result<InfoOutput> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let startup = config::load_startup_config_with_paths(&beads_dir, cli.db.as_ref())?;
let snapshot = load_info_snapshot_without_recovery(args, &startup.paths);
let resolved_prefix = config::configured_issue_prefix_from_map(&startup.merged_config.runtime)
.or_else(|| snapshot.detected_prefix.clone())
.or_else(|| {
config::first_prefix_from_jsonl(&startup.paths.jsonl_path)
.ok()
.flatten()
});
let db_path = canonicalize_lossy(&startup.paths.db_path);
let db_size = std::fs::metadata(&startup.paths.db_path)
.map(|metadata| metadata.len())
.ok();
let jsonl_size = std::fs::metadata(&startup.paths.jsonl_path)
.map(|metadata| metadata.len())
.ok();
Ok(InfoOutput {
database_path: db_path.display().to_string(),
beads_dir: canonicalize_lossy(&beads_dir).display().to_string(),
mode: "direct".to_string(),
daemon_connected: false,
resolved_prefix,
daemon_fallback_reason: Some("no-daemon".to_string()),
daemon_detail: Some("br runs in direct mode only".to_string()),
issue_count: snapshot.issue_count,
config: snapshot.config_map,
schema: snapshot.schema,
db_size,
jsonl_path: Some(
canonicalize_lossy(&startup.paths.jsonl_path)
.display()
.to_string(),
),
jsonl_size,
})
}
fn load_info_snapshot_without_recovery(
args: &InfoArgs,
paths: &config::ConfigPaths,
) -> InfoSnapshot {
if !paths.db_path.is_file() {
return InfoSnapshot::default();
}
match config::with_database_family_snapshot(&paths.db_path, |snapshot_db_path| {
let conn = Connection::open(snapshot_db_path.to_string_lossy().into_owned())?;
let issue_count = query_issue_count(&conn);
let config_map = load_config_map(&conn);
let detected_prefix = detect_prefix(&conn, config_map.as_ref());
let schema = if args.schema {
Some(build_schema_info(
&conn,
config_map.as_ref(),
detected_prefix.clone(),
))
} else {
None
};
Ok(InfoSnapshot {
issue_count,
config_map,
detected_prefix,
schema,
})
}) {
Ok(snapshot) => snapshot,
Err(err) => {
debug!(
path = %paths.db_path.display(),
error = %err,
"Skipping DB-backed info details because the database could not be snapshotted for read-only access"
);
InfoSnapshot::default()
}
}
}
fn query_issue_count(conn: &Connection) -> Option<usize> {
conn.query_row("SELECT COUNT(*) FROM issues")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_integer))
.and_then(|count| usize::try_from(count).ok())
}
fn load_config_map(conn: &Connection) -> Option<HashMap<String, String>> {
let rows = conn.query("SELECT key, value FROM config").ok()?;
let mut config_map = HashMap::new();
for row in rows {
let Some(key) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(value) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
config_map.insert(key.to_string(), value.to_string());
}
(!config_map.is_empty()).then_some(config_map)
}
fn build_schema_info(
conn: &Connection,
config_map: Option<&HashMap<String, String>>,
detected_prefix: Option<String>,
) -> SchemaInfo {
let tables = actual_table_names(conn);
let sample_issue_ids: Vec<String> = conn
.query("SELECT id FROM issues ORDER BY id LIMIT 3")
.ok()
.map(|rows| {
rows.into_iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default();
SchemaInfo {
tables,
schema_version: actual_schema_version(conn),
config: config_map.cloned(),
sample_issue_ids,
detected_prefix,
}
}
fn detect_prefix(
conn: &Connection,
config_map: Option<&HashMap<String, String>>,
) -> Option<String> {
config_map
.and_then(config::configured_issue_prefix_from_map)
.or_else(|| {
conn.query("SELECT id FROM issues ORDER BY id LIMIT 1")
.ok()
.and_then(|rows| rows.first().cloned())
.and_then(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.and_then(|id| parse_id(&id).ok().map(|parsed| parsed.prefix))
})
}
fn actual_table_names(conn: &Connection) -> Vec<String> {
conn.query(
"SELECT name FROM sqlite_master \
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
ORDER BY name",
)
.ok()
.map(|rows| {
rows.into_iter()
.filter_map(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
fn actual_schema_version(conn: &Connection) -> String {
conn.query_row("PRAGMA user_version")
.ok()
.and_then(|row| row.get(0).and_then(SqliteValue::as_integer))
.map_or_else(|| "unknown".to_string(), |version| version.to_string())
}
fn print_human(info: &InfoOutput) {
println!("Beads Database Information");
println!("Beads dir: {}", info.beads_dir);
println!("Database: {}", info.database_path);
if let Some(size) = info.db_size {
println!("Database size: {}", format_bytes(size));
}
if let Some(jsonl_path) = &info.jsonl_path {
println!("JSONL: {jsonl_path}");
if let Some(size) = info.jsonl_size {
println!("JSONL size: {}", format_bytes(size));
}
}
println!("Mode: {}", info.mode);
if info.daemon_connected {
println!("Daemon: connected");
} else if let Some(reason) = &info.daemon_fallback_reason {
println!("Daemon: not connected ({reason})");
if let Some(detail) = &info.daemon_detail {
println!(" {detail}");
}
}
if let Some(count) = info.issue_count {
println!("Issue count: {count}");
}
if let Some(prefix) = info.resolved_prefix.as_deref() {
println!("Issue prefix: {prefix}");
}
if let Some(schema) = &info.schema {
println!();
println!("Schema:");
println!(" Version: {}", schema.schema_version);
println!(" Tables: {}", schema.tables.join(", "));
if let Some(prefix) = &schema.detected_prefix {
println!(" Detected prefix: {prefix}");
}
if !schema.sample_issue_ids.is_empty() {
println!(" Sample IDs: {}", schema.sample_issue_ids.join(", "));
}
}
}
#[allow(clippy::unnecessary_wraps)]
fn print_message(ctx: &OutputContext, message: &str, key: &str) -> Result<()> {
if ctx.is_json() {
let payload = serde_json::json!({ key: message });
ctx.json_pretty(&payload);
} else if ctx.is_toon() {
let payload = serde_json::json!({ key: message });
ctx.toon(&payload);
} else if matches!(ctx.mode(), OutputMode::Quiet) {
return Ok(());
} else if ctx.is_rich() {
let console = Console::default();
let theme = ctx.theme();
let text = Text::styled(message, theme.muted.clone());
console.print_renderable(&text);
} else {
println!("{message}");
}
Ok(())
}
fn render_info_rich(info: &InfoOutput, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("Location ", theme.dimmed.clone());
content.append_styled(&info.beads_dir, theme.accent.clone());
content.append("\n");
if let Some(prefix) = info.resolved_prefix.as_deref() {
content.append_styled("Prefix ", theme.dimmed.clone());
content.append_styled(prefix, theme.issue_id.clone());
content.append("\n");
}
content.append("\n");
content.append_styled("Database\n", theme.section.clone());
content.append_styled(" Path ", theme.dimmed.clone());
content.append_styled(&info.database_path, theme.accent.clone());
content.append("\n");
if let Some(size) = info.db_size {
content.append_styled(" Size ", theme.dimmed.clone());
content.append(&format_bytes(size));
content.append("\n");
}
if let Some(count) = info.issue_count {
content.append_styled(" Issues ", theme.dimmed.clone());
content.append_styled(&format!("{count}"), theme.emphasis.clone());
content.append_styled(" total\n", theme.dimmed.clone());
}
if let Some(jsonl_path) = &info.jsonl_path {
content.append("\n");
content.append_styled("JSONL\n", theme.section.clone());
content.append_styled(" Path ", theme.dimmed.clone());
content.append_styled(jsonl_path, theme.accent.clone());
content.append("\n");
if let Some(size) = info.jsonl_size {
content.append_styled(" Size ", theme.dimmed.clone());
content.append(&format_bytes(size));
content.append("\n");
}
}
content.append("\n");
content.append_styled("Mode ", theme.dimmed.clone());
content.append(&info.mode);
if !info.daemon_connected {
content.append_styled(" (no daemon)", theme.muted.clone());
}
content.append("\n");
if let Some(schema) = &info.schema {
content.append("\n");
content.append_styled("Schema\n", theme.section.clone());
content.append_styled(" Version ", theme.dimmed.clone());
content.append(&schema.schema_version);
content.append("\n");
content.append_styled(" Tables ", theme.dimmed.clone());
content.append(&schema.tables.join(", "));
content.append("\n");
if let Some(prefix) = &schema.detected_prefix {
content.append_styled(" Prefix ", theme.dimmed.clone());
content.append_styled(prefix, theme.issue_id.clone());
content.append("\n");
}
if !schema.sample_issue_ids.is_empty() {
content.append_styled(" Samples ", theme.dimmed.clone());
content.append(&schema.sample_issue_ids.join(", "));
content.append("\n");
}
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
"Project Information",
theme.panel_title.clone(),
))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
fn canonicalize_lossy(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::InfoArgs;
use crate::config::CliOverrides;
use crate::storage::SqliteStorage;
use crate::storage::schema::CURRENT_SCHEMA_VERSION;
use std::env;
use std::path::Path;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use tempfile::TempDir;
static TEST_DIR_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
struct DirGuard {
previous: PathBuf,
}
impl DirGuard {
fn new(target: &Path) -> Self {
let previous = env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
env::set_current_dir(target).expect("set current dir");
Self { previous }
}
}
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = env::set_current_dir(&self.previous);
}
}
#[test]
fn test_format_bytes_small() {
assert_eq!(format_bytes(500), "500 B");
assert_eq!(format_bytes(0), "0 B");
}
#[test]
fn test_format_bytes_kb() {
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
}
#[test]
fn test_format_bytes_mb() {
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
assert_eq!(format_bytes(2_500_000), "2.4 MB");
}
#[test]
fn test_format_bytes_gb() {
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn test_collect_info_output_does_not_create_missing_db() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert!(
!beads_dir.join("beads.db").exists(),
"info collection should not create a missing database"
);
assert!(output.issue_count.is_none());
assert_eq!(
output.database_path,
beads_dir.join("beads.db").display().to_string()
);
}
#[test]
fn test_collect_info_output_reads_existing_db_without_recovery() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_config("issue_prefix", "bd").unwrap();
let issue = crate::model::Issue {
id: "bd-abc12".to_string(),
title: "Example".to_string(),
issue_type: crate::model::IssueType::Task,
priority: crate::model::Priority::LOW,
..crate::model::Issue::default()
};
storage.create_issue(&issue, "test").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(
&InfoArgs {
schema: true,
..InfoArgs::default()
},
&CliOverrides::default(),
)
.unwrap();
assert_eq!(output.issue_count, Some(1));
assert_eq!(
output
.config
.as_ref()
.and_then(|config| config.get("issue_prefix"))
.map(String::as_str),
Some("bd")
);
let expected_version = CURRENT_SCHEMA_VERSION.to_string();
assert_eq!(
output
.schema
.as_ref()
.map(|schema| schema.schema_version.as_str()),
Some(expected_version.as_str())
);
let expected_tables = vec![
"blocked_issues_cache".to_string(),
"child_counters".to_string(),
"comments".to_string(),
"config".to_string(),
"dependencies".to_string(),
"dirty_issues".to_string(),
"events".to_string(),
"export_hashes".to_string(),
"issues".to_string(),
"labels".to_string(),
"metadata".to_string(),
];
assert_eq!(
output
.schema
.as_ref()
.map(|schema| schema.tables.as_slice()),
Some(expected_tables.as_slice())
);
assert_eq!(
output
.schema
.as_ref()
.and_then(|schema| schema.detected_prefix.as_deref()),
Some("bd")
);
}
#[test]
fn test_collect_info_output_reports_actual_schema_snapshot() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
let db_path = beads_dir.join("beads.db");
let conn = Connection::open(db_path.to_string_lossy().into_owned()).unwrap();
conn.execute("CREATE TABLE issues (id TEXT PRIMARY KEY)")
.unwrap();
conn.execute("PRAGMA user_version = 1").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(
&InfoArgs {
schema: true,
..InfoArgs::default()
},
&CliOverrides::default(),
)
.unwrap();
let schema = output.schema.expect("schema details");
assert_eq!(schema.schema_version, "1");
assert_eq!(schema.tables, vec!["issues".to_string()]);
assert!(schema.config.is_none());
assert!(schema.sample_issue_ids.is_empty());
}
#[test]
fn test_collect_info_output_prefers_startup_issue_prefix_over_db_config() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
std::fs::write(beads_dir.join("config.yaml"), "issue_prefix: proj\n").unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_config("issue_prefix", "bd").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert_eq!(output.resolved_prefix.as_deref(), Some("proj"));
assert_eq!(
output
.config
.as_ref()
.and_then(|config| config.get("issue_prefix"))
.map(String::as_str),
Some("bd"),
"serialized DB config should remain unchanged"
);
}
#[test]
fn test_collect_info_output_detects_prefix_without_schema_flag() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_config("issue_prefix", "proj").unwrap();
let issue = crate::model::Issue {
id: "proj-abc12".to_string(),
title: "Example".to_string(),
issue_type: crate::model::IssueType::Task,
priority: crate::model::Priority::LOW,
..crate::model::Issue::default()
};
storage.create_issue(&issue, "test").unwrap();
storage.delete_config("issue_prefix").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert_eq!(output.resolved_prefix.as_deref(), Some("proj"));
assert!(output.schema.is_none());
assert!(
output
.config
.as_ref()
.and_then(|config| config.get("issue_prefix"))
.is_none()
);
}
#[test]
fn test_collect_info_output_uses_jsonl_prefix_when_db_is_missing() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
std::fs::write(
beads_dir.join("issues.jsonl"),
r#"{"id":"proj-abc12","title":"Example"}"#,
)
.unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert_eq!(output.resolved_prefix.as_deref(), Some("proj"));
assert!(output.issue_count.is_none());
assert!(output.config.is_none());
assert!(output.schema.is_none());
}
#[test]
fn test_collect_info_output_accepts_startup_prefix_alias() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
std::fs::write(beads_dir.join("config.yaml"), "prefix: proj\n").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert_eq!(output.resolved_prefix.as_deref(), Some("proj"));
}
#[test]
fn test_collect_info_output_accepts_db_prefix_alias() {
let _lock = TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().unwrap();
let beads_dir = temp.path().join(".beads");
std::fs::create_dir_all(&beads_dir).unwrap();
std::fs::write(
beads_dir.join("metadata.json"),
r#"{"database":"beads.db","jsonl_export":"issues.jsonl"}"#,
)
.unwrap();
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).unwrap();
storage.set_config("prefix", "proj").unwrap();
let _guard = DirGuard::new(temp.path());
let output = collect_info_output(&InfoArgs::default(), &CliOverrides::default()).unwrap();
assert_eq!(output.resolved_prefix.as_deref(), Some("proj"));
assert_eq!(
output
.config
.as_ref()
.and_then(|config| config.get("prefix"))
.map(String::as_str),
Some("proj")
);
}
}