1use crate::scoring::IsolationGrade;
10use crate::spec::Spec;
11use crate::spec_group::get_members;
12use regex::Regex;
13use std::collections::HashSet;
14
15pub fn calculate_isolation(spec: &Spec, all_specs: &[Spec]) -> Option<IsolationGrade> {
55 let members = get_members(&spec.id, all_specs);
57
58 if members.is_empty() {
60 return None;
61 }
62
63 if members.len() == 1 {
65 return Some(IsolationGrade::A);
66 }
67
68 let members_with_cross_refs = count_members_with_cross_references(&members);
70
71 let isolation_percentage =
73 ((members.len() - members_with_cross_refs) as f64 / members.len() as f64) * 100.0;
74
75 let file_overlap_percentage = calculate_file_overlap_percentage(&members);
77
78 if isolation_percentage <= 50.0 || file_overlap_percentage > 50.0 {
81 return Some(IsolationGrade::D);
82 }
83
84 if isolation_percentage > 90.0 && file_overlap_percentage < 20.0 {
86 return Some(IsolationGrade::A);
87 }
88
89 if isolation_percentage > 70.0 {
91 return Some(IsolationGrade::B);
92 }
93
94 Some(IsolationGrade::C)
96}
97
98fn count_members_with_cross_references(members: &[&Spec]) -> usize {
102 let member_pattern = Regex::new(r"(?i)\bmember\s+\d+\b").unwrap();
104
105 members
106 .iter()
107 .filter(|member| member_pattern.is_match(&member.body))
108 .count()
109}
110
111fn calculate_file_overlap_percentage(members: &[&Spec]) -> f64 {
116 let mut file_counts: std::collections::HashMap<String, usize> =
118 std::collections::HashMap::new();
119
120 for member in members {
121 if let Some(target_files) = &member.frontmatter.target_files {
122 let unique_files: HashSet<_> = target_files.iter().collect();
124 for file in unique_files {
125 *file_counts.entry(file.clone()).or_insert(0) += 1;
126 }
127 }
128 }
129
130 if file_counts.is_empty() {
132 return 0.0;
133 }
134
135 let shared_files = file_counts.values().filter(|&&count| count > 1).count();
137
138 (shared_files as f64 / file_counts.len() as f64) * 100.0
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::spec::SpecFrontmatter;
146
147 fn make_spec(id: &str, body: &str, target_files: Option<Vec<String>>) -> Spec {
148 Spec {
149 id: id.to_string(),
150 frontmatter: SpecFrontmatter {
151 target_files,
152 ..Default::default()
153 },
154 title: Some(format!("Test spec {}", id)),
155 body: body.to_string(),
156 }
157 }
158
159 #[test]
160 fn test_non_group_returns_none() {
161 let driver = make_spec("2026-01-30-abc", "Driver spec body", None);
163 let all_specs = vec![driver.clone()];
164
165 assert_eq!(calculate_isolation(&driver, &all_specs), None);
166 }
167
168 #[test]
169 fn test_single_member_returns_grade_a() {
170 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
172 let member1 = make_spec("2026-01-30-abc.1", "Member 1 body", None);
173 let all_specs = vec![driver.clone(), member1];
174
175 assert_eq!(
176 calculate_isolation(&driver, &all_specs),
177 Some(IsolationGrade::A)
178 );
179 }
180
181 #[test]
182 fn test_grade_a_perfect_isolation() {
183 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
185 let member1 = make_spec(
186 "2026-01-30-abc.1",
187 "Implement feature A",
188 Some(vec!["file1.rs".to_string()]),
189 );
190 let member2 = make_spec(
191 "2026-01-30-abc.2",
192 "Implement feature B",
193 Some(vec!["file2.rs".to_string()]),
194 );
195 let member3 = make_spec(
196 "2026-01-30-abc.3",
197 "Implement feature C",
198 Some(vec!["file3.rs".to_string()]),
199 );
200 let member4 = make_spec(
201 "2026-01-30-abc.4",
202 "Implement feature D",
203 Some(vec!["file4.rs".to_string()]),
204 );
205 let member5 = make_spec(
206 "2026-01-30-abc.5",
207 "Implement feature E",
208 Some(vec!["file5.rs".to_string()]),
209 );
210
211 let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
212
213 assert_eq!(
214 calculate_isolation(&driver, &all_specs),
215 Some(IsolationGrade::A)
216 );
217 }
218
219 #[test]
220 fn test_grade_b_good_isolation() {
221 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
223 let member1 = make_spec(
224 "2026-01-30-abc.1",
225 "Implement feature A. See Member 2 for details.",
226 Some(vec!["file1.rs".to_string()]),
227 );
228 let member2 = make_spec(
229 "2026-01-30-abc.2",
230 "Implement feature B independently.",
231 Some(vec!["file2.rs".to_string()]),
232 );
233 let member3 = make_spec(
234 "2026-01-30-abc.3",
235 "Implement feature C",
236 Some(vec!["file3.rs".to_string()]),
237 );
238 let member4 = make_spec(
239 "2026-01-30-abc.4",
240 "Implement feature D",
241 Some(vec!["file4.rs".to_string()]),
242 );
243 let member5 = make_spec(
244 "2026-01-30-abc.5",
245 "Implement feature E",
246 Some(vec!["file5.rs".to_string()]),
247 );
248 let member6 = make_spec(
249 "2026-01-30-abc.6",
250 "Implement feature F",
251 Some(vec!["file1.rs".to_string()]), );
253
254 let all_specs = vec![
255 driver.clone(),
256 member1,
257 member2,
258 member3,
259 member4,
260 member5,
261 member6,
262 ];
263
264 assert_eq!(
265 calculate_isolation(&driver, &all_specs),
266 Some(IsolationGrade::B)
267 );
268 }
269
270 #[test]
271 fn test_grade_d_low_isolation() {
272 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
274 let member1 = make_spec(
275 "2026-01-30-abc.1",
276 "Implement feature A. See Member 2.",
277 Some(vec!["file1.rs".to_string()]),
278 );
279 let member2 = make_spec(
280 "2026-01-30-abc.2",
281 "Implement feature B. Depends on Member 1 and Member 3.",
282 Some(vec!["file2.rs".to_string()]),
283 );
284 let member3 = make_spec(
285 "2026-01-30-abc.3",
286 "Implement feature C. Uses Member 2.",
287 Some(vec!["file3.rs".to_string()]),
288 );
289 let member4 = make_spec(
290 "2026-01-30-abc.4",
291 "Implement feature D",
292 Some(vec!["file4.rs".to_string()]),
293 );
294
295 let all_specs = vec![driver.clone(), member1, member2, member3, member4];
296
297 assert_eq!(
298 calculate_isolation(&driver, &all_specs),
299 Some(IsolationGrade::D)
300 );
301 }
302
303 #[test]
304 fn test_grade_d_high_file_overlap() {
305 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
307 let member1 = make_spec(
308 "2026-01-30-abc.1",
309 "Implement feature A",
310 Some(vec!["shared1.rs".to_string(), "shared2.rs".to_string()]),
311 );
312 let member2 = make_spec(
313 "2026-01-30-abc.2",
314 "Implement feature B",
315 Some(vec!["shared1.rs".to_string(), "shared2.rs".to_string()]),
316 );
317 let member3 = make_spec(
318 "2026-01-30-abc.3",
319 "Implement feature C",
320 Some(vec!["file3.rs".to_string()]),
321 );
322
323 let all_specs = vec![driver.clone(), member1, member2, member3];
324
325 assert_eq!(
327 calculate_isolation(&driver, &all_specs),
328 Some(IsolationGrade::D)
329 );
330 }
331
332 #[test]
333 fn test_cross_reference_detection_case_insensitive() {
334 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
335 let member1 = make_spec(
336 "2026-01-30-abc.1",
337 "See MEMBER 2 for details. Also check member 3.",
338 None,
339 );
340 let member2 = make_spec("2026-01-30-abc.2", "Independent work", None);
341 let member3 = make_spec("2026-01-30-abc.3", "Independent work", None);
342
343 let all_specs = vec![driver.clone(), member1, member2, member3];
344
345 assert_eq!(
348 calculate_isolation(&driver, &all_specs),
349 Some(IsolationGrade::C)
350 );
351 }
352
353 #[test]
354 fn test_no_target_files_no_overlap() {
355 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
357 let member1 = make_spec("2026-01-30-abc.1", "Feature A", None);
358 let member2 = make_spec("2026-01-30-abc.2", "Feature B", None);
359 let member3 = make_spec("2026-01-30-abc.3", "Feature C", None);
360 let member4 = make_spec("2026-01-30-abc.4", "Feature D", None);
361 let member5 = make_spec("2026-01-30-abc.5", "Feature E", None);
362
363 let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
364
365 assert_eq!(
367 calculate_isolation(&driver, &all_specs),
368 Some(IsolationGrade::A)
369 );
370 }
371
372 #[test]
373 fn test_grade_c_medium_isolation() {
374 let driver = make_spec("2026-01-30-abc", "Driver spec", None);
376 let member1 = make_spec(
377 "2026-01-30-abc.1",
378 "See Member 2",
379 Some(vec!["file1.rs".to_string()]),
380 );
381 let member2 = make_spec(
382 "2026-01-30-abc.2",
383 "Depends on Member 1",
384 Some(vec!["file2.rs".to_string()]),
385 );
386 let member3 = make_spec(
387 "2026-01-30-abc.3",
388 "Independent",
389 Some(vec!["file3.rs".to_string()]),
390 );
391 let member4 = make_spec(
392 "2026-01-30-abc.4",
393 "Independent",
394 Some(vec!["file4.rs".to_string()]),
395 );
396 let member5 = make_spec(
397 "2026-01-30-abc.5",
398 "Independent",
399 Some(vec!["file5.rs".to_string()]),
400 );
401
402 let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
403
404 assert_eq!(
405 calculate_isolation(&driver, &all_specs),
406 Some(IsolationGrade::C)
407 );
408 }
409}