Skip to main content

chant/score/
splittability.rs

1//! Splittability scoring based on spec structure and decomposability.
2//!
3//! Analyzes whether a spec can be effectively decomposed by examining:
4//! - Number of markdown headers (subsections)
5//! - Number of target files
6//! - Number of acceptance criteria
7//! - Presence of coupling keywords indicating tight dependencies
8
9use crate::scoring::SplittabilityGrade;
10use crate::spec::Spec;
11
12/// Coupling keywords that indicate tightly coupled components
13const COUPLING_KEYWORDS: &[&str] = &["depends on each other", "tightly coupled"];
14
15/// Calculate splittability grade based on spec structure and decomposability.
16///
17/// Grading rules:
18/// - Grade A: Clear subsections (3+ headers), multiple target files (3+), independent tasks
19/// - Grade B: Some structure (1-2 headers), 2 target files
20/// - Grade C: Single concern, 1 target file, minimal structure
21/// - Grade D: Coupling keywords present AND high structural complexity (downgrade from A/B)
22///
23/// Edge cases:
24/// - Specs already part of a group (has parent_id) should be Grade C (already split)
25/// - Specs with 1 criterion should be Grade C (atomic)
26/// - Detection of coupling keywords: "depends on each other", "tightly coupled"
27///
28/// # Arguments
29///
30/// * `spec` - The spec to analyze
31///
32/// # Returns
33///
34/// A `SplittabilityGrade` based on the spec's decomposability
35pub fn calculate_splittability(spec: &Spec) -> SplittabilityGrade {
36    // Edge case: Specs already part of a group (already split) → Grade C
37    if is_part_of_group(&spec.id) {
38        return SplittabilityGrade::C;
39    }
40
41    // Edge case: Specs with 1 criterion are atomic → Grade C
42    let criteria_count = spec.count_total_checkboxes();
43    if criteria_count == 1 {
44        return SplittabilityGrade::C;
45    }
46
47    // Count structural elements
48    let header_count = count_markdown_headers(&spec.body);
49    let file_count = count_target_files(spec);
50    let has_coupling = has_coupling_keywords(&spec.body);
51
52    // Grade A: 3+ headers, 3+ files, independent tasks
53    // Downgrade to D if coupling keywords present (complex AND coupled)
54    if header_count >= 3 && file_count >= 3 {
55        return if has_coupling {
56            SplittabilityGrade::D
57        } else {
58            SplittabilityGrade::A
59        };
60    }
61
62    // Grade B: 1-2 headers, 2 files
63    // Downgrade to D if coupling keywords present
64    if (1..=2).contains(&header_count) && file_count == 2 {
65        return if has_coupling {
66            SplittabilityGrade::D
67        } else {
68            SplittabilityGrade::B
69        };
70    }
71
72    // Grade C: Single concern, 1 target file, minimal structure
73    // This is the default for specs that don't fit A or B criteria
74    // Coupling keywords don't affect Grade C (already focused)
75    SplittabilityGrade::C
76}
77
78/// Count markdown headers (##, ###, etc.) in the spec body.
79///
80/// Only counts headers outside of code fences.
81/// Does not count the top-level title (single #).
82fn count_markdown_headers(body: &str) -> usize {
83    let mut count = 0;
84    let mut in_code_fence = false;
85
86    for line in body.lines() {
87        let trimmed = line.trim();
88
89        // Track code fences
90        if trimmed.starts_with("```") {
91            in_code_fence = !in_code_fence;
92            continue;
93        }
94
95        // Skip lines inside code blocks
96        if in_code_fence {
97            continue;
98        }
99
100        // Count headers (## or more, not single #)
101        if trimmed.starts_with("##") {
102            count += 1;
103        }
104    }
105
106    count
107}
108
109/// Count the number of target files in the spec.
110fn count_target_files(spec: &Spec) -> usize {
111    spec.frontmatter
112        .target_files
113        .as_ref()
114        .map(|files| files.len())
115        .unwrap_or(0)
116}
117
118/// Check if the spec body contains coupling keywords.
119///
120/// Returns true if any coupling keyword is found (case-insensitive).
121fn has_coupling_keywords(body: &str) -> bool {
122    let body_lower = body.to_lowercase();
123
124    for keyword in COUPLING_KEYWORDS {
125        if body_lower.contains(&keyword.to_lowercase()) {
126            return true;
127        }
128    }
129
130    false
131}
132
133/// Check if a spec ID indicates it's part of a group.
134///
135/// Group members have IDs in the format: DRIVER_ID.N or DRIVER_ID.N.M
136/// where N and M are numbers.
137///
138/// Examples:
139/// - "2026-01-25-00y-abc.1" → true (member of group)
140/// - "2026-01-25-00y-abc.1.2" → true (nested member)
141/// - "2026-01-25-00y-abc" → false (driver, not member)
142fn is_part_of_group(spec_id: &str) -> bool {
143    // A spec is part of a group if its ID contains a dot followed by a number
144    // We need to check if there's a pattern like ".N" where N is a digit
145
146    // Split by dots and check if there's at least one numeric segment after the base ID
147    let parts: Vec<&str> = spec_id.split('.').collect();
148
149    // If there's more than one part and any part after the first contains only digits,
150    // this is a group member
151    if parts.len() > 1 {
152        for part in &parts[1..] {
153            if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
154                return true;
155            }
156        }
157    }
158
159    false
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::spec::SpecFrontmatter;
166
167    #[test]
168    fn test_grade_a_multiple_headers_and_files() {
169        // 4 headers, 5 files, 8 criteria → Grade A
170        let spec = Spec {
171            id: "test".to_string(),
172            frontmatter: SpecFrontmatter {
173                target_files: Some(vec![
174                    "file1.rs".to_string(),
175                    "file2.rs".to_string(),
176                    "file3.rs".to_string(),
177                    "file4.rs".to_string(),
178                    "file5.rs".to_string(),
179                ]),
180                ..Default::default()
181            },
182            title: Some("Test".to_string()),
183            body: r#"
184## Section 1
185- [ ] Criterion 1
186- [ ] Criterion 2
187
188## Section 2
189- [ ] Criterion 3
190- [ ] Criterion 4
191
192## Section 3
193- [ ] Criterion 5
194- [ ] Criterion 6
195
196## Section 4
197- [ ] Criterion 7
198- [ ] Criterion 8
199"#
200            .to_string(),
201        };
202
203        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::A);
204    }
205
206    #[test]
207    fn test_grade_b_some_structure() {
208        // 1 header, 2 files, 3 criteria → Grade B
209        let spec = Spec {
210            id: "test".to_string(),
211            frontmatter: SpecFrontmatter {
212                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
213                ..Default::default()
214            },
215            title: Some("Test".to_string()),
216            body: r#"
217## Acceptance Criteria
218- [ ] Criterion 1
219- [ ] Criterion 2
220- [ ] Criterion 3
221"#
222            .to_string(),
223        };
224
225        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::B);
226    }
227
228    #[test]
229    fn test_grade_c_single_concern() {
230        // 0 headers, 1 file, 1 criterion → Grade C
231        let spec = Spec {
232            id: "test".to_string(),
233            frontmatter: SpecFrontmatter {
234                target_files: Some(vec!["file1.rs".to_string()]),
235                ..Default::default()
236            },
237            title: Some("Test".to_string()),
238            body: r#"
239- [ ] Single criterion
240"#
241            .to_string(),
242        };
243
244        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::C);
245    }
246
247    #[test]
248    fn test_grade_d_coupling_keywords_with_grade_b_structure() {
249        // 2 headers, 2 files with coupling keywords → Grade D (downgrade from B)
250        let spec = Spec {
251            id: "test".to_string(),
252            frontmatter: SpecFrontmatter {
253                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
254                ..Default::default()
255            },
256            title: Some("Test".to_string()),
257            body: r#"
258## Section 1
259- [ ] Criterion 1
260
261## Section 2
262- [ ] Criterion 2
263
264These components are tightly coupled and cannot be separated.
265"#
266            .to_string(),
267        };
268
269        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::D);
270    }
271
272    #[test]
273    fn test_edge_case_group_member() {
274        // Spec with ID indicating group membership → Grade C
275        let spec = Spec {
276            id: "2026-01-25-00y-abc.1".to_string(),
277            frontmatter: SpecFrontmatter {
278                target_files: Some(vec![
279                    "file1.rs".to_string(),
280                    "file2.rs".to_string(),
281                    "file3.rs".to_string(),
282                ]),
283                ..Default::default()
284            },
285            title: Some("Test".to_string()),
286            body: r#"
287## Section 1
288- [ ] Criterion 1
289- [ ] Criterion 2
290
291## Section 2
292- [ ] Criterion 3
293"#
294            .to_string(),
295        };
296
297        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::C);
298    }
299
300    #[test]
301    fn test_edge_case_single_criterion_atomic() {
302        // Spec with only 1 criterion → Grade C (atomic)
303        let spec = Spec {
304            id: "test".to_string(),
305            frontmatter: SpecFrontmatter {
306                target_files: Some(vec![
307                    "file1.rs".to_string(),
308                    "file2.rs".to_string(),
309                    "file3.rs".to_string(),
310                ]),
311                ..Default::default()
312            },
313            title: Some("Test".to_string()),
314            body: r#"
315## Section 1
316- [ ] Single criterion
317"#
318            .to_string(),
319        };
320
321        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::C);
322    }
323
324    #[test]
325    fn test_count_markdown_headers() {
326        let body = r#"
327# Title (not counted)
328
329## Section 1
330Some content
331
332## Section 2
333More content
334
335### Subsection
336Even more
337
338```rust
339// ## This header in code is not counted
340## Neither is this
341```
342
343## Section 3
344Final section
345"#;
346
347        assert_eq!(count_markdown_headers(body), 4); // Sections 1, 2, Subsection, Section 3
348    }
349
350    #[test]
351    fn test_count_target_files() {
352        let spec = Spec {
353            id: "test".to_string(),
354            frontmatter: SpecFrontmatter {
355                target_files: Some(vec![
356                    "file1.rs".to_string(),
357                    "file2.rs".to_string(),
358                    "file3.rs".to_string(),
359                ]),
360                ..Default::default()
361            },
362            title: Some("Test".to_string()),
363            body: String::new(),
364        };
365
366        assert_eq!(count_target_files(&spec), 3);
367    }
368
369    #[test]
370    fn test_count_target_files_none() {
371        let spec = Spec {
372            id: "test".to_string(),
373            frontmatter: SpecFrontmatter {
374                target_files: None,
375                ..Default::default()
376            },
377            title: Some("Test".to_string()),
378            body: String::new(),
379        };
380
381        assert_eq!(count_target_files(&spec), 0);
382    }
383
384    #[test]
385    fn test_has_coupling_keywords_shared() {
386        // "shared" keyword removed - should not match
387        let body = "This code uses shared state between components.";
388        assert!(!has_coupling_keywords(body));
389    }
390
391    #[test]
392    fn test_has_coupling_keywords_depends_on_each_other() {
393        let body = "These modules depends on each other heavily.";
394        assert!(has_coupling_keywords(body));
395    }
396
397    #[test]
398    fn test_has_coupling_keywords_tightly_coupled() {
399        let body = "The components are TIGHTLY COUPLED.";
400        assert!(has_coupling_keywords(body));
401    }
402
403    #[test]
404    fn test_has_coupling_keywords_none() {
405        let body = "This is a simple independent module.";
406        assert!(!has_coupling_keywords(body));
407    }
408
409    #[test]
410    fn test_is_part_of_group_member() {
411        assert!(is_part_of_group("2026-01-25-00y-abc.1"));
412        assert!(is_part_of_group("2026-01-25-00y-abc.2"));
413        assert!(is_part_of_group("2026-01-25-00y-abc.1.2"));
414    }
415
416    #[test]
417    fn test_is_part_of_group_driver() {
418        assert!(!is_part_of_group("2026-01-25-00y-abc"));
419    }
420
421    #[test]
422    fn test_is_part_of_group_edge_cases() {
423        // Edge case: dot but not numeric
424        assert!(!is_part_of_group("2026-01-25-00y-abc.md"));
425
426        // Edge case: multiple dots with numbers
427        assert!(is_part_of_group("2026-01-25-00y-abc.1.2.3"));
428    }
429
430    #[test]
431    fn test_grade_b_two_headers() {
432        // 2 headers, 2 files → Grade B
433        let spec = Spec {
434            id: "test".to_string(),
435            frontmatter: SpecFrontmatter {
436                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
437                ..Default::default()
438            },
439            title: Some("Test".to_string()),
440            body: r#"
441## Section 1
442- [ ] Criterion 1
443
444## Section 2
445- [ ] Criterion 2
446"#
447            .to_string(),
448        };
449
450        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::B);
451    }
452
453    #[test]
454    fn test_grade_c_no_structure() {
455        // 0 headers, 2 files, 3 criteria → Grade C (default)
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: r#"
464- [ ] Criterion 1
465- [ ] Criterion 2
466- [ ] Criterion 3
467"#
468            .to_string(),
469        };
470
471        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::C);
472    }
473
474    #[test]
475    fn test_coupling_downgrades_complex_structure() {
476        // 4 headers and 5 files with coupling keywords → Grade D
477        let spec = Spec {
478            id: "test".to_string(),
479            frontmatter: SpecFrontmatter {
480                target_files: Some(vec![
481                    "file1.rs".to_string(),
482                    "file2.rs".to_string(),
483                    "file3.rs".to_string(),
484                    "file4.rs".to_string(),
485                    "file5.rs".to_string(),
486                ]),
487                ..Default::default()
488            },
489            title: Some("Test".to_string()),
490            body: r#"
491## Section 1
492- [ ] Criterion 1
493
494## Section 2
495- [ ] Criterion 2
496
497## Section 3
498- [ ] Criterion 3
499
500## Section 4
501- [ ] Criterion 4
502
503Note: These components are tightly coupled.
504"#
505            .to_string(),
506        };
507
508        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::D);
509    }
510
511    #[test]
512    fn test_coupling_does_not_affect_simple_structure() {
513        // 0 headers, 2 files with coupling keywords → still Grade C
514        let spec = Spec {
515            id: "test".to_string(),
516            frontmatter: SpecFrontmatter {
517                target_files: Some(vec!["file1.rs".to_string(), "file2.rs".to_string()]),
518                ..Default::default()
519            },
520            title: Some("Test".to_string()),
521            body: r#"
522- [ ] Criterion 1
523- [ ] Criterion 2
524
525Note: Components depends on each other.
526"#
527            .to_string(),
528        };
529
530        assert_eq!(calculate_splittability(&spec), SplittabilityGrade::C);
531    }
532}