Skip to main content

chant/domain/
quality.rs

1//! Quality assessment for specs.
2//!
3//! Pure functions for scoring spec quality across multiple dimensions.
4
5use serde::{Deserialize, Serialize};
6
7use crate::scoring::{ACQualityGrade, ComplexityGrade, ConfidenceGrade, SplittabilityGrade};
8
9/// Quality assessment result for a spec
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct QualityAssessment {
12    /// Complexity grade (size/effort)
13    pub complexity: ComplexityGrade,
14    /// Confidence grade (structure/clarity)
15    pub confidence: ConfidenceGrade,
16    /// Splittability grade (decomposability)
17    pub splittability: SplittabilityGrade,
18    /// Acceptance criteria quality grade
19    pub ac_quality: ACQualityGrade,
20}
21
22/// Assess the quality of a spec
23///
24/// This is a pure function that computes quality metrics without any I/O.
25pub fn assess_quality(spec: &crate::spec::Spec) -> QualityAssessment {
26    use crate::score::{ac_quality, confidence, splittability};
27    use crate::scoring::calculate_complexity;
28
29    // Create a minimal config for confidence calculation
30    let config = make_minimal_config();
31
32    // Calculate each dimension
33    let complexity = calculate_complexity(spec);
34    let confidence_grade = confidence::calculate_confidence(spec, &config);
35    let splittability_grade = splittability::calculate_splittability(spec);
36
37    // Calculate AC quality from the spec's acceptance criteria
38    let criteria = extract_acceptance_criteria(spec);
39    let ac_quality_grade = ac_quality::calculate_ac_quality(&criteria);
40
41    QualityAssessment {
42        complexity,
43        confidence: confidence_grade,
44        splittability: splittability_grade,
45        ac_quality: ac_quality_grade,
46    }
47}
48
49fn make_minimal_config() -> crate::config::Config {
50    crate::config::Config {
51        project: crate::config::ProjectConfig {
52            name: "test".to_string(),
53            prefix: None,
54            silent: false,
55        },
56        defaults: crate::config::DefaultsConfig::default(),
57        providers: crate::provider::ProviderConfig::default(),
58        parallel: crate::config::ParallelConfig::default(),
59        repos: vec![],
60        enterprise: crate::config::EnterpriseConfig::default(),
61        approval: crate::config::ApprovalConfig::default(),
62        validation: crate::config::OutputValidationConfig::default(),
63        site: crate::config::SiteConfig::default(),
64        lint: crate::config::LintConfig::default(),
65        watch: crate::config::WatchConfig::default(),
66    }
67}
68
69/// Extract acceptance criteria from a spec's body
70fn extract_acceptance_criteria(spec: &crate::spec::Spec) -> Vec<String> {
71    let acceptance_criteria_marker = "## Acceptance Criteria";
72    let mut criteria = Vec::new();
73    let mut in_code_fence = false;
74    let mut in_ac_section = false;
75
76    for line in spec.body.lines() {
77        let trimmed = line.trim_start();
78
79        if trimmed.starts_with("```") {
80            in_code_fence = !in_code_fence;
81            continue;
82        }
83
84        if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
85            in_ac_section = true;
86            continue;
87        }
88
89        // Stop if we hit another ## heading
90        if in_ac_section && !in_code_fence && trimmed.starts_with("## ") {
91            break;
92        }
93
94        // Extract checkbox items
95        if in_ac_section
96            && !in_code_fence
97            && (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [x]"))
98        {
99            // Extract text after checkbox
100            let text = trimmed
101                .trim_start_matches("- [ ]")
102                .trim_start_matches("- [x]")
103                .trim()
104                .to_string();
105            criteria.push(text);
106        }
107    }
108
109    criteria
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::spec::{Spec, SpecFrontmatter};
116
117    #[test]
118    fn test_assess_quality_simple_spec() {
119        let spec = Spec {
120            id: "test".to_string(),
121            frontmatter: SpecFrontmatter {
122                target_files: Some(vec!["file1.rs".to_string()]),
123                ..Default::default()
124            },
125            title: Some("Simple test spec".to_string()),
126            body: r#"## Problem
127
128This is a simple test spec.
129
130## Solution
131
132Do something simple.
133
134## Acceptance Criteria
135
136- [ ] Create a new file
137- [ ] Add a function
138- [ ] Write a test
139
140Simple implementation."#
141                .to_string(),
142        };
143
144        let assessment = assess_quality(&spec);
145
146        // Simple spec should score well on complexity (few criteria, few files, short)
147        assert_eq!(assessment.complexity, ComplexityGrade::A);
148
149        // Should have reasonable AC quality
150        assert!(matches!(
151            assessment.ac_quality,
152            ACQualityGrade::A | ACQualityGrade::B | ACQualityGrade::C
153        ));
154    }
155
156    #[test]
157    fn test_assess_quality_empty_body() {
158        let spec = Spec {
159            id: "test".to_string(),
160            frontmatter: SpecFrontmatter::default(),
161            title: Some("Empty spec".to_string()),
162            body: String::new(),
163        };
164
165        let assessment = assess_quality(&spec);
166
167        // Empty body should score low confidence
168        assert_eq!(assessment.confidence, ConfidenceGrade::D);
169    }
170
171    #[test]
172    fn test_assess_quality_detailed_ac() {
173        let spec = Spec {
174            id: "test".to_string(),
175            frontmatter: SpecFrontmatter {
176                target_files: Some(vec!["file1.rs".to_string()]),
177                ..Default::default()
178            },
179            title: Some("Detailed spec".to_string()),
180            body: r#"## Problem
181
182Need comprehensive acceptance criteria.
183
184## Acceptance Criteria
185
186- [ ] Implement function calculate_total with proper error handling
187- [ ] Add unit tests covering edge cases for empty inputs
188- [ ] Create integration test validating end-to-end workflow
189- [ ] Update API documentation with new endpoint details
190- [ ] Verify performance meets sub-100ms response time requirement
191- [ ] Validate input sanitization prevents SQL injection
192
193Well-structured requirements."#
194                .to_string(),
195        };
196
197        let assessment = assess_quality(&spec);
198
199        // Spec with 5+ specific AC should score high ac_quality
200        assert!(matches!(
201            assessment.ac_quality,
202            ACQualityGrade::A | ACQualityGrade::B
203        ));
204    }
205
206    #[test]
207    fn test_assess_quality_vague_ac() {
208        let spec = Spec {
209            id: "test".to_string(),
210            frontmatter: SpecFrontmatter {
211                target_files: Some(vec!["file1.rs".to_string()]),
212                ..Default::default()
213            },
214            title: Some("Vague spec".to_string()),
215            body: r#"## Problem
216
217Poorly defined criteria.
218
219## Acceptance Criteria
220
221- [ ] The code works
222- [ ] Everything is good
223- [ ] Make sure it's okay
224
225That should do it."#
226                .to_string(),
227        };
228
229        let assessment = assess_quality(&spec);
230
231        // AC quality is now count-based only (3 criteria = Grade B)
232        // Content analysis was removed as it was too aggressive
233        assert_eq!(assessment.ac_quality, ACQualityGrade::B);
234    }
235
236    #[test]
237    fn test_assess_quality_long_body() {
238        // Create a spec with over 200 words to trigger complexity Grade B
239        let long_body = format!(
240            r#"## Problem
241
242{}
243
244## Acceptance Criteria
245
246- [ ] Implement feature
247- [ ] Add tests
248- [ ] Document changes"#,
249            "word ".repeat(210)
250        );
251
252        let spec = Spec {
253            id: "test".to_string(),
254            frontmatter: SpecFrontmatter {
255                target_files: Some(vec!["file1.rs".to_string()]),
256                ..Default::default()
257            },
258            title: Some("Long spec".to_string()),
259            body: long_body,
260        };
261
262        let assessment = assess_quality(&spec);
263
264        // Spec over 200 words should flag complexity concern (Grade B or higher)
265        assert!(matches!(
266            assessment.complexity,
267            ComplexityGrade::B | ComplexityGrade::C | ComplexityGrade::D
268        ));
269    }
270}