use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::cli::OutputConfig;
use crate::config::Config;
use crate::storage::sqlite::{BeadLineageRecord, MetadataStore, NewBeadLineage, TouchedSymbol};
#[derive(Args)]
pub struct BeadArgs {
#[command(subcommand)]
command: BeadCommand,
}
#[derive(Subcommand)]
enum BeadCommand {
Link {
bead_id: String,
commit: Option<String>,
#[arg(long)]
files: Option<String>,
#[arg(long, name = "type")]
bead_type: Option<String>,
#[arg(long)]
bundles: Option<String>,
#[arg(long, default_value = "linked")]
action: String,
},
AutoLink {
#[arg(long, default_value = "HEAD")]
commit: String,
},
Similar {
title: String,
#[arg(long)]
description: Option<String>,
#[arg(long, default_value = "0.55")]
threshold: f32,
#[arg(long, default_value = "5")]
limit: usize,
#[arg(long)]
all_statuses: bool,
},
ReconstructCausality {
#[arg(long)]
bug: Option<String>,
#[arg(long, default_value = "200")]
limit: usize,
},
History {
bead_id: Option<String>,
#[arg(long)]
commit: Option<String>,
#[arg(long, short = 'n', default_value = "20")]
limit: usize,
},
}
#[derive(Serialize)]
struct LinkOutput {
id: i64,
bead_id: String,
commit_sha: Option<String>,
touched_files: Vec<String>,
}
#[derive(Serialize)]
struct HistoryEntry {
id: i64,
created_at: String,
bead_id: String,
bead_type: Option<String>,
commit_sha: Option<String>,
bundle_slugs: Option<String>,
touched_files: Vec<String>,
action_type: Option<String>,
feature_id: Option<String>,
lines_added: Option<i64>,
lines_deleted: Option<i64>,
touched_symbols: Vec<TouchedSymbol>,
}
impl From<BeadLineageRecord> for HistoryEntry {
fn from(r: BeadLineageRecord) -> Self {
HistoryEntry {
id: r.id,
created_at: r.created_at,
bead_id: r.bead_id,
bead_type: r.bead_type,
commit_sha: r.commit_sha,
bundle_slugs: r.bundle_slugs,
touched_files: r.touched_files,
action_type: r.action_type,
feature_id: r.feature_id,
lines_added: r.lines_added,
lines_deleted: r.lines_deleted,
touched_symbols: r.touched_symbols,
}
}
}
pub async fn run(args: BeadArgs, output: OutputConfig) -> Result<()> {
if let BeadCommand::Similar {
title,
description,
threshold,
limit,
all_statuses,
} = &args.command
{
return run_similar(
title,
description.as_deref(),
*threshold,
*limit,
!all_statuses,
&output,
)
.await;
}
let repo_root = super::find_bobbin_root()
.ok_or_else(|| anyhow!("Not inside a bobbin repository (run `bobbin init` first)"))?;
let store = MetadataStore::open(&Config::db_path(&repo_root))
.context("Failed to open metadata store")?;
match args.command {
BeadCommand::Link {
bead_id,
commit,
files,
bead_type,
bundles,
action,
} => {
let (touched_files, lines_added, lines_deleted): (
Vec<String>,
Option<i64>,
Option<i64>,
) = if let Some(f) = files {
let parsed = f
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(parsed, None, None)
} else if let Some(ref sha) = commit {
match commit_numstat(&repo_root, sha) {
Ok((files, added, deleted)) => (files, Some(added), Some(deleted)),
Err(_) => (Vec::new(), None, None),
}
} else {
(Vec::new(), None, None)
};
let bundle_slugs = bundles.or_else(|| bundle_slugs_from_labels(&bead_id));
let feature_id = resolve_feature_id(&bead_id);
let touched_symbols = match commit.as_ref() {
Some(sha) => extract_touched_symbols(&repo_root, sha, &touched_files),
None => Vec::new(),
};
let id = store.record_bead_lineage(&NewBeadLineage {
bead_id: bead_id.clone(),
bead_type,
commit_sha: commit.clone(),
bundle_slugs,
touched_files: touched_files.clone(),
action_type: Some(action),
feature_id,
lines_added,
lines_deleted,
touched_symbols,
})?;
if output.json {
let out = LinkOutput {
id,
bead_id,
commit_sha: commit,
touched_files,
};
println!("{}", serde_json::to_string_pretty(&out)?);
} else if !output.quiet {
println!(
"{} Linked {} {} ({} file{})",
"✓".green(),
bead_id.cyan(),
commit
.as_deref()
.map(|c| format!("→ {}", &c[..c.len().min(8)]))
.unwrap_or_default(),
touched_files.len(),
if touched_files.len() == 1 { "" } else { "s" },
);
}
}
BeadCommand::AutoLink { commit } => {
run_auto_link(&repo_root, &store, &commit, &output)?;
}
BeadCommand::Similar { .. } => unreachable!("dispatched before store open"),
BeadCommand::ReconstructCausality { bug, limit } => {
run_reconstruct_causality(&repo_root, &store, bug.as_deref(), limit, &output)?;
}
BeadCommand::History {
bead_id,
commit,
limit,
} => {
let records = store.list_bead_lineage(bead_id.as_deref(), commit.as_deref(), limit)?;
if output.json {
let entries: Vec<HistoryEntry> =
records.into_iter().map(HistoryEntry::from).collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if !output.quiet {
if records.is_empty() {
println!("{}", "No bead lineage recorded yet.".dimmed());
} else {
for r in &records {
let sha = r
.commit_sha
.as_deref()
.map(|c| &c[..c.len().min(8)])
.unwrap_or("-");
println!(
"{} {} {} {} file(s) {}",
r.created_at.dimmed(),
r.bead_id.cyan(),
sha.yellow(),
r.touched_files.len(),
r.action_type.as_deref().unwrap_or("").dimmed(),
);
}
}
}
}
}
Ok(())
}
mod autolink;
mod causality;
mod helpers;
mod similar;
use causality::commit_numstat;
use helpers::*;
use autolink::run_auto_link;
use causality::run_reconstruct_causality;
use similar::run_similar;
#[cfg(test)]
mod tests;