1use crate::application::services::{DatabaseService, FilesystemService};
2use crate::dal::database::models::{Document, NewDocument};
3use crate::domain::documents::{
4 factory::DocumentFactory, traits::Document as DocumentTrait, types::DocumentId,
5};
6use crate::{MetisError, Result};
7use serde_json;
8use std::path::Path;
9
10pub struct SyncService<'a> {
12 db_service: &'a mut DatabaseService,
13 workspace_dir: Option<&'a Path>,
14}
15
16impl<'a> SyncService<'a> {
17 pub fn new(db_service: &'a mut DatabaseService) -> Self {
18 Self {
19 db_service,
20 workspace_dir: None,
21 }
22 }
23
24 pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
26 self.workspace_dir = Some(workspace_dir);
27 self
28 }
29
30 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
33 let path_str = file_path.as_ref().to_string_lossy().to_string();
34
35 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
37 MetisError::ValidationFailed {
38 message: format!("Failed to parse document: {}", e),
39 }
40 })?;
41
42 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
44 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
45 let content = FilesystemService::read_file(&file_path)?;
46
47 let new_doc = self.domain_to_database_model(
49 document_obj.as_ref(),
50 &path_str,
51 file_hash,
52 updated_at,
53 content,
54 )?;
55
56 self.db_service.create_document(new_doc)
58 }
59
60 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
63 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
65 MetisError::DocumentNotFound {
66 id: filepath.to_string(),
67 }
68 })?;
69
70 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
72 message: "Document has no content".to_string(),
73 })?;
74
75 FilesystemService::write_file(filepath, &content)?;
77
78 Ok(())
79 }
80
81 fn domain_to_database_model(
83 &self,
84 document_obj: &dyn DocumentTrait,
85 filepath: &str,
86 file_hash: String,
87 updated_at: f64,
88 content: String,
89 ) -> Result<NewDocument> {
90 let core = document_obj.core();
91 let phase = document_obj
92 .phase()
93 .map_err(|e| MetisError::ValidationFailed {
94 message: format!("Failed to get document phase: {}", e),
95 })?
96 .to_string();
97
98 let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
100 Self::extract_lineage_from_path(filepath, workspace_dir)
101 } else {
102 (None, None)
103 };
104
105 let final_strategy_id = fs_strategy_id
107 .or_else(|| core.strategy_id.clone())
108 .map(|id| id.to_string());
109 let final_initiative_id = fs_initiative_id
110 .or_else(|| core.initiative_id.clone())
111 .map(|id| id.to_string());
112
113 Ok(NewDocument {
114 filepath: filepath.to_string(),
115 id: document_obj.id().to_string(),
116 title: core.title.clone(),
117 document_type: document_obj.document_type().to_string(),
118 created_at: core.metadata.created_at.timestamp() as f64,
119 updated_at,
120 archived: core.archived,
121 exit_criteria_met: document_obj.exit_criteria_met(),
122 file_hash,
123 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
124 content: Some(content),
125 phase,
126 strategy_id: final_strategy_id,
127 initiative_id: final_initiative_id,
128 short_code: core.metadata.short_code.clone(),
129 })
130 }
131
132 fn extract_lineage_from_path<P: AsRef<Path>>(
135 file_path: P,
136 workspace_dir: &Path,
137 ) -> (Option<DocumentId>, Option<DocumentId>) {
138 let path = file_path.as_ref();
139
140 let relative_path = match path.strip_prefix(workspace_dir) {
142 Ok(rel) => rel,
143 Err(_) => return (None, None),
144 };
145
146 let path_parts: Vec<&str> = relative_path
147 .components()
148 .filter_map(|c| c.as_os_str().to_str())
149 .collect();
150
151 match path_parts.as_slice() {
153 ["strategies", strategy_id, "strategy.md"] => {
155 if strategy_id == &"NULL" {
156 (None, None)
157 } else {
158 (Some(DocumentId::from(*strategy_id)), None)
159 }
160 }
161 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
163 let strat_id = if strategy_id == &"NULL" {
164 None
165 } else {
166 Some(DocumentId::from(*strategy_id))
167 };
168 let init_id = if initiative_id == &"NULL" {
169 None
170 } else {
171 Some(DocumentId::from(*initiative_id))
172 };
173 (strat_id, init_id)
174 }
175 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
177 let strat_id = if strategy_id == &"NULL" {
178 None
179 } else {
180 Some(DocumentId::from(*strategy_id))
181 };
182 let init_id = if initiative_id == &"NULL" {
183 None
184 } else {
185 Some(DocumentId::from(*initiative_id))
186 };
187 (strat_id, init_id)
188 }
189 ["backlog", _] => (None, None),
191 ["adrs", _] => (None, None),
193 ["vision.md"] => (None, None),
195 _ => (None, None),
197 }
198 }
199
200 fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
202 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
204 MetisError::ValidationFailed {
205 message: format!("Failed to read file: {}", e),
206 }
207 })?;
208
209 use gray_matter::{engine::YAML, Matter};
211 let matter = Matter::<YAML>::new();
212 let result = matter.parse(&raw_content);
213
214 if let Some(frontmatter) = result.data {
216 let fm_map = match frontmatter {
217 gray_matter::Pod::Hash(map) => map,
218 _ => {
219 return Err(MetisError::ValidationFailed {
220 message: "Frontmatter must be a hash/map".to_string(),
221 });
222 }
223 };
224
225 if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
226 return Ok(short_code_str.clone());
227 }
228 }
229
230 Err(MetisError::ValidationFailed {
231 message: "Document missing short_code in frontmatter".to_string(),
232 })
233 }
234
235 async fn update_moved_document<P: AsRef<Path>>(
237 &mut self,
238 existing_doc: &Document,
239 new_file_path: P,
240 ) -> Result<()> {
241 self.db_service.delete_document(&existing_doc.filepath)?;
243
244 self.import_from_file(&new_file_path).await?;
246
247 Ok(())
248 }
249
250 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
252 let path_str = file_path.as_ref().to_string_lossy().to_string();
253
254 let file_exists = FilesystemService::file_exists(&file_path);
256
257 let db_doc_by_path = self.db_service.find_by_filepath(&path_str)?;
259
260 match (file_exists, db_doc_by_path) {
261 (true, None) => {
263 let short_code = Self::extract_document_short_code(&file_path)?;
265
266 if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
268 let old_path = existing_doc.filepath.clone();
270 self.update_moved_document(&existing_doc, &file_path)
271 .await?;
272 Ok(SyncResult::Moved {
273 from: old_path,
274 to: path_str,
275 })
276 } else {
277 self.import_from_file(&file_path).await?;
279 Ok(SyncResult::Imported { filepath: path_str })
280 }
281 }
282
283 (false, Some(_)) => {
285 self.db_service.delete_document(&path_str)?;
286 Ok(SyncResult::Deleted { filepath: path_str })
287 }
288
289 (true, Some(db_doc)) => {
291 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
292
293 if db_doc.file_hash != current_hash {
294 self.db_service.delete_document(&path_str)?;
296 self.import_from_file(&file_path).await?;
297 Ok(SyncResult::Updated { filepath: path_str })
298 } else {
299 Ok(SyncResult::UpToDate { filepath: path_str })
300 }
301 }
302
303 (false, None) => Ok(SyncResult::NotFound { filepath: path_str }),
305 }
306 }
307
308 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
310 let mut results = Vec::new();
311
312 let files = FilesystemService::find_markdown_files(&dir_path)?;
314
315 for file_path in files {
317 match self.sync_file(&file_path).await {
318 Ok(result) => results.push(result),
319 Err(e) => results.push(SyncResult::Error {
320 filepath: file_path,
321 error: e.to_string(),
322 }),
323 }
324 }
325
326 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
328 for (_, filepath) in db_pairs {
329 if !FilesystemService::file_exists(&filepath) {
330 match self.db_service.delete_document(&filepath) {
332 Ok(_) => results.push(SyncResult::Deleted { filepath }),
333 Err(e) => results.push(SyncResult::Error {
334 filepath,
335 error: e.to_string(),
336 }),
337 }
338 }
339 }
340
341 Ok(results)
342 }
343
344 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
346 let mut issues = Vec::new();
347
348 let files = FilesystemService::find_markdown_files(&dir_path)?;
350
351 for file_path in &files {
353 let path_str = file_path.to_string();
354
355 if let Some(db_doc) = self.db_service.find_by_filepath(&path_str)? {
356 let current_hash = FilesystemService::compute_file_hash(file_path)?;
357 if db_doc.file_hash != current_hash {
358 issues.push(SyncIssue::OutOfSync {
359 filepath: path_str,
360 reason: "File hash mismatch".to_string(),
361 });
362 }
363 } else {
364 issues.push(SyncIssue::MissingFromDatabase { filepath: path_str });
365 }
366 }
367
368 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
370 for (_, filepath) in db_pairs {
371 if !files.contains(&filepath) && !FilesystemService::file_exists(&filepath) {
372 issues.push(SyncIssue::MissingFromFilesystem {
373 filepath: filepath.clone(),
374 });
375 }
376 }
377
378 Ok(issues)
379 }
380}
381
382#[derive(Debug, Clone, PartialEq)]
384pub enum SyncResult {
385 Imported { filepath: String },
386 Updated { filepath: String },
387 Deleted { filepath: String },
388 UpToDate { filepath: String },
389 NotFound { filepath: String },
390 Error { filepath: String, error: String },
391 Moved { from: String, to: String },
392}
393
394impl SyncResult {
395 pub fn filepath(&self) -> &str {
397 match self {
398 SyncResult::Imported { filepath }
399 | SyncResult::Updated { filepath }
400 | SyncResult::Deleted { filepath }
401 | SyncResult::UpToDate { filepath }
402 | SyncResult::NotFound { filepath }
403 | SyncResult::Error { filepath, .. } => filepath,
404 SyncResult::Moved { to, .. } => to,
405 }
406 }
407
408 pub fn is_change(&self) -> bool {
410 matches!(
411 self,
412 SyncResult::Imported { .. }
413 | SyncResult::Updated { .. }
414 | SyncResult::Deleted { .. }
415 | SyncResult::Moved { .. }
416 )
417 }
418
419 pub fn is_error(&self) -> bool {
421 matches!(self, SyncResult::Error { .. })
422 }
423}
424
425#[derive(Debug, Clone)]
427pub enum SyncIssue {
428 MissingFromDatabase { filepath: String },
429 MissingFromFilesystem { filepath: String },
430 OutOfSync { filepath: String, reason: String },
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::dal::Database;
437 use tempfile::tempdir;
438
439 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
440 let temp_dir = tempdir().expect("Failed to create temp dir");
441 let db = Database::new(":memory:").expect("Failed to create test database");
442 let db_service = DatabaseService::new(db.into_repository());
443 (temp_dir, db_service)
444 }
445
446 fn create_test_document_content() -> String {
447 "---\n".to_string()
448 + "id: test-document\n"
449 + "title: Test Document\n"
450 + "level: vision\n"
451 + "created_at: \"2021-01-01T00:00:00Z\"\n"
452 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
453 + "archived: false\n"
454 + "short_code: TEST-V-9003\n"
455 + "exit_criteria_met: false\n"
456 + "tags:\n"
457 + " - \"#phase/draft\"\n"
458 + "---\n\n"
459 + "# Test Document\n\n"
460 + "Test content.\n"
461 }
462
463 #[tokio::test]
464 async fn test_import_from_file() {
465 let (temp_dir, mut db_service) = setup_services();
466 let mut sync_service = SyncService::new(&mut db_service);
467
468 let file_path = temp_dir.path().join("test.md");
469 FilesystemService::write_file(&file_path, &create_test_document_content())
470 .expect("Failed to write file");
471
472 let doc = sync_service
473 .import_from_file(&file_path)
474 .await
475 .expect("Failed to import");
476 assert_eq!(doc.title, "Test Document");
477 assert_eq!(doc.document_type, "vision");
478
479 assert!(db_service
481 .document_exists(&file_path.to_string_lossy())
482 .expect("Failed to check"));
483 }
484
485 #[tokio::test]
486 async fn test_sync_file_operations() {
487 let (temp_dir, mut db_service) = setup_services();
488 let mut sync_service = SyncService::new(&mut db_service);
489
490 let file_path = temp_dir.path().join("test.md");
491 let path_str = file_path.to_string_lossy().to_string();
492
493 let result = sync_service
495 .sync_file(&file_path)
496 .await
497 .expect("Failed to sync");
498 assert_eq!(
499 result,
500 SyncResult::NotFound {
501 filepath: path_str.clone()
502 }
503 );
504
505 FilesystemService::write_file(&file_path, &create_test_document_content())
507 .expect("Failed to write file");
508
509 let result = sync_service
510 .sync_file(&file_path)
511 .await
512 .expect("Failed to sync");
513 assert_eq!(
514 result,
515 SyncResult::Imported {
516 filepath: path_str.clone()
517 }
518 );
519
520 let result = sync_service
522 .sync_file(&file_path)
523 .await
524 .expect("Failed to sync");
525 assert_eq!(
526 result,
527 SyncResult::UpToDate {
528 filepath: path_str.clone()
529 }
530 );
531
532 let modified_content =
534 &create_test_document_content().replace("Test content.", "Modified content.");
535 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
536
537 let result = sync_service
538 .sync_file(&file_path)
539 .await
540 .expect("Failed to sync");
541 assert_eq!(
542 result,
543 SyncResult::Updated {
544 filepath: path_str.clone()
545 }
546 );
547
548 FilesystemService::delete_file(&file_path).expect("Failed to delete");
550
551 let result = sync_service
552 .sync_file(&file_path)
553 .await
554 .expect("Failed to sync");
555 assert_eq!(
556 result,
557 SyncResult::Deleted {
558 filepath: path_str.clone()
559 }
560 );
561
562 assert!(!db_service
564 .document_exists(&path_str)
565 .expect("Failed to check"));
566 }
567
568 #[tokio::test]
569 async fn test_sync_directory() {
570 let (temp_dir, mut db_service) = setup_services();
571 let mut sync_service = SyncService::new(&mut db_service);
572
573 let files = vec![
575 ("doc1.md", "test-1"),
576 ("subdir/doc2.md", "test-2"),
577 ("subdir/nested/doc3.md", "test-3"),
578 ];
579
580 for (i, (file_path, id)) in files.iter().enumerate() {
581 let full_path = temp_dir.path().join(file_path);
582 let content = &create_test_document_content()
583 .replace("Test Document", &format!("Test Document {}", id))
584 .replace("test-document", id)
585 .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
586 FilesystemService::write_file(&full_path, content).expect("Failed to write");
587 }
588
589 let results = sync_service
591 .sync_directory(temp_dir.path())
592 .await
593 .expect("Failed to sync directory");
594
595 let imports = results
597 .iter()
598 .filter(|r| matches!(r, SyncResult::Imported { .. }))
599 .count();
600 assert_eq!(imports, 3);
601
602 let results = sync_service
604 .sync_directory(temp_dir.path())
605 .await
606 .expect("Failed to sync directory");
607 let up_to_date = results
608 .iter()
609 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
610 .count();
611 assert_eq!(up_to_date, 3);
612
613 for (file_path, _) in &files {
615 let full_path = temp_dir
616 .path()
617 .join(file_path)
618 .to_string_lossy()
619 .to_string();
620 assert!(results.iter().any(|r| r.filepath() == full_path));
621 }
622 }
623}