paceflow 0.2.1

Local-first CLI that turns AI coding session history and git metadata into engineering analytics.
Documentation
use anyhow::Result;
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::BTreeMap;

use crate::change_intel::types::{LineHashCount, LineSide};
use crate::db;

use super::types::{
    CommitAttribution, CommitTaskAttribution, GitCommitDiff, SessionAttributionRow,
};

pub fn list_repo_roots_from_session_facts(conn: &Connection) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT DISTINCT repo_root
         FROM fact_session_code_change
         WHERE repo_root IS NOT NULL AND TRIM(repo_root) != ''
         ORDER BY repo_root",
    )?;

    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row?);
    }
    Ok(out)
}

pub fn min_ai_timestamp(conn: &Connection, repo_root: &str) -> Result<Option<String>> {
    conn.query_row(
        "SELECT MIN(change_ts)
         FROM fact_session_code_change
         WHERE repo_root = ?1 AND change_ts IS NOT NULL",
        params![repo_root],
        |r| r.get::<_, Option<String>>(0),
    )
    .optional()
    .map(|v| v.flatten())
    .map_err(Into::into)
}

pub fn upsert_git_commit_with_diffs(
    conn: &Connection,
    repo_root: &str,
    commit: &GitCommitDiff,
) -> Result<()> {
    db::upsert_metadata_repository(conn, repo_root)?;
    let total_added: i64 = commit.file_diffs.iter().map(|f| f.added_lines).sum();
    let total_removed: i64 = commit.file_diffs.iter().map(|f| f.removed_lines).sum();
    db::upsert_fact_commit(
        conn,
        repo_root,
        &commit.commit_sha,
        commit.parent_sha.as_deref(),
        &commit.commit_time,
        &commit.subject,
        total_added,
        total_removed,
    )?;

    let fact_files: Vec<(String, String, i64, i64)> = commit
        .file_diffs
        .iter()
        .map(|file| {
            (
                file.rel_path.clone(),
                file.change_type.clone(),
                file.added_lines,
                file.removed_lines,
            )
        })
        .collect();
    db::replace_fact_commit_file_changes(conn, repo_root, &commit.commit_sha, &fact_files)?;

    for file in &commit.file_diffs {
        db::upsert_metadata_file(conn, repo_root, &file.rel_path)?;
        db::replace_fact_commit_file_change_line_hashes(
            conn,
            repo_root,
            &commit.commit_sha,
            &file.rel_path,
            &file.line_hashes,
        )?;
    }

    Ok(())
}

pub fn upsert_commit_attribution(
    conn: &Connection,
    repo_root: &str,
    commit_sha: &str,
    attribution: &CommitAttribution,
    session_rows: &[SessionAttributionRow],
    assoc_session_facts_version: i64,
) -> Result<()> {
    db::update_fact_commit_attribution(
        conn,
        repo_root,
        commit_sha,
        attribution.matched_total_lines,
        attribution.matched_added_lines,
        attribution.matched_removed_lines,
        attribution.ai_share,
        attribution.heavy_ai,
        assoc_session_facts_version,
    )?;

    let fact_rows: Vec<(String, String, f64, f64, f64)> = session_rows
        .iter()
        .map(|row| {
            (
                row.provider.clone(),
                row.session_id.clone(),
                row.matched_lines,
                row.share_of_commit,
                row.share_of_ai,
            )
        })
        .collect();
    db::replace_fact_commit_session_matches(conn, repo_root, commit_sha, &fact_rows)?;

    Ok(())
}

pub fn list_cached_commit_shas(conn: &Connection, repo_root: &str) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT commit_sha
         FROM fact_commit
         WHERE repo_root = ?1
         ORDER BY commit_time, commit_sha",
    )?;
    let rows = stmt.query_map(params![repo_root], |row| row.get(0))?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row?);
    }
    Ok(out)
}

pub fn list_dirty_cached_commit_shas(
    conn: &Connection,
    repo_root: &str,
    session_facts_version: i64,
) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT DISTINCT fc.commit_sha
         FROM fact_commit fc
         JOIN fact_commit_file_change fcf
           ON fcf.repo_root = fc.repo_root
          AND fcf.commit_sha = fc.commit_sha
         JOIN fact_commit_file_change_line_hashes fclh
           ON fclh.file_change_id = fcf.id
         JOIN commit_assoc_dirty_hash dh
           ON dh.repo_root = fc.repo_root
          AND dh.side = fclh.side
          AND dh.line_hash = fclh.line_hash
         WHERE fc.repo_root = ?1
           AND fc.assoc_session_facts_version < ?2
         ORDER BY fc.commit_time, fc.commit_sha",
    )?;
    let rows = stmt.query_map(params![repo_root, session_facts_version], |row| row.get(0))?;
    let mut out = Vec::new();
    for row in rows {
        out.push(row?);
    }
    Ok(out)
}

