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_spec_score_creation_with_all_a() {
280        let score = SpecScore {
281            complexity: ComplexityGrade::A,
282            confidence: ConfidenceGrade::A,
283            splittability: SplittabilityGrade::A,
284            isolation: Some(IsolationGrade::A),
285            ac_quality: ACQualityGrade::A,
286            traffic_light: TrafficLight::Ready,
287        };
288
289        assert_eq!(score.complexity, ComplexityGrade::A);
290        assert_eq!(score.confidence, ConfidenceGrade::A);
291        assert_eq!(score.splittability, SplittabilityGrade::A);
292        assert_eq!(score.isolation, Some(IsolationGrade::A));
293        assert_eq!(score.ac_quality, ACQualityGrade::A);
294        assert_eq!(score.traffic_light, TrafficLight::Ready);
295    }
296
297    #[test]
298    fn test_complexity_grade_display() {
299        assert_eq!(ComplexityGrade::B.to_string(), "B");
300        assert_eq!(ComplexityGrade::A.to_string(), "A");
301        assert_eq!(ComplexityGrade::C.to_string(), "C");
302        assert_eq!(ComplexityGrade::D.to_string(), "D");
303    }
304
305    #[test]
306    fn test_confidence_grade_display() {
307        assert_eq!(ConfidenceGrade::B.to_string(), "B");
308    }
309
310    #[test]
311    fn test_splittability_grade_display() {
312        assert_eq!(SplittabilityGrade::B.to_string(), "B");
313    }
314
315    #[test]
316    fn test_isolation_grade_display() {
317        assert_eq!(IsolationGrade::B.to_string(), "B");
318    }
319
320    #[test]
321    fn test_ac_quality_grade_display() {
322        assert_eq!(ACQualityGrade::B.to_string(), "B");
323    }
324
325    #[test]
326    fn test_traffic_light_display() {
327        assert_eq!(TrafficLight::Ready.to_string(), "🟢 Ready");
328        assert_eq!(TrafficLight::Review.to_string(), "🟡 Review");
329        assert_eq!(TrafficLight::Refine.to_string(), "🔴 Refine");
330    }
331
332    #[test]
333    fn test_spec_score_serialization() {
334        let score = SpecScore {
335            complexity: ComplexityGrade::A,
336            confidence: ConfidenceGrade::B,
337            splittability: SplittabilityGrade::A,
338            isolation: None,
339            ac_quality: ACQualityGrade::A,
340            traffic_light: TrafficLight::Ready,
341        };
342
343        let json = serde_json::to_string(&score).unwrap();
344        let deserialized: SpecScore = serde_json::from_str(&json).unwrap();
345
346        assert_eq!(deserialized.complexity, ComplexityGrade::A);
347        assert_eq!(deserialized.confidence, ConfidenceGrade::B);
348        assert_eq!(deserialized.splittability, SplittabilityGrade::A);
349        assert_eq!(deserialized.isolation, None);
350        assert_eq!(deserialized.ac_quality, ACQualityGrade::A);
351        assert_eq!(deserialized.traffic_light, TrafficLight::Ready);
352    }
353
354    #[test]
355    fn test_isolation_is_optional() {
356        let score = SpecScore {
357            complexity: ComplexityGrade::A,
358            confidence: ConfidenceGrade::A,
359            splittability: SplittabilityGrade::A,
360            isolation: None, // Should work fine without isolation
361            ac_quality: ACQualityGrade::A,
362            traffic_light: TrafficLight::Ready,
363        };
364
365        assert_eq!(score.isolation, None);
366    }
367
368    #[test]
369    fn test_default_spec_score() {
370        let score = SpecScore::default();
371        assert_eq!(score.complexity, ComplexityGrade::A);
372        assert_eq!(score.confidence, ConfidenceGrade::A);
373        assert_eq!(score.splittability, SplittabilityGrade::A);
374        assert_eq!(score.isolation, None);
375        assert_eq!(score.ac_quality, ACQualityGrade::A);
376        assert_eq!(score.traffic_light, TrafficLight::Ready);
377    }
378
379    #[test]
380    fn test_calculate_complexity_grade_a() {
381        use crate::spec::{Spec, SpecFrontmatter};
382
383        // 2 criteria, 1 file, 150 words → Grade A
384        let spec = Spec {
385            id: "test".to_string(),
386            frontmatter: SpecFrontmatter {
387                target_files: Some(vec!["file1.rs".to_string()]),
388                ..Default::default()
389            },
390            title: Some("Test".to_string()),
391            body: format!(
392                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\n{}",
393                "word ".repeat(150)
394            ),
395        };
396
397        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
398    }
399
400    #[test]
401    fn test_calculate_complexity_grade_b() {
402        use crate::spec::{Spec, SpecFrontmatter};
403
404        // 5 criteria, 3 files, 300 words → Grade B
405        let spec = Spec {
406            id: "test".to_string(),
407            frontmatter: SpecFrontmatter {
408                target_files: Some(vec![
409                    "file1.rs".to_string(),
410                    "file2.rs".to_string(),
411                    "file3.rs".to_string(),
412                ]),
413                ..Default::default()
414            },
415            title: Some("Test".to_string()),
416            body: format!(
417                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n- [ ] Third\n- [ ] Fourth\n- [ ] Fifth\n\n{}",
418                "word ".repeat(300)
419            ),
420        };
421
422        assert_eq!(calculate_complexity(&spec), ComplexityGrade::B);
423    }
424
425    #[test]
426    fn test_calculate_complexity_grade_c() {
427        use crate::spec::{Spec, SpecFrontmatter};
428
429        // 6 criteria, 4 files, 500 words → Grade C
430        let spec = Spec {
431            id: "test".to_string(),
432            frontmatter: SpecFrontmatter {
433                target_files: Some(vec![
434                    "file1.rs".to_string(),
435                    "file2.rs".to_string(),
436                    "file3.rs".to_string(),
437                    "file4.rs".to_string(),
438                ]),
439                ..Default::default()
440            },
441            title: Some("Test".to_string()),
442            body: format!(
443                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n- [ ] Third\n- [ ] Fourth\n- [ ] Fifth\n- [ ] Sixth\n\n{}",
444                "word ".repeat(500)
445            ),
446        };
447
448        assert_eq!(calculate_complexity(&spec), ComplexityGrade::C);
449    }
450
451    #[test]
452    fn test_calculate_complexity_grade_d_criteria() {
453        use crate::spec::{Spec, SpecFrontmatter};
454
455        // 10 criteria, 2 files, 100 words → Grade D (criteria exceeds threshold)
456        let spec = Spec {
457            id: "test".to_string(),
458            frontmatter: SpecFrontmatter {
459                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
460                ..Default::default()
461            },
462            title: Some("Test".to_string()),
463            body: format!(
464                "## Acceptance Criteria\n{}\n\n{}",
465                (1..=10)
466                    .map(|i| format!("- [ ] Item {}", i))
467                    .collect::<Vec<_>>()
468                    .join("\n"),
469                "word ".repeat(100)
470            ),
471        };
472
473        assert_eq!(calculate_complexity(&spec), ComplexityGrade::D);
474    }
475
476    #[test]
477    fn test_calculate_complexity_grade_d_files() {
478        use crate::spec::{Spec, SpecFrontmatter};
479
480        // 2 criteria, 5 files, 100 words → Grade D (files exceeds threshold)
481        let spec = Spec {
482            id: "test".to_string(),
483            frontmatter: SpecFrontmatter {
484                target_files: Some(vec![
485                    "file1.rs".to_string(),
486                    "file2.rs".to_string(),
487                    "file3.rs".to_string(),
488                    "file4.rs".to_string(),
489                    "file5.rs".to_string(),
490                ]),
491                ..Default::default()
492            },
493            title: Some("Test".to_string()),
494            body: format!(
495                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\n{}",
496                "word ".repeat(100)
497            ),
498        };
499
500        assert_eq!(calculate_complexity(&spec), ComplexityGrade::D);
501    }
502
503    #[test]
504    fn test_calculate_complexity_grade_d_words() {
505        use crate::spec::{Spec, SpecFrontmatter};
506
507        // 2 criteria, 1 file, 700 words → Grade D (words exceeds threshold)
508        let spec = Spec {
509            id: "test".to_string(),
510            frontmatter: SpecFrontmatter {
511                target_files: Some(vec!["file1.rs".to_string()]),
512                ..Default::default()
513            },
514            title: Some("Test".to_string()),
515            body: format!(
516                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\n{}",
517                "word ".repeat(700)
518            ),
519        };
520
521        assert_eq!(calculate_complexity(&spec), ComplexityGrade::D);
522    }
523
524    #[test]
525    fn test_calculate_complexity_no_target_files() {
526        use crate::spec::{Spec, SpecFrontmatter};
527
528        // Specs with no target_files should default to 0 files
529        let spec = Spec {
530            id: "test".to_string(),
531            frontmatter: SpecFrontmatter {
532                target_files: None,
533                ..Default::default()
534            },
535            title: Some("Test".to_string()),
536            body:
537                "## Acceptance Criteria\n- [ ] First\n- [ ] Second\n\nSome content here with words."
538                    .to_string(),
539        };
540
541        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
542    }
543
544    #[test]
545    fn test_calculate_complexity_empty_body() {
546        use crate::spec::{Spec, SpecFrontmatter};
547
548        // Empty body should have word count of 0
549        let spec = Spec {
550            id: "test".to_string(),
551            frontmatter: SpecFrontmatter {
552                target_files: Some(vec!["file1.rs".to_string()]),
553                ..Default::default()
554            },
555            title: Some("Test".to_string()),
556            body: String::new(),
557        };
558
559        assert_eq!(calculate_complexity(&spec), ComplexityGrade::A);
560    }
561}