use std::io::{self, BufRead, Write};
use clap::{Args, Subcommand};
use rusqlite::params;
use tga::core::config::Config;
use tga::core::db::Database;
#[derive(Args, Debug)]
pub struct AliasesArgs {
#[command(subcommand)]
pub subcommand: AliasesSubcommand,
}
#[derive(Subcommand, Debug)]
pub enum AliasesSubcommand {
List,
Merge {
src: String,
dst: String,
#[arg(long, default_value_t = false)]
yes: bool,
},
}
pub fn run(_config: Config, db: &mut Database, args: AliasesArgs) -> anyhow::Result<()> {
match args.subcommand {
AliasesSubcommand::List => list(db),
AliasesSubcommand::Merge { src, dst, yes } => {
merge(db, &src, &dst, yes, &mut io::stdin().lock())
}
}
}
fn list(db: &Database) -> anyhow::Result<()> {
let conn = db.connection();
let mut stmt = conn.prepare(
"SELECT canonical_name, canonical_email, aliases FROM authors \
ORDER BY canonical_email",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
let mut count = 0usize;
println!(
"{:<32} {:<48} aliases",
"canonical_name", "canonical_email"
);
println!("{}", "-".repeat(96));
for r in rows {
let (name, email, aliases) = r?;
let parsed: Vec<String> = serde_json::from_str(&aliases).unwrap_or_default();
let alias_str = if parsed.is_empty() {
"-".to_string()
} else {
parsed.join(", ")
};
println!("{:<32} {:<48} {}", name, email, alias_str);
count += 1;
}
if count == 0 {
println!("(no authors found — run `tga collect` first)");
}
Ok(())
}
fn merge<R: BufRead>(
db: &mut Database,
src_email: &str,
dst_email: &str,
skip_confirm: bool,
reader: &mut R,
) -> anyhow::Result<()> {
if src_email == dst_email {
anyhow::bail!("source and destination emails are identical: {src_email}");
}
let (src_id, src_name, src_aliases_json) = lookup_author(db, src_email)?
.ok_or_else(|| anyhow::anyhow!("source identity not found: {src_email}"))?;
let (dst_id, _dst_name, dst_aliases_json) = lookup_author(db, dst_email)?
.ok_or_else(|| anyhow::anyhow!("destination identity not found: {dst_email}"))?;
if !skip_confirm {
print!(
"Merge {src_name} <{src_email}> (id={src_id}) into <{dst_email}> (id={dst_id})? [y/N] "
);
io::stdout().flush()?;
let mut line = String::new();
reader.read_line(&mut line)?;
if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Aborted.");
return Ok(());
}
}
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_commits = 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()?;
println!(
"Merged {src_email} → {dst_email} (reassigned {n_commits} commits, source author row deleted)"
);
Ok(())
}
fn lookup_author(db: &Database, email: &str) -> anyhow::Result<Option<(i64, String, String)>> {
let conn = db.connection();
let mut stmt =
conn.prepare("SELECT id, canonical_name, aliases FROM authors WHERE canonical_email = ?1")?;
let mut rows = stmt.query(params![email])?;
if let Some(row) = rows.next()? {
Ok(Some((row.get(0)?, row.get(1)?, row.get(2)?)))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn insert_author(db: &Database, name: &str, email: &str) -> i64 {
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email, aliases) VALUES (?1, ?2, '[]')",
params![name, email],
)
.expect("insert");
db.connection().last_insert_rowid()
}
fn insert_commit(db: &Database, sha: &str, author_id: i64) {
db.connection()
.execute(
"INSERT INTO commits (sha, author_id, author_name, author_email, timestamp, \
message, repository) VALUES (?1, ?2, 'n', 'e', '2024-01-01T00:00:00Z', 'm', 'r')",
params![sha, author_id],
)
.expect("insert commit");
}
#[test]
fn merge_moves_commits_and_deletes_source() {
let mut db = Database::open_in_memory().expect("open");
let src = insert_author(&db, "Alice", "old@example.com");
let dst = insert_author(&db, "Alice", "new@example.com");
insert_commit(&db, "sha1", src);
insert_commit(&db, "sha2", src);
insert_commit(&db, "sha3", dst);
let mut input: &[u8] = b"y\n";
merge(
&mut db,
"old@example.com",
"new@example.com",
false,
&mut input,
)
.expect("merge ok");
let src_exists: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_email = 'old@example.com'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(src_exists, 0);
let n: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM commits WHERE author_id = ?1",
params![dst],
|r| r.get(0),
)
.expect("count");
assert_eq!(n, 3);
}
#[test]
fn merge_rejects_identical_emails() {
let mut db = Database::open_in_memory().expect("open");
insert_author(&db, "A", "a@example.com");
let mut input: &[u8] = b"y\n";
let err = merge(&mut db, "a@example.com", "a@example.com", true, &mut input).unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[test]
fn merge_errors_when_source_missing() {
let mut db = Database::open_in_memory().expect("open");
insert_author(&db, "B", "b@example.com");
let mut input: &[u8] = b"y\n";
let err = merge(
&mut db,
"missing@example.com",
"b@example.com",
true,
&mut input,
)
.unwrap_err();
assert!(err.to_string().contains("source identity not found"));
}
}