use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use crate::config::{BeadsConfig, BeadsSource};
use crate::types::{Chunk, ChunkType};
#[derive(Debug, Clone)]
pub struct LiveBeadMetadata {
pub status: String,
pub priority: i32,
pub assignee: Option<String>,
pub title: String,
pub issue_type: String,
pub owner: String,
pub labels: Vec<String>,
pub created_at: Option<String>,
}
#[derive(Debug)]
struct BeadRow {
id: String,
title: String,
description: String,
status: String,
priority: i32,
assignee: Option<String>,
notes: String,
metadata: String,
}
pub fn content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
fn metadata_is_meaningful(metadata: &str) -> bool {
let t = metadata.trim();
!(t.is_empty() || t == "{}" || t == "null")
}
fn bead_excluded(labels: &[String], exclude_labels: &[String]) -> bool {
if exclude_labels.is_empty() {
return false;
}
labels
.iter()
.any(|l| exclude_labels.iter().any(|x| x.eq_ignore_ascii_case(l)))
}
fn build_bead_content(issue: &BeadRow, comments: &[&CommentRow], labels: &[String]) -> String {
let mut content = format!("{}\n\n{}", issue.title, issue.description);
if !issue.notes.is_empty() {
content.push_str("\n\nNotes:\n");
content.push_str(&issue.notes);
}
if !labels.is_empty() {
content.push_str("\n\nLabels: ");
content.push_str(&labels.join(", "));
}
if metadata_is_meaningful(&issue.metadata) {
content.push_str("\n\nMetadata:\n");
content.push_str(issue.metadata.trim());
}
content.push_str(&format!(
"\n\nStatus: {} | Priority: P{} | Assignee: {}",
issue.status,
issue.priority,
issue.assignee.as_deref().unwrap_or("unassigned")
));
if !comments.is_empty() {
content.push_str("\n\nComments:");
for c in comments {
content.push_str(&format!("\n--- {} ---\n{}", c.author, c.text));
}
}
content
}
#[derive(Debug)]
struct CommentRow {
issue_id: String,
author: String,
text: String,
}
mod dolt;
mod jsonl;
mod keys;
pub(crate) use keys::issues_where_clause;
pub use keys::{bead_file_path, bead_file_paths, rig_of};
fn rows_to_chunks(
rig: &str,
issues: Vec<BeadRow>,
comments: &[CommentRow],
labels_by_issue: &HashMap<String, Vec<String>>,
exclude_labels: &[String],
) -> Vec<Chunk> {
let mut comments_by_issue: HashMap<&str, Vec<&CommentRow>> = HashMap::new();
for comment in comments {
comments_by_issue
.entry(comment.issue_id.as_str())
.or_default()
.push(comment);
}
issues
.into_iter()
.filter_map(|issue| {
let labels = labels_by_issue.get(&issue.id).cloned().unwrap_or_default();
if bead_excluded(&labels, exclude_labels) {
return None;
}
let issue_comments: Vec<&CommentRow> = comments_by_issue
.get(issue.id.as_str())
.cloned()
.unwrap_or_default();
let content = build_bead_content(&issue, &issue_comments, &labels);
let file_path = bead_file_path(rig, &issue.id);
let mut hasher = Sha256::new();
hasher.update(file_path.as_bytes());
let id = hex::encode(hasher.finalize());
Some(Chunk {
id,
file_path,
chunk_type: ChunkType::Issue,
name: Some(issue.title),
start_line: 0,
end_line: 0,
content,
language: "beads".to_string(),
tags: labels.join(","),
})
})
.collect()
}
fn jsonl_path_for<'a>(config: &'a BeadsConfig, db_name: &str) -> Result<&'a str> {
match config.jsonl_paths.get(db_name) {
Some(p) if !p.trim().is_empty() => Ok(p.as_str()),
_ => bail!(
"beads source = \"jsonl\" but no jsonl_paths entry for database \"{db_name}\" \
(set [beads.jsonl_paths] {db_name} = \"/path/to/.beads/issues.jsonl\")"
),
}
}
pub async fn fetch_beads(config: &BeadsConfig) -> Result<Vec<Chunk>> {
if !config.enabled || config.databases.is_empty() {
return Ok(vec![]);
}
let mut all_chunks = Vec::new();
for db_name in &config.databases {
let chunks = fetch_from_source(config, db_name, None)
.await
.with_context(|| format!("Failed to fetch beads from {}", db_name))?;
all_chunks.extend(chunks);
}
Ok(all_chunks)
}
pub async fn fetch_bead(
config: &BeadsConfig,
bead_id: &str,
rig_filter: Option<&str>,
) -> Result<Vec<Chunk>> {
if !config.enabled || config.databases.is_empty() {
return Ok(vec![]);
}
let mut found = Vec::new();
for db_name in &config.databases {
if rig_filter.is_some_and(|want| want != rig_of(db_name)) {
continue;
}
let chunks = fetch_from_source(config, db_name, Some(bead_id))
.await
.with_context(|| format!("Failed to fetch bead {} from {}", bead_id, db_name))?;
found.extend(chunks);
}
Ok(found)
}
fn export_age(path: &str) -> String {
let Ok(modified) = std::fs::metadata(path).and_then(|m| m.modified()) else {
return "UNREADABLE".to_string();
};
match modified.elapsed() {
Ok(d) if d.as_secs() < 3600 => format!("{}m old", d.as_secs() / 60),
Ok(d) if d.as_secs() < 86_400 => format!("{}h old", d.as_secs() / 3600),
Ok(d) => format!("{}d old", d.as_secs() / 86_400),
Err(_) => "mtime in the future".to_string(),
}
}
pub fn source_label(config: &BeadsConfig) -> String {
match config.source {
BeadsSource::Dolt => format!("Dolt at {}:{}", config.host, config.port),
BeadsSource::Jsonl => {
let paths: Vec<String> = config
.databases
.iter()
.filter_map(|db| config.jsonl_paths.get(db))
.map(|p| format!("{p} [{}]", export_age(p)))
.collect();
if paths.is_empty() {
"br JSONL (NO PATHS CONFIGURED)".to_string()
} else {
format!("br JSONL ({})", paths.join(", "))
}
}
}
}
async fn fetch_from_source(
config: &BeadsConfig,
db_name: &str,
only_id: Option<&str>,
) -> Result<Vec<Chunk>> {
match config.source {
BeadsSource::Dolt => dolt::fetch_from_database(config, db_name, only_id).await,
BeadsSource::Jsonl => {
jsonl::fetch_from_file(config, jsonl_path_for(config, db_name)?, db_name, only_id)
}
}
}
pub async fn fetch_bead_metadata(
config: &BeadsConfig,
bead_ids: &[(String, String)], ) -> Result<HashMap<String, LiveBeadMetadata>> {
if !config.enabled || bead_ids.is_empty() {
return Ok(HashMap::new());
}
match config.source {
BeadsSource::Dolt => dolt::fetch_bead_metadata(config, bead_ids).await,
BeadsSource::Jsonl => jsonl::fetch_bead_metadata(config, bead_ids),
}
}
#[cfg(test)]
#[path = "beads_tests.rs"]
mod tests;