pub fn load_cached_commit_diff(
    conn: &Connection,
    repo_root: &str,
    commit_sha: &str,
) -> Result<Option<GitCommitDiff>> {
    let commit_meta = conn
        .query_row(
            "SELECT parent_sha, commit_time, subject
             FROM fact_commit
             WHERE repo_root = ?1 AND commit_sha = ?2",
            params![repo_root, commit_sha],
            |row| {
                Ok((
                    row.get::<_, Option<String>>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                ))
            },
        )
        .optional()?;

    let Some((parent_sha, commit_time, subject)) = commit_meta else {
        return Ok(None);
    };

    let mut stmt = conn.prepare(
        "SELECT id, rel_path, change_type, added_lines, removed_lines
         FROM fact_commit_file_change
         WHERE repo_root = ?1 AND commit_sha = ?2
         ORDER BY rel_path",
    )?;
    let rows = stmt.query_map(params![repo_root, commit_sha], |row| {
        Ok((
            row.get::<_, i64>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
            row.get::<_, i64>(3)?,
            row.get::<_, i64>(4)?,
        ))
    })?;

    let mut file_diffs = Vec::new();
    for row in rows {
        let (file_change_id, rel_path, change_type, added_lines, removed_lines) = row?;
        file_diffs.push(super::types::GitFileDiff {
            rel_path,
            change_type,
            added_lines,
            removed_lines,
            line_hashes: load_commit_file_line_hashes(conn, file_change_id)?,
        });
    }

    Ok(Some(GitCommitDiff {
        commit_sha: commit_sha.to_string(),
        parent_sha,
        commit_time,
        subject,
        file_diffs,
    }))
}

fn load_commit_file_line_hashes(
    conn: &Connection,
    file_change_id: i64,
) -> Result<Vec<LineHashCount>> {
    let mut stmt = conn.prepare(
        "SELECT side, line_hash, count
         FROM fact_commit_file_change_line_hashes
         WHERE file_change_id = ?1
         ORDER BY side, line_hash",
    )?;
    let rows = stmt.query_map(params![file_change_id], |row| {
        let side_raw: String = row.get(0)?;
        let side = match side_raw.as_str() {
            "+" => LineSide::Added,
            "-" => LineSide::Removed,
            other => {
                return Err(rusqlite::Error::FromSqlConversionFailure(
                    0,
                    rusqlite::types::Type::Text,
                    Box::new(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        format!("unsupported line side '{other}'"),
                    )),
                ));
            }
        };
        Ok(LineHashCount {
            side,
            line_hash: row.get(1)?,
            count: row.get(2)?,
        })
    })?;

    let mut merged: BTreeMap<(LineSide, String), i64> = BTreeMap::new();
    for row in rows {
        let row = row?;
        *merged.entry((row.side, row.line_hash)).or_insert(0) += row.count;
    }

    Ok(merged
        .into_iter()
        .map(|((side, line_hash), count)| LineHashCount {
            side,
            line_hash,
            count,
        })
        .collect())
}

pub fn upsert_commit_task_attribution(
    conn: &Connection,
    repo_root: &str,
    commit_sha: &str,
    task: &CommitTaskAttribution,
) -> Result<()> {
    db::upsert_metadata_branch(conn, repo_root, &task.branch_name, Some(&task.task_key))?;
    db::upsert_fact_task_commit_assignment(
        conn,
        repo_root,
        commit_sha,
        &task.branch_name,
        &task.task_key,
        &task.source,
        task.is_fallback,
        task.candidate_count,
        task.distance_to_tip,
        task.confidence,
    )?;
    Ok(())
}

pub fn insert_commit_assoc_error(
    conn: &Connection,
    repo_root: &str,
    commit_sha: Option<&str>,
    stage: &str,
    error: &str,
) -> Result<()> {
    conn.execute(
        "INSERT INTO commit_assoc_errors (
            repo_root, commit_sha, stage, error
         ) VALUES (?1, ?2, ?3, ?4)",
        params![repo_root, commit_sha, stage, error],
    )?;
    Ok(())
}