1use crate::scoring::SplittabilityGrade;
10use crate::spec::Spec;
11
12const COUPLING_KEYWORDS: &[&str] = &["shared", "depends on each other", "tightly coupled"];
14
15pub fn calculate_splittability(spec: &Spec) -> SplittabilityGrade {
36 if has_coupling_keywords(&spec.body) {
38 return SplittabilityGrade::D;
39 }
40
41 if is_part_of_group(&spec.id) {
43 return SplittabilityGrade::C;
44 }
45
46 let criteria_count = spec.count_total_checkboxes();
48 if criteria_count == 1 {
49 return SplittabilityGrade::C;
50 }
51
52 let header_count = count_markdown_headers(&spec.body);
54 let file_count = count_target_files(spec);
55
56 if header_count >= 3 && file_count >= 3 {
58 return SplittabilityGrade::A;
59 }
60
61 if (1..=2).contains(&header_count) && file_count == 2 {
63 return SplittabilityGrade::B;
64 }
65
66 SplittabilityGrade::C
69}
70
71fn 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 if trimmed.starts_with("```") {
84 in_code_fence = !in_code_fence;
85 continue;
86 }
87
88 if in_code_fence {
90 continue;
91 }
92
93 if trimmed.starts_with("##") {
95 count += 1;
96 }
97 }
98
99 count
100}
101
102fn 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
111fn 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
126fn is_part_of_group(spec_id: &str) -> bool {
136 let parts: Vec<&str> = spec_id.split('.').collect();
141
142 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 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 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 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 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 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 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); }
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 assert!(!is_part_of_group("2026-01-25-00y-abc.md"));
421
422 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 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 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 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}