use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use std::path::{Path, PathBuf};
use super::OutputConfig;
use crate::access::RepoFilter;
use crate::config::Config;
use crate::storage::{MetadataStore, VectorStore};
#[derive(Args)]
pub struct RelatedArgs {
file: PathBuf,
#[arg(long, short = 'n', default_value = "10")]
limit: usize,
#[arg(long, default_value = "0.0")]
threshold: f32,
}
#[derive(Serialize)]
struct RelatedOutput {
file: String,
related: Vec<RelatedFile>,
}
#[derive(Serialize)]
struct RelatedFile {
path: String,
score: f32,
co_changes: u32,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
}
pub async fn run(args: RelatedArgs, output: OutputConfig) -> Result<()> {
let file_path = args
.file
.canonicalize()
.with_context(|| format!("File not found: {}", args.file.display()))?;
let repo_root = find_repo_root(&file_path)?;
let db_path = Config::db_path(&repo_root);
let lance_path = Config::lance_path(&repo_root);
let vector_store = VectorStore::open(&lance_path)
.await
.context("Failed to open vector store")?;
let store = MetadataStore::open(&db_path).context("Failed to open metadata store")?;
let rel_path = file_path
.strip_prefix(&repo_root)
.context("File is not inside the repository")?
.to_string_lossy()
.to_string();
if vector_store.get_file(&rel_path).await?.is_none() {
if output.json {
println!(
"{}",
serde_json::to_string_pretty(&RelatedOutput {
file: rel_path,
related: vec![],
})?
);
} else {
bail!("File not found in index: {}", rel_path);
}
return Ok(());
}
let couplings = store.get_coupling(&rel_path, args.limit)?;
let mut related: Vec<RelatedFile> = couplings
.into_iter()
.filter(|c| c.score >= args.threshold)
.map(|c| {
let other_path = if c.file_a == rel_path {
c.file_b
} else {
c.file_a
};
RelatedFile {
path: other_path,
score: c.score,
co_changes: c.co_changes,
repo: None,
}
})
.collect();
let config = Config::load(&Config::config_path(&repo_root)).unwrap_or_default();
let filter = RepoFilter::from_config(&config.access, &output.role);
let seed_repo = single_repo_name(&store);
let cross = crate::index::cross_repo::related_cross_repo(
&store,
seed_repo.as_deref(),
&rel_path,
args.limit,
args.threshold,
&filter,
)?;
related.extend(cross.into_iter().map(|c| RelatedFile {
path: c.path,
score: c.score,
co_changes: c.co_changes,
repo: Some(c.repo),
}));
related.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
related.truncate(args.limit);
if output.json {
let json_output = RelatedOutput {
file: rel_path,
related,
};
println!("{}", serde_json::to_string_pretty(&json_output)?);
} else {
println!("Related to {}:", rel_path.cyan());
if related.is_empty() {
println!(" No related files found (no shared commit history)");
} else {
for (i, file) in related.into_iter().enumerate() {
let path_label = match &file.repo {
Some(repo) => format!("{} [{}]", file.path, repo.magenta()),
None => file.path.clone(),
};
println!(
"{}. {} (score: {:.2}) - Co-changed {} times",
i + 1,
path_label,
file.score,
file.co_changes
);
}
}
}
Ok(())
}
fn single_repo_name(store: &MetadataStore) -> Option<String> {
let entries = store.get_meta_by_prefix("repo_source:").ok()?;
if entries.len() == 1 {
entries[0]
.0
.strip_prefix("repo_source:")
.map(|s| s.to_string())
} else {
None
}
}
fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
let mut current = start_path;
if current.is_file() {
if let Some(p) = current.parent() {
current = p;
}
}
loop {
if Config::config_path(current).exists() {
return Ok(current.to_path_buf());
}
match current.parent() {
Some(p) => current = p,
None => break,
}
}
bail!("Bobbin not initialized. Run `bobbin init` first.")
}