Skip to main content

sqlite_graphrag/storage/
memories.rs

1//! Persistence layer for the `memories` table and its vector companion.
2//!
3//! Functions here encapsulate every SQL statement touching `memories`,
4//! `vec_memories` and the FTS5 `fts_memories` shadow table. Callers receive
5//! typed [`MemoryRow`] or [`NewMemory`] values and never build SQL strings.
6
7use crate::embedder::f32_to_bytes;
8use crate::errors::AppError;
9use crate::storage::utils::with_busy_retry;
10use rusqlite::{params, Connection};
11use serde::{Deserialize, Serialize};
12
13/// Input payload for inserting or updating a memory.
14///
15/// `body_hash` must be the BLAKE3 digest of `body`. The `metadata` field is
16/// stored as a TEXT column containing JSON.
17#[derive(Debug, Serialize, Deserialize)]
18pub struct NewMemory {
19    pub namespace: String,
20    pub name: String,
21    pub memory_type: String,
22    pub description: String,
23    pub body: String,
24    pub body_hash: String,
25    pub session_id: Option<String>,
26    pub source: String,
27    pub metadata: serde_json::Value,
28}
29
30/// Fully materialized row from the `memories` table.
31///
32/// Returned by [`read_by_name`], [`read_full`], [`list`] and [`fts_search`].
33/// The `metadata` field is kept as a JSON string to avoid double parsing.
34#[derive(Debug, Serialize)]
35pub struct MemoryRow {
36    pub id: i64,
37    pub namespace: String,
38    pub name: String,
39    pub memory_type: String,
40    pub description: String,
41    pub body: String,
42    pub body_hash: String,
43    pub session_id: Option<String>,
44    pub source: String,
45    pub metadata: String,
46    pub created_at: i64,
47    pub updated_at: i64,
48    /// Unix epoch when the memory was soft-deleted, or `None` for active memories.
49    /// Surfaced in `list --include-deleted --json` so LLM consumers can distinguish
50    /// active from soft-deleted rows without a second SQL query (v1.0.37 H7+M9 fix).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub deleted_at: Option<i64>,
53}
54
55/// Finds a live memory by `(namespace, name)` and returns key metadata.
56///
57/// # Arguments
58///
59/// - `conn` — open SQLite connection configured with the project pragmas.
60/// - `namespace` — resolved namespace for the lookup.
61/// - `name` — kebab-case memory name.
62///
63/// # Returns
64///
65/// `Ok(Some((id, updated_at, max_version)))` when the memory exists and is
66/// not soft-deleted, `Ok(None)` otherwise.
67///
68/// # Errors
69///
70/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
71pub fn find_by_name(
72    conn: &Connection,
73    namespace: &str,
74    name: &str,
75) -> Result<Option<(i64, i64, i64)>, AppError> {
76    let mut stmt = conn.prepare_cached(
77        "SELECT m.id, m.updated_at, COALESCE(MAX(v.version), 0)
78         FROM memories m
79         LEFT JOIN memory_versions v ON v.memory_id = m.id
80         WHERE m.namespace = ?1 AND m.name = ?2 AND m.deleted_at IS NULL
81         GROUP BY m.id",
82    )?;
83    let result = stmt.query_row(params![namespace, name], |r| {
84        Ok((
85            r.get::<_, i64>(0)?,
86            r.get::<_, i64>(1)?,
87            r.get::<_, i64>(2)?,
88        ))
89    });
90    match result {
91        Ok(row) => Ok(Some(row)),
92        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
93        Err(e) => Err(AppError::Database(e)),
94    }
95}
96
97/// Looks up a live memory by exact `body_hash` within a namespace.
98///
99/// Used during `remember` to short-circuit semantic duplicates before
100/// spending an embedding call.
101///
102/// # Returns
103///
104/// `Ok(Some(id))` when a live memory with the same hash exists,
105/// `Ok(None)` otherwise.
106///
107/// # Errors
108///
109/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
110pub fn find_by_hash(
111    conn: &Connection,
112    namespace: &str,
113    body_hash: &str,
114) -> Result<Option<i64>, AppError> {
115    let mut stmt = conn.prepare_cached(
116        "SELECT id FROM memories WHERE namespace = ?1 AND body_hash = ?2 AND deleted_at IS NULL",
117    )?;
118    match stmt.query_row(params![namespace, body_hash], |r| r.get(0)) {
119        Ok(id) => Ok(Some(id)),
120        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
121        Err(e) => Err(AppError::Database(e)),
122    }
123}
124
125/// Inserts a new row into the `memories` table.
126///
127/// # Arguments
128///
129/// - `conn` — active SQLite connection, typically inside a transaction.
130/// - `m` — validated payload including `body_hash` and serialized metadata.
131///
132/// # Returns
133///
134/// The `rowid` assigned to the newly inserted memory.
135///
136/// # Errors
137///
138/// Returns `Err(AppError::Database)` on insertion failure and
139/// `Err(AppError::Json)` if metadata serialization fails.
140pub fn insert(conn: &Connection, m: &NewMemory) -> Result<i64, AppError> {
141    conn.execute(
142        "INSERT INTO memories (namespace, name, type, description, body, body_hash, session_id, source, metadata)
143         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
144        params![
145            m.namespace, m.name, m.memory_type, m.description, m.body,
146            m.body_hash, m.session_id, m.source,
147            serde_json::to_string(&m.metadata)?
148        ],
149    )?;
150    Ok(conn.last_insert_rowid())
151}
152
153/// Updates an existing memory optionally guarded by optimistic concurrency.
154///
155/// When `expected_updated_at` is `Some(ts)` the row is only updated if its
156/// current `updated_at` equals `ts`. This protects concurrent `edit` calls
157/// from silently clobbering each other.
158///
159/// # Returns
160///
161/// `Ok(true)` when exactly one row was updated, `Ok(false)` when the
162/// optimistic check failed or the memory does not exist.
163///
164/// # Errors
165///
166/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
167pub fn update(
168    conn: &Connection,
169    id: i64,
170    m: &NewMemory,
171    expected_updated_at: Option<i64>,
172) -> Result<bool, AppError> {
173    let affected = if let Some(ts) = expected_updated_at {
174        conn.execute(
175            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
176             session_id=?6, source=?7, metadata=?8
177             WHERE id=?1 AND updated_at=?9 AND deleted_at IS NULL",
178            params![
179                id,
180                m.memory_type,
181                m.description,
182                m.body,
183                m.body_hash,
184                m.session_id,
185                m.source,
186                serde_json::to_string(&m.metadata)?,
187                ts
188            ],
189        )?
190    } else {
191        conn.execute(
192            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
193             session_id=?6, source=?7, metadata=?8
194             WHERE id=?1 AND deleted_at IS NULL",
195            params![
196                id,
197                m.memory_type,
198                m.description,
199                m.body,
200                m.body_hash,
201                m.session_id,
202                m.source,
203                serde_json::to_string(&m.metadata)?
204            ],
205        )?
206    };
207    Ok(affected == 1)
208}
209
210/// Replaces the vector row for a memory in `vec_memories`.
211///
212/// `sqlite-vec` virtual tables do not implement `INSERT OR REPLACE`, so the
213/// existing row is deleted first and a fresh vector is inserted. Callers
214/// must pass an `embedding` with length [`crate::constants::EMBEDDING_DIM`].
215///
216/// # Errors
217///
218/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
219pub fn upsert_vec(
220    conn: &Connection,
221    memory_id: i64,
222    namespace: &str,
223    memory_type: &str,
224    embedding: &[f32],
225    name: &str,
226    snippet: &str,
227) -> Result<(), AppError> {
228    // sqlite-vec virtual tables do not support INSERT OR REPLACE semantics.
229    // Must delete the existing row first, then insert.  Both statements are
230    // wrapped in `with_busy_retry` because WAL-mode concurrent writers can
231    // cause SQLITE_BUSY on vec0 virtual table writes.
232    let embedding_bytes = f32_to_bytes(embedding);
233    with_busy_retry(|| {
234        conn.execute(
235            "DELETE FROM vec_memories WHERE memory_id = ?1",
236            params![memory_id],
237        )?;
238        conn.execute(
239            "INSERT INTO vec_memories(memory_id, namespace, type, embedding, name, snippet)
240             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
241            params![
242                memory_id,
243                namespace,
244                memory_type,
245                &embedding_bytes,
246                name,
247                snippet
248            ],
249        )?;
250        Ok(())
251    })
252}
253
254/// Deletes the vector row for `memory_id` from `vec_memories`.
255///
256/// Called during `forget` and `purge` to keep the vector table consistent
257/// with the logical state of `memories`.
258///
259/// # Errors
260///
261/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
262pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
263    conn.execute(
264        "DELETE FROM vec_memories WHERE memory_id = ?1",
265        params![memory_id],
266    )?;
267    Ok(())
268}
269
270/// Fetches a live memory by `(namespace, name)` and returns all columns.
271///
272/// # Returns
273///
274/// `Ok(Some(row))` when found, `Ok(None)` when missing or soft-deleted.
275///
276/// # Errors
277///
278/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
279pub fn read_by_name(
280    conn: &Connection,
281    namespace: &str,
282    name: &str,
283) -> Result<Option<MemoryRow>, AppError> {
284    let mut stmt = conn.prepare_cached(
285        "SELECT id, namespace, name, type, description, body, body_hash,
286                session_id, source, metadata, created_at, updated_at, deleted_at
287         FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
288    )?;
289    match stmt.query_row(params![namespace, name], |r| {
290        Ok(MemoryRow {
291            id: r.get(0)?,
292            namespace: r.get(1)?,
293            name: r.get(2)?,
294            memory_type: r.get(3)?,
295            description: r.get(4)?,
296            body: r.get(5)?,
297            body_hash: r.get(6)?,
298            session_id: r.get(7)?,
299            source: r.get(8)?,
300            metadata: r.get(9)?,
301            created_at: r.get(10)?,
302            updated_at: r.get(11)?,
303            deleted_at: r.get(12)?,
304        })
305    }) {
306        Ok(m) => Ok(Some(m)),
307        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
308        Err(e) => Err(AppError::Database(e)),
309    }
310}
311
312/// Soft-deletes a memory by setting `deleted_at = unixepoch()`.
313///
314/// Versions and chunks are preserved so `restore` can undo the operation
315/// until a subsequent `purge` reclaims the storage permanently.
316///
317/// # Returns
318///
319/// `Ok(true)` when a live memory was soft-deleted, `Ok(false)` when no
320/// matching live row existed.
321///
322/// # Errors
323///
324/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
325pub fn soft_delete(conn: &Connection, namespace: &str, name: &str) -> Result<bool, AppError> {
326    let affected = conn.execute(
327        "UPDATE memories SET deleted_at = unixepoch() WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
328        params![namespace, name],
329    )?;
330    Ok(affected == 1)
331}
332
333/// Lists live memories in a namespace ordered by `updated_at` descending.
334///
335/// # Arguments
336///
337/// - `memory_type` — optional filter on the `type` column.
338/// - `limit` / `offset` — standard pagination controls in rows.
339///
340/// # Errors
341///
342/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
343pub fn list(
344    conn: &Connection,
345    namespace: &str,
346    memory_type: Option<&str>,
347    limit: usize,
348    offset: usize,
349    include_deleted: bool,
350) -> Result<Vec<MemoryRow>, AppError> {
351    let deleted_clause = if include_deleted {
352        ""
353    } else {
354        " AND deleted_at IS NULL"
355    };
356    if let Some(mt) = memory_type {
357        let sql = format!(
358            "SELECT id, namespace, name, type, description, body, body_hash,
359                    session_id, source, metadata, created_at, updated_at, deleted_at
360             FROM memories WHERE namespace=?1 AND type=?2{deleted_clause}
361             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
362        );
363        let mut stmt = conn.prepare(&sql)?;
364        let rows = stmt
365            .query_map(params![namespace, mt, limit as i64, offset as i64], |r| {
366                Ok(MemoryRow {
367                    id: r.get(0)?,
368                    namespace: r.get(1)?,
369                    name: r.get(2)?,
370                    memory_type: r.get(3)?,
371                    description: r.get(4)?,
372                    body: r.get(5)?,
373                    body_hash: r.get(6)?,
374                    session_id: r.get(7)?,
375                    source: r.get(8)?,
376                    metadata: r.get(9)?,
377                    created_at: r.get(10)?,
378                    updated_at: r.get(11)?,
379                    deleted_at: r.get(12)?,
380                })
381            })?
382            .collect::<Result<Vec<_>, _>>()?;
383        Ok(rows)
384    } else {
385        let sql = format!(
386            "SELECT id, namespace, name, type, description, body, body_hash,
387                    session_id, source, metadata, created_at, updated_at, deleted_at
388             FROM memories WHERE namespace=?1{deleted_clause}
389             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
390        );
391        let mut stmt = conn.prepare(&sql)?;
392        let rows = stmt
393            .query_map(params![namespace, limit as i64, offset as i64], |r| {
394                Ok(MemoryRow {
395                    id: r.get(0)?,
396                    namespace: r.get(1)?,
397                    name: r.get(2)?,
398                    memory_type: r.get(3)?,
399                    description: r.get(4)?,
400                    body: r.get(5)?,
401                    body_hash: r.get(6)?,
402                    session_id: r.get(7)?,
403                    source: r.get(8)?,
404                    metadata: r.get(9)?,
405                    created_at: r.get(10)?,
406                    updated_at: r.get(11)?,
407                    deleted_at: r.get(12)?,
408                })
409            })?
410            .collect::<Result<Vec<_>, _>>()?;
411        Ok(rows)
412    }
413}
414
415/// Runs a KNN search over `vec_memories`, optionally restricted to namespaces.
416///
417/// # Arguments
418///
419/// - `embedding` — query vector of length [`crate::constants::EMBEDDING_DIM`].
420/// - `namespaces` — namespaces to search. Empty slice means "all namespaces".
421/// - `memory_type` — optional filter on the `type` column.
422/// - `k` — maximum number of hits to return.
423///
424/// # Returns
425///
426/// A vector of `(memory_id, distance)` pairs sorted by ascending distance.
427///
428/// # Errors
429///
430/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
431pub fn knn_search(
432    conn: &Connection,
433    embedding: &[f32],
434    namespaces: &[String],
435    memory_type: Option<&str>,
436    k: usize,
437) -> Result<Vec<(i64, f32)>, AppError> {
438    let bytes = f32_to_bytes(embedding);
439
440    match namespaces.len() {
441        0 => {
442            // No namespace filter — search all namespaces.
443            if let Some(mt) = memory_type {
444                let mut stmt = conn.prepare(
445                    "SELECT memory_id, distance FROM vec_memories \
446                     WHERE embedding MATCH ?1 AND type = ?2 \
447                     ORDER BY distance LIMIT ?3",
448                )?;
449                let rows = stmt
450                    .query_map(params![bytes, mt, k as i64], |r| {
451                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
452                    })?
453                    .collect::<Result<Vec<_>, _>>()?;
454                Ok(rows)
455            } else {
456                let mut stmt = conn.prepare(
457                    "SELECT memory_id, distance FROM vec_memories \
458                     WHERE embedding MATCH ?1 \
459                     ORDER BY distance LIMIT ?2",
460                )?;
461                let rows = stmt
462                    .query_map(params![bytes, k as i64], |r| {
463                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
464                    })?
465                    .collect::<Result<Vec<_>, _>>()?;
466                Ok(rows)
467            }
468        }
469        1 => {
470            // Fast single-namespace path (preserved from previous implementation).
471            let ns = &namespaces[0];
472            if let Some(mt) = memory_type {
473                let mut stmt = conn.prepare(
474                    "SELECT memory_id, distance FROM vec_memories \
475                     WHERE embedding MATCH ?1 AND namespace = ?2 AND type = ?3 \
476                     ORDER BY distance LIMIT ?4",
477                )?;
478                let rows = stmt
479                    .query_map(params![bytes, ns, mt, k as i64], |r| {
480                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
481                    })?
482                    .collect::<Result<Vec<_>, _>>()?;
483                Ok(rows)
484            } else {
485                let mut stmt = conn.prepare(
486                    "SELECT memory_id, distance FROM vec_memories \
487                     WHERE embedding MATCH ?1 AND namespace = ?2 \
488                     ORDER BY distance LIMIT ?3",
489                )?;
490                let rows = stmt
491                    .query_map(params![bytes, ns, k as i64], |r| {
492                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
493                    })?
494                    .collect::<Result<Vec<_>, _>>()?;
495                Ok(rows)
496            }
497        }
498        _ => {
499            // Multiple explicit namespaces: build IN clause with positional placeholders.
500            // rusqlite does not support array binding, so we generate "?,?,..." manually.
501            let placeholders = (0..namespaces.len())
502                .map(|_| "?")
503                .collect::<Vec<_>>()
504                .join(",");
505            if let Some(mt) = memory_type {
506                let query = format!(
507                    "SELECT memory_id, distance FROM vec_memories \
508                     WHERE embedding MATCH ? AND type = ? AND namespace IN ({placeholders}) \
509                     ORDER BY distance LIMIT ?"
510                );
511                let mut stmt = conn.prepare(&query)?;
512                // Params: [bytes, mt, ns0, ns1, ..., k]
513                let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> =
514                    vec![Box::new(bytes), Box::new(mt.to_string())];
515                for ns in namespaces {
516                    raw_params.push(Box::new(ns.clone()));
517                }
518                raw_params.push(Box::new(k as i64));
519                let param_refs: Vec<&dyn rusqlite::ToSql> =
520                    raw_params.iter().map(|b| b.as_ref()).collect();
521                let rows = stmt
522                    .query_map(param_refs.as_slice(), |r| {
523                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
524                    })?
525                    .collect::<Result<Vec<_>, _>>()?;
526                Ok(rows)
527            } else {
528                let query = format!(
529                    "SELECT memory_id, distance FROM vec_memories \
530                     WHERE embedding MATCH ? AND namespace IN ({placeholders}) \
531                     ORDER BY distance LIMIT ?"
532                );
533                let mut stmt = conn.prepare(&query)?;
534                // Params: [bytes, ns0, ns1, ..., k]
535                let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(bytes)];
536                for ns in namespaces {
537                    raw_params.push(Box::new(ns.clone()));
538                }
539                raw_params.push(Box::new(k as i64));
540                let param_refs: Vec<&dyn rusqlite::ToSql> =
541                    raw_params.iter().map(|b| b.as_ref()).collect();
542                let rows = stmt
543                    .query_map(param_refs.as_slice(), |r| {
544                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
545                    })?
546                    .collect::<Result<Vec<_>, _>>()?;
547                Ok(rows)
548            }
549        }
550    }
551}
552
553/// Fetches a live memory by primary key and returns all columns.
554///
555/// Mirrors [`read_by_name`] but keyed on `rowid` for use after a KNN search.
556///
557/// # Errors
558///
559/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
560pub fn read_full(conn: &Connection, memory_id: i64) -> Result<Option<MemoryRow>, AppError> {
561    let mut stmt = conn.prepare_cached(
562        "SELECT id, namespace, name, type, description, body, body_hash,
563                session_id, source, metadata, created_at, updated_at, deleted_at
564         FROM memories WHERE id=?1 AND deleted_at IS NULL",
565    )?;
566    match stmt.query_row(params![memory_id], |r| {
567        Ok(MemoryRow {
568            id: r.get(0)?,
569            namespace: r.get(1)?,
570            name: r.get(2)?,
571            memory_type: r.get(3)?,
572            description: r.get(4)?,
573            body: r.get(5)?,
574            body_hash: r.get(6)?,
575            session_id: r.get(7)?,
576            source: r.get(8)?,
577            metadata: r.get(9)?,
578            created_at: r.get(10)?,
579            updated_at: r.get(11)?,
580            deleted_at: r.get(12)?,
581        })
582    }) {
583        Ok(m) => Ok(Some(m)),
584        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
585        Err(e) => Err(AppError::Database(e)),
586    }
587}
588
589/// Fetches all memory_ids in a namespace that are soft-deleted and whose
590/// `deleted_at` is older than `before_ts` (unix epoch seconds).
591///
592/// Used by `purge` to collect stale rows for permanent deletion.
593///
594/// # Errors
595///
596/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
597pub fn list_deleted_before(
598    conn: &Connection,
599    namespace: &str,
600    before_ts: i64,
601) -> Result<Vec<i64>, AppError> {
602    let mut stmt = conn.prepare_cached(
603        "SELECT id FROM memories WHERE namespace = ?1 AND deleted_at IS NOT NULL AND deleted_at < ?2",
604    )?;
605    let ids = stmt
606        .query_map(params![namespace, before_ts], |r| r.get::<_, i64>(0))?
607        .collect::<Result<Vec<_>, _>>()?;
608    Ok(ids)
609}
610
611/// Preprocesses a raw user query for FTS5 `MATCH`.
612///
613/// Technical separators (`-`, `.`, `_`, `/`) are treated as word boundaries by
614/// the `unicode61` tokenizer.  When the query contains any of these characters
615/// the function builds a compound FTS5 expression:
616///   1. A phrase query with the separated tokens (exact compound matching).
617///   2. Individual prefix terms joined with OR (broader recall).
618///
619/// Queries without separators keep the original `term*` prefix behaviour.
620fn preprocess_fts_query(raw: &str) -> String {
621    const SEPARATORS: &[char] = &['-', '.', '_', '/'];
622    let trimmed = raw.trim();
623    if trimmed.is_empty() {
624        return String::new();
625    }
626    if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
627        return trimmed
628            .split_whitespace()
629            .map(|t| format!("{t}*"))
630            .collect::<Vec<_>>()
631            .join(" ");
632    }
633    let tokens: Vec<&str> = trimmed
634        .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
635        .filter(|t| !t.is_empty())
636        .collect();
637    if tokens.is_empty() {
638        return String::new();
639    }
640    let phrase = format!("\"{}\"", tokens.join(" "));
641    let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
642    format!("{phrase} OR {}", prefix_terms.join(" OR "))
643}
644
645/// Executes an FTS5 search against `fts_memories` with query preprocessing.
646///
647/// Technical separators in the query are converted to phrase + prefix OR
648/// expressions so compound terms like `graphrag-precompact.sh` match correctly.
649///
650/// # Errors
651///
652/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
653pub fn fts_search(
654    conn: &Connection,
655    query: &str,
656    namespace: &str,
657    memory_type: Option<&str>,
658    limit: usize,
659) -> Result<Vec<MemoryRow>, AppError> {
660    let fts_query = preprocess_fts_query(query);
661    if let Some(mt) = memory_type {
662        let mut stmt = conn.prepare(
663            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
664                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
665             FROM fts_memories fts
666             JOIN memories m ON m.id = fts.rowid
667             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.type = ?3 AND m.deleted_at IS NULL
668             ORDER BY rank LIMIT ?4",
669        )?;
670        let rows = stmt
671            .query_map(params![fts_query, namespace, mt, limit as i64], |r| {
672                Ok(MemoryRow {
673                    id: r.get(0)?,
674                    namespace: r.get(1)?,
675                    name: r.get(2)?,
676                    memory_type: r.get(3)?,
677                    description: r.get(4)?,
678                    body: r.get(5)?,
679                    body_hash: r.get(6)?,
680                    session_id: r.get(7)?,
681                    source: r.get(8)?,
682                    metadata: r.get(9)?,
683                    created_at: r.get(10)?,
684                    updated_at: r.get(11)?,
685                    deleted_at: r.get(12)?,
686                })
687            })?
688            .collect::<Result<Vec<_>, _>>()?;
689        Ok(rows)
690    } else {
691        let mut stmt = conn.prepare(
692            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
693                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
694             FROM fts_memories fts
695             JOIN memories m ON m.id = fts.rowid
696             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
697             ORDER BY rank LIMIT ?3",
698        )?;
699        let rows = stmt
700            .query_map(params![fts_query, namespace, limit as i64], |r| {
701                Ok(MemoryRow {
702                    id: r.get(0)?,
703                    namespace: r.get(1)?,
704                    name: r.get(2)?,
705                    memory_type: r.get(3)?,
706                    description: r.get(4)?,
707                    body: r.get(5)?,
708                    body_hash: r.get(6)?,
709                    session_id: r.get(7)?,
710                    source: r.get(8)?,
711                    metadata: r.get(9)?,
712                    created_at: r.get(10)?,
713                    updated_at: r.get(11)?,
714                    deleted_at: r.get(12)?,
715                })
716            })?
717            .collect::<Result<Vec<_>, _>>()?;
718        Ok(rows)
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use rusqlite::Connection;
726
727    type TestResult = Result<(), Box<dyn std::error::Error>>;
728
729    fn setup_conn() -> Result<Connection, Box<dyn std::error::Error>> {
730        crate::storage::connection::register_vec_extension();
731        let mut conn = Connection::open_in_memory()?;
732        conn.execute_batch(
733            "PRAGMA foreign_keys = ON;
734             PRAGMA temp_store = MEMORY;",
735        )?;
736        crate::migrations::runner().run(&mut conn)?;
737        Ok(conn)
738    }
739
740    fn new_memory(name: &str) -> NewMemory {
741        NewMemory {
742            namespace: "global".to_string(),
743            name: name.to_string(),
744            memory_type: "user".to_string(),
745            description: "descricao de teste".to_string(),
746            body: "test memory body".to_string(),
747            body_hash: format!("hash-{name}"),
748            session_id: None,
749            source: "agent".to_string(),
750            metadata: serde_json::json!({}),
751        }
752    }
753
754    #[test]
755    fn insert_and_find_by_name_return_id() -> TestResult {
756        let conn = setup_conn()?;
757        let m = new_memory("mem-alpha");
758        let id = insert(&conn, &m)?;
759        assert!(id > 0);
760
761        let found = find_by_name(&conn, "global", "mem-alpha")?;
762        assert!(found.is_some());
763        let (found_id, _, _) = found.ok_or("mem-alpha should exist")?;
764        assert_eq!(found_id, id);
765        Ok(())
766    }
767
768    #[test]
769    fn find_by_name_returns_none_when_not_found() -> TestResult {
770        let conn = setup_conn()?;
771        let result = find_by_name(&conn, "global", "inexistente")?;
772        assert!(result.is_none());
773        Ok(())
774    }
775
776    #[test]
777    fn find_by_hash_returns_correct_id() -> TestResult {
778        let conn = setup_conn()?;
779        let m = new_memory("mem-hash");
780        let id = insert(&conn, &m)?;
781
782        let found = find_by_hash(&conn, "global", "hash-mem-hash")?;
783        assert_eq!(found, Some(id));
784        Ok(())
785    }
786
787    #[test]
788    fn find_by_hash_returns_none_when_hash_not_found() -> TestResult {
789        let conn = setup_conn()?;
790        let result = find_by_hash(&conn, "global", "hash-inexistente")?;
791        assert!(result.is_none());
792        Ok(())
793    }
794
795    #[test]
796    fn find_by_hash_ignores_different_namespace() -> TestResult {
797        let conn = setup_conn()?;
798        let m = new_memory("mem-ns");
799        insert(&conn, &m)?;
800
801        let result = find_by_hash(&conn, "outro-namespace", "hash-mem-ns")?;
802        assert!(result.is_none());
803        Ok(())
804    }
805
806    #[test]
807    fn read_by_name_returns_full_memory() -> TestResult {
808        let conn = setup_conn()?;
809        let m = new_memory("mem-read");
810        let id = insert(&conn, &m)?;
811
812        let row = read_by_name(&conn, "global", "mem-read")?.ok_or("mem-read should exist")?;
813        assert_eq!(row.id, id);
814        assert_eq!(row.name, "mem-read");
815        assert_eq!(row.memory_type, "user");
816        assert_eq!(row.body, "test memory body");
817        assert_eq!(row.namespace, "global");
818        Ok(())
819    }
820
821    #[test]
822    fn read_by_name_returns_none_for_missing() -> TestResult {
823        let conn = setup_conn()?;
824        let result = read_by_name(&conn, "global", "nao-existe")?;
825        assert!(result.is_none());
826        Ok(())
827    }
828
829    #[test]
830    fn read_full_by_id_returns_memory() -> TestResult {
831        let conn = setup_conn()?;
832        let m = new_memory("mem-full");
833        let id = insert(&conn, &m)?;
834
835        let row = read_full(&conn, id)?.ok_or("mem-full should exist")?;
836        assert_eq!(row.id, id);
837        assert_eq!(row.name, "mem-full");
838        Ok(())
839    }
840
841    #[test]
842    fn read_full_returns_none_for_missing_id() -> TestResult {
843        let conn = setup_conn()?;
844        let result = read_full(&conn, 9999)?;
845        assert!(result.is_none());
846        Ok(())
847    }
848
849    #[test]
850    fn update_without_optimism_modifies_fields() -> TestResult {
851        let conn = setup_conn()?;
852        let m = new_memory("mem-upd");
853        let id = insert(&conn, &m)?;
854
855        let mut m2 = new_memory("mem-upd");
856        m2.body = "updated body".to_string();
857        m2.body_hash = "hash-novo".to_string();
858        let ok = update(&conn, id, &m2, None)?;
859        assert!(ok);
860
861        let row = read_full(&conn, id)?.ok_or("mem-upd should exist")?;
862        assert_eq!(row.body, "updated body");
863        assert_eq!(row.body_hash, "hash-novo");
864        Ok(())
865    }
866
867    #[test]
868    fn update_with_correct_expected_updated_at_succeeds() -> TestResult {
869        let conn = setup_conn()?;
870        let m = new_memory("mem-opt");
871        let id = insert(&conn, &m)?;
872
873        let (_, updated_at, _) =
874            find_by_name(&conn, "global", "mem-opt")?.ok_or("mem-opt should exist")?;
875
876        let mut m2 = new_memory("mem-opt");
877        m2.body = "optimistic body".to_string();
878        m2.body_hash = "hash-optimistic".to_string();
879        let ok = update(&conn, id, &m2, Some(updated_at))?;
880        assert!(ok);
881
882        let row = read_full(&conn, id)?.ok_or("mem-opt should exist after update")?;
883        assert_eq!(row.body, "optimistic body");
884        Ok(())
885    }
886
887    #[test]
888    fn update_with_wrong_expected_updated_at_returns_false() -> TestResult {
889        let conn = setup_conn()?;
890        let m = new_memory("mem-conflict");
891        let id = insert(&conn, &m)?;
892
893        let mut m2 = new_memory("mem-conflict");
894        m2.body = "must not appear".to_string();
895        m2.body_hash = "hash-x".to_string();
896        let ok = update(&conn, id, &m2, Some(0))?;
897        assert!(!ok);
898
899        let row = read_full(&conn, id)?.ok_or("mem-conflict should exist")?;
900        assert_eq!(row.body, "test memory body");
901        Ok(())
902    }
903
904    #[test]
905    fn update_missing_id_returns_false() -> TestResult {
906        let conn = setup_conn()?;
907        let m = new_memory("fantasma");
908        let ok = update(&conn, 9999, &m, None)?;
909        assert!(!ok);
910        Ok(())
911    }
912
913    #[test]
914    fn soft_delete_marks_deleted_at() -> TestResult {
915        let conn = setup_conn()?;
916        let m = new_memory("mem-del");
917        insert(&conn, &m)?;
918
919        let ok = soft_delete(&conn, "global", "mem-del")?;
920        assert!(ok);
921
922        let result = find_by_name(&conn, "global", "mem-del")?;
923        assert!(result.is_none());
924
925        let result_read = read_by_name(&conn, "global", "mem-del")?;
926        assert!(result_read.is_none());
927        Ok(())
928    }
929
930    #[test]
931    fn soft_delete_returns_false_when_not_found() -> TestResult {
932        let conn = setup_conn()?;
933        let ok = soft_delete(&conn, "global", "nao-existe")?;
934        assert!(!ok);
935        Ok(())
936    }
937
938    #[test]
939    fn double_soft_delete_returns_false_on_second_call() -> TestResult {
940        let conn = setup_conn()?;
941        let m = new_memory("mem-del2");
942        insert(&conn, &m)?;
943
944        soft_delete(&conn, "global", "mem-del2")?;
945        let ok = soft_delete(&conn, "global", "mem-del2")?;
946        assert!(!ok);
947        Ok(())
948    }
949
950    #[test]
951    fn list_returns_memories_from_namespace() -> TestResult {
952        let conn = setup_conn()?;
953        insert(&conn, &new_memory("mem-list-a"))?;
954        insert(&conn, &new_memory("mem-list-b"))?;
955
956        let rows = list(&conn, "global", None, 10, 0, false)?;
957        assert!(rows.len() >= 2);
958        let nomes: Vec<_> = rows.iter().map(|r| r.name.as_str()).collect();
959        assert!(nomes.contains(&"mem-list-a"));
960        assert!(nomes.contains(&"mem-list-b"));
961        Ok(())
962    }
963
964    #[test]
965    fn list_with_type_filter_returns_only_correct_type() -> TestResult {
966        let conn = setup_conn()?;
967        insert(&conn, &new_memory("mem-user"))?;
968
969        let mut m2 = new_memory("mem-feedback");
970        m2.memory_type = "feedback".to_string();
971        insert(&conn, &m2)?;
972
973        let rows_user = list(&conn, "global", Some("user"), 10, 0, false)?;
974        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
975
976        let rows_fb = list(&conn, "global", Some("feedback"), 10, 0, false)?;
977        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
978        Ok(())
979    }
980
981    #[test]
982    fn list_exclui_soft_deleted() -> TestResult {
983        let conn = setup_conn()?;
984        let m = new_memory("mem-excluida");
985        insert(&conn, &m)?;
986        soft_delete(&conn, "global", "mem-excluida")?;
987
988        let rows = list(&conn, "global", None, 10, 0, false)?;
989        assert!(rows.iter().all(|r| r.name != "mem-excluida"));
990        Ok(())
991    }
992
993    #[test]
994    fn list_pagination_works() -> TestResult {
995        let conn = setup_conn()?;
996        for i in 0..5 {
997            insert(&conn, &new_memory(&format!("mem-pag-{i}")))?;
998        }
999
1000        let pagina1 = list(&conn, "global", None, 2, 0, false)?;
1001        let pagina2 = list(&conn, "global", None, 2, 2, false)?;
1002        assert!(pagina1.len() <= 2);
1003        assert!(pagina2.len() <= 2);
1004        if !pagina1.is_empty() && !pagina2.is_empty() {
1005            assert_ne!(pagina1[0].id, pagina2[0].id);
1006        }
1007        Ok(())
1008    }
1009
1010    #[test]
1011    fn upsert_vec_and_delete_vec_work() -> TestResult {
1012        let conn = setup_conn()?;
1013        let m = new_memory("mem-vec");
1014        let id = insert(&conn, &m)?;
1015
1016        let embedding: Vec<f32> = vec![0.1; 384];
1017        upsert_vec(
1018            &conn, id, "global", "user", &embedding, "mem-vec", "snippet",
1019        )?;
1020
1021        let count: i64 = conn.query_row(
1022            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1023            params![id],
1024            |r| r.get(0),
1025        )?;
1026        assert_eq!(count, 1);
1027
1028        delete_vec(&conn, id)?;
1029
1030        let count_after: i64 = conn.query_row(
1031            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1032            params![id],
1033            |r| r.get(0),
1034        )?;
1035        assert_eq!(count_after, 0);
1036        Ok(())
1037    }
1038
1039    #[test]
1040    fn upsert_vec_replaces_existing_vector() -> TestResult {
1041        let conn = setup_conn()?;
1042        let m = new_memory("mem-vec-upsert");
1043        let id = insert(&conn, &m)?;
1044
1045        let emb1: Vec<f32> = vec![0.1; 384];
1046        upsert_vec(&conn, id, "global", "user", &emb1, "mem-vec-upsert", "s1")?;
1047
1048        let emb2: Vec<f32> = vec![0.9; 384];
1049        upsert_vec(&conn, id, "global", "user", &emb2, "mem-vec-upsert", "s2")?;
1050
1051        let count: i64 = conn.query_row(
1052            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1053            params![id],
1054            |r| r.get(0),
1055        )?;
1056        assert_eq!(count, 1);
1057        Ok(())
1058    }
1059
1060    #[test]
1061    fn knn_search_returns_results_by_distance() -> TestResult {
1062        let conn = setup_conn()?;
1063
1064        // emb_a: predominantemente positivo — cosseno alto com query [1.0; 384]
1065        let ma = new_memory("mem-knn-a");
1066        let id_a = insert(&conn, &ma)?;
1067        let emb_a: Vec<f32> = vec![1.0; 384];
1068        upsert_vec(&conn, id_a, "global", "user", &emb_a, "mem-knn-a", "s")?;
1069
1070        // emb_b: predominantemente negativo — cosseno baixo com query [1.0; 384]
1071        let mb = new_memory("mem-knn-b");
1072        let id_b = insert(&conn, &mb)?;
1073        let emb_b: Vec<f32> = vec![-1.0; 384];
1074        upsert_vec(&conn, id_b, "global", "user", &emb_b, "mem-knn-b", "s")?;
1075
1076        let query: Vec<f32> = vec![1.0; 384];
1077        let results = knn_search(&conn, &query, &["global".to_string()], None, 2)?;
1078        assert!(!results.is_empty());
1079        assert_eq!(results[0].0, id_a);
1080        Ok(())
1081    }
1082
1083    #[test]
1084    fn knn_search_with_type_filter_restricts_result() -> TestResult {
1085        let conn = setup_conn()?;
1086
1087        let ma = new_memory("mem-knn-tipo-user");
1088        let id_a = insert(&conn, &ma)?;
1089        let emb: Vec<f32> = vec![1.0; 384];
1090        upsert_vec(
1091            &conn,
1092            id_a,
1093            "global",
1094            "user",
1095            &emb,
1096            "mem-knn-tipo-user",
1097            "s",
1098        )?;
1099
1100        let mut mb = new_memory("mem-knn-tipo-fb");
1101        mb.memory_type = "feedback".to_string();
1102        let id_b = insert(&conn, &mb)?;
1103        upsert_vec(
1104            &conn,
1105            id_b,
1106            "global",
1107            "feedback",
1108            &emb,
1109            "mem-knn-tipo-fb",
1110            "s",
1111        )?;
1112
1113        let query: Vec<f32> = vec![1.0; 384];
1114        let results_user = knn_search(&conn, &query, &["global".to_string()], Some("user"), 5)?;
1115        assert!(results_user.iter().all(|(id, _)| *id == id_a));
1116
1117        let results_fb = knn_search(&conn, &query, &["global".to_string()], Some("feedback"), 5)?;
1118        assert!(results_fb.iter().all(|(id, _)| *id == id_b));
1119        Ok(())
1120    }
1121
1122    #[test]
1123    fn fts_search_finds_by_prefix_in_body() -> TestResult {
1124        let conn = setup_conn()?;
1125        let mut m = new_memory("mem-fts");
1126        m.body = "linguagem de programacao rust".to_string();
1127        insert(&conn, &m)?;
1128
1129        conn.execute_batch(
1130            "INSERT INTO fts_memories(rowid, name, description, body)
1131             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1132        )?;
1133
1134        let rows = fts_search(&conn, "programacao", "global", None, 10)?;
1135        assert!(!rows.is_empty());
1136        assert!(rows.iter().any(|r| r.name == "mem-fts"));
1137        Ok(())
1138    }
1139
1140    #[test]
1141    fn fts_search_with_type_filter() -> TestResult {
1142        let conn = setup_conn()?;
1143        let mut m = new_memory("mem-fts-tipo");
1144        m.body = "linguagem especial para filtro".to_string();
1145        insert(&conn, &m)?;
1146
1147        let mut m2 = new_memory("mem-fts-feedback");
1148        m2.memory_type = "feedback".to_string();
1149        m2.body = "linguagem especial para filtro".to_string();
1150        insert(&conn, &m2)?;
1151
1152        conn.execute_batch(
1153            "INSERT INTO fts_memories(rowid, name, description, body)
1154             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1155        )?;
1156
1157        let rows_user = fts_search(&conn, "especial", "global", Some("user"), 10)?;
1158        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
1159
1160        let rows_fb = fts_search(&conn, "especial", "global", Some("feedback"), 10)?;
1161        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
1162        Ok(())
1163    }
1164
1165    #[test]
1166    fn fts_search_excludes_deleted() -> TestResult {
1167        let conn = setup_conn()?;
1168        let mut m = new_memory("mem-fts-del");
1169        m.body = "deleted fts content".to_string();
1170        insert(&conn, &m)?;
1171
1172        conn.execute_batch(
1173            "INSERT INTO fts_memories(rowid, name, description, body)
1174             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1175        )?;
1176
1177        soft_delete(&conn, "global", "mem-fts-del")?;
1178
1179        let rows = fts_search(&conn, "deleted", "global", None, 10)?;
1180        assert!(rows.iter().all(|r| r.name != "mem-fts-del"));
1181        Ok(())
1182    }
1183
1184    #[test]
1185    fn list_deleted_before_returns_correct_ids() -> TestResult {
1186        let conn = setup_conn()?;
1187        let m = new_memory("mem-purge");
1188        insert(&conn, &m)?;
1189        soft_delete(&conn, "global", "mem-purge")?;
1190
1191        let ids = list_deleted_before(&conn, "global", i64::MAX)?;
1192        assert!(!ids.is_empty());
1193
1194        let ids_antes = list_deleted_before(&conn, "global", 0)?;
1195        assert!(ids_antes.is_empty());
1196        Ok(())
1197    }
1198
1199    #[test]
1200    fn find_by_name_returns_correct_max_version() -> TestResult {
1201        let conn = setup_conn()?;
1202        let m = new_memory("mem-ver");
1203        let id = insert(&conn, &m)?;
1204
1205        let (_, _, v0) = find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist")?;
1206        assert_eq!(v0, 0);
1207
1208        conn.execute(
1209            "INSERT INTO memory_versions (memory_id, version, name, type, description, body, metadata, change_reason)
1210             VALUES (?1, 1, 'mem-ver', 'user', 'desc', 'body', '{}', 'create')",
1211            params![id],
1212        )?;
1213
1214        let (_, _, v1) =
1215            find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist after insert")?;
1216        assert_eq!(v1, 1);
1217        Ok(())
1218    }
1219
1220    #[test]
1221    fn insert_com_metadata_json() -> TestResult {
1222        let conn = setup_conn()?;
1223        let mut m = new_memory("mem-meta");
1224        m.metadata = serde_json::json!({"chave": "valor", "numero": 42});
1225        let id = insert(&conn, &m)?;
1226
1227        let row = read_full(&conn, id)?.ok_or("mem-meta should exist")?;
1228        let meta: serde_json::Value = serde_json::from_str(&row.metadata)?;
1229        assert_eq!(meta["chave"], "valor");
1230        assert_eq!(meta["numero"], 42);
1231        Ok(())
1232    }
1233
1234    #[test]
1235    fn insert_com_session_id() -> TestResult {
1236        let conn = setup_conn()?;
1237        let mut m = new_memory("mem-session");
1238        m.session_id = Some("sessao-xyz".to_string());
1239        let id = insert(&conn, &m)?;
1240
1241        let row = read_full(&conn, id)?.ok_or("mem-session should exist")?;
1242        assert_eq!(row.session_id, Some("sessao-xyz".to_string()));
1243        Ok(())
1244    }
1245
1246    #[test]
1247    fn delete_vec_for_nonexistent_id_does_not_fail() -> TestResult {
1248        let conn = setup_conn()?;
1249        let result = delete_vec(&conn, 99999);
1250        assert!(result.is_ok());
1251        Ok(())
1252    }
1253
1254    #[test]
1255    fn preprocess_fts_query_no_separators() {
1256        assert_eq!(preprocess_fts_query("hello"), "hello*");
1257        assert_eq!(preprocess_fts_query("hello world"), "hello* world*");
1258    }
1259
1260    #[test]
1261    fn preprocess_fts_query_with_hyphens() {
1262        let result = preprocess_fts_query("graphrag-precompact");
1263        assert!(result.contains("\"graphrag precompact\""));
1264        assert!(result.contains("graphrag*"));
1265        assert!(result.contains("precompact*"));
1266    }
1267
1268    #[test]
1269    fn preprocess_fts_query_with_dots() {
1270        let result = preprocess_fts_query("v1.0.44");
1271        assert!(result.contains("\"v1 0 44\""));
1272        assert!(result.contains("v1*"));
1273        assert!(result.contains("44*"));
1274    }
1275
1276    #[test]
1277    fn preprocess_fts_query_with_mixed_separators() {
1278        let result = preprocess_fts_query("graphrag-precompact.sh");
1279        assert!(result.contains("\"graphrag precompact sh\""));
1280        assert!(result.contains("graphrag*"));
1281    }
1282
1283    #[test]
1284    fn preprocess_fts_query_empty_and_whitespace() {
1285        assert_eq!(preprocess_fts_query(""), "");
1286        assert_eq!(preprocess_fts_query("  "), "");
1287    }
1288
1289    #[test]
1290    fn fts_search_finds_compound_term_with_hyphen() -> TestResult {
1291        let conn = setup_conn()?;
1292        let mut m = new_memory("mem-compound");
1293        m.body = "the graphrag-precompact script runs daily".to_string();
1294        insert(&conn, &m)?;
1295        conn.execute_batch(
1296            "INSERT INTO fts_memories(rowid, name, description, body)
1297             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1298        )?;
1299        let rows = fts_search(&conn, "graphrag-precompact", "global", None, 10)?;
1300        assert!(!rows.is_empty(), "should find compound hyphenated term");
1301        Ok(())
1302    }
1303}