Skip to main content

docx_core/store/
surreal.rs

1use std::{error::Error, fmt, str::FromStr, sync::Arc};
2
3use docx_store::models::{
4    DocBlock,
5    DocChunk,
6    DocSource,
7    Ingest,
8    Project,
9    RelationRecord,
10    Symbol,
11};
12use docx_store::schema::{
13    TABLE_DOC_BLOCK,
14    TABLE_DOC_SOURCE,
15    TABLE_INGEST,
16    TABLE_PROJECT,
17    TABLE_SYMBOL,
18    make_record_id,
19};
20use surrealdb::{Connection, Surreal};
21use surrealdb::sql::{Id, Regex, Thing};
22use uuid::Uuid;
23
24/// Errors returned by the `SurrealDB` store implementation.
25#[derive(Debug)]
26pub enum StoreError {
27    Surreal(Box<surrealdb::Error>),
28    InvalidInput(String),
29}
30
31impl fmt::Display for StoreError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Surreal(err) => write!(f, "SurrealDB error: {err}"),
35            Self::InvalidInput(message) => write!(f, "Invalid input: {message}"),
36        }
37    }
38}
39
40impl Error for StoreError {}
41
42impl From<surrealdb::Error> for StoreError {
43    fn from(err: surrealdb::Error) -> Self {
44        Self::Surreal(Box::new(err))
45    }
46}
47
48pub type StoreResult<T> = Result<T, StoreError>;
49
50/// Store implementation backed by `SurrealDB`.
51pub struct SurrealDocStore<C: Connection> {
52    db: Arc<Surreal<C>>,
53}
54
55impl<C: Connection> Clone for SurrealDocStore<C> {
56    fn clone(&self) -> Self {
57        Self {
58            db: self.db.clone(),
59        }
60    }
61}
62
63impl<C: Connection> SurrealDocStore<C> {
64    #[must_use]
65    pub fn new(db: Surreal<C>) -> Self {
66        Self {
67            db: Arc::new(db),
68        }
69    }
70
71    #[must_use]
72    pub const fn from_arc(db: Arc<Surreal<C>>) -> Self {
73        Self { db }
74    }
75
76    #[must_use]
77    pub fn db(&self) -> &Surreal<C> {
78        &self.db
79    }
80
81    /// Upserts a project record by id.
82    ///
83    /// # Errors
84    /// Returns `StoreError` if validation fails or the database write fails.
85    pub async fn upsert_project(&self, mut project: Project) -> StoreResult<Project> {
86        ensure_non_empty(&project.project_id, "project_id")?;
87        let id = project
88            .id
89            .clone()
90            .unwrap_or_else(|| project.project_id.clone());
91        project.id = Some(id.clone());
92        let record = Thing::from((TABLE_PROJECT, id.as_str()));
93        self.db
94            .query("UPSERT $record CONTENT $data RETURN NONE;")
95            .bind(("record", record))
96            .bind(("data", project.clone()))
97            .await?;
98        Ok(project)
99    }
100
101    /// Fetches a project by id.
102    ///
103    /// # Errors
104    /// Returns `StoreError` if the database query fails.
105    pub async fn get_project(&self, project_id: &str) -> StoreResult<Option<Project>> {
106        let record: Option<Project> = self.db.select((TABLE_PROJECT, project_id)).await?;
107        Ok(record)
108    }
109
110    /// Fetches an ingest record by id.
111    ///
112    /// # Errors
113    /// Returns `StoreError` if the database query fails.
114    pub async fn get_ingest(&self, ingest_id: &str) -> StoreResult<Option<Ingest>> {
115        let record = Thing::from((TABLE_INGEST, ingest_id));
116        let mut response = self
117            .db
118            .query("SELECT * FROM $record;")
119            .bind(("record", record))
120            .await?;
121        let records: Vec<IngestRow> = response.take(0)?;
122        Ok(records.into_iter().next().map(Ingest::from))
123    }
124
125    /// Lists projects up to the provided limit.
126    ///
127    /// # Errors
128    /// Returns `StoreError` if the limit is invalid or the database query fails.
129    pub async fn list_projects(&self, limit: usize) -> StoreResult<Vec<Project>> {
130        let limit = limit_to_i64(limit)?;
131        let query = "SELECT * FROM project LIMIT $limit;";
132        let mut response = self.db.query(query).bind(("limit", limit)).await?;
133        let records: Vec<Project> = response.take(0)?;
134        Ok(records)
135    }
136
137    /// Searches projects by name or alias pattern.
138    ///
139    /// # Errors
140    /// Returns `StoreError` if the limit or pattern is invalid or the database query fails.
141    pub async fn search_projects(&self, pattern: &str, limit: usize) -> StoreResult<Vec<Project>> {
142        let Some(pattern) = normalize_pattern(pattern) else {
143            return self.list_projects(limit).await;
144        };
145        let limit = limit_to_i64(limit)?;
146        let regex = build_project_regex(&pattern)?;
147        let query = "SELECT * FROM project WHERE search_text != NONE AND string::matches(search_text, $pattern) LIMIT $limit;";
148        let mut response = self
149            .db
150            .query(query)
151            .bind(("pattern", regex))
152            .bind(("limit", limit))
153            .await?;
154        let records: Vec<Project> = response.take(0)?;
155        Ok(records)
156    }
157
158    /// Lists ingest records for a project.
159    ///
160    /// # Errors
161    /// Returns `StoreError` if the limit is invalid or the database query fails.
162    pub async fn list_ingests(&self, project_id: &str, limit: usize) -> StoreResult<Vec<Ingest>> {
163        let project_id = project_id.to_string();
164        let limit = limit_to_i64(limit)?;
165        let query =
166            "SELECT * FROM ingest WHERE project_id = $project_id ORDER BY ingested_at DESC LIMIT $limit;";
167        let mut response = self
168            .db
169            .query(query)
170            .bind(("project_id", project_id))
171            .bind(("limit", limit))
172            .await?;
173        let records: Vec<IngestRow> = response.take(0)?;
174        Ok(records.into_iter().map(Ingest::from).collect())
175    }
176
177    /// Creates an ingest record.
178    ///
179    /// # Errors
180    /// Returns `StoreError` if the database write fails.
181    pub async fn create_ingest(&self, mut ingest: Ingest) -> StoreResult<Ingest> {
182        let id = ingest.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
183        ingest.id = Some(id.clone());
184        let record = Thing::from((TABLE_INGEST, id.as_str()));
185        self.db
186            .query("UPSERT $record CONTENT $data RETURN NONE;")
187            .bind(("record", record))
188            .bind(("data", ingest.clone()))
189            .await?;
190        Ok(ingest)
191    }
192
193    /// Creates a document source record.
194    ///
195    /// # Errors
196    /// Returns `StoreError` if the database write fails.
197    pub async fn create_doc_source(&self, mut source: DocSource) -> StoreResult<DocSource> {
198        let id = source.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
199        source.id = Some(id.clone());
200        self.db
201            .query("CREATE doc_source CONTENT $data RETURN NONE;")
202            .bind(("data", source.clone()))
203            .await?;
204        Ok(source)
205    }
206
207    /// Upserts a symbol record by symbol key.
208    ///
209    /// # Errors
210    /// Returns `StoreError` if validation fails or the database write fails.
211    pub async fn upsert_symbol(&self, mut symbol: Symbol) -> StoreResult<Symbol> {
212        ensure_non_empty(&symbol.symbol_key, "symbol_key")?;
213        let id = symbol
214            .id
215            .clone()
216            .unwrap_or_else(|| symbol.symbol_key.clone());
217        symbol.id = Some(id.clone());
218        let record = Thing::from((TABLE_SYMBOL, id.as_str()));
219        self.db
220            .query("UPSERT $record CONTENT $data RETURN NONE;")
221            .bind(("record", record))
222            .bind(("data", symbol.clone()))
223            .await?;
224        Ok(symbol)
225    }
226
227    /// Creates a document block record.
228    ///
229    /// # Errors
230    /// Returns `StoreError` if the database write fails.
231    pub async fn create_doc_block(&self, mut block: DocBlock) -> StoreResult<DocBlock> {
232        let id = block.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
233        block.id = Some(id.clone());
234        self.db
235            .query("CREATE doc_block CONTENT $data RETURN NONE;")
236            .bind(("data", block.clone()))
237            .await?;
238        Ok(block)
239    }
240
241    /// Creates document block records.
242    ///
243    /// # Errors
244    /// Returns `StoreError` if the database write fails.
245    pub async fn create_doc_blocks(&self, blocks: Vec<DocBlock>) -> StoreResult<Vec<DocBlock>> {
246        if blocks.is_empty() {
247            return Ok(Vec::new());
248        }
249        let mut stored = Vec::with_capacity(blocks.len());
250        for block in blocks {
251            stored.push(self.create_doc_block(block).await?);
252        }
253        Ok(stored)
254    }
255
256    /// Creates document chunk records.
257    ///
258    /// # Errors
259    /// Returns `StoreError` if the database write fails.
260    pub async fn create_doc_chunks(&self, chunks: Vec<DocChunk>) -> StoreResult<Vec<DocChunk>> {
261        if chunks.is_empty() {
262            return Ok(Vec::new());
263        }
264        let mut stored = Vec::with_capacity(chunks.len());
265        for mut chunk in chunks {
266            let id = chunk.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
267            chunk.id = Some(id.clone());
268            self.db
269                .query("CREATE doc_chunk CONTENT $data RETURN NONE;")
270                .bind(("data", chunk.clone()))
271                .await?;
272            stored.push(chunk);
273        }
274        Ok(stored)
275    }
276
277    /// Creates a relation record in the specified table.
278    ///
279    /// # Errors
280    /// Returns `StoreError` if the database write fails.
281    pub async fn create_relation(
282        &self,
283        table: &str,
284        mut relation: RelationRecord,
285    ) -> StoreResult<RelationRecord> {
286        let id = relation.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
287        relation.id = Some(id.clone());
288        let statement = format!("CREATE {table} CONTENT $data RETURN NONE;");
289        self.db
290            .query(statement)
291            .bind(("data", relation.clone()))
292            .await?;
293        Ok(relation)
294    }
295
296    /// Creates relation records in the specified table.
297    ///
298    /// # Errors
299    /// Returns `StoreError` if the database write fails.
300    pub async fn create_relations(
301        &self,
302        table: &str,
303        relations: Vec<RelationRecord>,
304    ) -> StoreResult<Vec<RelationRecord>> {
305        if relations.is_empty() {
306            return Ok(Vec::new());
307        }
308        let mut stored = Vec::with_capacity(relations.len());
309        for mut relation in relations {
310            let id = relation.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string());
311            relation.id = Some(id.clone());
312            let statement = format!("CREATE {table} CONTENT $data RETURN NONE;");
313            self.db
314                .query(statement)
315                .bind(("data", relation.clone()))
316                .await?;
317            stored.push(relation);
318        }
319        Ok(stored)
320    }
321
322    /// Fetches a symbol by key.
323    ///
324    /// # Errors
325    /// Returns `StoreError` if the database query fails.
326    pub async fn get_symbol(&self, symbol_key: &str) -> StoreResult<Option<Symbol>> {
327        let record: Option<Symbol> = self.db.select((TABLE_SYMBOL, symbol_key)).await?;
328        Ok(record)
329    }
330
331    /// Fetches a symbol by project id and key.
332    ///
333    /// # Errors
334    /// Returns `StoreError` if the database query fails.
335    pub async fn get_symbol_by_project(
336        &self,
337        project_id: &str,
338        symbol_key: &str,
339    ) -> StoreResult<Option<Symbol>> {
340        let project_id = project_id.to_string();
341        let symbol_key = symbol_key.to_string();
342        let query = "SELECT * FROM symbol WHERE project_id = $project_id AND symbol_key = $symbol_key LIMIT 1;";
343        let mut response = self
344            .db
345            .query(query)
346            .bind(("project_id", project_id))
347            .bind(("symbol_key", symbol_key))
348            .await?;
349        let mut records: Vec<Symbol> = response.take(0)?;
350        Ok(records.pop())
351    }
352
353    /// Lists symbols by name match within a project.
354    ///
355    /// # Errors
356    /// Returns `StoreError` if the limit is invalid or the database query fails.
357    pub async fn list_symbols_by_name(
358        &self,
359        project_id: &str,
360        name: &str,
361        limit: usize,
362    ) -> StoreResult<Vec<Symbol>> {
363        let project_id = project_id.to_string();
364        let name = name.to_string();
365        let limit = limit_to_i64(limit)?;
366        let query = "SELECT * FROM symbol WHERE project_id = $project_id AND name CONTAINS $name LIMIT $limit;";
367        let mut response = self
368            .db
369            .query(query)
370            .bind(("project_id", project_id))
371            .bind(("name", name))
372            .bind(("limit", limit))
373            .await?;
374        let records: Vec<Symbol> = response.take(0)?;
375        Ok(records)
376    }
377
378    /// Lists distinct symbol kinds for a project.
379    ///
380    /// # Errors
381    /// Returns `StoreError` if the database query fails.
382    pub async fn list_symbol_kinds(&self, project_id: &str) -> StoreResult<Vec<String>> {
383        let project_id = project_id.to_string();
384        let query = "SELECT kind FROM symbol WHERE project_id = $project_id GROUP BY kind;";
385        let mut response = self
386            .db
387            .query(query)
388            .bind(("project_id", project_id))
389            .await?;
390        let records: Vec<SymbolKindRow> = response.take(0)?;
391        let mut kinds: Vec<String> = records
392            .into_iter()
393            .filter_map(|row| row.kind)
394            .filter(|value| !value.trim().is_empty())
395            .collect();
396        kinds.sort();
397        kinds.dedup();
398        Ok(kinds)
399    }
400
401    /// Lists members by scope prefix or glob pattern.
402    ///
403    /// # Errors
404    /// Returns `StoreError` if the scope or limit is invalid or the database query fails.
405    pub async fn list_members_by_scope(
406        &self,
407        project_id: &str,
408        scope: &str,
409        limit: usize,
410    ) -> StoreResult<Vec<Symbol>> {
411        let Some(scope) = normalize_pattern(scope) else {
412            return Ok(Vec::new());
413        };
414        let project_id = project_id.to_string();
415        let limit = limit_to_i64(limit)?;
416        let mut response = if scope.contains('*') {
417            let regex = build_scope_regex(&scope)?;
418            let query = "SELECT * FROM symbol WHERE project_id = $project_id AND qualified_name != NONE AND string::matches(string::lowercase(qualified_name), $pattern) LIMIT $limit;";
419            self.db
420                .query(query)
421                .bind(("project_id", project_id))
422                .bind(("pattern", regex))
423                .bind(("limit", limit))
424                .await?
425        } else {
426            let query = "SELECT * FROM symbol WHERE project_id = $project_id AND qualified_name != NONE AND string::starts_with(string::lowercase(qualified_name), $scope) LIMIT $limit;";
427            self.db
428                .query(query)
429                .bind(("project_id", project_id))
430                .bind(("scope", scope))
431                .bind(("limit", limit))
432                .await?
433        };
434        let records: Vec<Symbol> = response.take(0)?;
435        Ok(records)
436    }
437
438    /// Lists document blocks for a symbol, optionally filtering by ingest id.
439    ///
440    /// # Errors
441    /// Returns `StoreError` if the database query fails.
442    pub async fn list_doc_blocks(
443        &self,
444        project_id: &str,
445        symbol_key: &str,
446        ingest_id: Option<&str>,
447    ) -> StoreResult<Vec<DocBlock>> {
448        let project_id = project_id.to_string();
449        let symbol_key = symbol_key.to_string();
450        let (query, binds) = ingest_id.map_or(
451            (
452                "SELECT * FROM doc_block WHERE project_id = $project_id AND symbol_key = $symbol_key;",
453                None,
454            ),
455            |ingest_id| (
456                "SELECT * FROM doc_block WHERE project_id = $project_id AND symbol_key = $symbol_key AND ingest_id = $ingest_id;",
457                Some(ingest_id.to_string()),
458            ),
459        );
460        let response = self
461            .db
462            .query(query)
463            .bind(("project_id", project_id))
464            .bind(("symbol_key", symbol_key));
465        let mut response = if let Some(ingest_id) = binds {
466            response.bind(("ingest_id", ingest_id)).await?
467        } else {
468            response.await?
469        };
470        let records: Vec<DocBlock> = response.take(0)?;
471        Ok(records)
472    }
473
474    /// Searches document blocks by text within a project.
475    ///
476    /// # Errors
477    /// Returns `StoreError` if the limit is invalid or the database query fails.
478    pub async fn search_doc_blocks(
479        &self,
480        project_id: &str,
481        text: &str,
482        limit: usize,
483    ) -> StoreResult<Vec<DocBlock>> {
484        let project_id = project_id.to_string();
485        let text = text.to_string();
486        let limit = limit_to_i64(limit)?;
487        let query = "SELECT * FROM doc_block WHERE project_id = $project_id AND (summary CONTAINS $text OR remarks CONTAINS $text OR returns CONTAINS $text) LIMIT $limit;";
488        let mut response = self
489            .db
490            .query(query)
491            .bind(("project_id", project_id))
492            .bind(("text", text))
493            .bind(("limit", limit))
494            .await?;
495        let records: Vec<DocBlock> = response.take(0)?;
496        Ok(records)
497    }
498
499    /// Lists document sources by project and ingest ids.
500    ///
501    /// # Errors
502    /// Returns `StoreError` if the database query fails.
503    pub async fn list_doc_sources(
504        &self,
505        project_id: &str,
506        ingest_ids: &[String],
507    ) -> StoreResult<Vec<DocSource>> {
508        if ingest_ids.is_empty() {
509            return Ok(Vec::new());
510        }
511        let project_id = project_id.to_string();
512        let ingest_ids = ingest_ids.to_vec();
513        let query = "SELECT * FROM doc_source WHERE project_id = $project_id AND ingest_id IN $ingest_ids;";
514        let mut response = self
515            .db
516            .query(query)
517            .bind(("project_id", project_id))
518            .bind(("ingest_ids", ingest_ids))
519            .await?;
520        let records: Vec<DocSourceRow> = response.take(0)?;
521        Ok(records.into_iter().map(DocSource::from).collect())
522    }
523
524    /// Fetches a document source by id.
525    ///
526    /// # Errors
527    /// Returns `StoreError` if the database query fails.
528    pub async fn get_doc_source(&self, doc_source_id: &str) -> StoreResult<Option<DocSource>> {
529        let record = Thing::from((TABLE_DOC_SOURCE, doc_source_id));
530        let mut response = self
531            .db
532            .query("SELECT * FROM $record;")
533            .bind(("record", record))
534            .await?;
535        let records: Vec<DocSourceRow> = response.take(0)?;
536        Ok(records.into_iter().next().map(DocSource::from))
537    }
538
539    /// Lists document sources for a project, optionally filtered by ingest id.
540    ///
541    /// # Errors
542    /// Returns `StoreError` if the limit is invalid or the database query fails.
543    pub async fn list_doc_sources_by_project(
544        &self,
545        project_id: &str,
546        ingest_id: Option<&str>,
547        limit: usize,
548    ) -> StoreResult<Vec<DocSource>> {
549        let project_id = project_id.to_string();
550        let limit = limit_to_i64(limit)?;
551        let (query, binds) = ingest_id.map_or(
552            (
553                "SELECT * FROM doc_source WHERE project_id = $project_id ORDER BY source_modified_at DESC LIMIT $limit;",
554                None,
555            ),
556            |ingest_id| (
557                "SELECT * FROM doc_source WHERE project_id = $project_id AND ingest_id = $ingest_id ORDER BY source_modified_at DESC LIMIT $limit;",
558                Some(ingest_id.to_string()),
559            ),
560        );
561        let response = self
562            .db
563            .query(query)
564            .bind(("project_id", project_id))
565            .bind(("limit", limit));
566        let mut response = if let Some(ingest_id) = binds {
567            response.bind(("ingest_id", ingest_id)).await?
568        } else {
569            response.await?
570        };
571        let records: Vec<DocSourceRow> = response.take(0)?;
572        Ok(records.into_iter().map(DocSource::from).collect())
573    }
574
575    /// Lists relation records in a table where the symbol is the source (outgoing).
576    ///
577    /// # Errors
578    /// Returns `StoreError` if the database query fails.
579    pub async fn list_relations_from_symbol(
580        &self,
581        table: &str,
582        project_id: &str,
583        symbol_id: &str,
584        limit: usize,
585    ) -> StoreResult<Vec<RelationRecord>> {
586        let project_id = project_id.to_string();
587        let limit = limit_to_i64(limit)?;
588        let record_id = make_record_id(TABLE_SYMBOL, symbol_id);
589        let query = format!(
590            "SELECT * FROM {table} WHERE project_id = $project_id AND out = $record_id LIMIT $limit;"
591        );
592        let mut response = self
593            .db
594            .query(query)
595            .bind(("project_id", project_id))
596            .bind(("record_id", record_id))
597            .bind(("limit", limit))
598            .await?;
599        let records: Vec<RelationRecord> = response.take(0)?;
600        Ok(records)
601    }
602
603    /// Lists relation records in a table where the symbol is the target (incoming).
604    ///
605    /// # Errors
606    /// Returns `StoreError` if the database query fails.
607    pub async fn list_relations_to_symbol(
608        &self,
609        table: &str,
610        project_id: &str,
611        symbol_id: &str,
612        limit: usize,
613    ) -> StoreResult<Vec<RelationRecord>> {
614        let project_id = project_id.to_string();
615        let limit = limit_to_i64(limit)?;
616        let record_id = make_record_id(TABLE_SYMBOL, symbol_id);
617        let query = format!(
618            "SELECT * FROM {table} WHERE project_id = $project_id AND in = $record_id LIMIT $limit;"
619        );
620        let mut response = self
621            .db
622            .query(query)
623            .bind(("project_id", project_id))
624            .bind(("record_id", record_id))
625            .bind(("limit", limit))
626            .await?;
627        let records: Vec<RelationRecord> = response.take(0)?;
628        Ok(records)
629    }
630
631    /// Lists relation records for a document block id.
632    ///
633    /// # Errors
634    /// Returns `StoreError` if the database query fails.
635    pub async fn list_relations_from_doc_block(
636        &self,
637        table: &str,
638        project_id: &str,
639        doc_block_id: &str,
640        limit: usize,
641    ) -> StoreResult<Vec<RelationRecord>> {
642        let project_id = project_id.to_string();
643        let limit = limit_to_i64(limit)?;
644        let record_id = make_record_id(TABLE_DOC_BLOCK, doc_block_id);
645        let query = format!(
646            "SELECT * FROM {table} WHERE project_id = $project_id AND in = $record_id LIMIT $limit;"
647        );
648        let mut response = self
649            .db
650            .query(query)
651            .bind(("project_id", project_id))
652            .bind(("record_id", record_id))
653            .bind(("limit", limit))
654            .await?;
655        let records: Vec<RelationRecord> = response.take(0)?;
656        Ok(records)
657    }
658}
659
660fn ensure_non_empty(value: &str, field: &str) -> StoreResult<()> {
661    if value.is_empty() {
662        return Err(StoreError::InvalidInput(format!("{field} is required")));
663    }
664    Ok(())
665}
666
667#[derive(serde::Deserialize)]
668struct IngestRow {
669    id: Thing,
670    project_id: String,
671    git_commit: Option<String>,
672    git_branch: Option<String>,
673    git_tag: Option<String>,
674    project_version: Option<String>,
675    source_modified_at: Option<String>,
676    ingested_at: Option<String>,
677    extra: Option<serde_json::Value>,
678}
679
680impl From<IngestRow> for Ingest {
681    fn from(row: IngestRow) -> Self {
682        Self {
683            id: Some(thing_id_to_string(row.id)),
684            project_id: row.project_id,
685            git_commit: row.git_commit,
686            git_branch: row.git_branch,
687            git_tag: row.git_tag,
688            project_version: row.project_version,
689            source_modified_at: row.source_modified_at,
690            ingested_at: row.ingested_at,
691            extra: row.extra,
692        }
693    }
694}
695
696#[derive(serde::Deserialize)]
697struct DocSourceRow {
698    id: Thing,
699    project_id: String,
700    ingest_id: Option<String>,
701    language: Option<String>,
702    source_kind: Option<String>,
703    path: Option<String>,
704    tool_version: Option<String>,
705    hash: Option<String>,
706    source_modified_at: Option<String>,
707    extra: Option<serde_json::Value>,
708}
709
710impl From<DocSourceRow> for DocSource {
711    fn from(row: DocSourceRow) -> Self {
712        Self {
713            id: Some(thing_id_to_string(row.id)),
714            project_id: row.project_id,
715            ingest_id: row.ingest_id,
716            language: row.language,
717            source_kind: row.source_kind,
718            path: row.path,
719            tool_version: row.tool_version,
720            hash: row.hash,
721            source_modified_at: row.source_modified_at,
722            extra: row.extra,
723        }
724    }
725}
726
727#[derive(serde::Deserialize)]
728struct SymbolKindRow {
729    kind: Option<String>,
730}
731
732fn normalize_pattern(pattern: &str) -> Option<String> {
733    let trimmed = pattern.trim().to_lowercase();
734    if trimmed.is_empty() {
735        None
736    } else {
737        Some(trimmed)
738    }
739}
740
741fn limit_to_i64(limit: usize) -> StoreResult<i64> {
742    i64::try_from(limit).map_err(|_| {
743        StoreError::InvalidInput("limit exceeds supported range".to_string())
744    })
745}
746
747fn thing_id_to_string(thing: Thing) -> String {
748    match thing.id {
749        Id::String(value) => value,
750        other => other.to_string(),
751    }
752}
753
754fn build_project_regex(pattern: &str) -> StoreResult<Regex> {
755    let body = glob_to_regex_body(pattern);
756    let regex = format!(r"(^|\|){body}(\||$)");
757    Regex::from_str(&regex).map_err(|err| {
758        StoreError::InvalidInput(format!("Invalid project search pattern: {err}"))
759    })
760}
761
762fn build_scope_regex(pattern: &str) -> StoreResult<Regex> {
763    let body = glob_to_regex_body(pattern);
764    let regex = format!(r"^{body}$");
765    Regex::from_str(&regex).map_err(|err| {
766        StoreError::InvalidInput(format!("Invalid scope search pattern: {err}"))
767    })
768}
769
770fn glob_to_regex_body(pattern: &str) -> String {
771    let mut escaped = String::new();
772    for ch in pattern.chars() {
773        match ch {
774            '*' => escaped.push_str(".*"),
775            '.' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => {
776                escaped.push('\\');
777                escaped.push(ch);
778            }
779            _ => escaped.push(ch),
780        }
781    }
782    escaped
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use docx_store::models::{DocSource, Ingest};
789    use surrealdb::engine::local::{Db, Mem};
790    use surrealdb::Surreal;
791
792    async fn build_store() -> SurrealDocStore<Db> {
793        let db = Surreal::new::<Mem>(())
794            .await
795            .expect("failed to create in-memory SurrealDB");
796        db.use_ns("docx")
797            .use_db("test")
798            .await
799            .expect("failed to set namespace/db");
800        SurrealDocStore::new(db)
801    }
802
803    #[tokio::test]
804    async fn list_ingests_includes_ids() {
805        let store = build_store().await;
806        let ingest = Ingest {
807            id: Some("ingest-1".to_string()),
808            project_id: "project".to_string(),
809            git_commit: None,
810            git_branch: None,
811            git_tag: None,
812            project_version: None,
813            source_modified_at: None,
814            ingested_at: None,
815            extra: None,
816        };
817
818        store
819            .create_ingest(ingest)
820            .await
821            .expect("failed to create ingest");
822        let ingests = store
823            .list_ingests("project", 10)
824            .await
825            .expect("failed to list ingests");
826
827        assert_eq!(ingests.len(), 1);
828        assert_eq!(ingests[0].id.as_deref(), Some("ingest-1"));
829    }
830
831    #[tokio::test]
832    async fn list_doc_sources_includes_ids() {
833        let store = build_store().await;
834        let source = DocSource {
835            id: Some("source-1".to_string()),
836            project_id: "project".to_string(),
837            ingest_id: Some("ingest-1".to_string()),
838            language: None,
839            source_kind: None,
840            path: None,
841            tool_version: None,
842            hash: None,
843            source_modified_at: None,
844            extra: None,
845        };
846
847        store
848            .create_doc_source(source)
849            .await
850            .expect("failed to create doc source");
851        let sources = store
852            .list_doc_sources_by_project("project", None, 10)
853            .await
854            .expect("failed to list doc sources");
855
856        assert_eq!(sources.len(), 1);
857        assert_eq!(sources[0].id.as_deref(), Some("source-1"));
858    }
859}