use std::io::{self, Write};
use std::path::Path;
use rusqlite::params;
use tga::collect::identity::resolver::configured_canonical_domain;
use tga::collect::identity::suggest::{detect_all, Suggestion, HIGH_CONFIDENCE_CUTOFF};
use tga::core::config::Config;
use tga::core::db::Database;
const NEAR_MISS_FLOOR: f64 = 0.50;
const REVIEW_HEADER: &str = "src\tdst\treason\tconfidence\tconfirmed";
pub(super) fn run(
config: &Config,
db: &mut Database,
confidence_floor: f64,
auto_accept: bool,
review_file: Option<&Path>,
) -> anyhow::Result<()> {
let mut out = io::stdout().lock();
run_to(
config,
db,
confidence_floor,
auto_accept,
review_file,
&mut out,
)
}
fn run_to<W: Write>(
config: &Config,
db: &mut Database,
confidence_floor: f64,
auto_accept: bool,
review_file: Option<&Path>,
out: &mut W,
) -> anyhow::Result<()> {
let canonical_domain = configured_canonical_domain(config);
let detect_floor = match review_file {
Some(_) => NEAR_MISS_FLOOR.min(confidence_floor),
None => confidence_floor,
};
let (suggestions, near_misses): (Vec<Suggestion>, Vec<Suggestion>) =
detect_all(db.connection(), canonical_domain.as_deref(), detect_floor)?
.into_iter()
.partition(|s| s.confidence >= confidence_floor);
if let Some(path) = review_file {
write_review_file(path, &near_misses)?;
writeln!(
out,
"Wrote {count} near-miss candidate(s) (confidence {NEAR_MISS_FLOOR:.2} up to \
{confidence_floor:.2}) to {path} — set `confirmed` to `yes` on a row to accept it.",
count = near_misses.len(),
path = path.display()
)?;
}
if suggestions.is_empty() {
writeln!(
out,
"No alias suggestions found above confidence {confidence_floor:.2}. \
(Try `--confidence 0.5` for a wider net.)"
)?;
return Ok(());
}
writeln!(
out,
"Suggested aliases (confidence ≥ {confidence_floor:.2}):"
)?;
for s in &suggestions {
let label = if s.confidence >= HIGH_CONFIDENCE_CUTOFF {
"HIGH"
} else {
"MED "
};
writeln!(
out,
" {label} {src} → {dst} [{reason}]",
src = s.src,
dst = s.dst,
reason = s.reason
)?;
}
writeln!(out)?;
if auto_accept {
let mut accepted = 0usize;
for s in &suggestions {
if s.confidence < HIGH_CONFIDENCE_CUTOFF {
continue;
}
let still_exists = super::lookup_author(db, &s.src)?.is_some()
&& super::lookup_author(db, &s.dst)?.is_some();
if !still_exists {
continue;
}
match apply_merge(db, &s.src, &s.dst) {
Ok(n) => {
accepted += 1;
writeln!(
out,
"Merged {} → {} ({} commits reassigned)",
s.src, s.dst, n
)?;
}
Err(e) => {
eprintln!("WARN: skip merge {} → {}: {e}", s.src, s.dst);
}
}
}
writeln!(out, "Auto-accepted {accepted} HIGH-confidence merge(s).")?;
} else {
writeln!(
out,
"Run `tga aliases merge <source> <dest>` to accept individual pairs, \
or `tga aliases suggest --auto-accept --confidence {HIGH_CONFIDENCE_CUTOFF:.2}` \
to accept all HIGH-confidence pairs at once."
)?;
}
Ok(())
}
fn write_review_file(path: &Path, near_misses: &[Suggestion]) -> anyhow::Result<()> {
let mut body = String::from(REVIEW_HEADER);
body.push('\n');
for s in near_misses {
body.push_str(&format!(
"{src}\t{dst}\t{reason}\t{confidence:.2}\tno\n",
src = tsv_field(&s.src),
dst = tsv_field(&s.dst),
reason = tsv_field(&s.reason),
confidence = s.confidence
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, body)?;
Ok(())
}
fn tsv_field(value: &str) -> String {
value.replace(['\t', '\n', '\r'], " ")
}
fn apply_merge(db: &mut Database, src_email: &str, dst_email: &str) -> anyhow::Result<usize> {
let (src_id, _, src_aliases_json) = super::lookup_author(db, src_email)?
.ok_or_else(|| anyhow::anyhow!("source identity not found: {src_email}"))?;
let (dst_id, _, dst_aliases_json) = super::lookup_author(db, dst_email)?
.ok_or_else(|| anyhow::anyhow!("destination identity not found: {dst_email}"))?;
let mut src_aliases: Vec<String> = serde_json::from_str(&src_aliases_json).unwrap_or_default();
let mut dst_aliases: Vec<String> = serde_json::from_str(&dst_aliases_json).unwrap_or_default();
dst_aliases.append(&mut src_aliases);
dst_aliases.push(src_email.to_string());
dst_aliases.sort();
dst_aliases.dedup();
let merged_aliases = serde_json::to_string(&dst_aliases)?;
let conn = db.connection_mut();
let tx = conn.transaction()?;
let n = tx.execute(
"UPDATE commits SET author_id = ?1 WHERE author_id = ?2",
params![dst_id, src_id],
)?;
tx.execute(
"UPDATE authors SET aliases = ?1 WHERE id = ?2",
params![merged_aliases, dst_id],
)?;
tx.execute("DELETE FROM authors WHERE id = ?1", params![src_id])?;
tx.commit()?;
Ok(n)
}
#[cfg(test)]
#[path = "suggest_tests.rs"]
mod tests;