1use crate::scoring::SplittabilityGrade;
10use crate::spec::Spec;
11
12const COUPLING_KEYWORDS: &[&str] = &["depends on each other", "tightly coupled"];
14
15pub fn calculate_splittability(spec: &Spec) -> SplittabilityGrade {
36 if is_part_of_group(&spec.id) {
38 return SplittabilityGrade::C;
39 }
40
41 let criteria_count = spec.count_total_checkboxes();
43 if criteria_count == 1 {
44 return SplittabilityGrade::C;
45 }
46
47 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 if header_count >= 3 && file_count >= 3 {
55 return if has_coupling {
56 SplittabilityGrade::D
57 } else {
58 SplittabilityGrade::A
59 };
60 }
61
62 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 SplittabilityGrade::C
76}
77
78fn 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 if trimmed.starts_with("```") {
91 in_code_fence = !in_code_fence;
92 continue;
93 }
94
95 if in_code_fence {
97 continue;
98 }
99
100 if trimmed.starts_with("##") {
102 count += 1;
103 }
104 }
105
106 count
107}
108
109fn 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
118fn 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
133fn is_part_of_group(spec_id: &str) -> bool {
143 let parts: Vec<&str> = spec_id.split('.').collect();
148
149 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 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 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 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 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 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 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); }
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 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 assert!(!is_part_of_group("2026-01-25-00y-abc.md"));
425
426 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 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 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 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 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}