foundry_mcp/core/
validation.rs

1//! Content validation logic
2
3use crate::utils::validation::{
4    conditional_error, conditional_suggestion, conditional_suggestions,
5};
6
7/// Content types that can be validated
8#[derive(Debug, Clone, Copy)]
9pub enum ContentType {
10    Vision,
11    TechStack,
12    Summary,
13    Spec,
14    Notes,
15    Tasks,
16}
17
18/// Validation result
19pub struct ValidationResult {
20    pub is_valid: bool,
21    pub errors: Vec<String>,
22    pub suggestions: Vec<String>,
23}
24
25/// Validate content based on type
26pub fn validate_content(content_type: ContentType, content: &str) -> ValidationResult {
27    match content_type {
28        ContentType::Vision => validate_vision_content(content),
29        ContentType::TechStack => validate_tech_stack_content(content),
30        ContentType::Summary => validate_summary_content(content),
31        ContentType::Spec => validate_spec_content(content),
32        ContentType::Notes => validate_notes_content(content),
33        ContentType::Tasks => validate_tasks_content(content),
34    }
35}
36
37/// Validate vision content (2-4 paragraphs, 200+ characters)
38fn validate_vision_content(content: &str) -> ValidationResult {
39    let errors = conditional_error(
40        content.len() < 200,
41        "Vision content must be at least 200 characters",
42    );
43
44    let paragraphs_count = content
45        .split("\n\n")
46        .filter(|p| !p.trim().is_empty())
47        .count();
48
49    let lower_content = content.to_lowercase();
50
51    // Context completeness scoring for future implementation
52    let context_keywords = [
53        "architecture",
54        "integration",
55        "dependencies",
56        "business",
57        "rationale",
58        "context",
59        "implementation",
60        "approach",
61    ];
62    let context_score = context_keywords
63        .iter()
64        .filter(|&k| lower_content.contains(k))
65        .count();
66
67    let mut suggestions = conditional_suggestions(&[
68        (
69            paragraphs_count < 2,
70            "Consider adding more paragraphs to provide comprehensive vision coverage",
71        ),
72        (
73            !lower_content.contains("problem") && !lower_content.contains("solve"),
74            "Consider including what problem this solves",
75        ),
76        (
77            !lower_content.contains("target") && !lower_content.contains("user"),
78            "Consider specifying target users or audience",
79        ),
80    ]);
81
82    // Add context completeness suggestions
83    if context_score < 3 {
84        suggestions.push("Consider adding more context for future implementers (architectural decisions, business rationale, implementation approach)".to_string());
85    }
86
87    ValidationResult {
88        is_valid: errors.is_empty(),
89        errors,
90        suggestions,
91    }
92}
93
94/// Validate tech stack content (150+ characters)
95fn validate_tech_stack_content(content: &str) -> ValidationResult {
96    let errors = conditional_error(
97        content.len() < 150,
98        "Tech stack content must be at least 150 characters",
99    );
100
101    let lower_content = content.to_lowercase();
102    let tech_keywords = [
103        "language",
104        "framework",
105        "database",
106        "deployment",
107        "infrastructure",
108    ];
109    let has_tech = tech_keywords
110        .iter()
111        .any(|&keyword| lower_content.contains(keyword));
112
113    let suggestions = conditional_suggestion(
114        !has_tech,
115        "Consider including specific technologies, frameworks, or deployment platforms",
116    );
117
118    ValidationResult {
119        is_valid: errors.is_empty(),
120        errors,
121        suggestions,
122    }
123}
124
125/// Validate summary content (100+ characters, concise)
126fn validate_summary_content(content: &str) -> ValidationResult {
127    let errors = conditional_error(
128        content.len() < 100,
129        "Summary content must be at least 100 characters",
130    );
131
132    let suggestions = conditional_suggestion(
133        content.len() > 500,
134        "Consider making the summary more concise (under 500 characters)",
135    );
136
137    ValidationResult {
138        is_valid: errors.is_empty(),
139        errors,
140        suggestions,
141    }
142}
143
144/// Validate spec content
145fn validate_spec_content(content: &str) -> ValidationResult {
146    let errors = conditional_error(
147        content.len() < 100,
148        "Spec content must be at least 100 characters",
149    );
150
151    let lower_content = content.to_lowercase();
152    let structure_keywords = ["requirements", "functionality", "behavior", "interface"];
153    let has_structure = structure_keywords
154        .iter()
155        .any(|&keyword| lower_content.contains(keyword));
156
157    // Context completeness scoring for future implementation
158    let context_keywords = [
159        "architecture",
160        "integration",
161        "dependencies",
162        "business",
163        "rationale",
164        "implementation",
165        "approach",
166        "constraints",
167        "edge",
168        "validation",
169    ];
170    let context_score = context_keywords
171        .iter()
172        .filter(|&k| lower_content.contains(k))
173        .count();
174
175    let mut suggestions = conditional_suggestion(
176        !has_structure,
177        "Consider adding requirements, functionality, or behavioral specifications",
178    );
179
180    // Add context completeness suggestions
181    if context_score < 4 {
182        suggestions.push("Consider adding more implementation context (architecture, dependencies, business rationale, constraints, edge cases)".to_string());
183    }
184
185    ValidationResult {
186        is_valid: errors.is_empty(),
187        errors,
188        suggestions,
189    }
190}
191
192/// Validate notes content
193fn validate_notes_content(content: &str) -> ValidationResult {
194    let errors = conditional_error(
195        content.len() < 50,
196        "Notes content must be at least 50 characters",
197    );
198
199    let lower_content = content.to_lowercase();
200
201    // Context completeness scoring for future implementation
202    let context_keywords = [
203        "rationale",
204        "decision",
205        "tradeoff",
206        "constraint",
207        "dependency",
208        "business",
209        "architecture",
210        "integration",
211        "why",
212        "because",
213    ];
214    let context_score = context_keywords
215        .iter()
216        .filter(|&k| lower_content.contains(k))
217        .count();
218
219    let mut suggestions = Vec::new();
220
221    // Add context completeness suggestions
222    if context_score < 3 {
223        suggestions.push("Consider adding more context for future implementers (decision rationale, tradeoffs, constraints, dependencies)".to_string());
224    }
225
226    ValidationResult {
227        is_valid: errors.is_empty(),
228        errors,
229        suggestions,
230    }
231}
232
233/// Validate tasks content
234fn validate_tasks_content(content: &str) -> ValidationResult {
235    let errors = conditional_error(
236        content.len() < 30,
237        "Tasks content must be at least 30 characters",
238    );
239
240    let lower_content = content.to_lowercase();
241    let task_keywords = ["todo", "task", "implement", "create", "add", "-", "*"];
242    let has_task_format = task_keywords
243        .iter()
244        .any(|&keyword| lower_content.contains(keyword));
245
246    let suggestions = conditional_suggestion(
247        !has_task_format,
248        "Consider using task list format with - or * bullets or TODO items",
249    );
250
251    ValidationResult {
252        is_valid: errors.is_empty(),
253        errors,
254        suggestions,
255    }
256}
257
258/// Convert content type string to enum
259pub fn parse_content_type(content_type: &str) -> anyhow::Result<ContentType> {
260    match content_type.to_lowercase().as_str() {
261        "vision" => Ok(ContentType::Vision),
262        "tech-stack" => Ok(ContentType::TechStack),
263        "summary" => Ok(ContentType::Summary),
264        "spec" => Ok(ContentType::Spec),
265        "notes" => Ok(ContentType::Notes),
266        "tasks" => Ok(ContentType::Tasks),
267        _ => Err(anyhow::anyhow!("Unknown content type: {}", content_type)),
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_validate_vision_content_too_short() {
277        let content = "Too short";
278        let result = validate_vision_content(content);
279
280        assert!(!result.is_valid);
281        assert_eq!(result.errors.len(), 1);
282        assert!(result.errors[0].contains("200 characters"));
283    }
284
285    #[test]
286    fn test_validate_tech_stack_content_too_short() {
287        let content = "Too short";
288        let result = validate_tech_stack_content(content);
289
290        assert!(!result.is_valid);
291        assert_eq!(result.errors.len(), 1);
292        assert!(result.errors[0].contains("150 characters"));
293    }
294
295    #[test]
296    fn test_validate_summary_content_too_short() {
297        let content = "Too short";
298        let result = validate_summary_content(content);
299
300        assert!(!result.is_valid);
301        assert_eq!(result.errors.len(), 1);
302        assert!(result.errors[0].contains("100 characters"));
303    }
304
305    #[test]
306    fn test_validate_spec_content_too_short() {
307        let content = "Too short";
308        let result = validate_spec_content(content);
309
310        assert!(!result.is_valid);
311        assert_eq!(result.errors.len(), 1);
312        assert!(result.errors[0].contains("100 characters"));
313    }
314
315    #[test]
316    fn test_validate_notes_content_too_short() {
317        let content = "Too short";
318        let result = validate_notes_content(content);
319
320        assert!(!result.is_valid);
321        assert_eq!(result.errors.len(), 1);
322        assert!(result.errors[0].contains("50 characters"));
323    }
324
325    #[test]
326    fn test_parse_content_type_valid() {
327        assert!(matches!(
328            parse_content_type("vision"),
329            Ok(ContentType::Vision)
330        ));
331        assert!(matches!(
332            parse_content_type("tech-stack"),
333            Ok(ContentType::TechStack)
334        ));
335        assert!(matches!(
336            parse_content_type("summary"),
337            Ok(ContentType::Summary)
338        ));
339        assert!(matches!(parse_content_type("spec"), Ok(ContentType::Spec)));
340        assert!(matches!(
341            parse_content_type("notes"),
342            Ok(ContentType::Notes)
343        ));
344    }
345
346    #[test]
347    fn test_parse_content_type_case_insensitive() {
348        assert!(matches!(
349            parse_content_type("VISION"),
350            Ok(ContentType::Vision)
351        ));
352        assert!(matches!(
353            parse_content_type("Tech-Stack"),
354            Ok(ContentType::TechStack)
355        ));
356        assert!(matches!(
357            parse_content_type("SUMMARY"),
358            Ok(ContentType::Summary)
359        ));
360    }
361
362    #[test]
363    fn test_parse_content_type_invalid() {
364        assert!(parse_content_type("invalid").is_err());
365        assert!(parse_content_type("").is_err());
366        assert!(parse_content_type("random").is_err());
367    }
368}