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#[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
50pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(®ex).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(®ex).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}