goosedump 0.12.43

Browse, search, compact, and learn from coding-agent sessions
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Project identity resolution, coalescing, and orphan cleanup.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use rusqlite::{Connection, OptionalExtension as _, Transaction, TransactionBehavior, params};

use crate::engine::memory::types::MemoryError;

#[derive(Debug)]
struct StoredProject {
    id: i64,
    path: String,
    created_at: i64,
}

pub(super) fn normalized_path(path: &Path) -> anyhow::Result<String> {
    let path = match fs::canonicalize(path) {
        Ok(path) => worktree_root(&path).unwrap_or(path),
        Err(_) => path.to_path_buf(),
    };
    let value = path.to_string_lossy().into_owned();
    if value.trim().is_empty() {
        return Err(MemoryError::EmptyProjectPath.into());
    }
    Ok(value)
}

fn worktree_root(path: &Path) -> Option<PathBuf> {
    let mut current = path;
    loop {
        let marker = current.join(".git");
        if marker.is_dir() || is_git_file(&marker) {
            return Some(current.to_path_buf());
        }
        current = current.parent()?;
    }
}

fn is_git_file(path: &Path) -> bool {
    path.is_file()
        && fs::read_to_string(path)
            .is_ok_and(|contents| contents.trim_start().starts_with("gitdir:"))
}

pub(super) fn coalesce_projects(conn: &mut Connection) -> anyhow::Result<()> {
    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
    let groups = project_groups(&tx)?;
    if groups
        .values()
        .all(|projects| projects.len() == 1 && projects[0].path == projects[0].canonical)
        && !has_orphans(&tx)?
    {
        tx.commit()?;
        return Ok(());
    }
    for projects in groups.into_values() {
        let canonical = &projects[0].canonical;
        let created_at = projects
            .iter()
            .map(|project| project.created_at)
            .min()
            .unwrap_or_default();
        tx.execute(
            "INSERT OR IGNORE INTO projects(path, created_at) VALUES (?1, ?2)",
            params![canonical, created_at],
        )?;
        let project_id: i64 = tx.query_row(
            "SELECT id FROM projects WHERE path = ?1",
            params![canonical],
            |row| row.get(0),
        )?;
        tx.execute(
            "UPDATE projects SET created_at = min(created_at, ?2) WHERE id = ?1",
            params![project_id, created_at],
        )?;
        for project in projects {
            if project.id != project_id {
                merge_project(&tx, project.id, project_id)?;
            }
        }
    }
    prune_orphans(&tx)?;
    tx.commit()?;
    Ok(())
}

#[derive(Debug)]
struct ProjectGroupMember {
    id: i64,
    path: String,
    canonical: String,
    created_at: i64,
}

fn project_groups(conn: &Connection) -> anyhow::Result<BTreeMap<String, Vec<ProjectGroupMember>>> {
    let projects = {
        let mut stmt = conn.prepare("SELECT id, path, created_at FROM projects ORDER BY id")?;
        stmt.query_map([], |row| {
            Ok(StoredProject {
                id: row.get(0)?,
                path: row.get(1)?,
                created_at: row.get(2)?,
            })
        })?
        .collect::<rusqlite::Result<Vec<_>>>()?
    };
    let mut groups = BTreeMap::<String, Vec<ProjectGroupMember>>::new();
    for project in projects {
        let canonical = normalized_path(Path::new(&project.path))?;
        groups
            .entry(canonical.clone())
            .or_default()
            .push(ProjectGroupMember {
                id: project.id,
                path: project.path,
                canonical,
                created_at: project.created_at,
            });
    }
    Ok(groups)
}

