Skip to main content

clayers_spec/
coverage.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::artifact;
5
6/// Coverage strength classification based on line count.
7#[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/// Classify coverage strength by line count.
25#[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/// Spec node coverage status.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SpecCoverage {
39    /// Node has a direct artifact mapping.
40    Direct,
41    /// Node inherits coverage from a parent/related node.
42    Inherited,
43    /// Node is explicitly exempt from coverage.
44    Exempt,
45    /// Node has no artifact mapping.
46    Unmapped,
47}
48
49/// Coverage report for a spec.
50#[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    /// Per-file code coverage (code→spec direction).
59    pub file_coverages: Vec<FileCoverage>,
60}
61
62/// Per-file code coverage analysis.
63#[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/// A range of lines covered by an artifact mapping.
74#[derive(Debug)]
75pub struct CoveredRange {
76    pub start_line: u64,
77    pub end_line: u64,
78    pub mapping_ids: Vec<String>,
79}
80
81/// A contiguous range of uncovered non-whitespace lines.
82#[derive(Debug)]
83pub struct UncoveredRange {
84    pub start_line: usize,
85    pub end_line: usize,
86    pub line_count: usize,
87}
88
89/// Coverage info for a single artifact mapping.
90#[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
98/// Analyze spec and code coverage.
99///
100/// # Errors
101///
102/// Returns an error if spec files cannot be read.
103pub 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        // Collect all node IDs and exempt declarations
122        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        // Collect artifact mappings
139        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    // Exempt nodes count as covered (not unmapped)
166    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    // Code→spec direction: per-file line coverage
171    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    // Count exempt nodes that are not also mapped (avoid double-counting)
180    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        // Skip artifact-namespace elements (mapping, exempt, spec-ref, etc.)
203        // — they are traceability infrastructure, not content nodes.
204        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/// Filter out whitespace-only lines from a range and return the non-whitespace line count.
242#[must_use]
243pub fn count_non_whitespace_lines(text: &str) -> usize {
244    text.lines().filter(|l| !l.trim().is_empty()).count()
245}
246
247/// Intermediate structure for building per-file coverage maps.
248struct FileRangeEntry {
249    start_line: u64,
250    end_line: u64,
251    mapping_id: String,
252}
253
254/// Build coverage maps from artifact mappings: ranged and whole-file.
255fn 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
304/// Compute line-level coverage for a single file from its range entries.
305fn 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
360/// Compute per-file code coverage from artifact mappings.
361fn 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        // Whole-file mapping with no ranged entries: 100% covered
387        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
416/// Find contiguous ranges of uncovered non-whitespace lines.
417fn 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); // 1-indexed
427            }
428        } else if let Some(start) = range_start {
429            // End of an uncovered range (we hit a covered or whitespace line)
430            let end = i; // last uncovered was i-1, so end = i (exclusive), 1-indexed end = i
431            let count = end + 1 - start;
432            ranges.push(UncoveredRange {
433                start_line: start,
434                end_line: end, // 1-indexed inclusive
435                line_count: count,
436            });
437            range_start = None;
438        }
439    }
440
441    // Close final range
442    if let Some(start) = range_start {
443        let end = covered.len(); // 1-indexed
444        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        // 5 lines, lines 2-3 covered, rest uncovered (0-indexed)
492        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        // Whitespace-only uncovered lines don't start new ranges
505        let covered = vec![true, false, false, true];
506        let lines = vec!["code", "", "  ", "code"];
507        let ranges = find_uncovered_ranges(&covered, &lines);
508        // Both uncovered lines are whitespace, so no uncovered ranges
509        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); // lines 1-2 are non-whitespace & covered
531        assert!(!fc.uncovered_ranges.is_empty()); // line 3 not covered
532        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        // No filter: both files
590        let (map, _) = build_coverage_maps(&mappings, None);
591        assert_eq!(map.len(), 2);
592
593        // Filter to "foo": only foo.rs
594        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        // Every file coverage should have non-zero total lines
657        for fc in &report.file_coverages {
658            assert!(fc.total_lines > 0, "file {} has 0 total lines", fc.file_path);
659        }
660    }
661}