Skip to main content

chant/
scoring.rs

1//! Spec quality scoring system
2//!
3//! Multi-dimensional analysis of spec quality including complexity, confidence,
4//! splittability, isolation, and acceptance criteria quality.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Macro to generate Display implementations for letter grade enums (A, B, C, D)
10macro_rules! impl_letter_grade_display {
11    ($type:ty) => {
12        impl fmt::Display for $type {
13            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14                match self {
15                    Self::A => write!(f, "A"),
16                    Self::B => write!(f, "B"),
17                    Self::C => write!(f, "C"),
18                    Self::D => write!(f, "D"),
19                }
20            }
21        }
22    };
23}
24
25/// Overall score for a spec across all dimensions
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SpecScore {
28    /// Complexity grade (size/effort)
29    pub complexity: ComplexityGrade,
30    /// Confidence grade (structure/clarity)
31    pub confidence: ConfidenceGrade,
32    /// Splittability grade (decomposability)
33    pub splittability: SplittabilityGrade,
34    /// Isolation grade (group/split quality) - only for groups with members
35    pub isolation: Option<IsolationGrade>,
36    /// Acceptance criteria quality grade
37    pub ac_quality: ACQualityGrade,
38    /// Overall traffic light status
39    pub traffic_light: TrafficLight,
40}
41
42impl Default for SpecScore {
43    fn default() -> Self {
44        Self {
45            complexity: ComplexityGrade::A,
46            confidence: ConfidenceGrade::A,
47            splittability: SplittabilityGrade::A,
48            isolation: None,
49            ac_quality: ACQualityGrade::A,
50            traffic_light: TrafficLight::Ready,
51        }
52    }
53}
54
55/// Complexity grade based on criteria count, target files, and word count
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ComplexityGrade {
58    /// 1-3 criteria, 1-2 files, <200 words
59    A,
60    /// 4-5 criteria, 3 files, 200-400 words
61    B,
62    /// 6-7 criteria, 4 files, 400-600 words
63    C,
64    /// 8+ criteria, 5+ files, 600+ words
65    D,
66}
67
68impl_letter_grade_display!(ComplexityGrade);
69
70/// Confidence grade based on structure and clarity
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72pub enum ConfidenceGrade {
73    /// Excellent structure, clear requirements
74    A,
75    /// Good structure, mostly clear
76    B,
77    /// Some structure issues or vague language
78    C,
79    /// Poor structure, vague requirements
80    D,
81}
82
83impl_letter_grade_display!(ConfidenceGrade);
84
85/// Splittability grade - can this spec be effectively split
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub enum SplittabilityGrade {
88    /// Clear subsections, independent tasks, multiple target files
89    A,
90    /// Some structure, could be split with effort
91    B,
92    /// Monolithic, single concern, splitting would fragment
93    C,
94    /// Tightly coupled, splitting would create circular deps
95    D,
96}
97
98impl_letter_grade_display!(SplittabilityGrade);
99
100/// Isolation grade - for groups with members, measures independence
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102pub enum IsolationGrade {
103    /// Excellent isolation, minimal cross-references
104    A,
105    /// Good isolation, some cross-references
106    B,
107    /// Some coupling, multiple cross-references
108    C,
109    /// Tightly coupled, many cross-references
110    D,
111}
112
113impl_letter_grade_display!(IsolationGrade);
114
115/// Acceptance criteria quality grade
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117pub enum ACQualityGrade {
118    /// Excellent AC: imperative, valuable, testable
119    A,
120    /// Good AC: mostly well-phrased
121    B,
122    /// Some AC issues
123    C,
124    /// Poor AC quality
125    D,
126}
127
128impl_letter_grade_display!(ACQualityGrade);
129
130/// Overall traffic light status combining all dimensions
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum TrafficLight {
133    /// Ready - All dimensions pass (Complexity ≤ B AND Confidence ≥ B AND AC Quality ≥ B)
134    Ready,
135    /// Review - Some dimensions need attention (Any dimension is C)
136    Review,
137    /// Refine - Significant issues (Any dimension is D OR Confidence is D)
138    Refine,
139}
140
141impl fmt::Display for TrafficLight {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::Ready => write!(f, "🟢 Ready"),
145            Self::Review => write!(f, "🟡 Review"),
146            Self::Refine => write!(f, "🔴 Refine"),
147        }
148    }
149}
150
151/// Calculate complexity grade based on criteria count, target files, and word count
152///
153/// Grading rules:
154/// - Grade A: 1-3 criteria, 1-2 files, <200 words
155/// - Grade B: 4-5 criteria, 3 files, 200-400 words
156/// - Grade C: 6-7 criteria, 4 files, 400-600 words
157/// - Grade D: 8+ criteria OR 5+ files OR 600+ words
158///
159/// If any single metric triggers D, overall grade is D.
160pub fn calculate_complexity(spec: &crate::spec::Spec) -> ComplexityGrade {
161    // Count acceptance criteria
162    let criteria_count = spec.count_total_checkboxes();
163
164    // Count target files (default to 0 if None)
165    let file_count = spec
166        .frontmatter
167        .target_files
168        .as_ref()
169        .map(|files| files.len())
170        .unwrap_or(0);
171
172    // Count words in body (split by whitespace, filter empty)
173    let word_count = spec.body.split_whitespace().count();
174
175    // Determine grade based on all three metrics
176    // If any single metric triggers D, overall is D
177    if criteria_count >= 8 || file_count >= 5 || word_count >= 600 {
178        return ComplexityGrade::D;
179    }
180
181    // Check for Grade C thresholds
182    if criteria_count >= 6 || file_count >= 4 || word_count >= 400 {
183        return ComplexityGrade::C;
184    }
185
186    // Check for Grade B thresholds
187    if criteria_count >= 4 || file_count >= 3 || word_count >= 200 {
188        return ComplexityGrade::B;
189    }
190
191    // Otherwise Grade A
192    ComplexityGrade::A
193}
194
195/// Extract acceptance criteria from a spec's body
196///
197/// Looks for checkboxes anywhere in the spec body, not just under
198/// a specific header. This matches the behavior of count_total_checkboxes().
199fn extract_acceptance_criteria(spec: &crate::spec::Spec) -> Vec<String> {
200    let mut criteria = Vec::new();
201    let mut in_code_fence = false;
202
203    for line in spec.body.lines() {
204        let trimmed = line.trim_start();
205
206        if trimmed.starts_with("```") {
207            in_code_fence = !in_code_fence;
208            continue;
209        }
210
211        // Skip content in code fences
212        if in_code_fence {
213            continue;
214        }
215
216        // Extract checkbox items (case insensitive for [x]/[X])
217        if trimmed.starts_with("- [ ]")
218            || trimmed.starts_with("- [x]")
219            || trimmed.starts_with("- [X]")
220        {
221            // Extract text after checkbox
222            let text = trimmed
223                .trim_start_matches("- [ ]")
224                .trim_start_matches("- [x]")
225                .trim_start_matches("- [X]")
226                .trim()
227                .to_string();
228            if !text.is_empty() {
229                criteria.push(text);
230            }
231        }
232    }
233
234    criteria
235}
236
237/// Calculate the overall SpecScore for a given spec
238///
239/// This function computes all scoring dimensions and determines the traffic light status.
240pub fn calculate_spec_score(
241    spec: &crate::spec::Spec,
242    all_specs: &[crate::spec::Spec],
243    config: &crate::config::Config,
244) -> SpecScore {
245    use crate::score::{ac_quality, confidence, isolation, splittability, traffic_light};
246
247    // Calculate each dimension
248    let complexity = calculate_complexity(spec);
249    let confidence_grade = confidence::calculate_confidence(spec, config);
250    let splittability_grade = splittability::calculate_splittability(spec);
251    let isolation_grade = isolation::calculate_isolation(spec, all_specs);
252
253    // Calculate AC quality from the spec's acceptance criteria
254    let criteria = extract_acceptance_criteria(spec);
255    let ac_quality_grade = ac_quality::calculate_ac_quality(&criteria);
256
257    // Create the score struct
258    let mut score = SpecScore {
259        complexity,
260        confidence: confidence_grade,
261        splittability: splittability_grade,
262        isolation: isolation_grade,
263        ac_quality: ac_quality_grade,
264        traffic_light: TrafficLight::Ready, // Temporary, will be recalculated
265    };
266
267    // Determine the traffic light status
268    score.traffic_light = traffic_light::determine_status(&score);
269
270    score
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_traffic_light_display() {
279        assert_eq!(TrafficLight::Ready.to_string(), "🟢 Ready");
280        assert_eq!(TrafficLight::Review.to_string(), "🟡 Review");
281        assert_eq!(TrafficLight::Refine.to_string(), "🔴 Refine");
282    }
283
284    #[test]
285    fn test_calculate_complexity_grade_a() {
286        use crate::spec::{Spec, SpecFrontmatter};
287
288        let spec = Spec {
289            id: "test".to_string(),
290            frontmatter: SpecFrontmatter {
291                target_files: Some(vec!["file1.rs".to_string()]),
292                ..Default::default()
293            },
294            title: Some("Test".to_string()),
295            body: format!(
296                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\n{}",
297                "word ".repeat(150)
298            ),
299        };
300
301        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
302    }
303
304    #[test]
305    fn test_calculate_complexity_grade_b() {
306        use crate::spec::{Spec, SpecFrontmatter};
307
308        let spec = Spec {
309            id: "test".to_string(),
310            frontmatter: SpecFrontmatter {
311                target_files: Some(vec![
312                    "file1.rs".to_string(),
313                    "file2.rs".to_string(),
314                    "file3.rs".to_string(),
315                ]),
316                ..Default::default()
317            },
318            title: Some("Test".to_string()),
319            body: format!(
320                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n- [ ] Third\n- [ ] Fourth\n- [ ] Fifth\n\n{}",
321                "word ".repeat(300)
322            ),
323        };
324
325        assert_eq!(calculate_complexity(&spec), ComplexityGrade::B);
326    }
327
328    #[test]
329    fn test_calculate_complexity_grade_d_criteria() {
330        use crate::spec::{Spec, SpecFrontmatter};
331
332        let spec = Spec {
333            id: "test".to_string(),
334            frontmatter: SpecFrontmatter {
335                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
336                ..Default::default()
337            },
338            title: Some("Test".to_string()),
339            body: format!(
340                "## Acceptance Criteria\n{}\n\n{}",
341                (1..=10)
342                    .map(|i| format!("- [ ] Item {}", i))
343                    .collect::<Vec<_>>()
344                    .join("\n"),
345                "word ".repeat(100)
346            ),
347        };
348
349        assert_eq!(calculate_complexity(&spec), ComplexityGrade::D);
350    }
351
352    #[test]
353    fn test_calculate_complexity_no_target_files() {
354        use crate::spec::{Spec, SpecFrontmatter};
355
356        let spec = Spec {
357            id: "test".to_string(),
358            frontmatter: SpecFrontmatter {
359                target_files: None,
360                ..Default::default()
361            },
362            title: Some("Test".to_string()),
363            body:
364                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\nSome content here with words."
365                    .to_string(),
366        };
367
368        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
369    }
370}