metis_core/application/services/document/
validation.rs

1use crate::domain::documents::types::DocumentType;
2use crate::Result;
3use crate::{Adr, Initiative, MetisError, Strategy, Task, Vision};
4use std::path::Path;
5
6/// Service for validating documents and detecting their types
7pub struct DocumentValidationService;
8
9/// Result of document validation
10#[derive(Debug)]
11pub struct ValidationResult {
12    pub document_type: DocumentType,
13    pub is_valid: bool,
14    pub errors: Vec<String>,
15}
16
17impl DocumentValidationService {
18    /// Create a new document validation service
19    pub fn new() -> Self {
20        Self
21    }
22
23    /// Validate a document file and detect its type
24    pub async fn validate_document<P: AsRef<Path>>(
25        &self,
26        file_path: P,
27    ) -> Result<ValidationResult> {
28        let file_path = file_path.as_ref();
29
30        // Check if file exists
31        if !file_path.exists() {
32            return Err(MetisError::NotFound("File does not exist".to_string()));
33        }
34
35        if !file_path.is_file() {
36            return Err(MetisError::NotFound("Path is not a file".to_string()));
37        }
38
39        // Try to parse as each document type and collect results
40        let mut validation_results = Vec::new();
41
42        // Try Vision
43        match Vision::from_file(file_path).await {
44            Ok(_vision) => {
45                validation_results.push(ValidationResult {
46                    document_type: DocumentType::Vision,
47                    is_valid: true,
48                    errors: vec![],
49                });
50            }
51            Err(e) => {
52                validation_results.push(ValidationResult {
53                    document_type: DocumentType::Vision,
54                    is_valid: false,
55                    errors: vec![format!("Vision validation failed: {}", e)],
56                });
57            }
58        }
59
60        // Try Strategy
61        match Strategy::from_file(file_path).await {
62            Ok(_strategy) => {
63                validation_results.push(ValidationResult {
64                    document_type: DocumentType::Strategy,
65                    is_valid: true,
66                    errors: vec![],
67                });
68            }
69            Err(e) => {
70                validation_results.push(ValidationResult {
71                    document_type: DocumentType::Strategy,
72                    is_valid: false,
73                    errors: vec![format!("Strategy validation failed: {}", e)],
74                });
75            }
76        }
77
78        // Try Initiative
79        match Initiative::from_file(file_path).await {
80            Ok(_initiative) => {
81                validation_results.push(ValidationResult {
82                    document_type: DocumentType::Initiative,
83                    is_valid: true,
84                    errors: vec![],
85                });
86            }
87            Err(e) => {
88                validation_results.push(ValidationResult {
89                    document_type: DocumentType::Initiative,
90                    is_valid: false,
91                    errors: vec![format!("Initiative validation failed: {}", e)],
92                });
93            }
94        }
95
96        // Try Task
97        match Task::from_file(file_path).await {
98            Ok(_task) => {
99                validation_results.push(ValidationResult {
100                    document_type: DocumentType::Task,
101                    is_valid: true,
102                    errors: vec![],
103                });
104            }
105            Err(e) => {
106                validation_results.push(ValidationResult {
107                    document_type: DocumentType::Task,
108                    is_valid: false,
109                    errors: vec![format!("Task validation failed: {}", e)],
110                });
111            }
112        }
113
114        // Try ADR
115        match Adr::from_file(file_path).await {
116            Ok(_adr) => {
117                validation_results.push(ValidationResult {
118                    document_type: DocumentType::Adr,
119                    is_valid: true,
120                    errors: vec![],
121                });
122            }
123            Err(e) => {
124                validation_results.push(ValidationResult {
125                    document_type: DocumentType::Adr,
126                    is_valid: false,
127                    errors: vec![format!("ADR validation failed: {}", e)],
128                });
129            }
130        }
131
132        // Find the first valid result
133        if let Some(valid_result) = validation_results.iter().find(|r| r.is_valid) {
134            return Ok(ValidationResult {
135                document_type: valid_result.document_type,
136                is_valid: true,
137                errors: vec![],
138            });
139        }
140
141        // If no valid results, return combined errors
142        let all_errors: Vec<String> = validation_results
143            .into_iter()
144            .flat_map(|r| r.errors)
145            .collect();
146
147        Ok(ValidationResult {
148            document_type: DocumentType::Vision, // Default, since we couldn't determine
149            is_valid: false,
150            errors: all_errors,
151        })
152    }
153
154    /// Validate a document and return just the document type (simpler interface)
155    pub async fn detect_document_type<P: AsRef<Path>>(&self, file_path: P) -> Result<DocumentType> {
156        let result = self.validate_document(file_path).await?;
157
158        if result.is_valid {
159            Ok(result.document_type)
160        } else {
161            Err(MetisError::InvalidDocument(format!(
162                "Could not determine document type: {}",
163                result.errors.join("; ")
164            )))
165        }
166    }
167
168    /// Validate a document of a specific expected type
169    pub async fn validate_document_as_type<P: AsRef<Path>>(
170        &self,
171        file_path: P,
172        expected_type: DocumentType,
173    ) -> Result<bool> {
174        let file_path = file_path.as_ref();
175
176        match expected_type {
177            DocumentType::Vision => match Vision::from_file(file_path).await {
178                Ok(_) => Ok(true),
179                Err(_) => Ok(false),
180            },
181            DocumentType::Strategy => match Strategy::from_file(file_path).await {
182                Ok(_) => Ok(true),
183                Err(_) => Ok(false),
184            },
185            DocumentType::Initiative => match Initiative::from_file(file_path).await {
186                Ok(_) => Ok(true),
187                Err(_) => Ok(false),
188            },
189            DocumentType::Task => match Task::from_file(file_path).await {
190                Ok(_) => Ok(true),
191                Err(_) => Ok(false),
192            },
193            DocumentType::Adr => match Adr::from_file(file_path).await {
194                Ok(_) => Ok(true),
195                Err(_) => Ok(false),
196            },
197        }
198    }
199
200    /// Check if a document is valid without loading the full document
201    pub async fn is_valid_document<P: AsRef<Path>>(&self, file_path: P) -> bool {
202        self.validate_document(file_path)
203            .await
204            .map(|result| result.is_valid)
205            .unwrap_or(false)
206    }
207}
208
209impl Default for DocumentValidationService {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::fs;
219    use tempfile::tempdir;
220
221    #[tokio::test]
222    async fn test_validate_valid_vision_document() {
223        let temp_dir = tempdir().unwrap();
224        let file_path = temp_dir.path().join("vision.md");
225
226        // Create a valid vision document
227        let vision_content = r##"---
228id: test-vision
229title: Test Vision
230level: vision
231created_at: 2023-01-01T00:00:00Z
232updated_at: 2023-01-01T00:00:00Z
233archived: false
234tags:
235  - "#vision"
236  - "#phase/draft"
237exit_criteria_met: false
238---
239
240# Test Vision
241
242This is a test vision document.
243"##;
244        fs::write(&file_path, vision_content).unwrap();
245
246        let service = DocumentValidationService::new();
247        let result = service.validate_document(&file_path).await.unwrap();
248
249        assert!(result.is_valid);
250        assert_eq!(result.document_type, DocumentType::Vision);
251        assert!(result.errors.is_empty());
252    }
253
254    #[tokio::test]
255    async fn test_validate_invalid_document() {
256        let temp_dir = tempdir().unwrap();
257        let file_path = temp_dir.path().join("invalid.md");
258
259        // Create an invalid document
260        let invalid_content = r##"# Invalid Document
261
262This has no frontmatter.
263"##;
264        fs::write(&file_path, invalid_content).unwrap();
265
266        let service = DocumentValidationService::new();
267        let result = service.validate_document(&file_path).await.unwrap();
268
269        assert!(!result.is_valid);
270        assert!(!result.errors.is_empty());
271    }
272
273    #[tokio::test]
274    async fn test_detect_document_type() {
275        let temp_dir = tempdir().unwrap();
276        let file_path = temp_dir.path().join("vision.md");
277
278        // Create a valid vision document
279        let vision_content = r##"---
280id: test-vision
281title: Test Vision
282level: vision
283created_at: 2023-01-01T00:00:00Z
284updated_at: 2023-01-01T00:00:00Z
285archived: false
286tags:
287  - "#vision"
288  - "#phase/draft"
289exit_criteria_met: false
290---
291
292# Test Vision
293
294This is a test vision document.
295"##;
296        fs::write(&file_path, vision_content).unwrap();
297
298        let service = DocumentValidationService::new();
299        let doc_type = service.detect_document_type(&file_path).await.unwrap();
300
301        assert_eq!(doc_type, DocumentType::Vision);
302    }
303
304    #[tokio::test]
305    async fn test_validate_document_as_type() {
306        let temp_dir = tempdir().unwrap();
307        let file_path = temp_dir.path().join("vision.md");
308
309        // Create a valid vision document
310        let vision_content = r##"---
311id: test-vision
312title: Test Vision
313level: vision
314created_at: 2023-01-01T00:00:00Z
315updated_at: 2023-01-01T00:00:00Z
316archived: false
317tags:
318  - "#vision"
319  - "#phase/draft"
320exit_criteria_met: false
321---
322
323# Test Vision
324
325This is a test vision document.
326"##;
327        fs::write(&file_path, vision_content).unwrap();
328
329        let service = DocumentValidationService::new();
330
331        // Should be valid as vision
332        assert!(service
333            .validate_document_as_type(&file_path, DocumentType::Vision)
334            .await
335            .unwrap());
336
337        // Should not be valid as strategy
338        assert!(!service
339            .validate_document_as_type(&file_path, DocumentType::Strategy)
340            .await
341            .unwrap());
342    }
343
344    #[tokio::test]
345    async fn test_validate_nonexistent_file() {
346        let service = DocumentValidationService::new();
347        let result = service.validate_document("/nonexistent/file.md").await;
348
349        assert!(result.is_err());
350        assert!(matches!(result.unwrap_err(), MetisError::NotFound(_)));
351    }
352}