use rag_rat_core::Config;
use crate::cli::{ClonesArgs, ClonesForArgs};
use crate::open_index;
use crate::render::print_output;
pub(crate) fn clones(config: &Config, args: &ClonesArgs) -> anyhow::Result<()> {
if args.precompute {
let _lock = rag_rat_core::locks::WriteLock::acquire_blocking(&config.database)?;
let db = open_index(config)?;
let report: rag_rat_core::index::CloneEdgeReport =
db.precompute_clone_graph(args.max_seconds)?;
return print_output(&report);
}
let db = open_index(config)?;
if args.recall_symbols {
for r in db.clone_symbol_refs(args.min_similarity, args.min_copies)? {
println!("{r}");
}
return Ok(());
}
let result = db.find_clones(rag_rat_core::index::FindClonesOptions {
min_similarity: args.min_similarity,
min_copies: args.min_copies,
limit: if args.recall_signature { None } else { args.limit },
})?;
if args.recall_signature {
print!("{}", recall_signature(&result));
return Ok(());
}
if let Some(key) = &args.explain {
let Some(class) = result.classes.iter().find(|c| &c.class_key == key) else {
anyhow::bail!("no clone class with key `{key}` in results");
};
print_clone_explain(class);
return Ok(());
}
print_output(&result)
}
fn recall_signature(result: &rag_rat_core::index::FindClonesResult) -> String {
let mut lines: Vec<String> = result
.classes
.iter()
.map(|c| {
let mut refs: Vec<&str> = c.members.iter().map(|m| m.r#ref.as_str()).collect();
refs.sort_unstable();
format!("{}\t{}", c.member_count, refs.join(","))
})
.collect();
lines.sort_unstable();
let total_members: usize = result.classes.iter().map(|c| c.member_count).sum();
let mut out = format!(
"# clone recall signature — {} classes, {total_members} clone members\n",
result.classes.len(),
);
for line in &lines {
out.push_str(line);
out.push('\n');
}
out
}
fn print_clone_explain(class: &rag_rat_core::index::CandidateCloneClass) {
println!("Clone class: {}", class.class_key);
println!(
" {} members, confidence: {}, coverage: {:.2}",
class.member_count,
class.confidence.as_deref().unwrap_or("n/a"),
class.anti_unify_coverage.unwrap_or(0.0),
);
println!();
if let Some(template) = &class.template {
println!("Template:");
println!("{template}");
println!();
}
if let Some(arr) = class.variation_points.as_ref().and_then(|v| v.as_array())
&& !arr.is_empty()
{
let canon_refs = class.canonical_member_refs.as_deref();
println!("Variation points ({}):", arr.len());
for vp in arr {
let id = vp["metavar_id"].as_str().unwrap_or("?");
let role = vp["extraction_role"].as_str().unwrap_or("?");
let conf = vp["confidence"].as_str().unwrap_or("?");
print!(" {id} ({role}, {conf})");
if let Some(vals) = vp["per_member_values"].as_array() {
let rendered: Vec<String> = match canon_refs {
Some(refs) if refs.len() == vals.len() => vals
.iter()
.zip(refs.iter())
.map(|(v, r)| {
let val = v.as_str().unwrap_or("");
let shown = if val.is_empty() { "<gap>" } else { val };
format!("{r}={shown}")
})
.collect(),
_ => vals.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect(),
};
print!(": {}", rendered.join(" | "));
}
println!();
}
println!();
}
if let Some(sig) = &class.proposed_signature {
let typedness = sig["typedness"].as_str().unwrap_or("unknown");
println!("Proposed signature (typedness: {typedness}):");
if let Some(text) = sig["text"].as_str() {
println!(" {text}");
} else if let Some(params) = sig["params"].as_array() {
let param_strs: Vec<String> = params
.iter()
.map(|p| {
let name = p["name"].as_str().unwrap_or("_");
match p["type_text"].as_str() {
Some(t) => format!("{name}: {t}"),
None => name.to_string(),
}
})
.collect();
println!(" fn extracted({}) {{ ... }}", param_strs.join(", "));
}
}
}
pub(crate) fn clones_for(config: &Config, args: &ClonesForArgs) -> anyhow::Result<()> {
use rag_rat_core::index::CloneSymbolSelector;
let db = open_index(config)?;
let selector = match (&args.symbol, &args.path, &args.line) {
(Some(sym), None, None) =>
if !sym.contains("::") && rag_rat_core::serde_big_id::parse_sym_handle(sym).is_some() {
CloneSymbolSelector::Id(sym.clone())
} else {
CloneSymbolSelector::Ref(sym.clone())
},
(None, Some(path), Some(line)) =>
CloneSymbolSelector::PathLine { path: path.clone(), line: *line },
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
anyhow::bail!(
"clones-for: SYMBOL and --path/--line are mutually exclusive — use one or the \
other"
);
},
(None, Some(_), None) | (None, None, Some(_)) => {
anyhow::bail!("clones-for: --path and --line must be used together");
},
(None, None, None) => {
anyhow::bail!("clones-for: requires a SYMBOL argument or --path <PATH> --line <N>");
},
};
let result = db.clones_for_symbol(selector)?;
print_output(&result)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use rag_rat_core::config::{ResolvedTarget, TargetKind};
use rag_rat_core::language::Language;
use rag_rat_core::{Config, IndexDatabase};
use crate::cli::ClonesArgs;
static N: AtomicU64 = AtomicU64::new(0);
#[test]
fn clones_for_sym_prefixed_ref_routes_to_ref_not_id() {
use rag_rat_core::index::CloneSymbolSelector;
use rag_rat_core::serde_big_id::parse_sym_handle;
fn classify(sym: &str) -> &'static str {
if !sym.contains("::") && parse_sym_handle(sym).is_some() { "Id" } else { "Ref" }
}
let valid_handle = rag_rat_core::serde_big_id::format_sym_handle(42i64);
assert_eq!(classify(&valid_handle), "Id", "a valid sym_<hex> handle must route to Id");
assert_eq!(classify("sym_utils.rs::load_user"), "Ref");
assert_eq!(classify("sym_something::fn_name"), "Ref");
assert_eq!(classify("src/foo.rs::my_fn"), "Ref");
let _ = CloneSymbolSelector::Ref("sym_utils.rs::load_user".to_string());
}
#[test]
fn clones_handler_returns_class_for_planted_pair() {
let root = std::env::temp_dir().join(format!(
"rag-rat-cli-clones-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
let clone_body =
"pub fn cloned_helper(x: i32, y: i32) -> i32 {\n x + y + 42\n}\n".to_string();
std::fs::write(root.join("src/lib.rs"), format!("{clone_body}pub mod a;\npub mod b;\n"))
.unwrap();
std::fs::write(root.join("src/a.rs"), &clone_body).unwrap();
std::fs::write(root.join("src/b.rs"), &clone_body).unwrap();
let config = Config {
root: root.clone(),
database: root.join(".rag-rat/index.sqlite"),
targets: vec![ResolvedTarget {
name: "rust".to_string(),
language: Language::Rust,
directories: vec![PathBuf::from("src")],
include: vec!["src/".to_string()],
exclude: Vec::new(),
kind: TargetKind::Source,
}],
llm: Default::default(),
watch: Default::default(),
version_check: Default::default(),
oracle: Default::default(),
search: Default::default(),
log: Default::default(),
};
IndexDatabase::rebuild(&config).unwrap();
let args = ClonesArgs {
min_similarity: None,
min_copies: Some(2),
limit: None,
explain: None,
recall_signature: false,
recall_symbols: false,
precompute: false,
max_seconds: None,
};
super::clones(&config, &args).unwrap_or_else(|err| panic!("clones handler failed: {err}"));
let db = IndexDatabase::open_config(&config).unwrap();
let result = db
.find_clones(rag_rat_core::index::FindClonesOptions {
min_similarity: None,
min_copies: Some(2),
limit: None,
})
.unwrap();
assert!(
result.classes.iter().any(|c| c.member_count >= 2),
"expected at least one clone class with >=2 members for the planted pair: {:?}",
result.classes
);
let sig = super::recall_signature(&result);
assert!(sig.starts_with("# clone recall signature —"), "signature header missing:\n{sig}");
let clone_line = sig
.lines()
.find(|l| l.starts_with("3\t"))
.unwrap_or_else(|| panic!("no 3-member class line in signature:\n{sig}"));
for member in
["src/lib.rs::cloned_helper", "src/a.rs::cloned_helper", "src/b.rs::cloned_helper"]
{
assert!(clone_line.contains(member), "signature line missing {member}: {clone_line}");
}
assert!(
clone_line.find("src/a.rs") < clone_line.find("src/b.rs"),
"member refs must be sorted within a class line: {clone_line}"
);
let syms = db.clone_symbol_refs(None, Some(2)).unwrap();
for member in
["src/a.rs::cloned_helper", "src/b.rs::cloned_helper", "src/lib.rs::cloned_helper"]
{
assert!(
syms.iter().any(|s| s == member),
"clone_symbol_refs missing {member}: {syms:?}"
);
}
assert!(syms.windows(2).all(|w| w[0] < w[1]), "clone_symbol_refs must be sorted+unique");
let _ = std::fs::remove_dir_all(&root);
}
}