use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use std::path::PathBuf;
use super::OutputConfig;
use crate::access::RepoFilter;
use crate::analysis::backend::{IndexBackend, StructuralBackend};
use crate::analysis::impact::{ImpactConfig, ImpactMode, ImpactSignal};
use crate::config::Config;
use crate::index::Embedder;
use crate::storage::{MetadataStore, VectorStore};
#[derive(Args)]
pub struct ImpactArgs {
target: String,
#[arg(long, default_value = ".")]
path: PathBuf,
#[arg(long, short = 'd', default_value = "1")]
depth: u32,
#[arg(long, short = 'm', default_value = "combined")]
mode: String,
#[arg(long, short = 'n', default_value = "15")]
limit: usize,
#[arg(long, short = 't', default_value = "0.1")]
threshold: f32,
#[arg(long, short = 'r')]
repo: Option<String>,
}
#[derive(Serialize)]
struct ImpactOutput {
target: String,
mode: String,
depth: u32,
count: usize,
results: Vec<ImpactEntry>,
}
#[derive(Serialize)]
struct ImpactEntry {
file: String,
signal: String,
score: f32,
reason: String,
}
fn parse_mode(s: &str) -> Result<ImpactMode> {
match s.to_lowercase().as_str() {
"combined" => Ok(ImpactMode::Combined),
"coupling" => Ok(ImpactMode::Coupling),
"semantic" => Ok(ImpactMode::Semantic),
"deps" => Ok(ImpactMode::Deps),
_ => bail!(
"Unknown mode '{}'. Use: combined, coupling, semantic, deps",
s
),
}
}
fn signal_name(signal: &ImpactSignal) -> &'static str {
match signal {
ImpactSignal::Coupling { .. } => "coupling",
ImpactSignal::Semantic { .. } => "semantic",
ImpactSignal::Dependency => "deps",
ImpactSignal::Combined => "combined",
}
}
pub async fn run(args: ImpactArgs, 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)?;
let mode = parse_mode(&args.mode)?;
let impact_config = ImpactConfig {
mode,
threshold: args.threshold,
limit: args.limit,
};
let mut metadata_store = MetadataStore::open(&Config::db_path(&repo_root))
.context("Failed to open metadata store")?;
let mut vector_store = VectorStore::open(&Config::lance_path(&repo_root))
.await
.context("Failed to open vector store")?;
let model_dir = Config::model_cache_dir()?;
let mut embedder = Embedder::from_config(&config.embedding, &model_dir)?;
let mut backend =
IndexBackend::with_impact(&mut vector_store, &mut metadata_store, &mut embedder);
let results = backend
.impact(
&args.target,
&impact_config,
args.depth,
args.repo.as_deref(),
)
.await?;
let access_filter = RepoFilter::from_config(&config.access, &output.role);
let results: Vec<_> = results
.into_iter()
.filter(|r| access_filter.is_allowed(RepoFilter::repo_from_path(&r.path)))
.collect();
if output.json {
let entries: Vec<ImpactEntry> = results
.iter()
.map(|r| ImpactEntry {
file: r.path.clone(),
signal: signal_name(&r.signal).to_string(),
score: r.score,
reason: r.reason.clone(),
})
.collect();
let json_output = ImpactOutput {
target: args.target,
mode: args.mode,
depth: args.depth,
count: entries.len(),
results: entries,
};
println!("{}", serde_json::to_string_pretty(&json_output)?);
} else if !output.quiet {
if results.is_empty() {
println!(
"{} No impact results for \"{}\" above threshold {:.2}.",
"!".yellow(),
args.target,
args.threshold,
);
return Ok(());
}
println!("Impact analysis for {}:\n", args.target.cyan(),);
println!(
" {:<4} {:<40} {:<10} {:<6} {}",
"#".bold(),
"File".bold(),
"Signal".bold(),
"Score".bold(),
"Reason".bold(),
);
for (i, r) in results.iter().enumerate() {
let sig = signal_name(&r.signal);
println!(
" {:<4} {:<40} {:<10} {:<6.3} {}",
format!("{}.", i + 1),
r.path.cyan(),
sig,
r.score,
r.reason.dimmed(),
);
}
if output.verbose {
println!("\n{}", "Legend:".bold());
println!(" score = impact likelihood (0.0-1.0)");
println!(" combined = max(coupling, semantic, deps)");
println!(" depth {} transitive expansion", args.depth);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_mode() {
assert_eq!(parse_mode("combined").unwrap(), ImpactMode::Combined);
assert_eq!(parse_mode("coupling").unwrap(), ImpactMode::Coupling);
assert_eq!(parse_mode("semantic").unwrap(), ImpactMode::Semantic);
assert_eq!(parse_mode("deps").unwrap(), ImpactMode::Deps);
assert_eq!(parse_mode("COMBINED").unwrap(), ImpactMode::Combined);
assert!(parse_mode("invalid").is_err());
}
#[test]
fn test_signal_name() {
assert_eq!(
signal_name(&ImpactSignal::Coupling { co_changes: 5 }),
"coupling"
);
assert_eq!(
signal_name(&ImpactSignal::Semantic { similarity: 0.9 }),
"semantic"
);
assert_eq!(signal_name(&ImpactSignal::Dependency), "deps");
assert_eq!(signal_name(&ImpactSignal::Combined), "combined");
}
#[test]
fn test_impact_output_serialization() {
let output = ImpactOutput {
target: "src/auth.rs".to_string(),
mode: "combined".to_string(),
depth: 1,
count: 1,
results: vec![ImpactEntry {
file: "src/session.rs".to_string(),
signal: "coupling".to_string(),
score: 0.82,
reason: "Co-changed 47 times".to_string(),
}],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("src/auth.rs"));
assert!(json.contains("src/session.rs"));
assert!(json.contains("0.82"));
}
}