use std::path::Path; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; use crate::error::{Entity, Error, OpenOp, Result}; /// Initialise the SQLite database with connection pool and run migrations. pub async fn init(db_path: &Path) -> Result { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|source| Error::Filesystem { path: parent.to_path_buf(), source, })?; } let options = SqliteConnectOptions::new() .filename(db_path) .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(5) .connect_with(options) .await .map_err(|source| Error::DatabaseOpen { operation: OpenOp::Connect, source, })?; sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .map_err(|source| Error::DatabaseOpen { operation: OpenOp::ForeignKeysPragma, source, })?; run_migrations(&pool).await?; Ok(pool) } /// Open the SQLite database in read-only mode without running migrations. /// /// Used by `lumotia-mcp` so the MCP server cannot write to the user's database /// regardless of which tools the dispatcher exposes — `read_only(true)` makes /// the constraint structural rather than relying on the request handler being /// well-behaved. Fails cleanly if the DB doesn't exist (no `create_if_missing`). pub async fn init_readonly(db_path: &Path) -> Result { let options = SqliteConnectOptions::new() .filename(db_path) .create_if_missing(false) .read_only(true); SqlitePoolOptions::new() .max_connections(2) .connect_with(options) .await .map_err(|source| Error::DatabaseOpen { operation: OpenOp::ReadOnlyConnect, source, }) } /// Run schema migrations via the versioned migration system. async fn run_migrations(pool: &SqlitePool) -> Result<()> { crate::migrations::run_migrations(pool).await } // --- Transcript CRUD --- /// Parameters for inserting a transcript with full provenance. pub struct InsertTranscriptParams<'a> { pub id: &'a str, pub text: &'a str, pub source: &'a str, pub profile_id: &'a str, pub title: Option<&'a str>, pub audio_path: Option<&'a str>, pub duration: f64, pub engine: Option<&'a str>, pub model_id: Option<&'a str>, pub inference_ms: Option, pub sample_rate: Option, pub audio_channels: Option, pub format_mode: Option<&'a str>, pub remove_fillers: bool, pub british_english: bool, pub anti_hallucination: bool, } pub async fn insert_transcript( pool: &SqlitePool, params: &InsertTranscriptParams<'_>, ) -> Result<()> { if !profile_exists(pool, params.profile_id).await? { return Err(Error::InvalidReference { entity: Entity::Profile, reason: format!("unknown profile id '{}'", params.profile_id).into(), }); } sqlx::query( "INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(params.id) .bind(params.text) .bind(params.source) .bind(params.profile_id) .bind(params.title) .bind(params.audio_path) .bind(params.duration) .bind(params.engine) .bind(params.model_id) .bind(params.inference_ms) .bind(params.sample_rate) .bind(params.audio_channels) .bind(params.format_mode) .bind(params.remove_fillers) .bind(params.british_english) .bind(params.anti_hallucination) .execute(pool) .await .map_err(|source| Error::Query { operation: "insert_transcript".into(), source, })?; Ok(()) } /// Fetch a transcript by id, EXCLUDING soft-deleted (deleted_at IS NOT NULL) /// rows. Soft-deleted rows are still in the table — they are scoped to the /// trash view via `list_trashed_transcripts` / `restore_transcript` — but the /// regular read path treats them as gone. Rev-2 (atomiser 2026-05-12). pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE id = ? AND deleted_at IS NULL", ) .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "get_transcript".into(), source, })?; Ok(row.map(|r| transcript_row_from(&r))) } pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result> { list_transcripts_paged(pool, limit, 0).await } /// Paginated transcript list. `limit` is rows-per-page, `offset` is rows to skip. /// Used by HistoryPage to load 50 at a time and append more on scroll. pub async fn list_transcripts_paged( pool: &SqlitePool, limit: i64, offset: i64, ) -> Result> { // `deleted_at IS NULL` filter is the Rev-2 soft-delete contract: rows // in the trash are kept in the table for restore, but the regular list // path treats them as gone. let rows = sqlx::query( "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_transcripts".into(), source, })?; Ok(rows.iter().map(transcript_row_from).collect()) } /// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI. /// Excludes soft-deleted (deleted_at IS NOT NULL) rows — they only count /// toward the trash view. pub async fn count_transcripts(pool: &SqlitePool) -> Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE deleted_at IS NULL") .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "count_transcripts".into(), source, })?; Ok(n) } /// Update mutable transcript fields. Currently `text` and `title`. Other /// columns (engine, model, audio path) are immutable post-creation. Returns /// the number of rows updated (0 if id not found). /// /// This is the function HistoryPage's rename flow needed; previously the /// rename was a UI-only state change with a TODO never wired up. /// (architecture-review.md §13) pub async fn update_transcript( pool: &SqlitePool, id: &str, text: Option<&str>, title: Option<&str>, ) -> Result { // Build the SET clause dynamically so we only update the fields the // caller actually changed. Two fields max so we can keep this simple // without a query builder. match (text, title) { (Some(t), Some(ttl)) => { let res = sqlx::query("UPDATE transcripts SET text = ?, title = ? WHERE id = ?") .bind(t) .bind(ttl) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_transcript".into(), source, })?; Ok(res.rows_affected()) } (Some(t), None) => { let res = sqlx::query("UPDATE transcripts SET text = ? WHERE id = ?") .bind(t) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_transcript".into(), source, })?; Ok(res.rows_affected()) } (None, Some(ttl)) => { let res = sqlx::query("UPDATE transcripts SET title = ? WHERE id = ?") .bind(ttl) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_transcript".into(), source, })?; Ok(res.rows_affected()) } (None, None) => Ok(0), } } /// Patch-style update for the Task 2.5 transcripts_meta columns (starred, /// manual_tags, template, language, segments_json). Follows the same /// COALESCE pattern as `update_task`: each `Option::Some` overwrites, /// each `None` preserves the existing column value. Returns the refreshed /// row or errors if `id` does not exist after the UPDATE. /// /// Keeping this separate from `update_transcript` (text / title) means the /// existing command surface stays stable — the viewer and history store /// keep calling `update_transcript` for content edits, and call this for /// UI-metadata persistence. #[allow(clippy::too_many_arguments)] // Each Option maps to one COALESCE. pub async fn update_transcript_meta( pool: &SqlitePool, id: &str, starred: Option, manual_tags: Option<&str>, template: Option<&str>, language: Option<&str>, segments_json: Option<&str>, llm_tags: Option<&str>, ) -> Result { sqlx::query( "UPDATE transcripts SET \ starred = COALESCE(?, starred), \ manual_tags = COALESCE(?, manual_tags), \ template = COALESCE(?, template), \ language = COALESCE(?, language), \ segments_json = COALESCE(?, segments_json), \ llm_tags = COALESCE(?, llm_tags) \ WHERE id = ?", ) .bind(starred.map(|b| b as i64)) .bind(manual_tags) .bind(template) .bind(language) .bind(segments_json) .bind(llm_tags) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_transcript_meta".into(), source, })?; get_transcript(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::Transcript, key: id.to_string(), }) } /// Soft-delete a transcript and best-effort remove its audio file. /// /// Rev-2 / Rev-3 reversibility kill (atomiser 2026-05-12). The previous /// implementation issued a hard `DELETE FROM transcripts WHERE id = ?` /// and never touched the WAV file at `audio_path`. Two failure modes /// fanned out from that: /// /// * Rev-2: a single click on the History "Clear All" / "Confirm" /// button immediately erased months of dictation with no trash, /// no export, no undo. /// * Rev-3: even single-row deletes left the audio file on disk; /// the recordings dir grew monotonically. /// /// The new contract: /// /// 1. Capture the existing row (so we still have `audio_path` even if /// the soft-delete is observed by a parallel reader before we get /// here). /// 2. UPDATE deleted_at = datetime('now'). FTS triggers fire on UPDATE /// and re-index the row, but `search_transcripts` / `list_transcripts` /// filter `deleted_at IS NULL`, so the trash rows don't surface. /// 3. Best-effort `tokio::fs::remove_file(audio_path)`. A failure /// (file already gone, permission denied) is logged but does NOT /// propagate — the DB soft-delete has already succeeded, and /// `purge_deleted_transcripts` will retry the removal later via /// its own audio_path lookup before hard-deleting the row. /// /// Returns `Ok(())` even when no row matched the id (idempotent). pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { // Capture audio_path FIRST. We deliberately bypass `get_transcript` // (which filters deleted_at IS NULL) so a double-delete still finds // the row and the second call is a no-op cleanup rather than an // error. let audio_path: Option = sqlx::query_scalar("SELECT audio_path FROM transcripts WHERE id = ?") .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "delete_transcript".into(), source, })? .flatten(); let res = sqlx::query( "UPDATE transcripts SET deleted_at = datetime('now') \ WHERE id = ? AND deleted_at IS NULL", ) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "delete_transcript".into(), source, })?; // Best-effort audio cleanup. Only attempt removal when the // soft-delete actually flipped a row from live -> trashed; on a // repeat soft-delete the file is already gone (or never existed) // and there's nothing to do. if res.rows_affected() > 0 { if let Some(path) = audio_path.as_deref() { if let Err(err) = tokio::fs::remove_file(path).await { if err.kind() != std::io::ErrorKind::NotFound { log::warn!( target: "lumotia_storage", "delete_transcript: failed to remove audio file at {path}: {err}" ); } } } } Ok(()) } /// Hard-delete soft-deleted rows older than `older_than_days`. Intended to /// run once per startup (or on a daily cron). Returns the number of rows /// removed. /// /// FK cascades: `segments` ON DELETE CASCADE (v1) fires; tasks with /// `source_transcript_id` ON DELETE SET NULL (v8) is preserved. /// /// Audio files are also best-effort removed here in case the original /// soft-delete failed at the filesystem layer (Rev-3 belt-and-braces). pub async fn purge_deleted_transcripts(pool: &SqlitePool, older_than_days: i64) -> Result { // Collect (id, audio_path) BEFORE the DELETE so we still have the // paths to clean up after the row is gone. let rows = sqlx::query( "SELECT id, audio_path FROM transcripts \ WHERE deleted_at IS NOT NULL \ AND deleted_at < datetime('now', ?)", ) .bind(format!("-{older_than_days} days")) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "purge_deleted_transcripts_select".into(), source, })?; if rows.is_empty() { return Ok(0); } let mut ids: Vec = Vec::with_capacity(rows.len()); let mut audio_paths: Vec = Vec::new(); for row in &rows { let id: String = row.get("id"); let audio: Option = row.get("audio_path"); ids.push(id); if let Some(p) = audio { audio_paths.push(p); } } // Build the IN clause manually because sqlx 0.8 doesn't expand Vec // bindings; we batch into chunks of 200 to stay well clear of // SQLITE_MAX_VARIABLE_NUMBER (default 999). let mut total: u64 = 0; for chunk in ids.chunks(200) { let placeholders = std::iter::repeat_n("?", chunk.len()) .collect::>() .join(","); let sql = format!("DELETE FROM transcripts WHERE id IN ({placeholders})"); let mut q = sqlx::query(&sql); for id in chunk { q = q.bind(id); } let res = q.execute(pool).await.map_err(|source| Error::Query { operation: "purge_deleted_transcripts_delete".into(), source, })?; total += res.rows_affected(); } // Best-effort audio cleanup. NotFound is the expected case for rows // whose audio was already removed at soft-delete time. for path in &audio_paths { if let Err(err) = tokio::fs::remove_file(path).await { if err.kind() != std::io::ErrorKind::NotFound { log::warn!( target: "lumotia_storage", "purge_deleted_transcripts: failed to remove audio file at {path}: {err}" ); } } } Ok(total) } /// List soft-deleted transcripts (the "trash" view), most-recently-deleted /// first. The contract mirrors `list_transcripts_paged` but filters /// `deleted_at IS NOT NULL` so the regular history view and the trash view /// are exact complements. pub async fn list_trashed_transcripts( pool: &SqlitePool, limit: i64, offset: i64, ) -> Result> { let rows = sqlx::query( "SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags \ FROM transcripts \ WHERE deleted_at IS NOT NULL \ ORDER BY deleted_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_trashed_transcripts".into(), source, })?; Ok(rows.iter().map(transcript_row_from).collect()) } /// Restore a soft-deleted transcript by clearing `deleted_at`. Idempotent: /// restoring a live row is a no-op. Note that the audio file at /// `audio_path` may already have been removed by `delete_transcript`'s /// best-effort filesystem cleanup; restoring the row recovers the text and /// metadata but the audio may still be missing. pub async fn restore_transcript(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("UPDATE transcripts SET deleted_at = NULL WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "restore_transcript".into(), source, })?; Ok(()) } /// Full-text search over transcripts using the FTS5 virtual table created /// in migration v2. Returns matched transcript rows ordered by FTS rank /// (best match first). `query` follows FTS5 syntax: bare words AND together /// by default; quote multi-word phrases; `OR`, `NOT` operators supported. pub async fn search_transcripts( pool: &SqlitePool, query: &str, limit: i64, ) -> Result> { // The FTS triggers from migration v2 keep `transcripts_fts` in sync // for every INSERT/UPDATE/DELETE — including the soft-delete UPDATE, // which keeps the row in transcripts_fts. The `t.deleted_at IS NULL` // filter on the JOIN keeps trashed rows out of search results. let rows = sqlx::query( "SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json, t.llm_tags \ FROM transcripts t \ JOIN transcripts_fts fts ON fts.rowid = t.rowid \ WHERE transcripts_fts MATCH ? AND t.deleted_at IS NULL \ ORDER BY fts.rank LIMIT ?", ) .bind(query) .bind(limit) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "search_transcripts".into(), source, })?; Ok(rows.iter().map(transcript_row_from).collect()) } // --- Task CRUD --- /// Insert a task. `list_id` and `effort` are nullable (schema predates their /// UI surfacing); `notes` defaults to '' at the column level. Callers that /// want to set metadata at creation time pass `Some(...)`; omit for defaults. /// /// Positional signature is deliberately flat — it mirrors the `tasks` /// schema columns one-to-one. Refactor to a params struct only if another /// nullable is added after `energy`. #[allow(clippy::too_many_arguments)] pub async fn insert_task( pool: &SqlitePool, id: &str, text: &str, bucket: &str, source_transcript_id: Option<&str>, list_id: Option<&str>, effort: Option<&str>, energy: Option<&str>, ) -> Result<()> { sqlx::query( "INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort, energy) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(text) .bind(bucket) .bind(source_transcript_id) .bind(list_id) .bind(effort) .bind(energy) .execute(pool) .await .map_err(|source| Error::Query { operation: "insert_task".into(), source, })?; Ok(()) } pub async fn list_tasks(pool: &SqlitePool) -> Result> { let rows = sqlx::query( "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ source_transcript_id, parent_task_id, energy \ FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC", ) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_tasks".into(), source, })?; Ok(rows.into_iter().map(task_row_from).collect()) } pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?", ) .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "get_task_by_id".into(), source, })?; Ok(row.map(task_row_from)) } /// Patch-style update. Each `Option` that is `Some` overwrites the column; /// `None` preserves the existing value via COALESCE. Returns the refreshed /// row, or an error if `id` does not exist after the UPDATE. /// /// Intentionally does not touch `done` / `done_at` — those go through the /// dedicated `complete_task` / `uncomplete_task` commands because they also /// set the server-side timestamp. pub async fn update_task( pool: &SqlitePool, id: &str, text: Option<&str>, bucket: Option<&str>, list_id: Option<&str>, effort: Option<&str>, notes: Option<&str>, ) -> Result { sqlx::query( "UPDATE tasks SET \ text = COALESCE(?, text), \ bucket = COALESCE(?, bucket), \ list_id = COALESCE(?, list_id), \ effort = COALESCE(?, effort), \ notes = COALESCE(?, notes) \ WHERE id = ?", ) .bind(text) .bind(bucket) .bind(list_id) .bind(effort) .bind(notes) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_task".into(), source, })?; get_task_by_id(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::Task, key: id.to_string(), }) } /// Dedicated tri-state energy setter. Exists as its own function because /// `update_task` uses `COALESCE(?, col)` to let `None` mean "preserve" — /// which makes it impossible to explicitly clear energy back to NULL. /// `set_task_energy` always writes exactly the value passed, including /// `None` to clear. Returns the refreshed row. /// /// Caller is responsible for validating `energy` is one of the allowed /// values; the CHECK constraint will reject anything else at commit time. pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>) -> Result { sqlx::query("UPDATE tasks SET energy = ? WHERE id = ?") .bind(energy) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "set_task_energy".into(), source, })?; get_task_by_id(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::Task, key: id.to_string(), }) } pub async fn insert_subtask( pool: &SqlitePool, id: &str, text: &str, parent_task_id: &str, ) -> Result<()> { sqlx::query("INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)") .bind(id) .bind(text) .bind(parent_task_id) .execute(pool) .await .map_err(|source| Error::Query { operation: "insert_subtask".into(), source, })?; Ok(()) } pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result> { let rows = sqlx::query( "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ source_transcript_id, parent_task_id, energy \ FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC", ) .bind(parent_id) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_subtasks".into(), source, })?; Ok(rows.into_iter().map(task_row_from).collect()) } /// Mark a subtask done. If all siblings are now done, auto-complete the parent. /// Runs in a transaction so concurrent completions see consistent sibling counts. pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> { let mut tx = pool.begin().await.map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.begin_transaction".into(), source, })?; sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") .bind(subtask_id) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.complete_subtask".into(), source, })?; let parent_id: Option = sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?") .bind(subtask_id) .fetch_one(&mut *tx) .await .map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.get_parent_task_id".into(), source, })?; if let Some(pid) = parent_id { let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0") .bind(&pid) .fetch_one(&mut *tx) .await .map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(), source, })?; if pending == 0 { // Phase 8: flag the cascade so the daily-count query can exclude // it. The subtask UPDATE (above) stays at auto_completed = 0. The // user explicitly ticked it, so it counts. sqlx::query( "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 1 \ WHERE id = ?", ) .bind(&pid) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.auto_complete_parent".into(), source, })?; } } tx.commit().await.map_err(|source| Error::Query { operation: "complete_subtask_and_check_parent.commit_transaction".into(), source, })?; Ok(()) } pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "complete_task".into(), source, })?; Ok(()) } pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> { let mut tx = pool.begin().await.map_err(|source| Error::Query { operation: "uncomplete_task.begin_transaction".into(), source, })?; sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?") .bind(id) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "uncomplete_task.uncomplete_row".into(), source, })?; // Mirror the auto-complete invariant from // `complete_subtask_and_check_parent`: a parent task is done iff // every child is done. If the child we just reopened had a done // parent, reopen the parent too so state stays consistent // (2026-04-22 review MAJOR). No-op for top-level tasks. let parent_id: Option = sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?") .bind(id) .fetch_optional(&mut *tx) .await .map_err(|source| Error::Query { operation: "uncomplete_task.get_parent_task_id".into(), source, })? .flatten(); if let Some(pid) = parent_id { sqlx::query( "UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 \ WHERE id = ? AND done = 1", ) .bind(&pid) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "uncomplete_task.reopen_parent".into(), source, })?; } tx.commit().await.map_err(|source| Error::Query { operation: "uncomplete_task.commit_transaction".into(), source, })?; Ok(()) } pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("DELETE FROM tasks WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "delete_task".into(), source, })?; Ok(()) } // --- Phase 8: daily completion counts ----------------------------------- // // Drives the Tasks-page badge ("3 today") and the 7-day momentum // sparkline. Counts every row whose done_at falls on a given local // day, excluding auto-cascade parents (auto_completed = 1). Subtasks // count on explicit completion. // // The query groups by local calendar day (DATE(done_at, 'localtime') // since done_at is stored as UTC). The Rust side then left-joins the // grouped result against a generated N-day spine so empty days are // explicit zeros, not missing entries. The frontend sparkline wants a // fixed-length array it can render as 7 bars. #[derive(Debug, Clone, serde::Serialize)] pub struct DailyCompletionCount { pub day: String, // "YYYY-MM-DD" in local time pub count: u32, } pub async fn list_recent_completions( pool: &SqlitePool, days: u32, ) -> Result> { // Guard: clamp to [1, 365]. The frontend only ever asks for 7 but // a zero or wild value here would produce an empty or huge series. let days = days.clamp(1, 365); // Pull the grouped counts from SQLite. We do not generate the spine // in SQL. It is easier to left-join in Rust and keep the query simple. // Use tuple form because the storage crate does not enable sqlx's // `derive` feature (FromRow macro is gated behind it). let rows: Vec<(String, i64)> = sqlx::query_as( "SELECT DATE(done_at, 'localtime') AS day, COUNT(*) AS count \ FROM tasks \ WHERE done = 1 \ AND done_at IS NOT NULL \ AND auto_completed = 0 \ AND DATE(done_at, 'localtime') >= DATE('now', 'localtime', ?) \ GROUP BY day", ) .bind(format!("-{} days", days - 1)) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_recent_completions.fetch_grouped".into(), source, })?; let lookup: std::collections::HashMap = rows .into_iter() .map(|(day, count)| (day, count.max(0) as u32)) .collect(); // Build the spine. `today` is local-day. Walk back `days - 1` entries. let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')") .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "list_recent_completions.get_local_today".into(), source, })?; let today = today_row.0; let mut series = Vec::with_capacity(days as usize); for offset in (0..days as i64).rev() { // Re-query SQLite for each offset so the date arithmetic is the // same calendar SQLite uses (month boundaries, DST, etc.). let (day,): (String,) = sqlx::query_as("SELECT DATE(?, ?)") .bind(&today) .bind(format!("-{offset} days")) .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "list_recent_completions.compute_spine_day".into(), source, })?; let count = lookup.get(&day).copied().unwrap_or(0); series.push(DailyCompletionCount { day, count }); } Ok(series) } // --- Implementation intentions --- #[allow(clippy::too_many_arguments)] pub async fn insert_implementation_rule( pool: &SqlitePool, id: &str, enabled: bool, trigger_kind: &str, trigger_value: &str, actions_json: &str, last_fired_key: Option<&str>, ) -> Result { sqlx::query( "INSERT INTO implementation_rules ( id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key ) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(id) .bind(enabled as i64) .bind(trigger_kind) .bind(trigger_value) .bind(actions_json) .bind(last_fired_key) .execute(pool) .await .map_err(|source| Error::Query { operation: "insert_implementation_rule".into(), source, })?; get_implementation_rule(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::ImplementationRule, key: id.to_string(), }) } pub async fn list_implementation_rules(pool: &SqlitePool) -> Result> { let rows = sqlx::query( "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \ created_at, updated_at \ FROM implementation_rules ORDER BY created_at DESC", ) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_implementation_rules".into(), source, })?; Ok(rows.into_iter().map(implementation_rule_row_from).collect()) } pub async fn get_implementation_rule( pool: &SqlitePool, id: &str, ) -> Result> { let row = sqlx::query( "SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \ created_at, updated_at \ FROM implementation_rules WHERE id = ?", ) .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "get_implementation_rule".into(), source, })?; Ok(row.map(implementation_rule_row_from)) } pub async fn set_implementation_rule_enabled( pool: &SqlitePool, id: &str, enabled: bool, ) -> Result { sqlx::query( "UPDATE implementation_rules SET enabled = ?, updated_at = datetime('now') WHERE id = ?", ) .bind(enabled as i64) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "set_implementation_rule_enabled".into(), source, })?; get_implementation_rule(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::ImplementationRule, key: id.to_string(), }) } pub async fn mark_implementation_rule_fired( pool: &SqlitePool, id: &str, last_fired_key: &str, ) -> Result { sqlx::query( "UPDATE implementation_rules SET last_fired_key = ?, updated_at = datetime('now') WHERE id = ?", ) .bind(last_fired_key) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "mark_implementation_rule_fired".into(), source, })?; get_implementation_rule(pool, id) .await? .ok_or_else(|| Error::NotFound { entity: Entity::ImplementationRule, key: id.to_string(), }) } pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("DELETE FROM implementation_rules WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "delete_implementation_rule".into(), source, })?; Ok(()) } // --- Settings CRUD --- pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> { sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .bind(key) .bind(value) .execute(pool) .await .map_err(|source| Error::Query { operation: "set_setting".into(), source, })?; Ok(()) } pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result> { let row = sqlx::query("SELECT value FROM settings WHERE key = ?") .bind(key) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "get_setting".into(), source, })?; Ok(row.map(|r| r.get("value"))) } /// One-shot key rename for settings rows carried over from the magnotia era /// (`magnotia_preferences`, `magnotia_morning_triage_last_shown`, etc.) to /// the lumotia naming convention. /// /// Idempotent. Returns `(renamed, orphans_deleted)`: /// * `renamed` — rows where only the legacy (magnotia) key existed; the /// row's key is updated to the lumotia equivalent. /// * `orphans_deleted` — rows where BOTH keys existed; the new (lumotia) /// row is authoritative and the legacy (magnotia) row is deleted to /// avoid silent debt. pub async fn migrate_legacy_setting_keys(pool: &SqlitePool) -> Result<(u64, u64)> { let mut tx = pool.begin().await.map_err(|source| Error::Query { operation: "migrate_legacy_setting_keys:begin".into(), source, })?; let renamed = sqlx::query( "UPDATE settings \ SET key = 'lumotia_' || substr(key, length('magnotia_') + 1) \ WHERE key LIKE 'magnotia_%' \ AND NOT EXISTS (\ SELECT 1 FROM settings AS dst \ WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ )", ) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "migrate_legacy_setting_keys:rename".into(), source, })? .rows_affected(); let deleted = sqlx::query( "DELETE FROM settings \ WHERE key LIKE 'magnotia_%' \ AND EXISTS (\ SELECT 1 FROM settings AS dst \ WHERE dst.key = 'lumotia_' || substr(settings.key, length('magnotia_') + 1)\ )", ) .execute(&mut *tx) .await .map_err(|source| Error::Query { operation: "migrate_legacy_setting_keys:delete_orphans".into(), source, })? .rows_affected(); tx.commit().await.map_err(|source| Error::Query { operation: "migrate_legacy_setting_keys:commit".into(), source, })?; Ok((renamed, deleted)) } // --- Row types --- #[derive(Debug, Clone)] pub struct ProfileRow { pub id: String, pub name: String, pub initial_prompt: String, pub created_at: String, } #[derive(Debug, Clone)] pub struct ProfileTermRow { pub id: String, pub profile_id: String, pub term: String, pub note: String, pub created_at: String, } #[derive(Debug, Clone)] pub struct TranscriptRow { pub id: String, pub text: String, pub source: String, pub profile_id: String, pub title: Option, pub audio_path: Option, pub duration: f64, pub engine: Option, pub model_id: Option, pub inference_ms: Option, pub sample_rate: Option, pub audio_channels: Option, pub format_mode: Option, pub remove_fillers: bool, pub british_english: bool, pub anti_hallucination: bool, pub created_at: String, // Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata // that previously lived in the removed localStorage `lumotia_history` cache. pub starred: bool, pub manual_tags: String, pub template: String, pub language: String, pub segments_json: String, /// Phase 9 — comma-joined LLM-generated content tags ("topic:foo", /// "intent:planning"). Migration v14. Empty string is the normal /// no-tags state. pub llm_tags: String, } #[derive(Debug, Clone)] pub struct TaskRow { pub id: String, pub text: String, pub bucket: String, pub list_id: Option, pub effort: Option, pub notes: String, pub done: bool, pub done_at: Option, pub created_at: String, pub source_transcript_id: Option, pub parent_task_id: Option, /// Phase 3 energy tagging: one of `"high"`, `"medium"`, `"brain_dead"`, /// or `None`. Enforced at the DB layer via a CHECK constraint (see /// migration v11). Unset is the expected normal case — the match-my- /// energy sort treats unset as Medium-equivalent. pub energy: Option, } #[derive(Debug, Clone)] pub struct ImplementationRuleRow { pub id: String, pub enabled: bool, pub trigger_kind: String, pub trigger_value: String, pub actions_json: String, pub last_fired_key: Option, pub created_at: String, pub updated_at: String, } fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { TranscriptRow { id: r.get("id"), text: r.get("text"), source: r.get("source"), profile_id: r.get("profile_id"), title: r.get("title"), audio_path: r.get("audio_path"), duration: r.get("duration"), engine: r.get("engine"), model_id: r.get("model_id"), inference_ms: r.get("inference_ms"), sample_rate: r.get("sample_rate"), audio_channels: r.get("audio_channels"), format_mode: r.get("format_mode"), remove_fillers: r.get("remove_fillers"), british_english: r.get("british_english"), anti_hallucination: r.get("anti_hallucination"), created_at: r.get("created_at"), // Task 2.5 (migration v5). INTEGER 0/1 → bool via `!= 0`. starred: r.get::("starred") != 0, manual_tags: r.get("manual_tags"), template: r.get("template"), language: r.get("language"), segments_json: r.get("segments_json"), llm_tags: r.get("llm_tags"), } } fn profile_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileRow { ProfileRow { id: r.get("id"), name: r.get("name"), initial_prompt: r.get("initial_prompt"), created_at: r.get("created_at"), } } fn profile_term_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileTermRow { ProfileTermRow { id: r.get("id"), profile_id: r.get("profile_id"), term: r.get("term"), note: r.get("note"), created_at: r.get("created_at"), } } fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { TaskRow { id: r.get("id"), text: r.get("text"), bucket: r.get("bucket"), list_id: r.get("list_id"), effort: r.get("effort"), notes: r.get("notes"), done: r.get("done"), done_at: r.get("done_at"), created_at: r.get("created_at"), source_transcript_id: r.get("source_transcript_id"), parent_task_id: r.get("parent_task_id"), energy: r.get("energy"), } } fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow { ImplementationRuleRow { id: r.get("id"), enabled: r.get::("enabled") != 0, trigger_kind: r.get("trigger_kind"), trigger_value: r.get("trigger_value"), actions_json: r.get("actions_json"), last_fired_key: r.get("last_fired_key"), created_at: r.get("created_at"), updated_at: r.get("updated_at"), } } // --- Profile CRUD (Phase 2 Task 11) --- // // Profiles partition the per-user transcription dictionary so that a single // database can hold multiple named contexts (work, personal, a client, a // project). Each profile owns an `initial_prompt` that steers Whisper's // decoder and a list of `profile_terms` that bias recognition and feed // post-transcription fix-ups. // // The `Default` profile (id = DEFAULT_PROFILE_ID, seeded by migration v6) // must always exist and must never be renamed. Two layers enforce this: // 1. The DB triggers `trg_protect_default_profile_delete` / // `trg_protect_default_profile_rename` from migration v6. // 2. Rust-layer fail-fast checks below that short-circuit BEFORE the // query hits sqlite, so UI callers get a friendly typed // `Error::InvalidReference` instead of an opaque // `SQLITE_CONSTRAINT_TRIGGER` wrapped in text. pub async fn list_profiles(pool: &SqlitePool) -> Result> { let rows = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC") .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_profiles".into(), source, })?; Ok(rows.iter().map(profile_row_from).collect()) } pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?") .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "get_profile".into(), source, })?; Ok(row.as_ref().map(profile_row_from)) } pub async fn create_profile( pool: &SqlitePool, name: &str, initial_prompt: &str, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); let row = sqlx::query( "INSERT INTO profiles (id, name, initial_prompt, created_at) \ VALUES (?, ?, ?, datetime('now')) \ RETURNING id, name, initial_prompt, created_at", ) .bind(&id) .bind(name) .bind(initial_prompt) .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "create_profile".into(), source, })?; Ok(profile_row_from(&row)) } /// Full replace of `name` and `initial_prompt` for a profile. /// /// Fails fast in Rust — before hitting sqlite — for two Default-profile /// rules that the migration v6 trigger also enforces but returns opaquely: /// * the Default id must not be renamed; /// * no other profile may be renamed to "Default" (would collide with /// the UNIQUE(name) constraint anyway, but we surface a better error). pub async fn update_profile( pool: &SqlitePool, id: &str, name: &str, initial_prompt: &str, ) -> Result<()> { if id == crate::DEFAULT_PROFILE_ID && name != "Default" { return Err(Error::InvalidReference { entity: Entity::Profile, reason: "Default profile cannot be renamed".into(), }); } if id != crate::DEFAULT_PROFILE_ID && name == "Default" { return Err(Error::InvalidReference { entity: Entity::Profile, reason: "cannot rename another profile to 'Default'".into(), }); } sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?") .bind(name) .bind(initial_prompt) .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "update_profile".into(), source, })?; Ok(()) } /// Delete a profile. Rejects the Default profile at the Rust layer so the /// UI sees a human-readable message instead of a trigger ABORT string. /// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children. pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> { if id == crate::DEFAULT_PROFILE_ID { return Err(Error::InvalidReference { entity: Entity::Profile, reason: "Default profile cannot be deleted".into(), }); } let transcript_count = transcript_count_for_profile(pool, id).await?; if transcript_count > 0 { return Err(Error::InvalidReference { entity: Entity::Profile, reason: format!( "cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first" ).into(), }); } sqlx::query("DELETE FROM profiles WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "delete_profile".into(), source, })?; Ok(()) } pub async fn list_profile_terms( pool: &SqlitePool, profile_id: &str, ) -> Result> { let rows = sqlx::query( "SELECT id, profile_id, term, note, created_at FROM profile_terms \ WHERE profile_id = ? ORDER BY term ASC", ) .bind(profile_id) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_profile_terms".into(), source, })?; Ok(rows.iter().map(profile_term_row_from).collect()) } /// UPSERT on UNIQUE(profile_id, term). Uses ON CONFLICT … DO UPDATE so that /// re-adding an existing term simply refreshes the note and returns the /// existing row (not a fresh id). pub async fn add_profile_term( pool: &SqlitePool, profile_id: &str, term: &str, note: &str, ) -> Result { let new_id = uuid::Uuid::new_v4().to_string(); let row = sqlx::query( "INSERT INTO profile_terms (id, profile_id, term, note, created_at) \ VALUES (?, ?, ?, ?, datetime('now')) \ ON CONFLICT(profile_id, term) DO UPDATE SET note = excluded.note \ RETURNING id, profile_id, term, note, created_at", ) .bind(&new_id) .bind(profile_id) .bind(term) .bind(note) .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "add_profile_term".into(), source, })?; Ok(profile_term_row_from(&row)) } pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("DELETE FROM profile_terms WHERE id = ?") .bind(id) .execute(pool) .await .map_err(|source| Error::Query { operation: "delete_profile_term".into(), source, })?; Ok(()) } async fn profile_exists(pool: &SqlitePool, id: &str) -> Result { let exists: Option = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1") .bind(id) .fetch_optional(pool) .await .map_err(|source| Error::Query { operation: "profile_exists".into(), source, })?; Ok(exists.is_some()) } async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result { sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?") .bind(id) .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "transcript_count_for_profile".into(), source, }) } // --- Error Logging --- /// Log a structured error to the `error_log` table. /// /// Available for Tauri command handlers to persist errors for diagnostics. /// Each entry records context (which subsystem), an optional error code, /// the human-readable message, and optional JSON metadata. /// /// # Example /// ```ignore /// log_error(&pool, "transcription", Some("WHISPER_INIT"), "Model load failed", None).await?; /// ``` /// /// TODO: Wire this into Tauri command error paths so runtime failures are /// persisted for user-facing error history and crash diagnostics. pub async fn log_error( pool: &SqlitePool, context: &str, error_code: Option<&str>, message: &str, metadata: Option<&str>, ) -> Result<()> { sqlx::query( "INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)", ) .bind(context) .bind(error_code) .bind(message) .bind(metadata) .execute(pool) .await .map_err(|source| Error::Query { operation: "log_error".into(), source, })?; Ok(()) } /// Read the most recent N rows from `error_log`, newest first. Used by the /// diagnostic-report bundler in Settings → About. #[derive(Debug, Clone)] pub struct ErrorLogRow { pub id: i64, pub timestamp: String, pub context: String, pub error_code: Option, pub message: String, pub metadata: Option, } /// Delete `error_log` rows older than `keep_days` whole days. Returns the /// row count removed. /// /// Called once on app startup so the table doesn't grow unbounded across /// months of dogfooding — without this it would eventually balloon the /// diagnostic-bundle export and slow the `list_recent_errors` query. /// 90 days is the default: long enough to triage a "this happened a few /// weeks ago" report, short enough that the table stays kilobyte-scale. pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result { let result = sqlx::query( "DELETE FROM error_log \ WHERE timestamp < datetime('now', ?)", ) .bind(format!("-{keep_days} days")) .execute(pool) .await .map_err(|source| Error::Query { operation: "prune_error_log".into(), source, })?; Ok(result.rows_affected()) } pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result> { let rows = sqlx::query( "SELECT id, timestamp, context, error_code, message, metadata \ FROM error_log ORDER BY id DESC LIMIT ?", ) .bind(limit) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_recent_errors".into(), source, })?; Ok(rows .into_iter() .map(|r| ErrorLogRow { id: r.get("id"), timestamp: r.get("timestamp"), context: r.get("context"), error_code: r.get("error_code"), message: r.get("message"), metadata: r.get("metadata"), }) .collect()) } // --- Feedback (HITL) ------------------------------------------------------- // // Phase 2 of the feature-complete roadmap: capture thumbs + corrections on // AI-generated output so the prompt builder can inject recent examples as // few-shot exemplars. Storage-only here; the prompt-conditioning logic lives // in lumotia-llm. Retrieval returns the most recent rows, narrowed to the // active profile when provided so feedback does not cross profiles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FeedbackTargetType { MicroStep, TaskExtraction, Cleanup, } impl FeedbackTargetType { pub fn as_str(self) -> &'static str { match self { FeedbackTargetType::MicroStep => "microstep", FeedbackTargetType::TaskExtraction => "task_extraction", FeedbackTargetType::Cleanup => "cleanup", } } /// Parse the database `target_type` string back into the enum. /// Named `parse` rather than `from_str` so it does not collide with /// the `std::str::FromStr` trait — the trait is overkill here /// because callers never want a `FromStr::Err` and already know the /// set of valid values at the call site. pub fn parse(s: &str) -> Option { match s { "microstep" => Some(FeedbackTargetType::MicroStep), "task_extraction" => Some(FeedbackTargetType::TaskExtraction), "cleanup" => Some(FeedbackTargetType::Cleanup), _ => None, } } } #[derive(Debug, Clone)] pub struct RecordFeedbackParams { pub target_type: FeedbackTargetType, pub target_id: Option, /// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up. pub rating: i8, pub original_text: Option, pub corrected_text: Option, pub context_json: Option, pub profile_id: Option, } #[derive(Debug, Clone)] pub struct FeedbackRow { pub id: i64, pub target_type: String, pub target_id: Option, pub rating: i64, pub original_text: Option, pub corrected_text: Option, pub context_json: Option, pub profile_id: String, pub created_at: String, } pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result { if !matches!(params.rating, -1..=1) { return Err(Error::InvalidReference { entity: Entity::Feedback, reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(), }); } let profile_id = params .profile_id .unwrap_or_else(|| crate::DEFAULT_PROFILE_ID.to_string()); let row = sqlx::query( "INSERT INTO feedback ( target_type, target_id, rating, original_text, corrected_text, context_json, profile_id ) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(params.target_type.as_str()) .bind(params.target_id) .bind(params.rating as i64) .bind(params.original_text) .bind(params.corrected_text) .bind(params.context_json) .bind(profile_id) .fetch_one(pool) .await .map_err(|source| Error::Query { operation: "record_feedback".into(), source, })?; Ok(row.get::("id")) } /// Fetch the most recent feedback rows for a given target type, scoped to /// the active profile. Used by the prompt builder to gather few-shot /// exemplars. Orders by `created_at DESC` so the most recent corrections /// outweigh older ones — the user's style drifts, and we want the LLM /// to track the current preference. /// /// `min_rating` filters out thumbs-down examples when the caller only /// wants positive reinforcement; pass `-1` to include everything. pub async fn list_feedback_examples( pool: &SqlitePool, target_type: FeedbackTargetType, limit: i64, min_rating: i8, profile_id: Option<&str>, ) -> Result> { let pid = profile_id.unwrap_or(crate::DEFAULT_PROFILE_ID); let rows = sqlx::query( "SELECT id, target_type, target_id, rating, original_text, corrected_text, context_json, profile_id, created_at FROM feedback WHERE target_type = ? AND profile_id = ? AND rating >= ? ORDER BY created_at DESC, id DESC LIMIT ?", ) .bind(target_type.as_str()) .bind(pid) .bind(min_rating as i64) .bind(limit) .fetch_all(pool) .await .map_err(|source| Error::Query { operation: "list_feedback_examples".into(), source, })?; Ok(rows .into_iter() .map(|r| FeedbackRow { id: r.get("id"), target_type: r.get("target_type"), target_id: r.get("target_id"), rating: r.get("rating"), original_text: r.get("original_text"), corrected_text: r.get("corrected_text"), context_json: r.get("context_json"), profile_id: r.get("profile_id"), created_at: r.get("created_at"), }) .collect()) } #[cfg(test)] mod tests { use super::*; async fn test_pool() -> SqlitePool { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::query("PRAGMA foreign_keys = ON") .execute(&pool) .await .unwrap(); run_migrations(&pool).await.unwrap(); pool } #[tokio::test] async fn transcript_crud_roundtrip() { let pool = test_pool().await; insert_transcript( &pool, &InsertTranscriptParams { id: "t1", text: "Hello world", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: Some("Test"), audio_path: None, duration: 1.5, engine: Some("whisper"), model_id: Some("whisper-tiny-en"), inference_ms: Some(250), sample_rate: Some(48000), audio_channels: Some(1), format_mode: Some("Clean"), remove_fillers: true, british_english: true, anti_hallucination: false, }, ) .await .unwrap(); let t = get_transcript(&pool, "t1").await.unwrap().unwrap(); assert_eq!(t.text, "Hello world"); assert_eq!(t.source, "microphone"); assert_eq!(t.profile_id, crate::DEFAULT_PROFILE_ID); assert_eq!(t.duration, 1.5); assert_eq!(t.engine.as_deref(), Some("whisper")); assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en")); assert_eq!(t.inference_ms, Some(250)); assert!(t.remove_fillers); assert!(t.british_english); let list = list_transcripts(&pool, 10).await.unwrap(); assert_eq!(list.len(), 1); delete_transcript(&pool, "t1").await.unwrap(); let deleted = get_transcript(&pool, "t1").await.unwrap(); assert!(deleted.is_none()); } #[tokio::test] async fn search_transcripts_uses_fts5_and_ranks_by_relevance() { // Locks in the FTS5 behaviour contract: MATCH-based substring-like // search (case-insensitive tokens) with rank-ordered results, so // anyone swapping `search_transcripts` out for embeddings later // knows what invariants they must preserve. let pool = test_pool().await; let rows = [ ("t1", "The Parakeet speech model runs on sherpa-onnx."), ( "t2", "Parakeet parakeet parakeet dominates English benchmarks.", ), ("t3", "Whisper large-v3 remains the multilingual champion."), ]; for (id, text) in rows { insert_transcript( &pool, &InsertTranscriptParams { id, text, source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: None, audio_path: None, duration: 1.0, engine: Some("whisper"), model_id: Some("whisper-tiny-en"), inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); } let hits = search_transcripts(&pool, "parakeet", 10).await.unwrap(); let ids: Vec<&str> = hits.iter().map(|row| row.id.as_str()).collect(); assert_eq!( ids, vec!["t2", "t1"], "t3 has no 'parakeet' token; t2 outranks t1 on repetition" ); let no_match = search_transcripts(&pool, "moonshine", 10).await.unwrap(); assert!(no_match.is_empty()); } #[tokio::test] async fn task_crud_roundtrip() { let pool = test_pool().await; insert_task( &pool, "task1", "Buy groceries", "today", None, None, None, None, ) .await .unwrap(); let tasks = list_tasks(&pool).await.unwrap(); assert_eq!(tasks.len(), 1); assert_eq!(tasks[0].text, "Buy groceries"); assert!(!tasks[0].done); complete_task(&pool, "task1").await.unwrap(); let tasks = list_tasks(&pool).await.unwrap(); assert!(tasks[0].done); delete_task(&pool, "task1").await.unwrap(); let tasks = list_tasks(&pool).await.unwrap(); assert!(tasks.is_empty()); } #[tokio::test] async fn subtask_crud_roundtrip() { let pool = test_pool().await; insert_task(&pool, "p1", "Write report", "inbox", None, None, None, None) .await .unwrap(); insert_subtask(&pool, "s1", "Open document", "p1") .await .unwrap(); insert_subtask(&pool, "s2", "Write introduction", "p1") .await .unwrap(); // list_tasks must exclude subtasks let top_level = list_tasks(&pool).await.unwrap(); assert_eq!(top_level.len(), 1); assert_eq!(top_level[0].id, "p1"); // list_subtasks returns both children let subs = list_subtasks(&pool, "p1").await.unwrap(); assert_eq!(subs.len(), 2); // completing s1 should NOT auto-complete parent (s2 still pending) complete_subtask_and_check_parent(&pool, "s1") .await .unwrap(); let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); assert!(!parent.done, "parent should not be done yet"); // completing s2 SHOULD auto-complete parent complete_subtask_and_check_parent(&pool, "s2") .await .unwrap(); let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); assert!( parent.done, "parent should auto-complete when all children done" ); } #[tokio::test] async fn uncomplete_subtask_reopens_auto_completed_parent() { // Regression for the 2026-04-22 review MAJOR on // asymmetric complete / uncomplete semantics: once all // subtasks completed auto-completes the parent, reopening a // child must also reopen the parent so "parent is done iff // every child is done" holds in both directions. let pool = test_pool().await; insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None) .await .unwrap(); insert_subtask(&pool, "s1", "Final test", "p1") .await .unwrap(); insert_subtask(&pool, "s2", "Tag release", "p1") .await .unwrap(); complete_subtask_and_check_parent(&pool, "s1") .await .unwrap(); complete_subtask_and_check_parent(&pool, "s2") .await .unwrap(); let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); assert!(parent.done); uncomplete_task(&pool, "s2").await.unwrap(); let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); assert!( !parent.done, "reopening any child must reopen the auto-completed parent" ); let s2 = get_task_by_id(&pool, "s2").await.unwrap().unwrap(); assert!(!s2.done, "subtask itself must also be reopened"); } #[tokio::test] async fn uncomplete_top_level_task_does_not_touch_siblings() { // Sanity: tasks without a parent must not trigger the parent- // reopen branch (it should no-op cleanly). let pool = test_pool().await; insert_task(&pool, "a", "A", "inbox", None, None, None, None) .await .unwrap(); insert_task(&pool, "b", "B", "inbox", None, None, None, None) .await .unwrap(); complete_task(&pool, "a").await.unwrap(); complete_task(&pool, "b").await.unwrap(); uncomplete_task(&pool, "a").await.unwrap(); let a = get_task_by_id(&pool, "a").await.unwrap().unwrap(); let b = get_task_by_id(&pool, "b").await.unwrap().unwrap(); assert!(!a.done); assert!(b.done, "sibling must be untouched"); } #[tokio::test] async fn implementation_rule_crud_roundtrip() { let pool = test_pool().await; let rule = insert_implementation_rule( &pool, "rule-1", true, "time_of_day", "09:00", r#"[{"kind":"speak_line","text":"time to plan the day"}]"#, Some("2026-04-24"), ) .await .unwrap(); assert_eq!(rule.id, "rule-1"); assert!(rule.enabled); assert_eq!(rule.trigger_kind, "time_of_day"); assert_eq!(rule.trigger_value, "09:00"); assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24")); let rules = list_implementation_rules(&pool).await.unwrap(); assert_eq!(rules.len(), 1); assert_eq!(rules[0].actions_json, rule.actions_json); let disabled = set_implementation_rule_enabled(&pool, "rule-1", false) .await .unwrap(); assert!(!disabled.enabled); let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25") .await .unwrap(); assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25")); delete_implementation_rule(&pool, "rule-1").await.unwrap(); assert!(list_implementation_rules(&pool).await.unwrap().is_empty()); } #[tokio::test] async fn update_transcript_meta_happy_path() { // Task 2.5 — insert a transcript, update starred=true, read it back. let pool = test_pool().await; insert_transcript( &pool, &InsertTranscriptParams { id: "tm1", text: "body", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: Some("Meta happy"), audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None, None) .await .unwrap(); assert!(row.starred, "starred must flip true after update"); let fetched = get_transcript(&pool, "tm1").await.unwrap().unwrap(); assert!(fetched.starred, "starred must persist via SELECT"); assert_eq!(fetched.manual_tags, ""); assert_eq!(fetched.template, ""); assert_eq!(fetched.language, ""); assert_eq!(fetched.segments_json, ""); assert_eq!(fetched.llm_tags, "", "llm_tags defaults to empty"); } #[tokio::test] async fn update_transcript_meta_partial_leaves_others_unchanged() { // Task 2.5 — partial update: only segments_json changes; the other // meta columns must be preserved by COALESCE. let pool = test_pool().await; insert_transcript( &pool, &InsertTranscriptParams { id: "tm2", text: "body", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: None, audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); // Seed starred + tags + template + language first. update_transcript_meta( &pool, "tm2", Some(true), Some("urgent,review"), Some("Meeting"), Some("en-GB"), None, None, ) .await .unwrap(); // Now update only segments_json. let row = update_transcript_meta( &pool, "tm2", None, None, None, None, Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#), None, ) .await .unwrap(); assert!(row.starred, "starred must survive partial update"); assert_eq!(row.manual_tags, "urgent,review"); assert_eq!(row.template, "Meeting"); assert_eq!(row.language, "en-GB"); assert_eq!( row.segments_json, r#"[{"start":0.0,"end":1.0,"text":"hi"}]"# ); } #[tokio::test] async fn update_transcript_meta_writes_llm_tags() { // Phase 9 — llm_tags writes via update_transcript_meta and round- // trips via SELECT. Migration v14 default is empty string. let pool = test_pool().await; insert_transcript( &pool, &InsertTranscriptParams { id: "tm-llm", text: "body", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: None, audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); let initial = get_transcript(&pool, "tm-llm").await.unwrap().unwrap(); assert_eq!(initial.llm_tags, "", "default after insert"); update_transcript_meta( &pool, "tm-llm", None, None, None, None, None, Some("topic:grant-application,intent:planning"), ) .await .unwrap(); let fetched = get_transcript(&pool, "tm-llm").await.unwrap().unwrap(); assert_eq!(fetched.llm_tags, "topic:grant-application,intent:planning",); assert_eq!(fetched.manual_tags, "", "manual_tags untouched"); } #[tokio::test] async fn update_task_overwrites_provided_fields() { // Task 2.6 — happy path: insert, update bucket + effort, read back. let pool = test_pool().await; insert_task(&pool, "u1", "Draft post", "inbox", None, None, None, None) .await .unwrap(); let row = update_task(&pool, "u1", None, Some("today"), None, Some("15m"), None) .await .unwrap(); assert_eq!(row.bucket, "today"); assert_eq!(row.effort.as_deref(), Some("15m")); assert_eq!(row.text, "Draft post", "text must be unchanged"); assert_eq!(row.notes, "", "notes default must survive"); } #[tokio::test] async fn update_task_partial_leaves_others_unchanged() { // Task 2.6 — partial update: only notes changes; text/bucket intact. let pool = test_pool().await; insert_task( &pool, "u2", "Prep slides", "today", None, None, Some("30m"), None, ) .await .unwrap(); let row = update_task( &pool, "u2", None, None, None, None, Some("remember venue wifi"), ) .await .unwrap(); assert_eq!(row.text, "Prep slides"); assert_eq!(row.bucket, "today"); assert_eq!(row.effort.as_deref(), Some("30m")); assert_eq!(row.notes, "remember venue wifi"); } #[tokio::test] async fn update_task_missing_id_errors() { let pool = test_pool().await; let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await; assert!( res.is_err(), "update_task must error when id does not exist" ); } // --- Energy tagging (Phase 3) --- #[tokio::test] async fn set_task_energy_round_trip_includes_explicit_clear() { let pool = test_pool().await; insert_task(&pool, "e1", "Write report", "inbox", None, None, None, None) .await .unwrap(); // Fresh task: energy must be NULL. let t = get_task_by_id(&pool, "e1").await.unwrap().unwrap(); assert!(t.energy.is_none(), "new task must start with energy unset"); // Set High. let t = set_task_energy(&pool, "e1", Some("high")).await.unwrap(); assert_eq!(t.energy.as_deref(), Some("high")); // Change to Medium. let t = set_task_energy(&pool, "e1", Some("medium")).await.unwrap(); assert_eq!(t.energy.as_deref(), Some("medium")); // Explicit clear back to NULL — the whole reason this function // exists separately from update_task's COALESCE semantics. let t = set_task_energy(&pool, "e1", None).await.unwrap(); assert!( t.energy.is_none(), "set_task_energy(None) must explicitly clear the column" ); } #[tokio::test] async fn set_task_energy_rejects_unknown_value_via_check_constraint() { // Migration v11 defines a CHECK constraint; invalid values must // be rejected at the DB layer even if a caller bypasses frontend // validation. let pool = test_pool().await; insert_task(&pool, "e2", "Task", "inbox", None, None, None, None) .await .unwrap(); let res = set_task_energy(&pool, "e2", Some("turbo")).await; assert!( res.is_err(), "CHECK constraint must reject energy values outside the enum" ); } // --- Profile CRUD tests (Task 11) --- #[tokio::test] async fn list_profiles_returns_seeded_default() { // Happy path: a fresh DB has exactly the seeded Default profile. let pool = test_pool().await; let profiles = list_profiles(&pool).await.unwrap(); assert_eq!(profiles.len(), 1, "Default profile must be seeded"); assert_eq!(profiles[0].id, crate::DEFAULT_PROFILE_ID); assert_eq!(profiles[0].name, "Default"); } #[tokio::test] async fn get_profile_happy_path_and_missing() { let pool = test_pool().await; let p = get_profile(&pool, crate::DEFAULT_PROFILE_ID).await.unwrap(); assert!(p.is_some()); assert_eq!(p.unwrap().name, "Default"); // Guardrail: unknown id returns None, not an error. let missing = get_profile(&pool, "00000000-0000-0000-0000-000000000099") .await .unwrap(); assert!(missing.is_none()); } #[tokio::test] async fn create_profile_happy_path() { let pool = test_pool().await; let row = create_profile(&pool, "Work", "You are a meeting notes assistant.") .await .unwrap(); assert_eq!(row.name, "Work"); assert_eq!(row.initial_prompt, "You are a meeting notes assistant."); assert!(!row.id.is_empty()); assert_ne!(row.id, crate::DEFAULT_PROFILE_ID); let profiles = list_profiles(&pool).await.unwrap(); assert_eq!(profiles.len(), 2, "Default + Work"); } #[tokio::test] async fn create_profile_rejects_duplicate_name() { // Guardrail: UNIQUE(name) collision surfaces as Error::Query. let pool = test_pool().await; create_profile(&pool, "Personal", "").await.unwrap(); let res = create_profile(&pool, "Personal", "").await; assert!(res.is_err(), "duplicate name must fail"); } #[tokio::test] async fn update_profile_happy_path() { let pool = test_pool().await; let created = create_profile(&pool, "ClientA", "prompt v1").await.unwrap(); update_profile(&pool, &created.id, "ClientA Renamed", "prompt v2") .await .unwrap(); let fetched = get_profile(&pool, &created.id).await.unwrap().unwrap(); assert_eq!(fetched.name, "ClientA Renamed"); assert_eq!(fetched.initial_prompt, "prompt v2"); } #[tokio::test] async fn update_profile_rejects_renaming_default() { // Guardrail: Rust layer rejects renaming the Default id; must not // reach sqlite trigger. let pool = test_pool().await; let res = update_profile(&pool, crate::DEFAULT_PROFILE_ID, "NotDefault", "").await; let msg = format!("{}", res.expect_err("must reject rename of Default")); assert!( msg.contains("Default profile cannot be renamed"), "got: {msg}" ); } #[tokio::test] async fn update_profile_rejects_renaming_other_to_default() { // Guardrail: another profile cannot adopt the name "Default". let pool = test_pool().await; let other = create_profile(&pool, "Temp", "").await.unwrap(); let res = update_profile(&pool, &other.id, "Default", "").await; let msg = format!("{}", res.expect_err("must reject adopting 'Default'")); assert!(msg.contains("Default"), "got: {msg}"); } #[tokio::test] async fn delete_profile_happy_path() { let pool = test_pool().await; let created = create_profile(&pool, "Ephemeral", "").await.unwrap(); delete_profile(&pool, &created.id).await.unwrap(); let fetched = get_profile(&pool, &created.id).await.unwrap(); assert!(fetched.is_none()); } #[tokio::test] async fn delete_profile_rejects_default() { let pool = test_pool().await; let res = delete_profile(&pool, crate::DEFAULT_PROFILE_ID).await; let msg = format!("{}", res.expect_err("must reject delete of Default")); assert!( msg.contains("Default profile cannot be deleted"), "got: {msg}" ); // And Default must still be listed. let profiles = list_profiles(&pool).await.unwrap(); assert_eq!(profiles.len(), 1); } #[tokio::test] async fn insert_transcript_rejects_unknown_profile_id() { let pool = test_pool().await; let err = insert_transcript( &pool, &InsertTranscriptParams { id: "bad-profile", text: "Hello", source: "microphone", profile_id: "profile-missing", title: None, audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .expect_err("unknown profile id must be rejected"); let msg = err.to_string(); assert!(msg.contains("unknown profile id"), "got: {msg}"); } #[tokio::test] async fn delete_profile_rejects_when_transcripts_reference_it() { let pool = test_pool().await; let profile = create_profile(&pool, "Referenced", "").await.unwrap(); insert_transcript( &pool, &InsertTranscriptParams { id: "referenced-transcript", text: "Hello", source: "microphone", profile_id: &profile.id, title: None, audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); let err = delete_profile(&pool, &profile.id) .await .expect_err("profile with transcript references must not delete"); let msg = err.to_string(); assert!(msg.contains("reassign transcripts first"), "got: {msg}"); } #[tokio::test] async fn list_profile_terms_happy_path() { let pool = test_pool().await; let p = create_profile(&pool, "WithTerms", "").await.unwrap(); add_profile_term(&pool, &p.id, "CORBEL", "company name") .await .unwrap(); add_profile_term(&pool, &p.id, "Wren", "agent name") .await .unwrap(); let terms = list_profile_terms(&pool, &p.id).await.unwrap(); assert_eq!(terms.len(), 2); // Sorted alphabetically. assert_eq!(terms[0].term, "CORBEL"); assert_eq!(terms[1].term, "Wren"); } #[tokio::test] async fn list_profile_terms_filters_by_profile() { // Guardrail: terms from one profile must not leak into another. let pool = test_pool().await; let a = create_profile(&pool, "A", "").await.unwrap(); let b = create_profile(&pool, "B", "").await.unwrap(); add_profile_term(&pool, &a.id, "alpha", "").await.unwrap(); add_profile_term(&pool, &b.id, "beta", "").await.unwrap(); let a_terms = list_profile_terms(&pool, &a.id).await.unwrap(); let b_terms = list_profile_terms(&pool, &b.id).await.unwrap(); assert_eq!(a_terms.len(), 1); assert_eq!(a_terms[0].term, "alpha"); assert_eq!(b_terms.len(), 1); assert_eq!(b_terms[0].term, "beta"); } #[tokio::test] async fn add_profile_term_happy_path() { let pool = test_pool().await; let p = create_profile(&pool, "P", "").await.unwrap(); let row = add_profile_term(&pool, &p.id, "ACME", "client") .await .unwrap(); assert_eq!(row.term, "ACME"); assert_eq!(row.note, "client"); assert_eq!(row.profile_id, p.id); } #[tokio::test] async fn add_profile_term_upsert_on_duplicate_term() { // Guardrail: UPSERT refreshes note and reuses the existing row id // rather than violating UNIQUE(profile_id, term). let pool = test_pool().await; let p = create_profile(&pool, "P2", "").await.unwrap(); let first = add_profile_term(&pool, &p.id, "ACME", "first note") .await .unwrap(); let second = add_profile_term(&pool, &p.id, "ACME", "second note") .await .unwrap(); assert_eq!(first.id, second.id, "UPSERT must preserve row id"); assert_eq!(second.note, "second note"); let terms = list_profile_terms(&pool, &p.id).await.unwrap(); assert_eq!(terms.len(), 1, "UPSERT must not create a second row"); } #[tokio::test] async fn delete_profile_term_happy_path() { let pool = test_pool().await; let p = create_profile(&pool, "P3", "").await.unwrap(); let t = add_profile_term(&pool, &p.id, "Foo", "").await.unwrap(); delete_profile_term(&pool, &t.id).await.unwrap(); let remaining = list_profile_terms(&pool, &p.id).await.unwrap(); assert!(remaining.is_empty()); } #[tokio::test] async fn delete_profile_term_missing_id_is_noop() { // Guardrail: deleting a non-existent term must not error; sqlite // DELETE on zero rows is a success. Verifies we're not hand-rolling // an existence check that would regress the UI's happy path on // double-click delete. let pool = test_pool().await; let p = create_profile(&pool, "NoopProfile", "").await.unwrap(); let t = add_profile_term(&pool, &p.id, "real", "").await.unwrap(); delete_profile_term(&pool, "00000000-dead-beef-0000-000000000000") .await .expect("missing id must be a silent no-op"); // Real term still exists. let terms = list_profile_terms(&pool, &p.id).await.unwrap(); assert_eq!(terms.len(), 1); assert_eq!(terms[0].id, t.id); } #[tokio::test] async fn delete_profile_cascades_to_terms() { // Guardrail: deleting a profile must cascade-delete its terms via // ON DELETE CASCADE (migration v6 FK). let pool = test_pool().await; let p = create_profile(&pool, "Doomed", "").await.unwrap(); add_profile_term(&pool, &p.id, "x", "").await.unwrap(); add_profile_term(&pool, &p.id, "y", "").await.unwrap(); delete_profile(&pool, &p.id).await.unwrap(); // Using a raw query because list_profile_terms would happily // return [] even if the FK had been forgotten. We want to prove // the child rows are actually gone from the table. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?") .bind(&p.id) .fetch_one(&pool) .await .unwrap(); assert_eq!(count, 0, "terms must cascade-delete with their profile"); } #[tokio::test] async fn settings_crud_roundtrip() { let pool = test_pool().await; set_setting(&pool, "theme", "dark").await.unwrap(); let val = get_setting(&pool, "theme").await.unwrap(); assert_eq!(val.as_deref(), Some("dark")); set_setting(&pool, "theme", "light").await.unwrap(); let val = get_setting(&pool, "theme").await.unwrap(); assert_eq!(val.as_deref(), Some("light")); let missing = get_setting(&pool, "nonexistent").await.unwrap(); assert!(missing.is_none()); } #[tokio::test] async fn cascade_sets_auto_completed_on_parent_only() { let pool = test_pool().await; // Parent + two subtasks. insert_task( &pool, "parent", "Parent task", "inbox", None, None, None, None, ) .await .unwrap(); insert_subtask(&pool, "s1", "Step 1", "parent") .await .unwrap(); insert_subtask(&pool, "s2", "Step 2", "parent") .await .unwrap(); complete_subtask_and_check_parent(&pool, "s1") .await .unwrap(); complete_subtask_and_check_parent(&pool, "s2") .await .unwrap(); // Parent auto-closed. let parent_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'") .fetch_one(&pool) .await .unwrap(); assert_eq!( parent_auto, 1, "parent closed by cascade must be auto_completed = 1" ); // Subtasks themselves are manual. let s1_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's1'") .fetch_one(&pool) .await .unwrap(); let s2_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's2'") .fetch_one(&pool) .await .unwrap(); assert_eq!(s1_auto, 0, "subtask completion is manual"); assert_eq!(s2_auto, 0, "subtask completion is manual"); } #[tokio::test] async fn uncomplete_clears_auto_completed_on_parent() { let pool = test_pool().await; insert_task( &pool, "parent", "Parent task", "inbox", None, None, None, None, ) .await .unwrap(); insert_subtask(&pool, "s1", "Only step", "parent") .await .unwrap(); // Cascade closes the parent with auto_completed = 1. complete_subtask_and_check_parent(&pool, "s1") .await .unwrap(); // Uncompleting the subtask reopens the parent (existing invariant) // and must also clear auto_completed on the parent so a later // manual re-completion is counted cleanly. uncomplete_task(&pool, "s1").await.unwrap(); let parent_done: i64 = sqlx::query_scalar("SELECT done FROM tasks WHERE id = 'parent'") .fetch_one(&pool) .await .unwrap(); let parent_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'") .fetch_one(&pool) .await .unwrap(); assert_eq!(parent_done, 0, "parent reopens when child is uncompleted"); assert_eq!(parent_auto, 0, "auto_completed must clear on reopen"); } #[tokio::test] async fn list_recent_completions_returns_n_entries_including_zero_days() { let pool = test_pool().await; // No completions at all. let series = list_recent_completions(&pool, 7).await.unwrap(); assert_eq!(series.len(), 7, "always returns exactly N entries"); assert!(series.iter().all(|d| d.count == 0), "all zero for empty DB"); // Oldest first, newest last. Dates should be strictly increasing. for w in series.windows(2) { assert!(w[0].day < w[1].day, "series must be oldest-first"); } } #[tokio::test] async fn list_recent_completions_excludes_auto_cascade_parents() { let pool = test_pool().await; insert_task(&pool, "p", "Parent", "inbox", None, None, None, None) .await .unwrap(); insert_subtask(&pool, "s1", "Step 1", "p").await.unwrap(); // Manual subtask complete. Cascade auto-closes parent. complete_subtask_and_check_parent(&pool, "s1") .await .unwrap(); let series = list_recent_completions(&pool, 7).await.unwrap(); let total: u32 = series.iter().map(|d| d.count).sum(); assert_eq!( total, 1, "subtask counts (1), cascade parent excluded. Got series: {series:?}" ); } #[tokio::test] async fn list_recent_completions_counts_manual_top_level() { let pool = test_pool().await; insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None) .await .unwrap(); complete_task(&pool, "t1").await.unwrap(); let series = list_recent_completions(&pool, 7).await.unwrap(); let total: u32 = series.iter().map(|d| d.count).sum(); assert_eq!(total, 1); // Most recent entry is today (local) and holds the 1. assert_eq!(series.last().unwrap().count, 1); } #[tokio::test] async fn list_recent_completions_excludes_uncompleted() { let pool = test_pool().await; insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None) .await .unwrap(); complete_task(&pool, "t1").await.unwrap(); uncomplete_task(&pool, "t1").await.unwrap(); let series = list_recent_completions(&pool, 7).await.unwrap(); let total: u32 = series.iter().map(|d| d.count).sum(); assert_eq!( total, 0, "uncompleted rows have NULL done_at and must not count" ); } #[tokio::test] async fn list_recent_completions_uses_local_day_boundary() { // Insert two rows with done_at values on either side of local // midnight (expressed in UTC, which is how datetime('now') stores). // The row whose local-day is "today" must land in today's bucket. // The row whose local-day is "yesterday" must land in yesterday's. // // We avoid hard-coding dates. Compute them at runtime from // SQLite so the test is stable across the real clock. The chosen // UTC times (2 days ago noon) are safely either side of midnight // for any local timezone that matters for this project. let pool = test_pool().await; // Seed two tasks that we will manipulate done_at on directly. insert_task(&pool, "a", "A", "inbox", None, None, None, None) .await .unwrap(); insert_task(&pool, "b", "B", "inbox", None, None, None, None) .await .unwrap(); // Put row "a" at a done_at that is definitely "2 days ago" in // the same local-day spine the query uses. Anchor to the local // date 2 days ago, then suffix " 12:00:00" so the bucket survives // timezone shifts at the query time (noon is far enough from // either day boundary). Phase 8's earlier "-2 days, start of // day, +12 hours" form mixed UTC `now` with the local-time spine // and drifted to "3 days ago" near UTC midnight; this anchor // matches the spine directly. Row "b" stays at the current moment. sqlx::query( "UPDATE tasks SET done = 1, \ done_at = datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00'), \ auto_completed = 0 WHERE id = 'a'", ) .execute(&pool) .await .unwrap(); sqlx::query( "UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 0 \ WHERE id = 'b'", ) .execute(&pool) .await .unwrap(); let series = list_recent_completions(&pool, 7).await.unwrap(); assert_eq!(series.len(), 7); // "today" is the last entry. "2 days ago" is 2 positions before // the last entry (0-indexed from end: -1 today, -2 yesterday, // -3 two days ago). let today_count = series.last().unwrap().count; let two_days_ago_count = series[series.len() - 3].count; assert_eq!(today_count, 1, "row B is today: {series:?}"); assert_eq!(two_days_ago_count, 1, "row A is 2 days ago: {series:?}"); let total: u32 = series.iter().map(|d| d.count).sum(); assert_eq!(total, 2, "exactly two completions across the window"); } #[tokio::test] async fn init_readonly_rejects_writes_and_serves_reads() { let dir = std::env::temp_dir().join(format!("lumotia-storage-ro-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("ro.db"); let _ = std::fs::remove_file(&path); // Seed via the writable path so migrations run. let writable = init(&path).await.unwrap(); insert_transcript( &writable, &InsertTranscriptParams { id: "ro1", text: "seed", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: None, audio_path: None, duration: 0.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: false, anti_hallucination: false, }, ) .await .unwrap(); writable.close().await; // Reopen read-only. let ro = init_readonly(&path).await.expect("read-only open"); assert_eq!(count_transcripts(&ro).await.unwrap(), 1, "reads must work"); let write_attempt = sqlx::query("DELETE FROM transcripts WHERE id = 'ro1'") .execute(&ro) .await; assert!( write_attempt.is_err(), "writes must be rejected on the read-only pool", ); ro.close().await; let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } #[tokio::test] async fn init_readonly_fails_when_db_missing() { let path = std::env::temp_dir().join(format!( "lumotia-storage-ro-missing-{}.db", std::process::id() )); let _ = std::fs::remove_file(&path); assert!( init_readonly(&path).await.is_err(), "must fail when DB file does not exist (no create_if_missing)", ); } #[tokio::test] async fn prune_error_log_removes_old_rows_only() { let pool = test_pool().await; // Three rows: one from now, one 30 days old, one 200 days old. sqlx::query( "INSERT INTO error_log (timestamp, context, error_code, message) VALUES \ (datetime('now'), 'recent', NULL, 'now'), \ (datetime('now', '-30 days'), 'older', NULL, '30d'), \ (datetime('now', '-200 days'), 'oldest', NULL, '200d')", ) .execute(&pool) .await .unwrap(); // Default 90-day retention removes only the 200-day row. let removed = prune_error_log(&pool, 90).await.unwrap(); assert_eq!(removed, 1, "only the 200-day row should be pruned"); let remaining: Vec = sqlx::query_scalar("SELECT context FROM error_log ORDER BY id ASC") .fetch_all(&pool) .await .unwrap(); assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]); // A 14-day window prunes the 30-day row too. let removed = prune_error_log(&pool, 14).await.unwrap(); assert_eq!(removed, 1); let remaining: Vec = sqlx::query_scalar("SELECT context FROM error_log") .fetch_all(&pool) .await .unwrap(); assert_eq!(remaining, vec!["recent".to_string()]); } #[tokio::test] async fn migrate_legacy_setting_keys_renames_lone_magnotia_rows() { let pool = test_pool().await; set_setting(&pool, "magnotia_preferences", "blob-A") .await .unwrap(); set_setting(&pool, "magnotia_morning_triage_last_shown", "2026-05-12") .await .unwrap(); let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); assert_eq!(renamed, 2); assert_eq!(deleted, 0); assert_eq!( get_setting(&pool, "lumotia_preferences") .await .unwrap() .as_deref(), Some("blob-A") ); assert_eq!( get_setting(&pool, "magnotia_preferences").await.unwrap(), None ); assert_eq!( get_setting(&pool, "lumotia_morning_triage_last_shown") .await .unwrap() .as_deref(), Some("2026-05-12") ); } #[tokio::test] async fn migrate_legacy_setting_keys_deletes_orphans_when_both_present() { let pool = test_pool().await; set_setting(&pool, "magnotia_preferences", "legacy-blob") .await .unwrap(); set_setting(&pool, "lumotia_preferences", "current-blob") .await .unwrap(); let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); assert_eq!(renamed, 0); assert_eq!(deleted, 1); assert_eq!( get_setting(&pool, "lumotia_preferences") .await .unwrap() .as_deref(), Some("current-blob"), "lumotia row preserved verbatim" ); assert_eq!( get_setting(&pool, "magnotia_preferences").await.unwrap(), None, "orphan lumotia row deleted" ); } #[tokio::test] async fn migrate_legacy_setting_keys_no_op_when_no_magnotia_rows() { let pool = test_pool().await; set_setting(&pool, "lumotia_preferences", "blob") .await .unwrap(); let (renamed, deleted) = migrate_legacy_setting_keys(&pool).await.unwrap(); assert_eq!(renamed, 0); assert_eq!(deleted, 0); } #[tokio::test] async fn migrate_legacy_setting_keys_is_idempotent() { let pool = test_pool().await; set_setting(&pool, "magnotia_preferences", "blob") .await .unwrap(); let first = migrate_legacy_setting_keys(&pool).await.unwrap(); let second = migrate_legacy_setting_keys(&pool).await.unwrap(); assert_eq!(first, (1, 0)); assert_eq!(second, (0, 0)); } fn minimal_transcript( id: &'static str, audio_path: Option<&'static str>, ) -> InsertTranscriptParams<'static> { InsertTranscriptParams { id, text: "soft-delete fixture", source: "microphone", profile_id: crate::DEFAULT_PROFILE_ID, title: None, audio_path, duration: 1.0, engine: None, model_id: None, inference_ms: None, sample_rate: None, audio_channels: None, format_mode: None, remove_fillers: false, british_english: true, anti_hallucination: false, } } #[tokio::test] async fn delete_transcript_soft_deletes() { // Rev-2 contract: delete_transcript flips `deleted_at`, does not // remove the row. A direct SELECT bypassing the deleted_at filter // must still find the row. let pool = test_pool().await; insert_transcript(&pool, &minimal_transcript("t-soft", None)) .await .unwrap(); delete_transcript(&pool, "t-soft").await.unwrap(); let deleted_at: Option = sqlx::query_scalar("SELECT deleted_at FROM transcripts WHERE id = ?") .bind("t-soft") .fetch_one(&pool) .await .unwrap(); assert!( deleted_at.is_some(), "row should still exist with deleted_at set" ); } #[tokio::test] async fn delete_transcript_removes_audio_file() { // Rev-3 contract: best-effort fs::remove_file fires when the // soft-delete actually flipped a row. A repeat delete is a // no-op and does NOT fail when the file is already gone. let pool = test_pool().await; let tmp = std::env::temp_dir().join(format!("lumotia-test-{}.wav", std::process::id())); std::fs::write(&tmp, b"fake wav").unwrap(); let path_owned = tmp.to_string_lossy().to_string(); let path_static: &'static str = Box::leak(path_owned.into_boxed_str()); insert_transcript(&pool, &minimal_transcript("t-audio", Some(path_static))) .await .unwrap(); assert!(tmp.exists(), "fixture file should exist pre-delete"); delete_transcript(&pool, "t-audio").await.unwrap(); assert!( !tmp.exists(), "audio file should be removed by delete_transcript" ); // Repeat delete must not surface an error even though both the // soft-delete UPDATE is a no-op AND the audio file is already gone. delete_transcript(&pool, "t-audio").await.unwrap(); } #[tokio::test] async fn list_transcripts_excludes_soft_deleted() { // Rev-2 list contract: soft-deleted rows are invisible to the // regular list path; the trash view is the inverse. let pool = test_pool().await; insert_transcript(&pool, &minimal_transcript("t-live", None)) .await .unwrap(); insert_transcript(&pool, &minimal_transcript("t-trashed", None)) .await .unwrap(); delete_transcript(&pool, "t-trashed").await.unwrap(); let live = list_transcripts(&pool, 100).await.unwrap(); assert_eq!(live.len(), 1); assert_eq!(live[0].id, "t-live"); let trashed = list_trashed_transcripts(&pool, 100, 0).await.unwrap(); assert_eq!(trashed.len(), 1); assert_eq!(trashed[0].id, "t-trashed"); let total = count_transcripts(&pool).await.unwrap(); assert_eq!(total, 1, "count_transcripts excludes the trash"); // Restore brings the row back into the live list. restore_transcript(&pool, "t-trashed").await.unwrap(); let after_restore = list_transcripts(&pool, 100).await.unwrap(); assert_eq!(after_restore.len(), 2); } #[tokio::test] async fn purge_deleted_transcripts_hard_deletes_old() { // Retention contract: purge_deleted_transcripts hard-removes rows // whose deleted_at is older than `older_than_days`. Newer trash // rows are preserved. let pool = test_pool().await; insert_transcript(&pool, &minimal_transcript("t-old", None)) .await .unwrap(); insert_transcript(&pool, &minimal_transcript("t-new", None)) .await .unwrap(); delete_transcript(&pool, "t-old").await.unwrap(); delete_transcript(&pool, "t-new").await.unwrap(); // Backdate t-old past the 30-day window. t-new keeps "now". sqlx::query("UPDATE transcripts SET deleted_at = datetime('now', '-60 days') WHERE id = ?") .bind("t-old") .execute(&pool) .await .unwrap(); let purged = purge_deleted_transcripts(&pool, 30).await.unwrap(); assert_eq!(purged, 1, "only t-old should be hard-deleted"); let old_exists: Option = sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") .bind("t-old") .fetch_optional(&pool) .await .unwrap(); assert!(old_exists.is_none(), "t-old should be hard-gone"); let new_exists: Option = sqlx::query_scalar("SELECT id FROM transcripts WHERE id = ?") .bind("t-new") .fetch_optional(&pool) .await .unwrap(); assert!( new_exists.is_some(), "t-new still inside the retention window" ); } }