use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use super::keys::{bead_visible, rig_of};
use super::{bead_excluded, rows_to_chunks, BeadRow, CommentRow, LiveBeadMetadata};
use crate::config::BeadsConfig;
use crate::types::Chunk;
#[derive(Debug, Deserialize)]
struct JsonlIssue {
id: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
status: String,
#[serde(default)]
priority: i32,
#[serde(default)]
issue_type: Option<String>,
#[serde(default)]
assignee: Option<String>,
#[serde(default)]
owner: Option<String>,
#[serde(default)]
notes: String,
#[serde(default)]
metadata: Option<serde_json::Value>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
comments: Vec<JsonlComment>,
}
#[derive(Debug, Deserialize)]
struct JsonlComment {
#[serde(default)]
author: String,
#[serde(default)]
text: String,
}
fn quick_id(line: &str) -> Option<&str> {
let rest = line.strip_prefix("{\"id\":\"")?;
let end = rest.find('"')?;
Some(&rest[..end])
}
fn read_records<F>(path: &str, mut keep: F) -> Result<Vec<JsonlIssue>>
where
F: FnMut(&str) -> bool,
{
let file = File::open(path).with_context(|| format!("open bead JSONL export at {path}"))?;
let reader = BufReader::new(file);
let mut records: Vec<JsonlIssue> = Vec::new();
let mut slot_of: HashMap<String, usize> = HashMap::new();
for (lineno, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("read {path} line {}", lineno + 1))?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(id) = quick_id(trimmed) {
if !keep(id) {
continue;
}
}
let issue: JsonlIssue = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
if !keep(&issue.id) {
continue;
}
match slot_of.get(&issue.id) {
Some(&slot) => records[slot] = issue,
None => {
slot_of.insert(issue.id.clone(), records.len());
records.push(issue);
}
}
}
Ok(records)
}
pub(super) fn fetch_from_file(
config: &BeadsConfig,
path: &str,
db_name: &str,
only_id: Option<&str>,
) -> Result<Vec<Chunk>> {
let rig = rig_of(db_name);
let records = read_records(path, |id| only_id.is_none_or(|want| want == id))?;
let mut issues: Vec<BeadRow> = Vec::new();
let mut comments: Vec<CommentRow> = Vec::new();
let mut labels_by_issue: HashMap<String, Vec<String>> = HashMap::new();
for rec in records {
if !bead_visible(config, &rec.status, rec.created_at.as_deref()) {
continue;
}
if bead_excluded(&rec.labels, &config.exclude_labels) {
continue;
}
if config.include_comments {
for c in &rec.comments {
comments.push(CommentRow {
issue_id: rec.id.clone(),
author: c.author.clone(),
text: c.text.clone(),
});
}
}
if !rec.labels.is_empty() {
labels_by_issue.insert(rec.id.clone(), rec.labels.clone());
}
issues.push(BeadRow {
id: rec.id,
title: rec.title,
description: rec.description,
status: rec.status,
priority: rec.priority,
assignee: rec.assignee,
notes: rec.notes,
metadata: rec.metadata.map(|m| m.to_string()).unwrap_or_default(),
});
}
Ok(rows_to_chunks(
rig,
issues,
&comments,
&labels_by_issue,
&config.exclude_labels,
))
}
pub(super) fn fetch_bead_metadata(
config: &BeadsConfig,
bead_ids: &[(String, String)],
) -> Result<HashMap<String, LiveBeadMetadata>> {
let mut result = HashMap::new();
let mut by_db: HashMap<String, HashSet<String>> = HashMap::new();
for (rig, bead_id) in bead_ids {
let db_name = format!("beads_{}", rig);
if config.databases.contains(&db_name) {
by_db.entry(db_name).or_default().insert(bead_id.clone());
}
}
for (db_name, wanted) in &by_db {
let path = super::jsonl_path_for(config, db_name)?;
let records = read_records(path, |id| wanted.contains(id))?;
for rec in records {
result.insert(
rec.id,
LiveBeadMetadata {
status: rec.status,
priority: rec.priority,
assignee: rec.assignee,
title: rec.title,
issue_type: rec.issue_type.unwrap_or_else(|| "task".to_string()),
owner: rec.owner.unwrap_or_default(),
labels: rec.labels,
created_at: rec
.created_at
.and_then(|t| t.get(..10).map(str::to_string))
.filter(|d| !d.is_empty()),
},
);
}
}
Ok(result)
}
#[cfg(test)]
#[path = "jsonl_tests.rs"]
mod tests;