metis_core/application/services/
synchronization.rs1use crate::application::services::{DatabaseService, FilesystemService};
2use crate::dal::database::models::{Document, NewDocument};
3use crate::domain::documents::{factory::DocumentFactory, traits::Document as DocumentTrait};
4use crate::{MetisError, Result};
5use serde_json;
6use std::path::Path;
7
8pub struct SyncService<'a> {
10 db_service: &'a mut DatabaseService,
11}
12
13impl<'a> SyncService<'a> {
14 pub fn new(db_service: &'a mut DatabaseService) -> Self {
15 Self { db_service }
16 }
17
18 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
21 let path_str = file_path.as_ref().to_string_lossy().to_string();
22
23 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
25 MetisError::ValidationFailed {
26 message: format!("Failed to parse document: {}", e),
27 }
28 })?;
29
30 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
32 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
33 let content = FilesystemService::read_file(&file_path)?;
34
35 let new_doc = self.domain_to_database_model(
37 document_obj.as_ref(),
38 &path_str,
39 file_hash,
40 updated_at,
41 content,
42 )?;
43
44 self.db_service.create_document(new_doc)
46 }
47
48 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
51 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
53 MetisError::DocumentNotFound {
54 id: filepath.to_string(),
55 }
56 })?;
57
58 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
60 message: "Document has no content".to_string(),
61 })?;
62
63 FilesystemService::write_file(filepath, &content)?;
65
66 Ok(())
67 }
68
69 fn domain_to_database_model(
71 &self,
72 document_obj: &dyn DocumentTrait,
73 filepath: &str,
74 file_hash: String,
75 updated_at: f64,
76 content: String,
77 ) -> Result<NewDocument> {
78 let core = document_obj.core();
79 let phase = document_obj
80 .phase()
81 .map_err(|e| MetisError::ValidationFailed {
82 message: format!("Failed to get document phase: {}", e),
83 })?
84 .to_string();
85
86 Ok(NewDocument {
87 filepath: filepath.to_string(),
88 id: document_obj.id().to_string(),
89 title: core.title.clone(),
90 document_type: document_obj.document_type().to_string(),
91 created_at: core.metadata.created_at.timestamp() as f64,
92 updated_at,
93 archived: core.archived,
94 exit_criteria_met: document_obj.exit_criteria_met(),
95 file_hash,
96 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
97 content: Some(content),
98 phase,
99 })
100 }
101
102 fn extract_document_id<P: AsRef<Path>>(file_path: P) -> Result<String> {
104 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
106 MetisError::ValidationFailed {
107 message: format!("Failed to read file: {}", e),
108 }
109 })?;
110
111 use gray_matter::{engine::YAML, Matter};
113 let matter = Matter::<YAML>::new();
114 let result = matter.parse(&raw_content);
115
116 if let Some(frontmatter) = result.data {
118 let fm_map = match frontmatter {
119 gray_matter::Pod::Hash(map) => map,
120 _ => {
121 return Err(MetisError::ValidationFailed {
122 message: "Frontmatter must be a hash/map".to_string(),
123 });
124 }
125 };
126
127 if let Some(gray_matter::Pod::String(id_str)) = fm_map.get("id") {
128 return Ok(id_str.clone());
129 }
130 }
131
132 Err(MetisError::ValidationFailed {
133 message: "Document missing ID in frontmatter".to_string(),
134 })
135 }
136
137 async fn update_moved_document<P: AsRef<Path>>(
139 &mut self,
140 existing_doc: &Document,
141 new_file_path: P,
142 ) -> Result<()> {
143 self.db_service.delete_document(&existing_doc.filepath)?;
145
146 self.import_from_file(&new_file_path).await?;
148
149 Ok(())
150 }
151
152 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
154 let path_str = file_path.as_ref().to_string_lossy().to_string();
155
156 let file_exists = FilesystemService::file_exists(&file_path);
158
159 let db_doc_by_path = self.db_service.find_by_filepath(&path_str)?;
161
162 match (file_exists, db_doc_by_path) {
163 (true, None) => {
165 let document_id = Self::extract_document_id(&file_path)?;
167
168 if let Some(existing_doc) = self.db_service.find_by_id(&document_id)? {
170 let old_path = existing_doc.filepath.clone();
172 self.update_moved_document(&existing_doc, &file_path)
173 .await?;
174 Ok(SyncResult::Moved {
175 from: old_path,
176 to: path_str,
177 })
178 } else {
179 self.import_from_file(&file_path).await?;
181 Ok(SyncResult::Imported { filepath: path_str })
182 }
183 }
184
185 (false, Some(_)) => {
187 self.db_service.delete_document(&path_str)?;
188 Ok(SyncResult::Deleted { filepath: path_str })
189 }
190
191 (true, Some(db_doc)) => {
193 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
194
195 if db_doc.file_hash != current_hash {
196 self.db_service.delete_document(&path_str)?;
198 self.import_from_file(&file_path).await?;
199 Ok(SyncResult::Updated { filepath: path_str })
200 } else {
201 Ok(SyncResult::UpToDate { filepath: path_str })
202 }
203 }
204
205 (false, None) => Ok(SyncResult::NotFound { filepath: path_str }),
207 }
208 }
209
210 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
212 let mut results = Vec::new();
213
214 let files = FilesystemService::find_markdown_files(&dir_path)?;
216
217 for file_path in files {
219 match self.sync_file(&file_path).await {
220 Ok(result) => results.push(result),
221 Err(e) => results.push(SyncResult::Error {
222 filepath: file_path,
223 error: e.to_string(),
224 }),
225 }
226 }
227
228 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
230 for (_, filepath) in db_pairs {
231 if !FilesystemService::file_exists(&filepath) {
232 match self.db_service.delete_document(&filepath) {
234 Ok(_) => results.push(SyncResult::Deleted { filepath }),
235 Err(e) => results.push(SyncResult::Error {
236 filepath,
237 error: e.to_string(),
238 }),
239 }
240 }
241 }
242
243 Ok(results)
244 }
245
246 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
248 let mut issues = Vec::new();
249
250 let files = FilesystemService::find_markdown_files(&dir_path)?;
252
253 for file_path in &files {
255 let path_str = file_path.to_string();
256
257 if let Some(db_doc) = self.db_service.find_by_filepath(&path_str)? {
258 let current_hash = FilesystemService::compute_file_hash(file_path)?;
259 if db_doc.file_hash != current_hash {
260 issues.push(SyncIssue::OutOfSync {
261 filepath: path_str,
262 reason: "File hash mismatch".to_string(),
263 });
264 }
265 } else {
266 issues.push(SyncIssue::MissingFromDatabase { filepath: path_str });
267 }
268 }
269
270 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
272 for (_, filepath) in db_pairs {
273 if !files.contains(&filepath) && !FilesystemService::file_exists(&filepath) {
274 issues.push(SyncIssue::MissingFromFilesystem {
275 filepath: filepath.clone(),
276 });
277 }
278 }
279
280 Ok(issues)
281 }
282}
283
284#[derive(Debug, Clone, PartialEq)]
286pub enum SyncResult {
287 Imported { filepath: String },
288 Updated { filepath: String },
289 Deleted { filepath: String },
290 UpToDate { filepath: String },
291 NotFound { filepath: String },
292 Error { filepath: String, error: String },
293 Moved { from: String, to: String },
294}
295
296impl SyncResult {
297 pub fn filepath(&self) -> &str {
299 match self {
300 SyncResult::Imported { filepath }
301 | SyncResult::Updated { filepath }
302 | SyncResult::Deleted { filepath }
303 | SyncResult::UpToDate { filepath }
304 | SyncResult::NotFound { filepath }
305 | SyncResult::Error { filepath, .. } => filepath,
306 SyncResult::Moved { to, .. } => to,
307 }
308 }
309
310 pub fn is_change(&self) -> bool {
312 matches!(
313 self,
314 SyncResult::Imported { .. }
315 | SyncResult::Updated { .. }
316 | SyncResult::Deleted { .. }
317 | SyncResult::Moved { .. }
318 )
319 }
320
321 pub fn is_error(&self) -> bool {
323 matches!(self, SyncResult::Error { .. })
324 }
325}
326
327#[derive(Debug, Clone)]
329pub enum SyncIssue {
330 MissingFromDatabase { filepath: String },
331 MissingFromFilesystem { filepath: String },
332 OutOfSync { filepath: String, reason: String },
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::dal::Database;
339 use tempfile::tempdir;
340
341 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
342 let temp_dir = tempdir().expect("Failed to create temp dir");
343 let db = Database::new(":memory:").expect("Failed to create test database");
344 let db_service = DatabaseService::new(db.into_repository());
345 (temp_dir, db_service)
346 }
347
348 fn create_test_document_content() -> String {
349 "---\n".to_string()
350 + "id: test-document\n"
351 + "title: Test Document\n"
352 + "level: vision\n"
353 + "created_at: \"2021-01-01T00:00:00Z\"\n"
354 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
355 + "archived: false\n"
356 + "exit_criteria_met: false\n"
357 + "tags:\n"
358 + " - \"#phase/draft\"\n"
359 + "---\n\n"
360 + "# Test Document\n\n"
361 + "Test content.\n"
362 }
363
364 #[tokio::test]
365 async fn test_import_from_file() {
366 let (temp_dir, mut db_service) = setup_services();
367 let mut sync_service = SyncService::new(&mut db_service);
368
369 let file_path = temp_dir.path().join("test.md");
370 FilesystemService::write_file(&file_path, &create_test_document_content())
371 .expect("Failed to write file");
372
373 let doc = sync_service
374 .import_from_file(&file_path)
375 .await
376 .expect("Failed to import");
377 assert_eq!(doc.title, "Test Document");
378 assert_eq!(doc.document_type, "vision");
379
380 assert!(db_service
382 .document_exists(&file_path.to_string_lossy())
383 .expect("Failed to check"));
384 }
385
386 #[tokio::test]
387 async fn test_sync_file_operations() {
388 let (temp_dir, mut db_service) = setup_services();
389 let mut sync_service = SyncService::new(&mut db_service);
390
391 let file_path = temp_dir.path().join("test.md");
392 let path_str = file_path.to_string_lossy().to_string();
393
394 let result = sync_service
396 .sync_file(&file_path)
397 .await
398 .expect("Failed to sync");
399 assert_eq!(
400 result,
401 SyncResult::NotFound {
402 filepath: path_str.clone()
403 }
404 );
405
406 FilesystemService::write_file(&file_path, &create_test_document_content())
408 .expect("Failed to write file");
409
410 let result = sync_service
411 .sync_file(&file_path)
412 .await
413 .expect("Failed to sync");
414 assert_eq!(
415 result,
416 SyncResult::Imported {
417 filepath: path_str.clone()
418 }
419 );
420
421 let result = sync_service
423 .sync_file(&file_path)
424 .await
425 .expect("Failed to sync");
426 assert_eq!(
427 result,
428 SyncResult::UpToDate {
429 filepath: path_str.clone()
430 }
431 );
432
433 let modified_content =
435 &create_test_document_content().replace("Test content.", "Modified content.");
436 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
437
438 let result = sync_service
439 .sync_file(&file_path)
440 .await
441 .expect("Failed to sync");
442 assert_eq!(
443 result,
444 SyncResult::Updated {
445 filepath: path_str.clone()
446 }
447 );
448
449 FilesystemService::delete_file(&file_path).expect("Failed to delete");
451
452 let result = sync_service
453 .sync_file(&file_path)
454 .await
455 .expect("Failed to sync");
456 assert_eq!(
457 result,
458 SyncResult::Deleted {
459 filepath: path_str.clone()
460 }
461 );
462
463 assert!(!db_service
465 .document_exists(&path_str)
466 .expect("Failed to check"));
467 }
468
469 #[tokio::test]
470 async fn test_sync_directory() {
471 let (temp_dir, mut db_service) = setup_services();
472 let mut sync_service = SyncService::new(&mut db_service);
473
474 let files = vec![
476 ("doc1.md", "test-1"),
477 ("subdir/doc2.md", "test-2"),
478 ("subdir/nested/doc3.md", "test-3"),
479 ];
480
481 for (file_path, id) in &files {
482 let full_path = temp_dir.path().join(file_path);
483 let content = &create_test_document_content()
484 .replace("Test Document", &format!("Test Document {}", id))
485 .replace("test-document", id);
486 FilesystemService::write_file(&full_path, content).expect("Failed to write");
487 }
488
489 let results = sync_service
491 .sync_directory(temp_dir.path())
492 .await
493 .expect("Failed to sync directory");
494
495 let imports = results
497 .iter()
498 .filter(|r| matches!(r, SyncResult::Imported { .. }))
499 .count();
500 assert_eq!(imports, 3);
501
502 let results = sync_service
504 .sync_directory(temp_dir.path())
505 .await
506 .expect("Failed to sync directory");
507 let up_to_date = results
508 .iter()
509 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
510 .count();
511 assert_eq!(up_to_date, 3);
512
513 for (file_path, _) in &files {
515 let full_path = temp_dir
516 .path()
517 .join(file_path)
518 .to_string_lossy()
519 .to_string();
520 assert!(results.iter().any(|r| r.filepath() == full_path));
521 }
522 }
523}