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    /// Get all documents of a specific type
128    pub fn find_by_type(&mut self, doc_type: &str) -> Result<Vec<Document>> {
129        use schema::documents::dsl::*;
130
131        documents
132            .filter(document_type.eq(doc_type))
133            .order(updated_at.desc())
134            .load(&mut self.connection)
135            .map_err(MetisError::Database)
136    }
137
138    /// Get documents with specific tags
139    pub fn find_by_tag(&mut self, tag_name: &str) -> Result<Vec<Document>> {
140        use schema::document_tags::dsl::*;
141        use schema::documents::dsl::*;
142
143        documents
144            .inner_join(document_tags.on(filepath.eq(document_filepath)))
145            .filter(tag.eq(tag_name))
146            .select(Document::as_select())
147            .load(&mut self.connection)
148            .map_err(MetisError::Database)
149    }
150
151    /// Get documents in a specific phase
152    pub fn find_by_phase(&mut self, phase_name: &str) -> Result<Vec<Document>> {
153        use schema::documents::dsl::*;
154
155        documents
156            .filter(phase.eq(phase_name))
157            .order(updated_at.desc())
158            .load(&mut self.connection)
159            .map_err(MetisError::Database)
160    }
161
162    /// Get documents by type and phase
163    pub fn find_by_type_and_phase(
164        &mut self,
165        doc_type: &str,
166        phase_name: &str,
167    ) -> Result<Vec<Document>> {
168        use schema::documents::dsl::*;
169
170        documents
171            .filter(document_type.eq(doc_type))
172            .filter(phase.eq(phase_name))
173            .order(updated_at.desc())
174            .load(&mut self.connection)
175            .map_err(MetisError::Database)
176    }
177
178    /// Get all documents belonging to a strategy
179    pub fn find_by_strategy_id(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
180        use schema::documents::dsl::*;
181
182        documents
183            .filter(strategy_id.eq(strategy_document_id))
184            .order(updated_at.desc())
185            .load(&mut self.connection)
186            .map_err(MetisError::Database)
187    }
188
189    /// Get all documents belonging to an initiative
190    pub fn find_by_initiative_id(&mut self, initiative_document_id: &str) -> Result<Vec<Document>> {
191        use schema::documents::dsl::*;
192
193        documents
194            .filter(initiative_id.eq(initiative_document_id))
195            .order(updated_at.desc())
196            .load(&mut self.connection)
197            .map_err(MetisError::Database)
198    }
199
200    /// Get all tags for a specific document by filepath
201    pub fn get_tags_for_document(&mut self, doc_filepath: &str) -> Result<Vec<String>> {
202        use schema::document_tags::dsl::*;
203
204        document_tags
205            .filter(document_filepath.eq(doc_filepath))
206            .select(tag)
207            .load::<String>(&mut self.connection)
208            .map_err(MetisError::Database)
209    }
210
211    /// Get all documents in a strategy hierarchy (strategy + its initiatives + their tasks)
212    pub fn find_strategy_hierarchy(&mut self, strategy_document_id: &str) -> Result<Vec<Document>> {
213        use schema::documents::dsl::*;
214
215        documents
216            .filter(
217                id.eq(strategy_document_id)
218                    .or(strategy_id.eq(strategy_document_id)),
219            )
220            .order((document_type.asc(), updated_at.desc()))
221            .load(&mut self.connection)
222            .map_err(MetisError::Database)
223    }
224
225    /// Get all documents in a strategy hierarchy by short code (strategy + its initiatives + their tasks)
226    pub fn find_strategy_hierarchy_by_short_code(
227        &mut self,
228        strategy_short_code: &str,
229    ) -> Result<Vec<Document>> {
230        use schema::documents::dsl::*;
231
232        documents
233            .filter(
234                short_code
235                    .eq(strategy_short_code)
236                    .or(strategy_id.eq(strategy_short_code)),
237            )
238            .order((document_type.asc(), updated_at.desc()))
239            .load(&mut self.connection)
240            .map_err(MetisError::Database)
241    }
242
243    /// Get all documents in an initiative hierarchy (initiative + its tasks)
244    pub fn find_initiative_hierarchy(
245        &mut self,
246        initiative_document_id: &str,
247    ) -> Result<Vec<Document>> {
248        use schema::documents::dsl::*;
249
250        documents
251            .filter(
252                id.eq(initiative_document_id)
253                    .or(initiative_id.eq(initiative_document_id)),
254            )
255            .order((document_type.asc(), updated_at.desc()))
256            .load(&mut self.connection)
257            .map_err(MetisError::Database)
258    }
259
260    /// Get all documents in an initiative hierarchy by short code (initiative + its tasks)
261    pub fn find_initiative_hierarchy_by_short_code(
262        &mut self,
263        initiative_short_code: &str,
264    ) -> Result<Vec<Document>> {
265        use schema::documents::dsl::*;
266
267        documents
268            .filter(
269                short_code
270                    .eq(initiative_short_code)
271                    .or(initiative_id.eq(initiative_short_code)),
272            )
273            .order((document_type.asc(), updated_at.desc()))
274            .load(&mut self.connection)
275            .map_err(MetisError::Database)
276    }
277
278    /// Generate a short code for a document type using the database configuration
279    pub fn generate_short_code(&mut self, doc_type: &str, db_path: &str) -> Result<String> {
280        let mut config_repo =
281            ConfigurationRepository::new(SqliteConnection::establish(db_path).map_err(|e| {
282                MetisError::ConfigurationError(
283                    crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
284                )
285            })?);
286
287        config_repo.generate_short_code(doc_type)
288    }
289
290    /// Find a document by its short code
291    pub fn find_by_short_code(&mut self, code: &str) -> Result<Option<Document>> {
292        use schema::documents::dsl::*;
293
294        documents
295            .filter(short_code.eq(code))
296            .first(&mut self.connection)
297            .optional()
298            .map_err(MetisError::Database)
299    }
300
301    /// Resolve short code to document ID for parent relationships
302    pub fn resolve_short_code_to_document_id(&mut self, code: &str) -> Result<String> {
303        match self.find_by_short_code(code)? {
304            Some(doc) => Ok(doc.id.to_string()),
305            None => Err(MetisError::NotFound(format!(
306                "Document with short code '{}' not found",
307                code
308            ))),
309        }
310    }
311
312    /// Resolve short code to file path for file operations
313    pub fn resolve_short_code_to_filepath(&mut self, code: &str) -> Result<String> {
314        match self.find_by_short_code(code)? {
315            Some(doc) => Ok(doc.filepath),
316            None => Err(MetisError::NotFound(format!(
317                "Document with short code '{}' not found",
318                code
319            ))),
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::dal::Database;
328
329    fn setup_test_repository() -> DocumentRepository {
330        let db = Database::new(":memory:").expect("Failed to create test database");
331        db.into_repository()
332    }
333
334    fn create_test_document() -> NewDocument {
335        NewDocument {
336            filepath: "/test/doc.md".to_string(),
337            id: "test-doc-1".to_string(),
338            title: "Test Document".to_string(),
339            document_type: "vision".to_string(),
340            created_at: 1609459200.0, // 2021-01-01
341            updated_at: 1609459200.0,
342            archived: false,
343            exit_criteria_met: false,
344            file_hash: "abc123".to_string(),
345            frontmatter_json: "{}".to_string(),
346            content: Some("Test content".to_string()),
347            phase: "draft".to_string(),
348            strategy_id: None,
349            initiative_id: None,
350            short_code: "TEST-V-0001".to_string(),
351        }
352    }
353
354    #[test]
355    fn test_create_and_find_document() {
356        let mut repo = setup_test_repository();
357
358        let new_doc = create_test_document();
359        let created = repo
360            .create_document(new_doc)
361            .expect("Failed to create document");
362
363        assert_eq!(created.filepath, "/test/doc.md");
364        assert_eq!(created.title, "Test Document");
365        assert_eq!(created.document_type, "vision");
366
367        // Test find by filepath
368        let found = repo
369            .find_by_filepath("/test/doc.md")
370            .expect("Failed to find document")
371            .expect("Document not found");
372        assert_eq!(found.id, "test-doc-1");
373
374        // Test find by id
375        let found_by_id = repo
376            .find_by_id("test-doc-1")
377            .expect("Failed to find document")
378            .expect("Document not found");
379        assert_eq!(found_by_id.filepath, "/test/doc.md");
380    }
381
382    #[test]
383    fn test_update_document() {
384        let mut repo = setup_test_repository();
385
386        let new_doc = create_test_document();
387        let mut created = repo
388            .create_document(new_doc)
389            .expect("Failed to create document");
390
391        // Update the document
392        created.title = "Updated Title".to_string();
393        created.updated_at = 1609462800.0; // 1 hour later
394
395        let updated = repo
396            .update_document("/test/doc.md", &created)
397            .expect("Failed to update document");
398
399        assert_eq!(updated.title, "Updated Title");
400        assert_eq!(updated.updated_at, 1609462800.0);
401    }
402
403    #[test]
404    fn test_delete_document() {
405        let mut repo = setup_test_repository();
406
407        let new_doc = create_test_document();
408        repo.create_document(new_doc)
409            .expect("Failed to create document");
410
411        // Delete the document
412        let deleted = repo
413            .delete_document("/test/doc.md")
414            .expect("Failed to delete document");
415        assert!(deleted);
416
417        // Verify it's gone
418        let found = repo
419            .find_by_filepath("/test/doc.md")
420            .expect("Failed to search for document");
421        assert!(found.is_none());
422
423        // Try to delete non-existent document
424        let deleted_again = repo
425            .delete_document("/test/doc.md")
426            .expect("Failed to delete document");
427        assert!(!deleted_again);
428    }
429
430    #[test]
431    fn test_document_relationships() {
432        let mut repo = setup_test_repository();
433
434        // Create parent document
435        let parent_doc = NewDocument {
436            filepath: "/parent.md".to_string(),
437            id: "parent-1".to_string(),
438            title: "Parent Document".to_string(),
439            document_type: "strategy".to_string(),
440            created_at: 1609459200.0,
441            updated_at: 1609459200.0,
442            archived: false,
443            exit_criteria_met: false,
444            file_hash: "parent123".to_string(),
445            frontmatter_json: "{}".to_string(),
446            content: Some("Parent content".to_string()),
447            phase: "shaping".to_string(),
448            strategy_id: None,
449            initiative_id: None,
450            short_code: "TEST-S-0001".to_string(),
451        };
452        repo.create_document(parent_doc)
453            .expect("Failed to create parent");
454
455        // Create child document
456        let child_doc = NewDocument {
457            filepath: "/child.md".to_string(),
458            id: "child-1".to_string(),
459            title: "Child Document".to_string(),
460            document_type: "initiative".to_string(),
461            created_at: 1609459200.0,
462            updated_at: 1609459200.0,
463            archived: false,
464            exit_criteria_met: false,
465            file_hash: "child123".to_string(),
466            frontmatter_json: "{}".to_string(),
467            content: Some("Child content".to_string()),
468            phase: "discovery".to_string(),
469            strategy_id: Some("parent-1".to_string()),
470            initiative_id: None,
471            short_code: "TEST-I-0001".to_string(),
472        };
473        repo.create_document(child_doc)
474            .expect("Failed to create child");
475
476        // Create relationship
477        let relationship = DocumentRelationship {
478            child_id: "child-1".to_string(),
479            parent_id: "parent-1".to_string(),
480            child_filepath: "/child.md".to_string(),
481            parent_filepath: "/parent.md".to_string(),
482        };
483        repo.create_relationship(relationship)
484            .expect("Failed to create relationship");
485
486        // Test find children
487        let children = repo
488            .find_children("parent-1")
489            .expect("Failed to find children");
490        assert_eq!(children.len(), 1);
491        assert_eq!(children[0].id, "child-1");
492
493        // Test find parent
494        let parent = repo
495            .find_parent("child-1")
496            .expect("Failed to find parent")
497            .expect("Parent not found");
498        assert_eq!(parent.id, "parent-1");
499    }
500
501    #[test]
502    fn test_find_by_type() {
503        let mut repo = setup_test_repository();
504
505        // Create documents of different types
506        let vision_doc = NewDocument {
507            document_type: "vision".to_string(),
508            filepath: "/vision.md".to_string(),
509            id: "vision-1".to_string(),
510            title: "Vision Doc".to_string(),
511            created_at: 1609459200.0,
512            updated_at: 1609459200.0,
513            archived: false,
514            exit_criteria_met: false,
515            file_hash: "vision123".to_string(),
516            frontmatter_json: "{}".to_string(),
517            content: None,
518            phase: "draft".to_string(),
519            strategy_id: None,
520            initiative_id: None,
521            short_code: "TEST-V-0002".to_string(),
522        };
523
524        let strategy_doc = NewDocument {
525            document_type: "strategy".to_string(),
526            filepath: "/strategy.md".to_string(),
527            id: "strategy-1".to_string(),
528            title: "Strategy Doc".to_string(),
529            created_at: 1609462800.0, // Later timestamp
530            updated_at: 1609462800.0,
531            archived: false,
532            exit_criteria_met: false,
533            file_hash: "strategy123".to_string(),
534            frontmatter_json: "{}".to_string(),
535            content: None,
536            phase: "shaping".to_string(),
537            strategy_id: None,
538            initiative_id: None,
539            short_code: "TEST-S-0002".to_string(),
540        };
541
542        repo.create_document(vision_doc)
543            .expect("Failed to create vision");
544        repo.create_document(strategy_doc)
545            .expect("Failed to create strategy");
546
547        // Test find by type
548        let visions = repo.find_by_type("vision").expect("Failed to find visions");
549        assert_eq!(visions.len(), 1);
550        assert_eq!(visions[0].document_type, "vision");
551
552        let strategies = repo
553            .find_by_type("strategy")
554            .expect("Failed to find strategies");
555        assert_eq!(strategies.len(), 1);
556        assert_eq!(strategies[0].document_type, "strategy");
557
558        // Verify ordering (newest first)
559        let _all_docs = repo.find_by_type("vision").expect("Failed to find docs");
560        // Since we only have one vision, we can't test ordering here
561        // But the query should work
562    }
563
564    #[test]
565    fn test_document_not_found() {
566        let mut repo = setup_test_repository();
567
568        let found = repo
569            .find_by_filepath("/nonexistent.md")
570            .expect("Failed to search for document");
571        assert!(found.is_none());
572
573        let found_by_id = repo
574            .find_by_id("nonexistent")
575            .expect("Failed to search for document");
576        assert!(found_by_id.is_none());
577    }
578}