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