use crate::export::{CURRENT_SCHEMA_VERSION, Snapshot};
use anyhow::{Context, Result, anyhow};
use rusqlite::params;
use serde_json::Value;
use super::Database;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImportMode {
#[default]
Fresh,
Replace,
Merge,
}
#[derive(Debug, Clone)]
pub struct DryRunResult {
pub mode: ImportMode,
pub database_is_empty: bool,
pub existing_rows: std::collections::BTreeMap<String, usize>,
pub would_delete: std::collections::BTreeMap<String, usize>,
pub would_insert: std::collections::BTreeMap<String, usize>,
pub would_skip: std::collections::BTreeMap<String, usize>,
pub would_succeed: bool,
pub failure_reason: Option<String>,
pub warnings: Vec<String>,
}
impl DryRunResult {
fn new(mode: ImportMode) -> Self {
Self {
mode,
database_is_empty: true,
existing_rows: std::collections::BTreeMap::new(),
would_delete: std::collections::BTreeMap::new(),
would_insert: std::collections::BTreeMap::new(),
would_skip: std::collections::BTreeMap::new(),
would_succeed: true,
failure_reason: None,
warnings: Vec::new(),
}
}
pub fn total_would_delete(&self) -> usize {
self.would_delete.values().sum()
}
pub fn total_would_insert(&self) -> usize {
self.would_insert.values().sum()
}
pub fn total_would_skip(&self) -> usize {
self.would_skip.values().sum()
}
pub fn total_existing(&self) -> usize {
self.existing_rows.values().sum()
}
}
#[derive(Debug, Clone)]
pub struct ImportResult {
pub rows_imported: std::collections::BTreeMap<String, usize>,
pub rows_deleted: std::collections::BTreeMap<String, usize>,
pub rows_skipped: std::collections::BTreeMap<String, usize>,
pub fts_rebuilt: bool,
pub warnings: Vec<String>,
}
impl ImportResult {
fn new() -> Self {
Self {
rows_imported: std::collections::BTreeMap::new(),
rows_deleted: std::collections::BTreeMap::new(),
rows_skipped: std::collections::BTreeMap::new(),
fts_rebuilt: false,
warnings: Vec::new(),
}
}
pub fn total_rows(&self) -> usize {
self.rows_imported.values().sum()
}
pub fn total_deleted(&self) -> usize {
self.rows_deleted.values().sum()
}
pub fn total_skipped(&self) -> usize {
self.rows_skipped.values().sum()
}
}
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub mode: ImportMode,
}
impl ImportOptions {
pub fn fresh() -> Self {
Self {
mode: ImportMode::Fresh,
}
}
pub fn replace() -> Self {
Self {
mode: ImportMode::Replace,
}
}
pub fn merge() -> Self {
Self {
mode: ImportMode::Merge,
}
}
}
const IMPORT_ORDER: &[&str] = &[
"tasks",
"dependencies",
"attachments",
"task_tags",
"task_needed_tags",
"task_wanted_tags",
"task_sequence",
];
impl Database {
pub fn import_snapshot(
&self,
snapshot: &Snapshot,
options: &ImportOptions,
) -> Result<ImportResult> {
if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
return Err(anyhow!(
"Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
snapshot.schema_version,
CURRENT_SCHEMA_VERSION
));
}
let mut result = ImportResult::new();
match options.mode {
ImportMode::Fresh => {
self.validate_empty_database()?;
}
ImportMode::Replace => {
result.rows_deleted = self.clear_project_data()?;
}
ImportMode::Merge => {
}
}
self.with_conn_mut(|conn| {
conn.execute("PRAGMA foreign_keys = OFF", [])?;
let tx = conn.transaction()?;
for table_name in IMPORT_ORDER {
if let Some(rows) = snapshot.tables.get(*table_name) {
let (imported, skipped) = if options.mode == ImportMode::Merge {
merge_table(&tx, table_name, rows)?
} else {
let count = import_table(&tx, table_name, rows)?;
(count, 0)
};
result
.rows_imported
.insert(table_name.to_string(), imported);
if skipped > 0 {
result.rows_skipped.insert(table_name.to_string(), skipped);
}
}
}
tx.commit()?;
conn.execute("PRAGMA foreign_keys = ON", [])?;
Ok(())
})?;
self.rebuild_fts_indexes()?;
result.fts_rebuilt = true;
Ok(result)
}
pub fn preview_import(&self, snapshot: &Snapshot, options: &ImportOptions) -> DryRunResult {
let mut result = DryRunResult::new(options.mode);
if snapshot.schema_version != CURRENT_SCHEMA_VERSION {
result.would_succeed = false;
result.failure_reason = Some(format!(
"Schema version mismatch: snapshot is v{}, database is v{}. Migration required.",
snapshot.schema_version, CURRENT_SCHEMA_VERSION
));
return result;
}
let existing = self.get_table_row_counts();
if let Err(e) = existing {
result.would_succeed = false;
result.failure_reason = Some(format!("Failed to query database: {}", e));
return result;
}
let existing = existing.unwrap();
result.existing_rows = existing.clone();
result.database_is_empty = existing.values().all(|&count| count == 0);
match options.mode {
ImportMode::Fresh => {
if !result.database_is_empty {
result.would_succeed = false;
let non_empty: Vec<_> = existing
.iter()
.filter(|&(_, count)| *count > 0)
.map(|(table, count)| format!("{}: {} rows", table, count))
.collect();
result.failure_reason = Some(format!(
"Database is not empty. Use --force to overwrite or --merge to add. Non-empty tables: {}",
non_empty.join(", ")
));
return result;
}
for table_name in IMPORT_ORDER {
let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
result.would_insert.insert(table_name.to_string(), count);
}
}
ImportMode::Replace => {
for (table, count) in &existing {
if *count > 0 {
result.would_delete.insert(table.clone(), *count);
}
}
for table_name in IMPORT_ORDER {
let count = snapshot.tables.get(*table_name).map_or(0, |v| v.len());
result.would_insert.insert(table_name.to_string(), count);
}
}
ImportMode::Merge => {
if let Err(e) = self.preview_merge(snapshot, &mut result) {
result.would_succeed = false;
result.failure_reason = Some(format!("Failed to analyze merge: {}", e));
return result;
}
}
}
result
}
fn preview_merge(&self, snapshot: &Snapshot, result: &mut DryRunResult) -> Result<()> {
self.with_conn(|conn| {
for table_name in IMPORT_ORDER {
if let Some(rows) = snapshot.tables.get(*table_name) {
let (would_insert, would_skip) = preview_merge_table(conn, table_name, rows)?;
result
.would_insert
.insert(table_name.to_string(), would_insert);
if would_skip > 0 {
result.would_skip.insert(table_name.to_string(), would_skip);
}
} else {
result.would_insert.insert(table_name.to_string(), 0);
}
}
Ok(())
})
}
fn get_table_row_counts(&self) -> Result<std::collections::BTreeMap<String, usize>> {
self.with_conn(|conn| {
let mut counts = std::collections::BTreeMap::new();
for table in IMPORT_ORDER {
let count: i64 =
conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
row.get(0)
})?;
counts.insert(table.to_string(), count as usize);
}
Ok(counts)
})
}
fn validate_empty_database(&self) -> Result<()> {
self.with_conn(|conn| {
for table in IMPORT_ORDER {
let count: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM {}", table),
[],
|row| row.get(0),
)?;
if count > 0 {
return Err(anyhow!(
"Database is not empty: table '{}' contains {} rows. Use --force to overwrite.",
table,
count
));
}
}
Ok(())
})
}
pub fn clear_project_data(&self) -> Result<std::collections::BTreeMap<String, usize>> {
let mut deleted = std::collections::BTreeMap::new();
self.with_conn_mut(|conn| {
conn.execute("PRAGMA foreign_keys = OFF", [])?;
let tx = conn.transaction()?;
for table_name in IMPORT_ORDER.iter().rev() {
let count: i64 =
tx.query_row(&format!("SELECT COUNT(*) FROM {}", table_name), [], |row| {
row.get(0)
})?;
if count > 0 {
tx.execute(&format!("DELETE FROM {}", table_name), [])?;
deleted.insert(table_name.to_string(), count as usize);
}
}
tx.execute("DELETE FROM tasks_fts", [])?;
tx.execute("DELETE FROM attachments_fts", [])?;
tx.execute(
"DELETE FROM sqlite_sequence WHERE name = 'task_sequence'",
[],
)?;
tx.commit()?;
conn.execute("PRAGMA foreign_keys = ON", [])?;
Ok(())
})?;
Ok(deleted)
}
pub fn rebuild_fts_indexes(&self) -> Result<()> {
self.with_conn(|conn| {
conn.execute("DELETE FROM tasks_fts", [])?;
conn.execute(
"INSERT INTO tasks_fts(task_id, title, description)
SELECT id, title, COALESCE(description, '')
FROM tasks",
[],
)?;
conn.execute("DELETE FROM attachments_fts", [])?;
conn.execute(
"INSERT INTO attachments_fts(task_id, attachment_type, sequence, name, content)
SELECT task_id, attachment_type, sequence, name, content
FROM attachments
WHERE mime_type LIKE 'text/%'",
[],
)?;
Ok(())
})
}
}
fn import_table(conn: &rusqlite::Connection, table_name: &str, rows: &[Value]) -> Result<usize> {
if rows.is_empty() {
return Ok(0);
}
match table_name {
"tasks" => import_tasks(conn, rows),
"dependencies" => import_dependencies(conn, rows),
"attachments" => import_attachments(conn, rows),
"task_tags" => import_task_tags(conn, rows),
"task_needed_tags" => import_task_needed_tags(conn, rows),
"task_wanted_tags" => import_task_wanted_tags(conn, rows),
"task_sequence" => import_task_sequence(conn, rows),
_ => Err(anyhow!("Unknown table: {}", table_name)),
}
}
fn merge_table(
conn: &rusqlite::Connection,
table_name: &str,
rows: &[Value],
) -> Result<(usize, usize)> {
if rows.is_empty() {
return Ok((0, 0));
}
match table_name {
"tasks" => merge_tasks(conn, rows),
"dependencies" => merge_dependencies(conn, rows),
"attachments" => merge_attachments(conn, rows),
"task_tags" => merge_task_tags(conn, rows),
"task_needed_tags" => merge_task_needed_tags(conn, rows),
"task_wanted_tags" => merge_task_wanted_tags(conn, rows),
"task_sequence" => merge_task_sequence(conn, rows),
_ => Err(anyhow!("Unknown table: {}", table_name)),
}
}
fn preview_merge_table(
conn: &rusqlite::Connection,
table_name: &str,
rows: &[Value],
) -> Result<(usize, usize)> {
if rows.is_empty() {
return Ok((0, 0));
}
match table_name {
"tasks" => preview_merge_tasks(conn, rows),
"dependencies" => preview_merge_dependencies(conn, rows),
"attachments" => preview_merge_attachments(conn, rows),
"task_tags" => preview_merge_task_tags(conn, rows),
"task_needed_tags" => preview_merge_task_needed_tags(conn, rows),
"task_wanted_tags" => preview_merge_task_wanted_tags(conn, rows),
"task_sequence" => Ok((0, rows.len())), _ => Err(anyhow!("Unknown table: {}", table_name)),
}
}
fn preview_merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row.as_object().context("Task row must be an object")?;
let task_id = get_string(obj, "id")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM tasks WHERE id = ?1",
params![&task_id],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn preview_merge_dependencies(
conn: &rusqlite::Connection,
rows: &[Value],
) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row
.as_object()
.context("Dependency row must be an object")?;
let from_id = get_string(obj, "from_task_id")?;
let to_id = get_string(obj, "to_task_id")?;
let dep_type = get_string(obj, "dep_type")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
params![&from_id, &to_id, &dep_type],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn preview_merge_attachments(
conn: &rusqlite::Connection,
rows: &[Value],
) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row
.as_object()
.context("Attachment row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let attachment_type = get_string(obj, "attachment_type")?;
let sequence = get_i32(obj, "sequence")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
params![&task_id, &attachment_type, sequence],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn preview_merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row.as_object().context("TaskTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn preview_merge_task_needed_tags(
conn: &rusqlite::Connection,
rows: &[Value],
) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskNeededTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn preview_merge_task_wanted_tags(
conn: &rusqlite::Connection,
rows: &[Value],
) -> Result<(usize, usize)> {
let mut would_insert = 0;
let mut would_skip = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskWantedTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
would_skip += 1;
} else {
would_insert += 1;
}
}
Ok((would_insert, would_skip))
}
fn merge_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt = conn.prepare(
"INSERT INTO tasks (
id, title, description, status, priority, worker_id, claimed_at,
needed_tags, wanted_tags, tags,
points, time_estimate_ms, time_actual_ms, started_at, completed_at,
current_thought,
metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
cost_usd,
deleted_at, deleted_by, deleted_reason,
created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7,
?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15,
?16,
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
?25,
?26, ?27, ?28,
?29, ?30
)",
)?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row.as_object().context("Task row must be an object")?;
let task_id = get_string(obj, "id")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM tasks WHERE id = ?1",
params![&task_id],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![
task_id,
get_string(obj, "title")?,
get_opt_string(obj, "description"),
get_string(obj, "status")?,
get_string(obj, "priority")?,
get_opt_string(obj, "worker_id"),
get_opt_i64(obj, "claimed_at"),
get_opt_string(obj, "needed_tags"),
get_opt_string(obj, "wanted_tags"),
get_opt_string(obj, "tags"),
get_opt_i32(obj, "points"),
get_opt_i64(obj, "time_estimate_ms"),
get_opt_i64(obj, "time_actual_ms"),
get_opt_i64(obj, "started_at"),
get_opt_i64(obj, "completed_at"),
get_opt_string(obj, "current_thought"),
get_i64_or_default(obj, "metric_0"),
get_i64_or_default(obj, "metric_1"),
get_i64_or_default(obj, "metric_2"),
get_i64_or_default(obj, "metric_3"),
get_i64_or_default(obj, "metric_4"),
get_i64_or_default(obj, "metric_5"),
get_i64_or_default(obj, "metric_6"),
get_i64_or_default(obj, "metric_7"),
get_f64_or_default(obj, "cost_usd"),
get_opt_i64(obj, "deleted_at"),
get_opt_string(obj, "deleted_by"),
get_opt_string(obj, "deleted_reason"),
get_i64(obj, "created_at")?,
get_i64(obj, "updated_at")?,
])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt = conn.prepare(
"INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
VALUES (?1, ?2, ?3)",
)?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row
.as_object()
.context("Dependency row must be an object")?;
let from_id = get_string(obj, "from_task_id")?;
let to_id = get_string(obj, "to_task_id")?;
let dep_type = get_string(obj, "dep_type")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM dependencies WHERE from_task_id = ?1 AND to_task_id = ?2 AND dep_type = ?3",
params![&from_id, &to_id, &dep_type],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![from_id, to_id, dep_type])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt = conn.prepare(
"INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row
.as_object()
.context("Attachment row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let attachment_type = get_string(obj, "attachment_type")?;
let sequence = get_i32(obj, "sequence")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
params![&task_id, &attachment_type, sequence],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![
task_id,
attachment_type,
sequence,
get_string(obj, "name")?,
get_string(obj, "mime_type")?,
get_string(obj, "content")?,
get_opt_string(obj, "file_path"),
get_i64(obj, "created_at")?,
])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row.as_object().context("TaskTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![task_id, tag])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt =
conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskNeededTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_needed_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![task_id, tag])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let mut insert_stmt =
conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut imported = 0;
let mut skipped = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskWantedTag row must be an object")?;
let task_id = get_string(obj, "task_id")?;
let tag = get_string(obj, "tag")?;
let exists: bool = conn
.query_row(
"SELECT 1 FROM task_wanted_tags WHERE task_id = ?1 AND tag = ?2",
params![&task_id, &tag],
|_| Ok(true),
)
.unwrap_or(false);
if exists {
skipped += 1;
continue;
}
insert_stmt.execute(params![task_id, tag])?;
imported += 1;
}
Ok((imported, skipped))
}
fn merge_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<(usize, usize)> {
let _ = conn; Ok((0, rows.len()))
}
fn import_tasks(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare(
"INSERT INTO tasks (
id, title, description, status, priority, worker_id, claimed_at,
needed_tags, wanted_tags, tags,
points, time_estimate_ms, time_actual_ms, started_at, completed_at,
current_thought,
metric_0, metric_1, metric_2, metric_3, metric_4, metric_5, metric_6, metric_7,
cost_usd,
deleted_at, deleted_by, deleted_reason,
created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7,
?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15,
?16,
?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24,
?25,
?26, ?27, ?28,
?29, ?30
)",
)?;
let mut count = 0;
for row in rows {
let obj = row.as_object().context("Task row must be an object")?;
stmt.execute(params![
get_string(obj, "id")?,
get_string(obj, "title")?,
get_opt_string(obj, "description"),
get_string(obj, "status")?,
get_string(obj, "priority")?,
get_opt_string(obj, "worker_id"),
get_opt_i64(obj, "claimed_at"),
get_opt_string(obj, "needed_tags"),
get_opt_string(obj, "wanted_tags"),
get_opt_string(obj, "tags"),
get_opt_i32(obj, "points"),
get_opt_i64(obj, "time_estimate_ms"),
get_opt_i64(obj, "time_actual_ms"),
get_opt_i64(obj, "started_at"),
get_opt_i64(obj, "completed_at"),
get_opt_string(obj, "current_thought"),
get_i64_or_default(obj, "metric_0"),
get_i64_or_default(obj, "metric_1"),
get_i64_or_default(obj, "metric_2"),
get_i64_or_default(obj, "metric_3"),
get_i64_or_default(obj, "metric_4"),
get_i64_or_default(obj, "metric_5"),
get_i64_or_default(obj, "metric_6"),
get_i64_or_default(obj, "metric_7"),
get_f64_or_default(obj, "cost_usd"),
get_opt_i64(obj, "deleted_at"),
get_opt_string(obj, "deleted_by"),
get_opt_string(obj, "deleted_reason"),
get_i64(obj, "created_at")?,
get_i64(obj, "updated_at")?,
])?;
count += 1;
}
Ok(count)
}
fn import_dependencies(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare(
"INSERT INTO dependencies (from_task_id, to_task_id, dep_type)
VALUES (?1, ?2, ?3)",
)?;
let mut count = 0;
for row in rows {
let obj = row
.as_object()
.context("Dependency row must be an object")?;
stmt.execute(params![
get_string(obj, "from_task_id")?,
get_string(obj, "to_task_id")?,
get_string(obj, "dep_type")?,
])?;
count += 1;
}
Ok(count)
}
fn import_attachments(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare(
"INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
let mut count = 0;
for row in rows {
let obj = row
.as_object()
.context("Attachment row must be an object")?;
stmt.execute(params![
get_string(obj, "task_id")?,
get_string(obj, "attachment_type")?,
get_i32(obj, "sequence")?,
get_string(obj, "name")?,
get_string(obj, "mime_type")?,
get_string(obj, "content")?,
get_opt_string(obj, "file_path"),
get_i64(obj, "created_at")?,
])?;
count += 1;
}
Ok(count)
}
fn import_task_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare("INSERT INTO task_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut count = 0;
for row in rows {
let obj = row.as_object().context("TaskTag row must be an object")?;
stmt.execute(params![
get_string(obj, "task_id")?,
get_string(obj, "tag")?,
])?;
count += 1;
}
Ok(count)
}
fn import_task_needed_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare("INSERT INTO task_needed_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut count = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskNeededTag row must be an object")?;
stmt.execute(params![
get_string(obj, "task_id")?,
get_string(obj, "tag")?,
])?;
count += 1;
}
Ok(count)
}
fn import_task_wanted_tags(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare("INSERT INTO task_wanted_tags (task_id, tag) VALUES (?1, ?2)")?;
let mut count = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskWantedTag row must be an object")?;
stmt.execute(params![
get_string(obj, "task_id")?,
get_string(obj, "tag")?,
])?;
count += 1;
}
Ok(count)
}
fn import_task_sequence(conn: &rusqlite::Connection, rows: &[Value]) -> Result<usize> {
let mut stmt = conn.prepare(
"INSERT INTO task_sequence (id, task_id, worker_id, status, phase, reason, timestamp, end_timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
let mut count = 0;
for row in rows {
let obj = row
.as_object()
.context("TaskSequenceEvent row must be an object")?;
stmt.execute(params![
get_i64(obj, "id")?,
get_string(obj, "task_id")?,
get_opt_string(obj, "worker_id"),
get_opt_string(obj, "status"),
get_opt_string(obj, "phase"),
get_opt_string(obj, "reason"),
get_i64(obj, "timestamp")?,
get_opt_i64(obj, "end_timestamp"),
])?;
count += 1;
}
Ok(count)
}
fn get_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<String> {
obj.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("Missing or invalid string field: {}", key))
}
fn get_opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
obj.get(key).and_then(|v| {
if v.is_null() {
None
} else {
v.as_str().map(|s| s.to_string())
}
})
}
fn get_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i64> {
obj.get(key)
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("Missing or invalid i64 field: {}", key))
}
fn get_opt_i64(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i64> {
obj.get(key)
.and_then(|v| if v.is_null() { None } else { v.as_i64() })
}
fn get_i64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> i64 {
get_opt_i64(obj, key).unwrap_or(0)
}
fn get_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Result<i32> {
obj.get(key)
.and_then(|v| v.as_i64())
.map(|i| i as i32)
.ok_or_else(|| anyhow!("Missing or invalid i32 field: {}", key))
}
#[allow(dead_code)]
fn get_opt_i32(obj: &serde_json::Map<String, Value>, key: &str) -> Option<i32> {
obj.get(key).and_then(|v| {
if v.is_null() {
None
} else {
v.as_i64().map(|i| i as i32)
}
})
}
fn get_f64_or_default(obj: &serde_json::Map<String, Value>, key: &str) -> f64 {
obj.get(key)
.and_then(|v| if v.is_null() { None } else { v.as_f64() })
.unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::IdsConfig;
use crate::export::Snapshot;
use serde_json::json;
#[test]
fn test_import_empty_snapshot() {
let db = Database::open_in_memory().unwrap();
let snapshot = Snapshot::new();
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.total_rows(), 0);
assert!(result.fts_rebuilt);
}
#[test]
fn test_import_tasks() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "task-1",
"title": "Test Task",
"description": "A test task",
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("tasks"), Some(&1));
assert!(result.fts_rebuilt);
let results = db.search_tasks("Test", None, false, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].task_id, "task-1");
}
#[test]
fn test_import_with_dependencies() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![
json!({
"id": "task-a",
"title": "Task A",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
json!({
"id": "task-b",
"title": "Task B",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
],
);
snapshot.tables.insert(
"dependencies".to_string(),
vec![json!({
"from_task_id": "task-a",
"to_task_id": "task-b",
"dep_type": "blocks"
})],
);
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("tasks"), Some(&2));
assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
}
#[test]
fn test_import_fails_on_non_empty_database() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
None,
"Existing task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let snapshot = Snapshot::new();
let options = ImportOptions::fresh();
let result = db.import_snapshot(&snapshot, &options);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not empty"));
}
#[test]
fn test_import_replace_mode() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
let existing_id = db
.create_task(
None,
"Existing task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let task = db.get_task(&existing_id.id).unwrap();
assert!(task.is_some());
assert_eq!(task.unwrap().title, "Existing task");
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "imported-task",
"title": "Imported Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
let options = ImportOptions::replace();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
assert_eq!(result.rows_imported.get("tasks"), Some(&1));
let old_task = db.get_task(&existing_id.id).unwrap();
assert!(old_task.is_none());
let new_task = db.get_task("imported-task").unwrap();
assert!(new_task.is_some());
assert_eq!(new_task.unwrap().title, "Imported Task");
}
#[test]
fn test_replace_mode_preserves_workers() {
let db = Database::open_in_memory().unwrap();
db.register_worker(
Some("test-worker".to_string()),
vec!["rust".to_string(), "test".to_string()],
false,
&IdsConfig::default(),
None,
)
.unwrap();
let workers = db.list_workers().unwrap();
assert_eq!(workers.len(), 1);
assert_eq!(workers[0].id, "test-worker");
use crate::config::StatesConfig;
db.create_task(
None,
"Task to replace".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let snapshot = Snapshot::new();
let options = ImportOptions::replace();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_deleted.get("tasks"), Some(&1));
let workers = db.list_workers().unwrap();
assert_eq!(workers.len(), 1);
assert_eq!(workers[0].id, "test-worker");
}
#[test]
fn test_clear_project_data() {
let db = Database::open_in_memory().unwrap();
use crate::config::{DependenciesConfig, StatesConfig};
let task_a = db
.create_task(
None,
"Task A".to_string(),
None,
None, None,
None,
None,
None,
None,
Some(vec!["rust".to_string(), "test".to_string()]), &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let task_b = db
.create_task(
None,
"Task B".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
db.add_dependency(
&task_a.id,
&task_b.id,
"blocks",
&DependenciesConfig::default(),
)
.unwrap();
let deleted = db.clear_project_data().unwrap();
assert_eq!(deleted.get("tasks"), Some(&2));
assert_eq!(deleted.get("dependencies"), Some(&1));
assert_eq!(deleted.get("task_tags"), Some(&2));
db.with_conn(|conn| {
for table in IMPORT_ORDER {
let count: i64 =
conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| {
row.get(0)
})?;
assert_eq!(count, 0, "Table {} should be empty", table);
}
Ok(())
})
.unwrap();
}
#[test]
fn test_import_schema_version_mismatch() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.schema_version = 999;
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Schema version mismatch")
);
}
#[test]
fn test_import_with_attachments() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "task-1",
"title": "Task with attachment",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
snapshot.tables.insert(
"attachments".to_string(),
vec![json!({
"task_id": "task-1",
"attachment_type": "notes",
"sequence": 0,
"name": "",
"mime_type": "text/plain",
"content": "Some searchable notes content",
"file_path": null,
"created_at": 1700000000000_i64
})],
);
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("tasks"), Some(&1));
assert_eq!(result.rows_imported.get("attachments"), Some(&1));
let results = db.search_tasks("searchable", None, true, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attachment_matches.len(), 1);
}
#[test]
fn test_import_with_tags() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "task-1",
"title": "Tagged Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
snapshot.tables.insert(
"task_tags".to_string(),
vec![
json!({"task_id": "task-1", "tag": "rust"}),
json!({"task_id": "task-1", "tag": "backend"}),
],
);
snapshot.tables.insert(
"task_needed_tags".to_string(),
vec![json!({"task_id": "task-1", "tag": "senior"})],
);
snapshot.tables.insert(
"task_wanted_tags".to_string(),
vec![json!({"task_id": "task-1", "tag": "rust-expert"})],
);
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("task_tags"), Some(&2));
assert_eq!(result.rows_imported.get("task_needed_tags"), Some(&1));
assert_eq!(result.rows_imported.get("task_wanted_tags"), Some(&1));
}
#[test]
fn test_import_task_sequence() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "task-1",
"title": "Task with history",
"description": null,
"status": "completed",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": 1700000001000_i64,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000001000_i64
})],
);
snapshot.tables.insert(
"task_sequence".to_string(),
vec![
json!({
"id": 1,
"task_id": "task-1",
"worker_id": null,
"event": "pending",
"reason": "Task created",
"timestamp": 1700000000000_i64,
"end_timestamp": 1700000000500_i64
}),
json!({
"id": 2,
"task_id": "task-1",
"worker_id": "worker-1",
"event": "working",
"reason": "Started work",
"timestamp": 1700000000500_i64,
"end_timestamp": 1700000001000_i64
}),
json!({
"id": 3,
"task_id": "task-1",
"worker_id": "worker-1",
"event": "completed",
"reason": "Done",
"timestamp": 1700000001000_i64,
"end_timestamp": null
}),
],
);
let options = ImportOptions::default();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("task_sequence"), Some(&3));
}
#[test]
fn test_rebuild_fts_indexes() {
let db = Database::open_in_memory().unwrap();
db.with_conn(|conn| {
conn.execute(
"INSERT INTO tasks (id, title, description, status, priority, created_at, updated_at)
VALUES ('test-task', 'Manual Insert Test', 'Bypass trigger', 'pending', '5', 1700000000000, 1700000000000)",
[],
)?;
Ok(())
}).unwrap();
let results = db.search_tasks("Manual", None, false, None).unwrap();
assert_eq!(results.len(), 1);
db.with_conn(|conn| {
conn.execute("DELETE FROM tasks_fts", [])?;
Ok(())
})
.unwrap();
let results = db.search_tasks("Manual", None, false, None).unwrap();
assert!(results.is_empty());
db.rebuild_fts_indexes().unwrap();
let results = db.search_tasks("Manual", None, false, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].task_id, "test-task");
}
#[test]
fn test_import_mode_default() {
let options = ImportOptions::default();
assert_eq!(options.mode, ImportMode::Fresh);
}
#[test]
fn test_import_result_total_deleted() {
let mut result = ImportResult::new();
result.rows_deleted.insert("tasks".to_string(), 5);
result.rows_deleted.insert("dependencies".to_string(), 3);
assert_eq!(result.total_deleted(), 8);
}
#[test]
fn test_import_result_total_skipped() {
let mut result = ImportResult::new();
result.rows_skipped.insert("tasks".to_string(), 3);
result.rows_skipped.insert("dependencies".to_string(), 2);
assert_eq!(result.total_skipped(), 5);
}
#[test]
fn test_merge_mode_skips_existing_tasks() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
Some("existing-task".to_string()),
"Existing task".to_string(),
None,
None, None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![
json!({
"id": "existing-task", "title": "Should Be Skipped",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
json!({
"id": "new-task", "title": "New Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
],
);
let options = ImportOptions::merge();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("tasks"), Some(&1));
assert_eq!(result.rows_skipped.get("tasks"), Some(&1));
let existing = db.get_task("existing-task").unwrap().unwrap();
assert_eq!(existing.title, "Existing task");
let new_task = db.get_task("new-task").unwrap();
assert!(new_task.is_some());
assert_eq!(new_task.unwrap().title, "New Task");
}
#[test]
fn test_merge_mode_skips_existing_dependencies() {
let db = Database::open_in_memory().unwrap();
use crate::config::{DependenciesConfig, StatesConfig};
db.create_task(
Some("task-a".to_string()),
"Task A".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
db.create_task(
Some("task-b".to_string()),
"Task B".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
db.create_task(
Some("task-c".to_string()),
"Task C".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
db.add_dependency("task-a", "task-b", "blocks", &DependenciesConfig::default())
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"dependencies".to_string(),
vec![
json!({
"from_task_id": "task-a",
"to_task_id": "task-b",
"dep_type": "blocks" }),
json!({
"from_task_id": "task-b",
"to_task_id": "task-c",
"dep_type": "blocks" }),
],
);
let options = ImportOptions::merge();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("dependencies"), Some(&1));
assert_eq!(result.rows_skipped.get("dependencies"), Some(&1));
}
#[test]
fn test_merge_mode_skips_state_sequence() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
Some("task-1".to_string()),
"Task 1".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"task_sequence".to_string(),
vec![json!({
"id": 999,
"task_id": "task-1",
"worker_id": null,
"event": "pending",
"reason": "Imported history",
"timestamp": 1700000000000_i64,
"end_timestamp": null
})],
);
let options = ImportOptions::merge();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("task_sequence"), Some(&0));
assert_eq!(result.rows_skipped.get("task_sequence"), Some(&1));
}
#[test]
fn test_merge_mode_adds_new_tags() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
Some("task-1".to_string()),
"Task 1".to_string(),
None,
None,
None,
None,
None,
None,
None,
Some(vec!["existing-tag".to_string()]), &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"task_tags".to_string(),
vec![
json!({"task_id": "task-1", "tag": "existing-tag"}), json!({"task_id": "task-1", "tag": "new-tag"}), ],
);
let options = ImportOptions::merge();
let result = db.import_snapshot(&snapshot, &options).unwrap();
assert_eq!(result.rows_imported.get("task_tags"), Some(&1));
assert_eq!(result.rows_skipped.get("task_tags"), Some(&1));
}
#[test]
fn test_import_options_merge() {
let options = ImportOptions::merge();
assert_eq!(options.mode, ImportMode::Merge);
}
#[test]
fn test_preview_fresh_mode_empty_db() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "task-1",
"title": "Test Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
let options = ImportOptions::fresh();
let preview = db.preview_import(&snapshot, &options);
assert!(preview.would_succeed);
assert!(preview.database_is_empty);
assert_eq!(preview.mode, ImportMode::Fresh);
assert_eq!(preview.total_would_insert(), 1);
assert_eq!(preview.total_would_delete(), 0);
assert_eq!(preview.total_would_skip(), 0);
}
#[test]
fn test_preview_fresh_mode_non_empty_db() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
None,
"Existing task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let snapshot = Snapshot::new();
let options = ImportOptions::fresh();
let preview = db.preview_import(&snapshot, &options);
assert!(!preview.would_succeed);
assert!(!preview.database_is_empty);
assert!(preview.failure_reason.is_some());
assert!(preview.failure_reason.unwrap().contains("not empty"));
}
#[test]
fn test_preview_replace_mode() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
Some("existing-1".to_string()),
"Existing 1".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
db.create_task(
Some("existing-2".to_string()),
"Existing 2".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![json!({
"id": "new-task",
"title": "New Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
})],
);
let options = ImportOptions::replace();
let preview = db.preview_import(&snapshot, &options);
assert!(preview.would_succeed);
assert!(!preview.database_is_empty);
assert_eq!(preview.mode, ImportMode::Replace);
assert_eq!(preview.would_delete.get("tasks"), Some(&2));
assert_eq!(preview.would_insert.get("tasks"), Some(&1));
assert_eq!(preview.total_would_skip(), 0);
}
#[test]
fn test_preview_merge_mode() {
let db = Database::open_in_memory().unwrap();
use crate::config::StatesConfig;
db.create_task(
Some("existing-task".to_string()),
"Existing Task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None, &StatesConfig::default(),
&IdsConfig::default(),
)
.unwrap();
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![
json!({
"id": "existing-task", "title": "Should Skip",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
json!({
"id": "new-task", "title": "New Task",
"description": null,
"status": "pending",
"priority": "5",
"worker_id": null,
"claimed_at": null,
"needed_tags": null,
"wanted_tags": null,
"tags": "[]",
"points": null,
"time_estimate_ms": null,
"time_actual_ms": null,
"started_at": null,
"completed_at": null,
"current_thought": null,
"metric_0": 0,
"metric_1": 0,
"metric_2": 0,
"metric_3": 0,
"metric_4": 0,
"metric_5": 0,
"metric_6": 0,
"metric_7": 0,
"cost_usd": 0.0,
"deleted_at": null,
"deleted_by": null,
"deleted_reason": null,
"created_at": 1700000000000_i64,
"updated_at": 1700000000000_i64
}),
],
);
let options = ImportOptions::merge();
let preview = db.preview_import(&snapshot, &options);
assert!(preview.would_succeed);
assert!(!preview.database_is_empty);
assert_eq!(preview.mode, ImportMode::Merge);
assert_eq!(preview.would_skip.get("tasks"), Some(&1));
assert_eq!(preview.would_insert.get("tasks"), Some(&1));
assert_eq!(preview.total_would_delete(), 0);
}
#[test]
fn test_preview_schema_version_mismatch() {
let db = Database::open_in_memory().unwrap();
let mut snapshot = Snapshot::new();
snapshot.schema_version = 999;
let options = ImportOptions::fresh();
let preview = db.preview_import(&snapshot, &options);
assert!(!preview.would_succeed);
assert!(preview.failure_reason.is_some());
assert!(
preview
.failure_reason
.unwrap()
.contains("Schema version mismatch")
);
}
#[test]
fn test_dry_run_result_totals() {
let mut result = DryRunResult::new(ImportMode::Replace);
result.existing_rows.insert("tasks".to_string(), 5);
result.existing_rows.insert("dependencies".to_string(), 3);
result.would_delete.insert("tasks".to_string(), 5);
result.would_delete.insert("dependencies".to_string(), 3);
result.would_insert.insert("tasks".to_string(), 2);
result.would_skip.insert("attachments".to_string(), 1);
assert_eq!(result.total_existing(), 8);
assert_eq!(result.total_would_delete(), 8);
assert_eq!(result.total_would_insert(), 2);
assert_eq!(result.total_would_skip(), 1);
}
}