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
196fn extract_acceptance_criteria(spec: &crate::spec::Spec) -> Vec<String> {
197    let acceptance_criteria_marker = "## Acceptance Criteria";
198    let mut criteria = Vec::new();
199    let mut in_code_fence = false;
200    let mut in_ac_section = false;
201
202    for line in spec.body.lines() {
203        let trimmed = line.trim_start();
204
205        if trimmed.starts_with("```") {
206            in_code_fence = !in_code_fence;
207            continue;
208        }
209
210        if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
211            in_ac_section = true;
212            continue;
213        }
214
215        // Stop if we hit another ## heading
216        if in_ac_section && !in_code_fence && trimmed.starts_with("## ") {
217            break;
218        }
219
220        // Extract checkbox items
221        if in_ac_section
222            && !in_code_fence
223            && (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [x]"))
224        {
225            // Extract text after checkbox
226            let text = trimmed
227                .trim_start_matches("- [ ]")
228                .trim_start_matches("- [x]")
229                .trim()
230                .to_string();
231            criteria.push(text);
232        }
233    }
234
235    criteria
236}
237
238/// Calculate the overall SpecScore for a given spec
239///
240/// This function computes all scoring dimensions and determines the traffic light status.
241pub fn calculate_spec_score(
242    spec: &crate::spec::Spec,
243    all_specs: &[crate::spec::Spec],
244    config: &crate::config::Config,
245) -> SpecScore {
246    use crate::score::{ac_quality, confidence, isolation, splittability, traffic_light};
247
248    // Calculate each dimension
249    let complexity = calculate_complexity(spec);
250    let confidence_grade = confidence::calculate_confidence(spec, config);
251    let splittability_grade = splittability::calculate_splittability(spec);
252    let isolation_grade = isolation::calculate_isolation(spec, all_specs);
253
254    // Calculate AC quality from the spec's acceptance criteria
255    let criteria = extract_acceptance_criteria(spec);
256    let ac_quality_grade = ac_quality::calculate_ac_quality(&criteria);
257
258    // Create the score struct
259    let mut score = SpecScore {
260        complexity,
261        confidence: confidence_grade,
262        splittability: splittability_grade,
263        isolation: isolation_grade,
264        ac_quality: ac_quality_grade,
265        traffic_light: TrafficLight::Ready, // Temporary, will be recalculated
266    };
267
268    // Determine the traffic light status
269    score.traffic_light = traffic_light::determine_status(&score);
270
271    score
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_traffic_light_display() {
280        assert_eq!(TrafficLight::Ready.to_string(), "🟢 Ready");
281        assert_eq!(TrafficLight::Review.to_string(), "🟡 Review");
282        assert_eq!(TrafficLight::Refine.to_string(), "🔴 Refine");
283    }
284
285    #[test]
286    fn test_calculate_complexity_grade_a() {
287        use crate::spec::{Spec, SpecFrontmatter};
288
289        let spec = Spec {
290            id: "test".to_string(),
291            frontmatter: SpecFrontmatter {
292                target_files: Some(vec!["file1.rs".to_string()]),
293                ..Default::default()
294            },
295            title: Some("Test".to_string()),
296            body: format!(
297                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\n{}",
298                "word ".repeat(150)
299            ),
300        };
301
302        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
303    }
304
305    #[test]
306    fn test_calculate_complexity_grade_b() {
307        use crate::spec::{Spec, SpecFrontmatter};
308
309        let spec = Spec {
310            id: "test".to_string(),
311            frontmatter: SpecFrontmatter {
312                target_files: Some(vec![
313                    "file1.rs".to_string(),
314                    "file2.rs".to_string(),
315                    "file3.rs".to_string(),
316                ]),
317                ..Default::default()
318            },
319            title: Some("Test".to_string()),
320            body: format!(
321                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n- [ ] Third\n- [ ] Fourth\n- [ ] Fifth\n\n{}",
322                "word ".repeat(300)
323            ),
324        };
325
326        assert_eq!(calculate_complexity(&spec), ComplexityGrade::B);
327    }
328
329    #[test]
330    fn test_calculate_complexity_grade_d_criteria() {
331        use crate::spec::{Spec, SpecFrontmatter};
332
333        let spec = Spec {
334            id: "test".to_string(),
335            frontmatter: SpecFrontmatter {
336                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
337                ..Default::default()
338            },
339            title: Some("Test".to_string()),
340            body: format!(
341                "## Acceptance Criteria\n{}\n\n{}",
342                (1..=10)
343                    .map(|i| format!("- [ ] Item {}", i))
344                    .collect::<Vec<_>>()
345                    .join("\n"),
346                "word ".repeat(100)
347            ),
348        };
349
350        assert_eq!(calculate_complexity(&spec), ComplexityGrade::D);
351    }
352
353    #[test]
354    fn test_calculate_complexity_no_target_files() {
355        use crate::spec::{Spec, SpecFrontmatter};
356
357        let spec = Spec {
358            id: "test".to_string(),
359            frontmatter: SpecFrontmatter {
360                target_files: None,
361                ..Default::default()
362            },
363            title: Some("Test".to_string()),
364            body:
365                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\nSome content here with words."
366                    .to_string(),
367        };
368
369        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
370    }
371}