use crate::domain::search::{SearchHit, SearchKind};
use crate::error::CarryCtxError;
use rusqlite::Connection;
fn db_err(e: rusqlite::Error) -> CarryCtxError {
CarryCtxError::database_error(e.to_string())
}
#[derive(Debug, Clone, Default)]
pub struct SearchOptions {
pub kind: Option<SearchKind>,
pub status: Option<String>,
pub agent_id: Option<String>,
pub limit: u32,
}
pub struct SearchRepository<'a> {
conn: &'a Connection,
}
impl<'a> SearchRepository<'a> {
pub fn new(conn: &'a Connection) -> Self {
Self { conn }
}
pub fn search(
&self,
project_id: &str,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
let kinds: Vec<SearchKind> = match options.kind {
Some(k) => vec![k],
None => vec![
SearchKind::Task,
SearchKind::Progress,
SearchKind::Checkpoint,
SearchKind::Decision,
],
};
let mut hits = Vec::new();
for kind in kinds {
hits.extend(self.search_kind(project_id, query, kind, options)?);
}
hits.sort_by(|a, b| {
a.score
.partial_cmp(&b.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
hits.truncate(options.limit.max(1) as usize);
Ok(hits)
}
fn search_kind(
&self,
project_id: &str,
query: &str,
kind: SearchKind,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
match kind {
SearchKind::Task => self.search_tasks(project_id, query, options),
SearchKind::Progress => self.search_progress(project_id, query, options),
SearchKind::Checkpoint => self.search_checkpoints(project_id, query, options),
SearchKind::Decision => self.search_decisions(project_id, query, options),
}
}
fn status_and_agent_clause(options: &SearchOptions, start_idx: usize) -> String {
let mut clause = String::new();
let mut idx = start_idx;
if options.status.is_some() {
clause.push_str(&format!(" AND t.status = ?{idx}"));
idx += 1;
}
if options.agent_id.is_some() {
clause.push_str(&format!(" AND t.owner_agent_id = ?{idx}"));
}
clause
}
fn bind_status_and_agent<'p>(
options: &'p SearchOptions,
params: &mut Vec<&'p dyn rusqlite::types::ToSql>,
) {
if let Some(status) = &options.status {
params.push(status);
}
if let Some(agent_id) = &options.agent_id {
params.push(agent_id);
}
}
fn search_tasks(
&self,
project_id: &str,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
let extra_clause = Self::status_and_agent_clause(options, 4);
let sql = format!(
"SELECT t.id, t.display_id, t.status, w.branch, t.created_at,
bm25(tasks_fts) AS score,
snippet(tasks_fts, -1, '[', ']', '...', 12) AS snip
FROM tasks_fts
JOIN tasks t ON t.rowid = tasks_fts.rowid
LEFT JOIN worktrees w ON w.task_id = t.id AND w.project_id = t.project_id
WHERE t.project_id = ?1 AND tasks_fts MATCH ?2{extra_clause}
ORDER BY score
LIMIT ?3"
);
let mut stmt = self.conn.prepare(&sql).map_err(db_err)?;
let limit = options.limit as i64;
let mut param_values: Vec<&dyn rusqlite::types::ToSql> = vec![&project_id, &query, &limit];
Self::bind_status_and_agent(options, &mut param_values);
let rows = stmt
.query_map(rusqlite::params_from_iter(param_values), |row| {
let task_id: String = row.get("id")?;
let task_display_id: String = row.get("display_id")?;
Ok(SearchHit {
kind: SearchKind::Task,
id: task_id.clone(),
display_id: None,
task_id,
task_display_id,
task_status: row.get("status")?,
branch: row.get("branch")?,
snippet: row.get("snip")?,
score: row.get("score")?,
created_at: row.get("created_at")?,
})
})
.map_err(db_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(db_err)
}
fn search_progress(
&self,
project_id: &str,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
let extra_clause = Self::status_and_agent_clause(options, 4);
let sql = format!(
"SELECT p.id, p.display_id, p.created_at,
t.id AS task_id, t.display_id AS task_display_id, t.status AS task_status,
w.branch,
bm25(progress_items_fts) AS score,
snippet(progress_items_fts, -1, '[', ']', '...', 12) AS snip
FROM progress_items_fts
JOIN progress_items p ON p.rowid = progress_items_fts.rowid
JOIN tasks t ON t.id = p.task_id
LEFT JOIN worktrees w ON w.task_id = t.id AND w.project_id = t.project_id
WHERE p.project_id = ?1 AND progress_items_fts MATCH ?2{extra_clause}
ORDER BY score
LIMIT ?3"
);
let mut stmt = self.conn.prepare(&sql).map_err(db_err)?;
let limit = options.limit as i64;
let mut param_values: Vec<&dyn rusqlite::types::ToSql> = vec![&project_id, &query, &limit];
Self::bind_status_and_agent(options, &mut param_values);
let rows = stmt
.query_map(rusqlite::params_from_iter(param_values), |row| {
Ok(SearchHit {
kind: SearchKind::Progress,
id: row.get("id")?,
display_id: row.get("display_id")?,
task_id: row.get("task_id")?,
task_display_id: row.get("task_display_id")?,
task_status: row.get("task_status")?,
branch: row.get("branch")?,
snippet: row.get("snip")?,
score: row.get("score")?,
created_at: row.get("created_at")?,
})
})
.map_err(db_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(db_err)
}
fn search_checkpoints(
&self,
project_id: &str,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
let extra_clause = Self::status_and_agent_clause(options, 4);
let sql = format!(
"SELECT c.id, c.created_at,
t.id AS task_id, t.display_id AS task_display_id, t.status AS task_status,
COALESCE(c.branch, w.branch) AS branch,
bm25(checkpoints_fts) AS score,
snippet(checkpoints_fts, -1, '[', ']', '...', 12) AS snip
FROM checkpoints_fts
JOIN checkpoints c ON c.rowid = checkpoints_fts.rowid
JOIN tasks t ON t.id = c.task_id
LEFT JOIN worktrees w ON w.task_id = t.id AND w.project_id = t.project_id
WHERE c.project_id = ?1 AND checkpoints_fts MATCH ?2{extra_clause}
ORDER BY score
LIMIT ?3"
);
let mut stmt = self.conn.prepare(&sql).map_err(db_err)?;
let limit = options.limit as i64;
let mut param_values: Vec<&dyn rusqlite::types::ToSql> = vec![&project_id, &query, &limit];
Self::bind_status_and_agent(options, &mut param_values);
let rows = stmt
.query_map(rusqlite::params_from_iter(param_values), |row| {
Ok(SearchHit {
kind: SearchKind::Checkpoint,
id: row.get("id")?,
display_id: None,
task_id: row.get("task_id")?,
task_display_id: row.get("task_display_id")?,
task_status: row.get("task_status")?,
branch: row.get("branch")?,
snippet: row.get("snip")?,
score: row.get("score")?,
created_at: row.get("created_at")?,
})
})
.map_err(db_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(db_err)
}
fn search_decisions(
&self,
project_id: &str,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchHit>, CarryCtxError> {
let extra_clause = Self::status_and_agent_clause(options, 4);
let sql = format!(
"SELECT d.id, d.display_id, d.created_at,
t.id AS task_id, t.display_id AS task_display_id, t.status AS task_status,
w.branch,
bm25(decisions_fts) AS score,
snippet(decisions_fts, -1, '[', ']', '...', 12) AS snip
FROM decisions_fts
JOIN decisions d ON d.rowid = decisions_fts.rowid
JOIN tasks t ON t.id = d.task_id
LEFT JOIN worktrees w ON w.task_id = t.id AND w.project_id = t.project_id
WHERE d.project_id = ?1 AND decisions_fts MATCH ?2{extra_clause}
ORDER BY score
LIMIT ?3"
);
let mut stmt = self.conn.prepare(&sql).map_err(db_err)?;
let limit = options.limit as i64;
let mut param_values: Vec<&dyn rusqlite::types::ToSql> = vec![&project_id, &query, &limit];
Self::bind_status_and_agent(options, &mut param_values);
let rows = stmt
.query_map(rusqlite::params_from_iter(param_values), |row| {
Ok(SearchHit {
kind: SearchKind::Decision,
id: row.get("id")?,
display_id: row.get("display_id")?,
task_id: row.get("task_id")?,
task_display_id: row.get("task_display_id")?,
task_status: row.get("task_status")?,
branch: row.get("branch")?,
snippet: row.get("snip")?,
score: row.get("score")?,
created_at: row.get("created_at")?,
})
})
.map_err(db_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(db_err)
}
}