use crate::config;
use crate::config::routing::follow_redirects;
use crate::error::BeadsError;
use crate::error::Result;
use crate::output::OutputContext;
use crate::util::parse_id;
use fsqlite::Connection;
use fsqlite_types::SqliteValue;
use rich_rust::prelude::*;
use serde::Serialize;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize)]
struct WhereOutput {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
redirected_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
database_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
jsonl_path: Option<String>,
}
pub fn execute(cli: &config::CliOverrides, ctx: &OutputContext) -> Result<()> {
let Some(output) = resolve_where_output(cli)? else {
return Err(BeadsError::NotInitialized);
};
if matches!(ctx.mode(), crate::output::OutputMode::Quiet) {
return Ok(());
}
if ctx.is_json() {
ctx.json_pretty(&output);
} else if ctx.is_toon() {
ctx.toon(&output);
} else if ctx.is_rich() {
render_where_rich(&output, ctx);
} else {
print_human(&output);
}
Ok(())
}
fn resolve_where_output(cli: &config::CliOverrides) -> Result<Option<WhereOutput>> {
let Some(source_dir) = config::discover_optional_beads_dir_candidate_with_cli(cli)? else {
return Ok(None);
};
let final_dir = follow_redirects(&source_dir, 10)?;
let redirected_from = if final_dir == source_dir {
None
} else {
Some(canonicalize_lossy(&source_dir).display().to_string())
};
let paths = config::resolve_paths(&final_dir, cli.db.as_ref())?;
let database_path = canonicalize_lossy(&paths.db_path).display().to_string();
let jsonl_path = canonicalize_lossy(&paths.jsonl_path).display().to_string();
let prefix = detect_prefix(&final_dir, &paths.db_path, &paths.jsonl_path, cli);
Ok(Some(WhereOutput {
path: canonicalize_lossy(&final_dir).display().to_string(),
redirected_from,
prefix,
database_path: Some(database_path),
jsonl_path: Some(jsonl_path),
}))
}
fn detect_prefix(
beads_dir: &Path,
db_path: &Path,
jsonl_path: &Path,
cli: &config::CliOverrides,
) -> Option<String> {
if let Ok(startup) = config::load_startup_config_with_paths(beads_dir, cli.db.as_ref())
&& let Some(prefix) =
config::configured_issue_prefix_from_map(&startup.merged_config.runtime)
{
return Some(prefix);
}
match inspect_jsonl_prefix(jsonl_path) {
JsonlPrefixState::Detected(prefix) => {
return configured_prefix_from_db_without_recovery(db_path).or(Some(prefix));
}
JsonlPrefixState::Mixed => return configured_prefix_from_db_without_recovery(db_path),
JsonlPrefixState::Missing => {}
}
prefix_from_db_without_recovery(db_path)
}
fn configured_prefix_from_db_without_recovery(db_path: &Path) -> Option<String> {
if !db_path.is_file() {
return None;
}
config::with_database_family_snapshot(db_path, |snapshot_db_path| {
let conn = Connection::open(snapshot_db_path.to_string_lossy().into_owned())?;
Ok(conn
.query(
"SELECT value FROM config \
WHERE key IN ('issue_prefix', 'issue-prefix', 'prefix') \
ORDER BY CASE key \
WHEN 'issue_prefix' THEN 0 \
WHEN 'issue-prefix' THEN 1 \
ELSE 2 \
END \
LIMIT 1",
)
.ok()
.and_then(|rows| rows.first().cloned())
.and_then(|row| {
row.get(0)
.and_then(SqliteValue::as_text)
.map(str::to_string)
})
.map(|prefix| prefix.trim().to_string())
.filter(|prefix| !prefix.is_empty()))
})
.ok()
.flatten()
}
fn prefix_from_db_without_recovery(db_path: &Path) -> Option<String> {
configured_prefix_from_db_without_recovery(db_path).or_else(|| {
if !db_path.is_file() {
return None;
}
config::with_database_family_snapshot(db_path, |snapshot_db_path| {
let conn = Connection::open(snapshot_db_path.to_string_lossy().into_owned())?;
Ok(conn
.query("SELECT id FROM issues ORDER BY rowid 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)))
})
.ok()
.flatten()
})
}
#[cfg(test)]
fn prefix_from_jsonl(path: &Path) -> Option<String> {
match inspect_jsonl_prefix(path) {
JsonlPrefixState::Detected(prefix) => Some(prefix),
JsonlPrefixState::Missing | JsonlPrefixState::Mixed => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum JsonlPrefixState {
Missing,
Detected(String),
Mixed,
}
fn inspect_jsonl_prefix(path: &Path) -> JsonlPrefixState {
if !path.is_file() {
return JsonlPrefixState::Missing;
}
let Ok(file) = File::open(path) else {
return JsonlPrefixState::Missing;
};
let reader = BufReader::new(file);
let mut detected_prefix: Option<String> = None;
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
continue;
};
if value
.get("status")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "tombstone")
{
continue;
}
let Some(id) = value.get("id").and_then(|value| value.as_str()) else {
continue;
};
let Ok(parsed) = parse_id(id) else {
continue;
};
match &detected_prefix {
None => detected_prefix = Some(parsed.prefix),
Some(prefix) if *prefix == parsed.prefix => {}
Some(_) => return JsonlPrefixState::Mixed,
}
}
detected_prefix.map_or(JsonlPrefixState::Missing, JsonlPrefixState::Detected)
}
fn print_human(output: &WhereOutput) {
println!("{}", output.path);
if let Some(origin) = &output.redirected_from {
println!(" (via redirect from {origin})");
}
if let Some(prefix) = &output.prefix {
println!(" prefix: {prefix}");
}
if let Some(db_path) = &output.database_path {
println!(" database: {db_path}");
}
if let Some(jsonl_path) = &output.jsonl_path {
println!(" jsonl: {jsonl_path}");
}
}
fn render_where_rich(output: &WhereOutput, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("Directory ", theme.dimmed.clone());
content.append_styled(&output.path, theme.accent.clone());
content.append("\n");
if let Some(origin) = &output.redirected_from {
content.append_styled(" ", theme.dimmed.clone());
content.append_styled("(via redirect from ", theme.muted.clone());
content.append_styled(origin, theme.accent.clone());
content.append_styled(")\n", theme.muted.clone());
}
if let Some(prefix) = &output.prefix {
content.append_styled("Prefix ", theme.dimmed.clone());
content.append_styled(prefix, theme.issue_id.clone());
content.append("\n");
}
if let Some(db_path) = &output.database_path {
content.append_styled("Database ", theme.dimmed.clone());
content.append_styled(db_path, theme.accent.clone());
content.append("\n");
}
if let Some(jsonl_path) = &output.jsonl_path {
content.append_styled("JSONL ", theme.dimmed.clone());
content.append_styled(jsonl_path, theme.accent.clone());
content.append("\n");
}
let title = output
.prefix
.as_ref()
.map_or_else(|| "Beads Location".to_string(), |p| format!("{p} Location"));
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(&title, theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn canonicalize_lossy(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CliOverrides;
use crate::storage::SqliteStorage;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
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 resolve_where_output_uses_explicit_db_override() {
let temp = TempDir::new().expect("tempdir");
let beads_dir = temp.path().join("external").join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, r#"{"id":"proj-abc12","title":"Example"}"#).expect("write jsonl");
let cli = CliOverrides {
db: Some(db_path.clone()),
..CliOverrides::default()
};
let output = resolve_where_output(&cli)
.expect("where output")
.expect("workspace output");
assert_eq!(
output.path,
canonicalize_lossy(&beads_dir).display().to_string()
);
assert_eq!(
output.database_path,
Some(canonicalize_lossy(&db_path).display().to_string())
);
assert_eq!(
output.jsonl_path,
Some(canonicalize_lossy(&jsonl_path).display().to_string())
);
assert_eq!(output.prefix.as_deref(), Some("proj"));
}
#[test]
fn resolve_where_output_preserves_redirect_origin() {
let _lock = crate::util::test_helpers::TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().expect("tempdir");
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
let source_beads = source_root.join(".beads");
let target_beads = target_root.join(".beads");
fs::create_dir_all(&source_beads).expect("create source beads dir");
fs::create_dir_all(&target_beads).expect("create target beads dir");
fs::write(source_beads.join("redirect"), "../../target/.beads").expect("write redirect");
fs::write(
target_beads.join("issues.jsonl"),
r#"{"id":"proj-abc12","title":"Example"}"#,
)
.expect("write jsonl");
let _guard = DirGuard::new(&source_root);
let output = resolve_where_output(&CliOverrides::default())
.expect("where output")
.expect("workspace output");
assert_eq!(
output.path,
canonicalize_lossy(&target_beads).display().to_string()
);
assert_eq!(
output.redirected_from,
Some(canonicalize_lossy(&source_beads).display().to_string())
);
assert_eq!(
output.database_path,
Some(
canonicalize_lossy(&target_beads.join("beads.db"))
.display()
.to_string()
)
);
assert_eq!(
output.jsonl_path,
Some(
canonicalize_lossy(&target_beads.join("issues.jsonl"))
.display()
.to_string()
)
);
assert_eq!(output.prefix.as_deref(), Some("proj"));
}
#[test]
fn resolve_where_output_does_not_create_missing_db() {
let _lock = crate::util::test_helpers::TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("workspace");
let beads_dir = root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let _guard = DirGuard::new(&root);
let output = resolve_where_output(&CliOverrides::default())
.expect("where output")
.expect("workspace output");
assert_eq!(
output.database_path,
Some(
canonicalize_lossy(&beads_dir.join("beads.db"))
.display()
.to_string()
)
);
assert!(
!beads_dir.join("beads.db").exists(),
"where must not create the database as a side effect"
);
}
#[test]
fn resolve_where_output_falls_back_for_external_db_override() {
let _lock = crate::util::test_helpers::TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("workspace");
let beads_dir = root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let external_db = temp.path().join("cache").join("custom.db");
let cli = CliOverrides {
db: Some(external_db.clone()),
..CliOverrides::default()
};
let _guard = DirGuard::new(&root);
let output = resolve_where_output(&cli)
.expect("where output")
.expect("workspace output");
assert_eq!(
output.path,
canonicalize_lossy(&beads_dir).display().to_string()
);
assert_eq!(
output.database_path,
Some(canonicalize_lossy(&external_db).display().to_string())
);
assert_eq!(
output.jsonl_path,
Some(
canonicalize_lossy(&temp.path().join("cache").join("issues.jsonl"))
.display()
.to_string()
)
);
}
#[test]
fn prefix_from_jsonl_returns_none_for_mixed_prefixes() {
let temp = TempDir::new().expect("tempdir");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(
&jsonl_path,
concat!(
r#"{"id":"proj-abc12","title":"Example"}"#,
"\n",
r#"{"id":"other-def34","title":"Second"}"#,
"\n",
),
)
.expect("write jsonl");
assert_eq!(prefix_from_jsonl(&jsonl_path), None);
}
#[test]
fn prefix_from_jsonl_skips_invalid_json_lines() {
let temp = TempDir::new().expect("tempdir");
let jsonl_path = temp.path().join("issues.jsonl");
fs::write(
&jsonl_path,
concat!(
r#"{"id":"proj-abc12","title":"Example"}"#,
"\n",
"{invalid json}\n",
),
)
.expect("write jsonl");
assert_eq!(prefix_from_jsonl(&jsonl_path), Some("proj".to_string()));
}
#[test]
fn detect_prefix_prefers_configured_storage_prefix_when_jsonl_prefixes_conflict() {
let temp = TempDir::new().expect("tempdir");
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).expect("open db");
storage
.set_config("issue_prefix", "proj")
.expect("set issue prefix");
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(
&jsonl_path,
concat!(
r#"{"id":"proj-abc12","title":"Example"}"#,
"\n",
r#"{"id":"other-def34","title":"Second"}"#,
"\n",
),
)
.expect("write mixed-prefix jsonl");
assert_eq!(
detect_prefix(&beads_dir, &db_path, &jsonl_path, &CliOverrides::default()),
Some("proj".to_string())
);
}
#[test]
fn resolve_where_output_accepts_startup_prefix_alias() {
let _lock = crate::util::test_helpers::TEST_DIR_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = TempDir::new().expect("tempdir");
let root = temp.path().join("workspace");
let beads_dir = root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
fs::write(beads_dir.join("config.yaml"), "prefix: proj\n").expect("write config");
let _guard = DirGuard::new(&root);
let output = resolve_where_output(&CliOverrides::default())
.expect("where output")
.expect("workspace output");
assert_eq!(output.prefix.as_deref(), Some("proj"));
}
#[test]
fn detect_prefix_prefers_db_prefix_over_jsonl_inference() {
let temp = TempDir::new().expect("tempdir");
let beads_dir = temp.path().join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
let db_path = beads_dir.join("beads.db");
let mut storage = SqliteStorage::open(&db_path).expect("open db");
storage.set_config("prefix", "dbpref").expect("set prefix");
let jsonl_path = beads_dir.join("issues.jsonl");
fs::write(&jsonl_path, r#"{"id":"jsonl-abc12","title":"Example"}"#).expect("write jsonl");
assert_eq!(
detect_prefix(&beads_dir, &db_path, &jsonl_path, &CliOverrides::default()),
Some("dbpref".to_string())
);
}
}