1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::artifact;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CoverageStrength {
9 Precise,
10 Moderate,
11 Broad,
12}
13
14impl std::fmt::Display for CoverageStrength {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Self::Precise => write!(f, "precise"),
18 Self::Moderate => write!(f, "moderate"),
19 Self::Broad => write!(f, "broad"),
20 }
21 }
22}
23
24#[must_use]
26pub fn classify_strength(line_count: usize) -> CoverageStrength {
27 if line_count <= 30 {
28 CoverageStrength::Precise
29 } else if line_count <= 100 {
30 CoverageStrength::Moderate
31 } else {
32 CoverageStrength::Broad
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SpecCoverage {
39 Direct,
41 Inherited,
43 Exempt,
45 Unmapped,
47}
48
49#[derive(Debug)]
51pub struct CoverageReport {
52 pub spec_name: String,
53 pub total_nodes: usize,
54 pub mapped_nodes: usize,
55 pub exempt_nodes: usize,
56 pub unmapped_nodes: Vec<String>,
57 pub artifact_coverages: Vec<ArtifactCoverage>,
58 pub file_coverages: Vec<FileCoverage>,
60}
61
62#[derive(Debug)]
64pub struct FileCoverage {
65 pub file_path: String,
66 pub total_lines: usize,
67 pub covered_lines: usize,
68 pub coverage_percent: f64,
69 pub covered_ranges: Vec<CoveredRange>,
70 pub uncovered_ranges: Vec<UncoveredRange>,
71}
72
73#[derive(Debug)]
75pub struct CoveredRange {
76 pub start_line: u64,
77 pub end_line: u64,
78 pub mapping_ids: Vec<String>,
79}
80
81#[derive(Debug)]
83pub struct UncoveredRange {
84 pub start_line: usize,
85 pub end_line: usize,
86 pub line_count: usize,
87}
88
89#[derive(Debug)]
91pub struct ArtifactCoverage {
92 pub mapping_id: String,
93 pub artifact_path: String,
94 pub strength: CoverageStrength,
95 pub line_count: usize,
96}
97
98pub fn analyze_coverage(
104 spec_dir: &Path,
105 code_path_filter: Option<&str>,
106) -> Result<CoverageReport, crate::Error> {
107 let index_files = crate::discovery::find_index_files(spec_dir)?;
108 let spec_name = spec_dir
109 .file_name()
110 .map_or_else(|| "unknown".into(), |n| n.to_string_lossy().into_owned());
111
112 let mut all_node_ids = std::collections::HashSet::new();
113 let mut mapped_node_ids = std::collections::HashSet::new();
114 let mut exempt_node_ids = std::collections::HashSet::new();
115 let mut artifact_coverages = Vec::new();
116 let mut artifact_coverages_raw = Vec::new();
117
118 for index_path in &index_files {
119 let file_paths = crate::discovery::discover_spec_files(index_path)?;
120
121 for file_path in &file_paths {
123 let content = std::fs::read_to_string(file_path)?;
124 let mut xot = xot::Xot::new();
125 let doc = xot.parse(&content).map_err(xot::Error::from)?;
126 let root = xot.document_element(doc)?;
127 let id_attr = xot.add_name("id");
128 let xml_ns = xot.add_namespace(crate::namespace::XML);
129 let xml_id_attr = xot.add_name_ns("id", xml_ns);
130 let art_ns = xot.add_namespace(crate::namespace::ARTIFACT);
131 collect_node_ids(&xot, root, id_attr, xml_id_attr, art_ns, &mut all_node_ids);
132
133 let exempt_tag = xot.add_name_ns("exempt", art_ns);
134 let node_attr = xot.add_name("node");
135 collect_exempt_nodes(&xot, root, exempt_tag, node_attr, &mut exempt_node_ids);
136 }
137
138 let mappings = artifact::collect_artifact_mappings(&file_paths)?;
140 for mapping in &mappings {
141 if !mapping.spec_ref_node.is_empty() {
142 mapped_node_ids.insert(mapping.spec_ref_node.clone());
143 }
144
145 #[allow(clippy::cast_possible_truncation)]
146 let line_count: usize = mapping
147 .ranges
148 .iter()
149 .map(|r| match (r.start_line, r.end_line) {
150 (Some(s), Some(e)) => (e.saturating_sub(s) + 1) as usize,
151 _ => 0,
152 })
153 .sum();
154
155 artifact_coverages.push(ArtifactCoverage {
156 mapping_id: mapping.id.clone(),
157 artifact_path: mapping.artifact_path.clone(),
158 strength: classify_strength(line_count),
159 line_count,
160 });
161 }
162 artifact_coverages_raw.extend(mappings);
163 }
164
165 let covered: std::collections::HashSet<_> =
167 mapped_node_ids.union(&exempt_node_ids).cloned().collect();
168 let unmapped: Vec<String> = all_node_ids.difference(&covered).cloned().collect();
169
170 let repo_root = artifact::find_repo_root(spec_dir);
172 let file_coverages = compute_file_coverages(
173 &artifact_coverages_raw,
174 spec_dir,
175 repo_root.as_deref(),
176 code_path_filter,
177 );
178
179 let exempt_only: usize = exempt_node_ids.difference(&mapped_node_ids).count();
181
182 Ok(CoverageReport {
183 spec_name,
184 total_nodes: all_node_ids.len(),
185 mapped_nodes: mapped_node_ids.len(),
186 exempt_nodes: exempt_only,
187 unmapped_nodes: unmapped,
188 artifact_coverages,
189 file_coverages,
190 })
191}
192
193fn collect_node_ids(
194 xot: &xot::Xot,
195 node: xot::Node,
196 id_attr: xot::NameId,
197 xml_id_attr: xot::NameId,
198 art_ns: xot::NamespaceId,
199 ids: &mut std::collections::HashSet<String>,
200) {
201 if xot.is_element(node) {
202 let is_artifact = xot
205 .element(node)
206 .is_some_and(|e| xot.namespace_for_name(e.name()) == art_ns);
207 if !is_artifact {
208 if let Some(id) = xot.get_attribute(node, id_attr) {
209 ids.insert(id.to_string());
210 }
211 if let Some(xml_id) = xot.get_attribute(node, xml_id_attr) {
212 ids.insert(xml_id.to_string());
213 }
214 }
215 }
216 for child in xot.children(node) {
217 collect_node_ids(xot, child, id_attr, xml_id_attr, art_ns, ids);
218 }
219}
220
221fn collect_exempt_nodes(
222 xot: &xot::Xot,
223 node: xot::Node,
224 exempt_tag: xot::NameId,
225 node_attr: xot::NameId,
226 exempt_ids: &mut std::collections::HashSet<String>,
227) {
228 if xot.is_element(node)
229 && xot
230 .element(node)
231 .is_some_and(|e| e.name() == exempt_tag)
232 && let Some(ref_node) = xot.get_attribute(node, node_attr)
233 {
234 exempt_ids.insert(ref_node.to_string());
235 }
236 for child in xot.children(node) {
237 collect_exempt_nodes(xot, child, exempt_tag, node_attr, exempt_ids);
238 }
239}
240
241#[must_use]
243pub fn count_non_whitespace_lines(text: &str) -> usize {
244 text.lines().filter(|l| !l.trim().is_empty()).count()
245}
246
247struct FileRangeEntry {
249 start_line: u64,
250 end_line: u64,
251 mapping_id: String,
252}
253
254fn build_coverage_maps(
256 mappings: &[artifact::ArtifactMapping],
257 code_path_filter: Option<&str>,
258) -> (
259 HashMap<String, Vec<FileRangeEntry>>,
260 HashMap<String, Vec<String>>,
261) {
262 let mut file_map: HashMap<String, Vec<FileRangeEntry>> = HashMap::new();
263 let mut whole_file_mappings: HashMap<String, Vec<String>> = HashMap::new();
264
265 for mapping in mappings {
266 if mapping.artifact_path.is_empty() {
267 continue;
268 }
269 if let Some(filter) = code_path_filter
270 && !mapping.artifact_path.contains(filter)
271 {
272 continue;
273 }
274
275 let has_line_ranges = mapping
276 .ranges
277 .iter()
278 .any(|r| r.start_line.is_some() && r.end_line.is_some());
279
280 if has_line_ranges {
281 for range in &mapping.ranges {
282 if let (Some(start), Some(end)) = (range.start_line, range.end_line) {
283 file_map
284 .entry(mapping.artifact_path.clone())
285 .or_default()
286 .push(FileRangeEntry {
287 start_line: start,
288 end_line: end,
289 mapping_id: mapping.id.clone(),
290 });
291 }
292 }
293 } else {
294 whole_file_mappings
295 .entry(mapping.artifact_path.clone())
296 .or_default()
297 .push(mapping.id.clone());
298 }
299 }
300
301 (file_map, whole_file_mappings)
302}
303
304fn compute_single_file_coverage(
306 artifact_path: &str,
307 lines: &[&str],
308 ranges: Option<&[FileRangeEntry]>,
309) -> FileCoverage {
310 let total_lines = lines.len();
311 let mut covered = vec![false; total_lines];
312 let mut covered_range_list: Vec<CoveredRange> = Vec::new();
313
314 if let Some(ranges) = ranges {
315 for entry in ranges {
316 #[allow(clippy::cast_possible_truncation)]
317 let start = std::cmp::min((entry.start_line as usize).saturating_sub(1), total_lines);
318 #[allow(clippy::cast_possible_truncation)]
319 let end = std::cmp::min(entry.end_line as usize, total_lines);
320 for c in &mut covered[start..end] {
321 *c = true;
322 }
323 covered_range_list.push(CoveredRange {
324 start_line: entry.start_line,
325 end_line: entry.end_line,
326 mapping_ids: vec![entry.mapping_id.clone()],
327 });
328 }
329 }
330
331 covered_range_list.sort_by_key(|r| (r.start_line, r.end_line));
332
333 let covered_count = covered
334 .iter()
335 .enumerate()
336 .filter(|(i, is_covered)| **is_covered && !lines[*i].trim().is_empty())
337 .count();
338
339 let non_ws_total = lines.iter().filter(|l| !l.trim().is_empty()).count();
340
341 #[allow(clippy::cast_precision_loss)]
342 let coverage_percent = if non_ws_total > 0 {
343 (covered_count as f64 / non_ws_total as f64) * 100.0
344 } else {
345 100.0
346 };
347
348 let uncovered_ranges = find_uncovered_ranges(&covered, lines);
349
350 FileCoverage {
351 file_path: artifact_path.to_string(),
352 total_lines,
353 covered_lines: covered_count,
354 coverage_percent,
355 covered_ranges: covered_range_list,
356 uncovered_ranges,
357 }
358}
359
360fn compute_file_coverages(
362 mappings: &[artifact::ArtifactMapping],
363 spec_dir: &Path,
364 repo_root: Option<&Path>,
365 code_path_filter: Option<&str>,
366) -> Vec<FileCoverage> {
367 let (file_map, whole_file_mappings) = build_coverage_maps(mappings, code_path_filter);
368
369 let all_paths: std::collections::HashSet<&String> = file_map
370 .keys()
371 .chain(whole_file_mappings.keys())
372 .collect();
373
374 let mut coverages: Vec<FileCoverage> = Vec::new();
375
376 for artifact_path in all_paths {
377 let resolved = artifact::resolve_artifact_path(artifact_path, spec_dir, repo_root);
378 let Ok(content) = std::fs::read_to_string(&resolved) else {
379 continue;
380 };
381 let lines: Vec<&str> = content.lines().collect();
382 if lines.is_empty() {
383 continue;
384 }
385
386 if let Some(wf_ids) = whole_file_mappings.get(artifact_path.as_str())
388 && !file_map.contains_key(artifact_path.as_str())
389 {
390 coverages.push(FileCoverage {
391 file_path: artifact_path.clone(),
392 total_lines: lines.len(),
393 covered_lines: lines.len(),
394 coverage_percent: 100.0,
395 covered_ranges: vec![CoveredRange {
396 start_line: 1,
397 end_line: lines.len() as u64,
398 mapping_ids: wf_ids.clone(),
399 }],
400 uncovered_ranges: Vec::new(),
401 });
402 continue;
403 }
404
405 coverages.push(compute_single_file_coverage(
406 artifact_path,
407 &lines,
408 file_map.get(artifact_path.as_str()).map(Vec::as_slice),
409 ));
410 }
411
412 coverages.sort_by(|a, b| a.file_path.cmp(&b.file_path));
413 coverages
414}
415
416fn find_uncovered_ranges(covered: &[bool], lines: &[&str]) -> Vec<UncoveredRange> {
418 let mut ranges = Vec::new();
419 let mut range_start: Option<usize> = None;
420
421 for (i, is_covered) in covered.iter().enumerate() {
422 let is_whitespace = lines[i].trim().is_empty();
423
424 if !is_covered && !is_whitespace {
425 if range_start.is_none() {
426 range_start = Some(i + 1); }
428 } else if let Some(start) = range_start {
429 let end = i; let count = end + 1 - start;
432 ranges.push(UncoveredRange {
433 start_line: start,
434 end_line: end, line_count: count,
436 });
437 range_start = None;
438 }
439 }
440
441 if let Some(start) = range_start {
443 let end = covered.len(); let count = end + 1 - start;
445 ranges.push(UncoveredRange {
446 start_line: start,
447 end_line: end,
448 line_count: count,
449 });
450 }
451
452 ranges
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn strength_precise() {
461 assert_eq!(classify_strength(1), CoverageStrength::Precise);
462 assert_eq!(classify_strength(30), CoverageStrength::Precise);
463 }
464
465 #[test]
466 fn strength_moderate() {
467 assert_eq!(classify_strength(31), CoverageStrength::Moderate);
468 assert_eq!(classify_strength(100), CoverageStrength::Moderate);
469 }
470
471 #[test]
472 fn strength_broad() {
473 assert_eq!(classify_strength(101), CoverageStrength::Broad);
474 assert_eq!(classify_strength(1000), CoverageStrength::Broad);
475 }
476
477 #[test]
478 fn whitespace_lines_excluded() {
479 let text = "line1\n \nline3\n\nline5\n";
480 assert_eq!(count_non_whitespace_lines(text), 3);
481 }
482
483 #[test]
484 fn empty_text_zero_lines() {
485 assert_eq!(count_non_whitespace_lines(""), 0);
486 assert_eq!(count_non_whitespace_lines(" \n \n"), 0);
487 }
488
489 #[test]
490 fn find_uncovered_ranges_basic() {
491 let covered = vec![false, true, true, false, false];
493 let lines = vec!["fn a() {", " let x = 1;", " let y = 2;", " z()", "}"];
494 let ranges = find_uncovered_ranges(&covered, &lines);
495 assert_eq!(ranges.len(), 2);
496 assert_eq!(ranges[0].start_line, 1);
497 assert_eq!(ranges[0].end_line, 1);
498 assert_eq!(ranges[1].start_line, 4);
499 assert_eq!(ranges[1].end_line, 5);
500 }
501
502 #[test]
503 fn find_uncovered_ranges_skips_whitespace() {
504 let covered = vec![true, false, false, true];
506 let lines = vec!["code", "", " ", "code"];
507 let ranges = find_uncovered_ranges(&covered, &lines);
508 assert!(ranges.is_empty());
510 }
511
512 #[test]
513 fn find_uncovered_ranges_all_covered() {
514 let covered = vec![true, true, true];
515 let lines = vec!["a", "b", "c"];
516 let ranges = find_uncovered_ranges(&covered, &lines);
517 assert!(ranges.is_empty());
518 }
519
520 #[test]
521 fn compute_single_file_coverage_basic() {
522 let lines = vec!["fn main() {", " println!(\"hi\");", "}"];
523 let ranges = vec![FileRangeEntry {
524 start_line: 1,
525 end_line: 2,
526 mapping_id: "map-1".to_string(),
527 }];
528 let fc = compute_single_file_coverage("test.rs", &lines, Some(&ranges));
529 assert_eq!(fc.total_lines, 3);
530 assert_eq!(fc.covered_lines, 2); assert!(!fc.uncovered_ranges.is_empty()); assert!(fc.coverage_percent < 100.0);
533 assert!(fc.coverage_percent > 50.0);
534 }
535
536 #[test]
537 fn compute_single_file_coverage_full() {
538 let lines = vec!["a", "b", "c"];
539 let ranges = vec![FileRangeEntry {
540 start_line: 1,
541 end_line: 3,
542 mapping_id: "map-all".to_string(),
543 }];
544 let fc = compute_single_file_coverage("test.rs", &lines, Some(&ranges));
545 assert_eq!(fc.covered_lines, 3);
546 assert!((fc.coverage_percent - 100.0).abs() < f64::EPSILON);
547 assert!(fc.uncovered_ranges.is_empty());
548 }
549
550 #[test]
551 fn build_coverage_maps_filters_by_path() {
552 let mappings = vec![
553 artifact::ArtifactMapping {
554 id: "m1".into(),
555 spec_ref_node: "n1".into(),
556 spec_ref_revision: "r1".into(),
557 node_hash: None,
558 artifact_path: "src/foo.rs".into(),
559 artifact_repo: "repo".into(),
560 ranges: vec![artifact::ArtifactRange {
561 hash: None,
562 start_line: Some(1),
563 end_line: Some(10),
564 start_byte: None,
565 end_byte: None,
566 }],
567 coverage: "full".into(),
568 source_file: std::path::PathBuf::new(),
569 },
570 artifact::ArtifactMapping {
571 id: "m2".into(),
572 spec_ref_node: "n2".into(),
573 spec_ref_revision: "r1".into(),
574 node_hash: None,
575 artifact_path: "src/bar.rs".into(),
576 artifact_repo: "repo".into(),
577 ranges: vec![artifact::ArtifactRange {
578 hash: None,
579 start_line: Some(5),
580 end_line: Some(20),
581 start_byte: None,
582 end_byte: None,
583 }],
584 coverage: "full".into(),
585 source_file: std::path::PathBuf::new(),
586 },
587 ];
588
589 let (map, _) = build_coverage_maps(&mappings, None);
591 assert_eq!(map.len(), 2);
592
593 let (map, _) = build_coverage_maps(&mappings, Some("foo"));
595 assert_eq!(map.len(), 1);
596 assert!(map.contains_key("src/foo.rs"));
597 }
598
599 #[test]
600 fn build_coverage_maps_whole_file_vs_ranged() {
601 let mappings = vec![
602 artifact::ArtifactMapping {
603 id: "m-ranged".into(),
604 spec_ref_node: "n1".into(),
605 spec_ref_revision: "r1".into(),
606 node_hash: None,
607 artifact_path: "ranged.rs".into(),
608 artifact_repo: "repo".into(),
609 ranges: vec![artifact::ArtifactRange {
610 hash: None,
611 start_line: Some(1),
612 end_line: Some(10),
613 start_byte: None,
614 end_byte: None,
615 }],
616 coverage: "full".into(),
617 source_file: std::path::PathBuf::new(),
618 },
619 artifact::ArtifactMapping {
620 id: "m-whole".into(),
621 spec_ref_node: "n2".into(),
622 spec_ref_revision: "r1".into(),
623 node_hash: None,
624 artifact_path: "whole.rs".into(),
625 artifact_repo: "repo".into(),
626 ranges: vec![artifact::ArtifactRange {
627 hash: None,
628 start_line: None,
629 end_line: None,
630 start_byte: None,
631 end_byte: None,
632 }],
633 coverage: "full".into(),
634 source_file: std::path::PathBuf::new(),
635 },
636 ];
637
638 let (file_map, whole_map) = build_coverage_maps(&mappings, None);
639 assert!(file_map.contains_key("ranged.rs"));
640 assert!(!file_map.contains_key("whole.rs"));
641 assert!(whole_map.contains_key("whole.rs"));
642 assert!(!whole_map.contains_key("ranged.rs"));
643 }
644
645 #[test]
646 fn shipped_spec_has_file_coverages() {
647 let spec_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
648 .join("../../clayers/clayers")
649 .canonicalize()
650 .expect("clayers/clayers/ not found");
651 let report = analyze_coverage(&spec_dir, None).expect("coverage failed");
652 assert!(
653 !report.file_coverages.is_empty(),
654 "shipped spec should have file coverages"
655 );
656 for fc in &report.file_coverages {
658 assert!(fc.total_lines > 0, "file {} has 0 total lines", fc.file_path);
659 }
660 }
661}