use rusqlite::Connection;
use tracing::debug;
use crate::core::errors::{Result, TgaError};
use super::column_names;
pub(super) fn apply(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE classifications ADD COLUMN top_level_category TEXT;\n\
CREATE INDEX IF NOT EXISTS idx_classifications_top_level ON classifications(top_level_category);",
)
.map_err(|e| {
TgaError::MigrationError(format!(
"migration 17 (pushdown_445) classifications step failed: {e}"
))
})?;
let fce_cols = column_names(conn, "fact_commit_effort")?;
if !fce_cols.iter().any(|c| c == "effort_tshirt") {
conn.execute_batch("ALTER TABLE fact_commit_effort ADD COLUMN effort_tshirt INTEGER;")
.map_err(|e| {
TgaError::MigrationError(format!(
"migration 17 (pushdown_445) effort_tshirt ALTER failed: {e}"
))
})?;
} else {
debug!(
"migration v17: effort_tshirt already present in fact_commit_effort \
(pre-release v16 build detected), skipping ADD COLUMN"
);
}
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_fact_commit_effort_tshirt ON fact_commit_effort(effort_tshirt);",
)
.map_err(|e| {
TgaError::MigrationError(format!(
"migration 17 (pushdown_445) effort_tshirt index failed: {e}"
))
})?;
conn.execute_batch(
"ALTER TABLE commits ADD COLUMN is_ai_assisted INTEGER NOT NULL DEFAULT 0;\n\
ALTER TABLE commits ADD COLUMN ai_tool TEXT;\n\
CREATE INDEX IF NOT EXISTS idx_commits_is_ai_assisted ON commits(is_ai_assisted);",
)
.map_err(|e| {
TgaError::MigrationError(format!(
"migration 17 (pushdown_445) commits step failed: {e}"
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::core::db::Database;
use rusqlite::params;
#[test]
fn migration_v17_is_idempotent_when_effort_tshirt_already_exists() {
use rusqlite::Connection;
let mut conn = Connection::open_in_memory().expect("open raw connection");
super::super::ensure_migrations_table(&conn).expect("ensure table");
for m in super::super::MIGRATIONS {
if m.version > 16 {
break;
}
let tx = conn.transaction().expect("begin tx");
tx.execute_batch(m.sql)
.unwrap_or_else(|e| panic!("migration {} failed: {e}", m.version));
tx.execute(
"INSERT INTO schema_migrations(version, name, applied_at) VALUES (?1, ?2, ?3)",
rusqlite::params![m.version, m.name, "2026-01-01T00:00:00Z"],
)
.expect("record migration");
tx.commit().expect("commit");
}
conn.execute_batch("ALTER TABLE fact_commit_effort ADD COLUMN effort_tshirt SMALLINT;")
.expect("pre-release ALTER TABLE (simulating old dev build)");
super::super::run(&mut conn).expect(
"migration v17 must succeed even when effort_tshirt already exists \
(ADD COLUMN IF NOT EXISTS guard must fire)",
);
conn.execute(
"INSERT INTO fact_commit_effort \
(sha, repository, size, score, loc, files, test_loc, tests_factor, \
formula_version, computed_at, effort_tshirt) \
VALUES ('sha_idem', 'repo', 'S', 5.0, 10, 1, 0, 1.0, 'v1', 1000000, 2)",
[],
)
.expect("insert with effort_tshirt must succeed post-migration");
let tshirt: Option<i64> = conn
.query_row(
"SELECT effort_tshirt FROM fact_commit_effort WHERE sha = 'sha_idem'",
[],
|r| r.get(0),
)
.expect("read effort_tshirt");
assert_eq!(
tshirt,
Some(2),
"effort_tshirt must be readable after idempotent migration"
);
let version: i64 = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |r| {
r.get(0)
})
.expect("read version");
assert!(
version >= 17,
"schema_migrations must record at least v17 after run(), got {version}"
);
}
#[test]
fn migration_v17_adds_pushdown_columns() {
let db = Database::open_in_memory().expect("open db");
let conn = db.connection();
conn.execute(
"INSERT INTO commits \
(sha, author_name, author_email, timestamp, message, repository, \
is_ai_assisted, ai_tool) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
"sha_v17_test",
"Alice",
"alice@example.com",
"2026-01-01T00:00:00Z",
"feat: AI-assisted commit\n\nCo-Authored-By: Claude Opus <noreply@anthropic.com>",
"testrepo",
1_i64,
"claude",
],
)
.expect("insert AI-assisted commit");
let (ai, tool): (i64, Option<String>) = conn
.query_row(
"SELECT is_ai_assisted, ai_tool FROM commits WHERE sha = 'sha_v17_test'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("read back");
assert_eq!(ai, 1, "is_ai_assisted must be 1");
assert_eq!(tool, Some("claude".to_string()), "ai_tool must be 'claude'");
conn.execute(
"INSERT INTO fact_commit_effort \
(sha, repository, size, score, loc, files, test_loc, tests_factor, \
formula_version, computed_at, effort_tshirt) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
"sha_v17_test",
"testrepo",
"M",
9.5,
50_i64,
2_i64,
0_i64,
1.0_f64,
"v1",
1_000_000_i64,
3_i64, ],
)
.expect("insert effort with tshirt");
let tshirt: i64 = conn
.query_row(
"SELECT effort_tshirt FROM fact_commit_effort WHERE sha = 'sha_v17_test'",
[],
|r| r.get(0),
)
.expect("read effort_tshirt");
assert_eq!(tshirt, 3, "effort_tshirt M must be 3");
conn.execute(
"INSERT INTO classifications \
(category, subcategory, confidence, method, top_level_category) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params!["feature", "feature", 0.95_f64, "exact_rule", "feature"],
)
.expect("insert classification with top_level_category");
let top: Option<String> = conn
.query_row(
"SELECT top_level_category FROM classifications WHERE category = 'feature' \
ORDER BY id DESC LIMIT 1",
[],
|r| r.get(0),
)
.expect("read top_level_category");
assert_eq!(
top,
Some("feature".to_string()),
"top_level_category must be 'feature'"
);
}
}