use super::FactsDb;
use crate::{CodeLoreError, Result};
pub fn materialize_path_lineage(db: &FactsDb) -> Result<()> {
use duckdb::params;
let sql = "CREATE OR REPLACE TEMPORARY TABLE path_lineage AS
WITH RECURSIVE lineage(
orig, current, current_date, current_rowid,
retired_date, retired_rowid, depth
) AS (
SELECT DISTINCT c.rename_from, c.path, co.date, co.rowid,
co.date, co.rowid, 1
FROM changes c
INNER JOIN commits co ON co.rev = c.rev
WHERE c.rename_from IS NOT NULL AND c.change_type = 'renamed'
UNION ALL
SELECT l.orig, c.path, co.date, co.rowid,
l.retired_date, l.retired_rowid, l.depth + 1
FROM lineage l
INNER JOIN changes c
ON c.rename_from = l.current
AND c.change_type = 'renamed'
INNER JOIN commits co ON co.rev = c.rev
WHERE l.depth < 50
AND (co.date > l.current_date
OR (co.date = l.current_date AND co.rowid < l.current_rowid))
),
resolved AS (
SELECT orig AS old_path, current AS canonical_path,
retired_date, retired_rowid,
ROW_NUMBER() OVER (
-- One winner per (orig, retirement epoch): each seed
-- rename resolves its own chain independently, so a
-- twice-retired name maps each epoch to its own
-- canonical target.
PARTITION BY orig, retired_date, retired_rowid
-- Secondary order on `current` so ties at the same
-- depth (possible when a non-linear rename graph
-- reaches the same intermediate via multiple paths)
-- break deterministically and run-to-run output stays
-- byte-equal.
ORDER BY depth DESC, current ASC
) AS rn
FROM lineage
),
epochs AS (
-- Epoch windows are computed BEFORE the self-mapping filter so
-- a chain that resolves back to its own name still contributes
-- its retirement boundary to the next epoch's window.
-- Chronological order over retirements: date ASC, and at
-- same-second ties rowid DESC (newer commits get SMALLER
-- rowids during the reverse-chronological ingest walk).
SELECT old_path, canonical_path, retired_date, retired_rowid,
LAG(retired_date) OVER (
PARTITION BY old_path
ORDER BY retired_date ASC, retired_rowid DESC
) AS prev_retired_date,
LAG(retired_rowid) OVER (
PARTITION BY old_path
ORDER BY retired_date ASC, retired_rowid DESC
) AS prev_retired_rowid
FROM resolved
WHERE rn = 1
)
SELECT * FROM epochs WHERE old_path != canonical_path";
db.conn()
.execute(sql, params![])
.map_err(|e| CodeLoreError::Analysis(format!("materialize path_lineage: {e}")))?;
Ok(())
}
pub fn materialize_changes_lineage(db: &FactsDb) -> Result<()> {
use duckdb::params;
if db.is_changes_lineage_built() {
return Ok(());
}
materialize_path_lineage(db)?;
let sql = "CREATE OR REPLACE TEMPORARY TABLE changes_lineage AS
SELECT
c.rev,
COALESCE(pl.canonical_path, c.path) AS path,
c.change_type,
c.rename_from,
c.loc_added,
c.loc_deleted
FROM changes c
INNER JOIN commits co ON co.rev = c.rev
LEFT JOIN path_lineage pl
ON pl.old_path = c.path
AND (co.date < pl.retired_date
OR (co.date = pl.retired_date
AND co.rowid > pl.retired_rowid))
AND (pl.prev_retired_date IS NULL
OR co.date > pl.prev_retired_date
OR (co.date = pl.prev_retired_date
AND co.rowid < pl.prev_retired_rowid))";
db.conn()
.execute(sql, params![])
.map_err(|e| CodeLoreError::Analysis(format!("materialize changes_lineage: {e}")))?;
for stmt in [
"CREATE INDEX IF NOT EXISTS idx_changes_lineage_path ON changes_lineage(path)",
"CREATE INDEX IF NOT EXISTS idx_changes_lineage_rev ON changes_lineage(rev)",
] {
db.conn()
.execute(stmt, params![])
.map_err(|e| CodeLoreError::Analysis(format!("index changes_lineage: {e}")))?;
}
db.mark_changes_lineage_built();
tracing::info!("materialized changes_lineage with canonical rename paths");
Ok(())
}