use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use std::path::PathBuf;
use super::OutputConfig;
use crate::config::Config;
use crate::index::embedder;
use crate::index::source::{index_hashed_item, ItemOutcome};
use crate::index::Embedder;
use crate::storage::{MetadataStore, VectorStore};
use super::index::BEADS_HASH_REPO;
const BEADS_SOURCE_LABEL: &str = "beads";
#[derive(Args)]
pub struct IndexBeadArgs {
pub bead_id: String,
#[arg(long)]
pub rig: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(default_value = ".")]
pub path: PathBuf,
}
#[derive(Serialize)]
struct IndexBeadOutput {
bead_id: String,
status: &'static str,
keys: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
}
fn outcome_rank(outcome: ItemOutcome) -> u8 {
match outcome {
ItemOutcome::Indexed => 3,
ItemOutcome::Removed => 2,
ItemOutcome::Unchanged => 1,
ItemOutcome::Absent => 0,
}
}
fn outcome_label(outcome: ItemOutcome) -> &'static str {
match outcome {
ItemOutcome::Indexed => "indexed",
ItemOutcome::Unchanged => "unchanged",
ItemOutcome::Removed => "removed",
ItemOutcome::Absent => "absent",
}
}
pub async fn run(args: IndexBeadArgs, output: OutputConfig) -> Result<()> {
let repo_root = args
.path
.canonicalize()
.with_context(|| format!("Invalid path: {}", args.path.display()))?;
let config_path = Config::config_path(&repo_root);
if !config_path.exists() {
bail!("{}", super::not_initialized_error(&repo_root));
}
let config = Config::load(&config_path).with_context(|| "Failed to load configuration")?;
if config.beads.databases.is_empty() {
bail!(
"No beads databases configured. Set `databases` under [beads] in {} \
(see docs/plans/beads-integration.md).",
config_path.display()
);
}
if let Some(ref rig) = args.rig {
let known: Vec<&str> = config
.beads
.databases
.iter()
.map(|db| crate::index::beads::rig_of(db))
.collect();
if !known.contains(&rig.as_str()) {
bail!(
"Unknown rig `{}`. Configured rigs: {}",
rig,
known.join(", ")
);
}
}
let db_path = Config::db_path(&repo_root);
let lance_path = Config::lance_path(&repo_root);
let metadata_store = MetadataStore::open(&db_path).context("Failed to open metadata store")?;
let current_model = config.embedding.model.as_str();
match metadata_store.get_meta("embedding_model")? {
None => bail!(
"No index yet at {}. Run `bobbin index --include-beads` once before \
using index-bead.",
lance_path.display()
),
Some(stored) if stored != current_model => bail!(
"Embedding model changed ({} -> {}). A single-bead reindex cannot \
migrate the index; run `bobbin index --force --include-beads`.",
stored,
current_model
),
Some(_) => {}
}
let model_dir = Config::model_cache_dir()?;
embedder::ensure_model_for_config(&model_dir, &config.embedding)
.await
.context("Failed to ensure embedding model is available")?;
let embedding_dim = embedder::resolve_dimension(&config.embedding)?;
let mut vector_store = VectorStore::open_with_dim(&lance_path, embedding_dim as i32)
.await
.context("Failed to open vector store")?;
let embed = Embedder::from_config(&config.embedding, &model_dir)
.context("Failed to load embedding model")?;
let mut beads_config = config.beads.clone();
beads_config.enabled = true;
let rig = args.rig.as_deref();
let keys = crate::index::beads::bead_file_paths(&beads_config, &args.bead_id, rig);
let found = crate::index::beads::fetch_bead(&beads_config, &args.bead_id, rig).await?;
let mut best = ItemOutcome::Absent;
let mut acted_key: Option<String> = None;
for key in &keys {
let chunk = found.iter().find(|c| &c.file_path == key);
let outcome = index_hashed_item(
BEADS_HASH_REPO,
BEADS_SOURCE_LABEL,
key,
chunk,
&mut vector_store,
&metadata_store,
&embed,
args.force,
)
.await?;
if outcome_rank(outcome) > outcome_rank(best) {
best = outcome;
acted_key = Some(key.clone());
}
}
let status = outcome_label(best);
if output.json {
println!(
"{}",
serde_json::to_string_pretty(&IndexBeadOutput {
bead_id: args.bead_id.clone(),
status,
keys,
key: acted_key,
})?
);
} else if !output.quiet {
match best {
ItemOutcome::Indexed => println!(
"{} Reindexed {} ({})",
"✓".green(),
args.bead_id,
acted_key.as_deref().unwrap_or("-")
),
ItemOutcome::Unchanged => {
println!(
"{} {} unchanged — nothing re-embedded",
"=".dimmed(),
args.bead_id
)
}
ItemOutcome::Removed => println!(
"{} Removed {} from the index (no longer visible in Dolt)",
"-".yellow(),
args.bead_id
),
ItemOutcome::Absent => println!(
"{} {} is not in Dolt and not in the index — nothing to do",
"=".dimmed(),
args.bead_id
),
}
}
Ok(())
}
#[cfg(test)]
#[path = "index_bead_tests.rs"]
mod tests;