fn merge_project(tx: &Transaction<'_>, source_id: i64, target_id: i64) -> anyhow::Result<()> {
    let entities = {
        let mut stmt = tx.prepare(
            "SELECT id, kind, normalized FROM entities WHERE project_id = ?1 ORDER BY id",
        )?;
        stmt.query_map(params![source_id], |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
            ))
        })?
        .collect::<rusqlite::Result<Vec<_>>>()?
    };
    for (entity_id, kind, normalized) in entities {
        let target_entity = tx
            .query_row(
                "SELECT id FROM entities WHERE project_id = ?1 AND kind = ?2 AND normalized = ?3",
                params![target_id, kind, normalized],
                |row| row.get::<_, i64>(0),
            )
            .optional()?;
        if let Some(target_entity) = target_entity {
            tx.execute(
                "INSERT OR IGNORE INTO claim_entities(claim_id, entity_id, origin)
                 SELECT claim_id, ?1, origin FROM claim_entities WHERE entity_id = ?2",
                params![target_entity, entity_id],
            )?;
            tx.execute("DELETE FROM entities WHERE id = ?1", params![entity_id])?;
        } else {
            tx.execute(
                "UPDATE entities SET project_id = ?1 WHERE id = ?2",
                params![target_id, entity_id],
            )?;
        }
    }
    let embeddings = load_project_embeddings(tx, source_id)?;
    tx.execute(
        "DELETE FROM claim_embeddings WHERE project_id = ?1",
        params![source_id],
    )?;
    tx.execute(
        "UPDATE evidence SET project_id = ?1 WHERE project_id = ?2",
        params![target_id, source_id],
    )?;
    tx.execute(
        "UPDATE claims SET project_id = ?1 WHERE project_id = ?2",
        params![target_id, source_id],
    )?;
    for embedding in embeddings {
        tx.execute(
            "INSERT INTO claim_embeddings(
                 claim_id, project_id, embedding_model, memory_type, memory_status, embedding
             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params![
                embedding.claim_id,
                target_id,
                embedding.embedding_model,
                embedding.memory_type,
                embedding.memory_status,
                embedding.embedding,
            ],
        )?;
    }
    tx.execute("DELETE FROM projects WHERE id = ?1", params![source_id])?;
    Ok(())
}

struct StoredEmbedding {
    claim_id: String,
    embedding_model: Option<String>,
    memory_type: Option<String>,
    memory_status: Option<String>,
    embedding: Vec<u8>,
}

fn load_project_embeddings(
    tx: &Transaction<'_>,
    project_id: i64,
) -> anyhow::Result<Vec<StoredEmbedding>> {
    let mut stmt = tx.prepare(
        "SELECT claim_id, embedding_model, memory_type, memory_status, embedding
         FROM claim_embeddings
         WHERE project_id = ?1",
    )?;
    Ok(stmt
        .query_map(params![project_id], |row| {
            Ok(StoredEmbedding {
                claim_id: row.get(0)?,
                embedding_model: row.get(1)?,
                memory_type: row.get(2)?,
                memory_status: row.get(3)?,
                embedding: row.get(4)?,
            })
        })?
        .collect::<rusqlite::Result<Vec<_>>>()?)
}

fn has_orphans(conn: &Connection) -> anyhow::Result<bool> {
    Ok(conn.query_row(
        "SELECT EXISTS(
             SELECT 1 FROM entities
             WHERE NOT EXISTS(
                 SELECT 1 FROM claim_entities WHERE claim_entities.entity_id = entities.id
             )
         ) OR EXISTS(
             SELECT 1 FROM projects
             WHERE NOT EXISTS(SELECT 1 FROM evidence WHERE evidence.project_id = projects.id)
               AND NOT EXISTS(SELECT 1 FROM claims WHERE claims.project_id = projects.id)
               AND NOT EXISTS(SELECT 1 FROM entities WHERE entities.project_id = projects.id)
         )",
        [],
        |row| row.get(0),
    )?)
}

pub(super) fn prune_orphans(tx: &Transaction<'_>) -> anyhow::Result<()> {
    tx.execute(
        "DELETE FROM entities
         WHERE NOT EXISTS(
             SELECT 1 FROM claim_entities WHERE claim_entities.entity_id = entities.id
         )",
        [],
    )?;
    tx.execute(
        "DELETE FROM projects
         WHERE NOT EXISTS(SELECT 1 FROM evidence WHERE evidence.project_id = projects.id)
           AND NOT EXISTS(SELECT 1 FROM claims WHERE claims.project_id = projects.id)
           AND NOT EXISTS(SELECT 1 FROM entities WHERE entities.project_id = projects.id)",
        [],
    )?;
    Ok(())
}