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(())
}