metis_core/dal/database/
repository.rs

1use crate::dal::database::configuration_repository::ConfigurationRepository;
2use crate::dal::database::models::*;
3use crate::dal::database::schema;
4use crate::{MetisError, Result};
5use diesel::prelude::*;
6use diesel::sqlite::SqliteConnection;
7
8/// Data access repository for document operations
9pub struct DocumentRepository {
10    connection: SqliteConnection,
11}
12
13impl DocumentRepository {
14    pub fn new(connection: SqliteConnection) -> Self {
15        Self { connection }
16    }
17
18    /// Insert a new document into the database
19    pub fn create_document(&mut self, doc: NewDocument) -> Result<Document> {
20        use schema::documents::dsl::*;
21
22        diesel::insert_into(documents)
23            .values(&doc)
24            .returning(Document::as_returning())
25            .get_result(&mut self.connection)
26            .map_err(MetisError::Database)
27    }
28
29    /// Find a document by its filepath
30    pub fn find_by_filepath(&mut self, file_path: &str) -> Result<Option<Document>> {
31        use schema::documents::dsl::*;
32
33        documents
34            .filter(filepath.eq(file_path))
35            .first(&mut self.connection)
36            .optional()
37            .map_err(MetisError::Database)
38    }
39
40    /// Find a document by its ID
41    pub fn find_by_id(&mut self, document_id: &str) -> Result<Option<Document>> {
42        use schema::documents::dsl::*;
43
44        documents
45            .filter(id.eq(document_id))
46            .first(&mut self.connection)
47            .optional()
48            .map_err(MetisError::Database)
49    }
50
51    /// Update an existing document
52    pub fn update_document(&mut self, file_path: &str, doc: &Document) -> Result<Document> {
53        use schema::documents::dsl::*;
54
55        diesel::update(documents.filter(filepath.eq(file_path)))
56            .set(doc)
57            .returning(Document::as_returning())
58            .get_result(&mut self.connection)
59            .map_err(MetisError::Database)
60    }
61
62    /// Delete a document and all its relationships
63    pub fn delete_document(&mut self, file_path: &str) -> Result<bool> {
64        use schema::documents::dsl::*;
65
66        let deleted_count = diesel::delete(documents.filter(filepath.eq(file_path)))
67            .execute(&mut self.connection)
68            .map_err(MetisError::Database)?;
69
70        Ok(deleted_count > 0)
71    }
72
73    /// Find all children of a document
74    pub fn find_children(&mut self, parent_document_id: &str) -> Result<Vec<Document>> {
75        use schema::document_relationships::dsl::*;
76        use schema::documents::dsl::*;
77
78        documents
79            .inner_join(document_relationships.on(id.eq(child_id)))
80            .filter(parent_id.eq(parent_document_id))
81            .select(Document::as_select())
82            .load(&mut self.connection)
83            .map_err(MetisError::Database)
84    }
85
86    /// Find the parent of a document
87    pub fn find_parent(&mut self, child_document_id: &str) -> Result<Option<Document>> {
88        use schema::document_relationships::dsl::*;
89        use schema::documents::dsl::*;
90
91        documents
92            .inner_join(document_relationships.on(id.eq(parent_id)))
93            .filter(child_id.eq(child_document_id))
94            .select(Document::as_select())
95            .first(&mut self.connection)
96            .optional()
97            .map_err(MetisError::Database)
98    }
99
100    /// Create a parent-child relationship
101    pub fn create_relationship(&mut self, relationship: DocumentRelationship) -> Result<()> {
102        use schema::document_relationships::dsl::*;
103
104        diesel::insert_into(document_relationships)
105            .values(&relationship)
106            .execute(&mut self.connection)
107            .map_err(MetisError::Database)?;
108
109        Ok(())
110    }
111
112    /// Search documents using FTS
113    pub fn search_documents(&mut self, query: &str) -> Result<Vec<Document>> {
114        // For SQLite FTS, we need to use sql_query for the MATCH operator
115        diesel::sql_query(
116            "
117            SELECT d.* FROM documents d
118            INNER JOIN document_search ds ON d.filepath = ds.document_filepath
119            WHERE document_search MATCH ?
120        ",
121        )
122        .bind::<diesel::sql_types::Text, _>(query)
123        .load::<Document>(&mut self.connection)
124        .map_err(MetisError::Database)
125    }
126
127    /// Search non-archived documents using FTS
128    pub fn search_documents_unarchived(&mut self, query: &str) -> Result<Vec<Document>> {
129        // For SQLite FTS, we need to use sql_query for the MATCH operator
130        diesel::sql_query(
131            "
132            SELECT d.* FROM documents d
133            INNER JOIN document_search ds ON d.filepath = ds.document_filepath
134            WHERE document_search MATCH ? AND d.archived = 0
135        ",
136        )
137        .bind::<diesel::sql_types::Text, _>(query)
138        .load::<Document>(&mut self.connection)
139        .map_err(MetisError::Database)
140    }
141
142    /// Get all documents of a specific type
143    pub fn find_by_type(&mut self, doc_type: &str) -> Result<Vec<Document>> {
144        use schema::documents::dsl::*;
145
146        documents
147            .filter(document_type.eq(doc_type))
148            .order(updated_at.desc())
149            .load(&mut self.connection)
150            .map_err(MetisError::Database)
151    }
152
153    /// Get all non-archived documents of a specific type
154    pub fn find_by_type_unarchived(&mut self, doc_type: &str) -> Result<Vec<Document>> {
155        use schema::documents::dsl::*;
156
157        documents
158            .filter(document_type.eq(doc_type))
159            .filter(archived.eq(false))
160            .order(updated_at.desc())
161            .load(&mut self.connection)
162            .map_err(MetisError::Database)
163    }
164
165    /// Get documents with specific tags
166    pub fn find_by_tag(&mut self, tag_name: &str) -> Result<Vec<Document>> {
167        use schema::document_tags::dsl::*;
168        use schema::documents::dsl::*;
169
170        documents
171            .inner_join(document_tags.on(filepath.eq(document_filepath)))
172            .filter(tag.eq(tag_name))
173            .select(Document::as_select())
174            .load(&mut self.connection)
175            .map_err(MetisError::Database)
176    }
177
178    /// Get documents in a specific phase
179    pub fn find_by_phase(&mut self, phase_name: &str) -> Result<Vec<Document>> {
180        use schema::documents::dsl::*;
181
182        documents
183            .filter(phase.eq(phase_name))
184            .order(updated_at.desc())
185            .load(&mut self.connection)
186            .map_err(MetisError::Database)
187    }
188
189    /// Get documents by type and phase
190    pub fn find_by_type_and_phase(
191        &mut self,
192        doc_type: &str,
193        phase_name: &str,
194    ) -> Result<Vec<Document>> {
195        use schema::documents::dsl::*;
196
197        documents
198            .filter(document_type.eq(doc_type))
199            .filter(phase.eq(phase_name))
200            .order(updated_at.desc())
201            .load(&mut self.connection)
202            .map_err(MetisError::Database)
203    }
204
205    /// Get all documents belonging to a strategy
206    pub fn find_by_strategy_id(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
207        use schema::documents::dsl::*;
208
209        documents
210            .filter(strategy_id.eq(strategy_document_id))
211            .order(updated_at.desc())
212            .load(&mut self.connection)
213            .map_err(MetisError::Database)
214    }
215
216    /// Get all documents belonging to an initiative
217    pub fn find_by_initiative_id(&mut self, initiative_document_id: &str) -> Result<Vec<Document>> {
218        use schema::documents::dsl::*;
219
220        documents
221            .filter(initiative_id.eq(initiative_document_id))
222            .order(updated_at.desc())
223            .load(&mut self.connection)
224            .map_err(MetisError::Database)
225    }
226
227    /// Get all tags for a specific document by filepath
228    pub fn get_tags_for_document(&mut self, doc_filepath: &str) -> Result<Vec<String>> {
229        use schema::document_tags::dsl::*;
230
231        document_tags
232            .filter(document_filepath.eq(doc_filepath))
233            .select(tag)
234            .load::<String>(&mut self.connection)
235            .map_err(MetisError::Database)
236    }
237
238    /// Get all documents in a strategy hierarchy (strategy + its initiatives + their tasks)
239    pub fn find_strategy_hierarchy(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
240        use schema::documents::dsl::*;
241
242        documents
243            .filter(
244                id.eq(strategy_document_id)
245                    .or(strategy_id.eq(strategy_document_id)),
246            )
247            .order((document_type.asc(), updated_at.desc()))
248            .load(&mut self.connection)
249            .map_err(MetisError::Database)
250    }
251
252    /// Get all documents in a strategy hierarchy by short code (strategy + its initiatives + their tasks)
253    pub fn find_strategy_hierarchy_by_short_code(
254        &mut self,
255        strategy_short_code: &str,
256    ) -> Result<Vec<Document>> {
257        use schema::documents::dsl::*;
258
259        documents
260            .filter(
261                short_code
262                    .eq(strategy_short_code)
263                    .or(strategy_id.eq(strategy_short_code)),
264            )
265            .order((document_type.asc(), updated_at.desc()))
266            .load(&mut self.connection)
267            .map_err(MetisError::Database)
268    }
269
270    /// Get all documents in an initiative hierarchy (initiative + its tasks)
271    pub fn find_initiative_hierarchy(
272        &mut self,
273        initiative_document_id: &str,
274    ) -> Result<Vec<Document>> {
275        use schema::documents::dsl::*;
276
277        documents
278            .filter(
279                id.eq(initiative_document_id)
280                    .or(initiative_id.eq(initiative_document_id)),
281            )
282            .order((document_type.asc(), updated_at.desc()))
283            .load(&mut self.connection)
284            .map_err(MetisError::Database)
285    }
286
287    /// Get all documents in an initiative hierarchy by short code (initiative + its tasks)
288    pub fn find_initiative_hierarchy_by_short_code(
289        &mut self,
290        initiative_short_code: &str,
291    ) -> Result<Vec<Document>> {
292        use schema::documents::dsl::*;
293
294        documents
295            .filter(
296                short_code
297                    .eq(initiative_short_code)
298                    .or(initiative_id.eq(initiative_short_code)),
299            )
300            .order((document_type.asc(), updated_at.desc()))
301            .load(&mut self.connection)
302            .map_err(MetisError::Database)
303    }
304
305    /// Generate a short code for a document type using the database configuration
306    pub fn generate_short_code(&mut self, doc_type: &str, db_path: &str) -> Result<String> {
307        let mut config_repo =
308            ConfigurationRepository::new(SqliteConnection::establish(db_path).map_err(|e| {
309                MetisError::ConfigurationError(
310                    crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
311                )
312            })?);
313
314        config_repo.generate_short_code(doc_type)
315    }
316
317    /// Find a document by its short code
318    pub fn find_by_short_code(&mut self, code: &str) -> Result<Option<Document>> {
319        use schema::documents::dsl::*;
320
321        documents
322            .filter(short_code.eq(code))
323            .first(&mut self.connection)
324            .optional()
325            .map_err(MetisError::Database)
326    }
327
328    /// Resolve short code to document ID for parent relationships
329    pub fn resolve_short_code_to_document_id(&mut self, code: &str) -> Result<String> {
330        match self.find_by_short_code(code)? {
331            Some(doc) => Ok(doc.id.to_string()),
332            None => Err(MetisError::NotFound(format!(
333                "Document with short code '{}' not found",
334                code
335            ))),
336        }
337    }
338
339    /// Resolve short code to file path for file operations
340    pub fn resolve_short_code_to_filepath(&mut self, code: &str) -> Result<String> {
341        match self.find_by_short_code(code)? {
342            Some(doc) => Ok(doc.filepath),
343            None => Err(MetisError::NotFound(format!(
344                "Document with short code '{}' not found",
345                code
346            ))),
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::dal::Database;
355
356    fn setup_test_repository() -> DocumentRepository {
357        let db = Database::new(":memory:").expect("Failed to create test database");
358        db.into_repository()
359    }
360
361    fn create_test_document() -> NewDocument {
362        NewDocument {
363            filepath: "/test/doc.md".to_string(),
364            id: "test-doc-1".to_string(),
365            title: "Test Document".to_string(),
366            document_type: "vision".to_string(),
367            created_at: 1609459200.0, // 2021-01-01
368            updated_at: 1609459200.0,
369            archived: false,
370            exit_criteria_met: false,
371            file_hash: "abc123".to_string(),
372            frontmatter_json: "{}".to_string(),
373            content: Some("Test content".to_string()),
374            phase: "draft".to_string(),
375            strategy_id: None,
376            initiative_id: None,
377            short_code: "TEST-V-0001".to_string(),
378        }
379    }
380
381    #[test]
382    fn test_create_and_find_document() {
383        let mut repo = setup_test_repository();
384
385        let new_doc = create_test_document();
386        let created = repo
387            .create_document(new_doc)
388            .expect("Failed to create document");
389
390        assert_eq!(created.filepath, "/test/doc.md");
391        assert_eq!(created.title, "Test Document");
392        assert_eq!(created.document_type, "vision");
393
394        // Test find by filepath
395        let found = repo
396            .find_by_filepath("/test/doc.md")
397            .expect("Failed to find document")
398            .expect("Document not found");
399        assert_eq!(found.id, "test-doc-1");
400
401        // Test find by id
402        let found_by_id = repo
403            .find_by_id("test-doc-1")
404            .expect("Failed to find document")
405            .expect("Document not found");
406        assert_eq!(found_by_id.filepath, "/test/doc.md");
407    }
408
409    #[test]
410    fn test_update_document() {
411        let mut repo = setup_test_repository();
412
413        let new_doc = create_test_document();
414        let mut created = repo
415            .create_document(new_doc)
416            .expect("Failed to create document");
417
418        // Update the document
419        created.title = "Updated Title".to_string();
420        created.updated_at = 1609462800.0; // 1 hour later
421
422        let updated = repo
423            .update_document("/test/doc.md", &created)
424            .expect("Failed to update document");
425
426        assert_eq!(updated.title, "Updated Title");
427        assert_eq!(updated.updated_at, 1609462800.0);
428    }
429
430    #[test]
431    fn test_delete_document() {
432        let mut repo = setup_test_repository();
433
434        let new_doc = create_test_document();
435        repo.create_document(new_doc)
436            .expect("Failed to create document");
437
438        // Delete the document
439        let deleted = repo
440            .delete_document("/test/doc.md")
441            .expect("Failed to delete document");
442        assert!(deleted);
443
444        // Verify it's gone
445        let found = repo
446            .find_by_filepath("/test/doc.md")
447            .expect("Failed to search for document");
448        assert!(found.is_none());
449
450        // Try to delete non-existent document
451        let deleted_again = repo
452            .delete_document("/test/doc.md")
453            .expect("Failed to delete document");
454        assert!(!deleted_again);
455    }
456
457    #[test]
458    fn test_document_relationships() {
459        let mut repo = setup_test_repository();
460
461        // Create parent document
462        let parent_doc = NewDocument {
463            filepath: "/parent.md".to_string(),
464            id: "parent-1".to_string(),
465            title: "Parent Document".to_string(),
466            document_type: "strategy".to_string(),
467            created_at: 1609459200.0,
468            updated_at: 1609459200.0,
469            archived: false,
470            exit_criteria_met: false,
471            file_hash: "parent123".to_string(),
472            frontmatter_json: "{}".to_string(),
473            content: Some("Parent content".to_string()),
474            phase: "shaping".to_string(),
475            strategy_id: None,
476            initiative_id: None,
477            short_code: "TEST-S-0001".to_string(),
478        };
479        repo.create_document(parent_doc)
480            .expect("Failed to create parent");
481
482        // Create child document
483        let child_doc = NewDocument {
484            filepath: "/child.md".to_string(),
485            id: "child-1".to_string(),
486            title: "Child Document".to_string(),
487            document_type: "initiative".to_string(),
488            created_at: 1609459200.0,
489            updated_at: 1609459200.0,
490            archived: false,
491            exit_criteria_met: false,
492            file_hash: "child123".to_string(),
493            frontmatter_json: "{}".to_string(),
494            content: Some("Child content".to_string()),
495            phase: "discovery".to_string(),
496            strategy_id: Some("parent-1".to_string()),
497            initiative_id: None,
498            short_code: "TEST-I-0001".to_string(),
499        };
500        repo.create_document(child_doc)
501            .expect("Failed to create child");
502
503        // Create relationship
504        let relationship = DocumentRelationship {
505            child_id: "child-1".to_string(),
506            parent_id: "parent-1".to_string(),
507            child_filepath: "/child.md".to_string(),
508            parent_filepath: "/parent.md".to_string(),
509        };
510        repo.create_relationship(relationship)
511            .expect("Failed to create relationship");
512
513        // Test find children
514        let children = repo
515            .find_children("parent-1")
516            .expect("Failed to find children");
517        assert_eq!(children.len(), 1);
518        assert_eq!(children[0].id, "child-1");
519
520        // Test find parent
521        let parent = repo
522            .find_parent("child-1")
523            .expect("Failed to find parent")
524            .expect("Parent not found");
525        assert_eq!(parent.id, "parent-1");
526    }
527
528    #[test]
529    fn test_find_by_type() {
530        let mut repo = setup_test_repository();
531
532        // Create documents of different types
533        let vision_doc = NewDocument {
534            document_type: "vision".to_string(),
535            filepath: "/vision.md".to_string(),
536            id: "vision-1".to_string(),
537            title: "Vision Doc".to_string(),
538            created_at: 1609459200.0,
539            updated_at: 1609459200.0,
540            archived: false,
541            exit_criteria_met: false,
542            file_hash: "vision123".to_string(),
543            frontmatter_json: "{}".to_string(),
544            content: None,
545            phase: "draft".to_string(),
546            strategy_id: None,
547            initiative_id: None,
548            short_code: "TEST-V-0002".to_string(),
549        };
550
551        let strategy_doc = NewDocument {
552            document_type: "strategy".to_string(),
553            filepath: "/strategy.md".to_string(),
554            id: "strategy-1".to_string(),
555            title: "Strategy Doc".to_string(),
556            created_at: 1609462800.0, // Later timestamp
557            updated_at: 1609462800.0,
558            archived: false,
559            exit_criteria_met: false,
560            file_hash: "strategy123".to_string(),
561            frontmatter_json: "{}".to_string(),
562            content: None,
563            phase: "shaping".to_string(),
564            strategy_id: None,
565            initiative_id: None,
566            short_code: "TEST-S-0002".to_string(),
567        };
568
569        repo.create_document(vision_doc)
570            .expect("Failed to create vision");
571        repo.create_document(strategy_doc)
572            .expect("Failed to create strategy");
573
574        // Test find by type
575        let visions = repo.find_by_type("vision").expect("Failed to find visions");
576        assert_eq!(visions.len(), 1);
577        assert_eq!(visions[0].document_type, "vision");
578
579        let strategies = repo
580            .find_by_type("strategy")
581            .expect("Failed to find strategies");
582        assert_eq!(strategies.len(), 1);
583        assert_eq!(strategies[0].document_type, "strategy");
584
585        // Verify ordering (newest first)
586        let _all_docs = repo.find_by_type("vision").expect("Failed to find docs");
587        // Since we only have one vision, we can't test ordering here
588        // But the query should work
589    }
590
591    #[test]
592    fn test_document_not_found() {
593        let mut repo = setup_test_repository();
594
595        let found = repo
596            .find_by_filepath("/nonexistent.md")
597            .expect("Failed to search for document");
598        assert!(found.is_none());
599
600        let found_by_id = repo
601            .find_by_id("nonexistent")
602            .expect("Failed to search for document");
603        assert!(found_by_id.is_none());
604    }
605}