;;; coverage-summary.el --- Terminal coverage summary for duet -*- lexical-binding: t; -*- ;; Copyright (C) 2026 Craig Jennings ;; Author: Craig Jennings ;; This program is free software: you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; Batch helper behind `make coverage-summary'. Parses the SimpleCov JSON that ;; `make coverage' writes and prints a terminal summary instead of just the ;; report's file size: ;; ;; - per tracked source file: covered/total executable lines and a percent, ;; worst-covered first; ;; - a line-weighted project figure over files present in the report; ;; - a source-weighted figure where a tracked source missing from the report ;; counts as 0% rather than being silently dropped; and ;; - an explicit list of tracked sources absent from the report. ;; ;; The missing-source handling is the robustness win: a source that never got ;; instrumented shows as 0% instead of vanishing from the numbers. DUET ships ;; a single source file today, so this is generalized over a list of tracked ;; sources and stays self-contained. ;;; Code: (require 'json) (require 'seq) (require 'subr-x) (defun duet-coverage--read-json (file) "Parse FILE as SimpleCov JSON and return the decoded hash table. Signal `user-error' when FILE is missing or the JSON is malformed." (unless (file-exists-p file) (user-error "Coverage report not found: %s (run 'make coverage' first)" file)) (let ((json-object-type 'hash-table) (json-array-type 'list) (json-key-type 'string)) (condition-case err (json-read-file file) (error (user-error "Malformed coverage JSON in %s: %s" file (error-message-string err)))))) (defun duet-coverage--collect (file predicate) "Return path -> (line -> t) hash for lines in FILE matching PREDICATE. PREDICATE receives each line's SimpleCov hits entry (a number, or nil for a non-executable line). Coverage is unioned across every top-level test-name key so undercover's `:merge-report' output accumulates correctly. A path is recorded even when no line matches, so callers can tell \"present but empty\" from \"absent\"." (let ((data (duet-coverage--read-json file)) (result (make-hash-table :test 'equal))) (maphash (lambda (_test-name section) (when (hash-table-p section) (let ((coverage (gethash "coverage" section))) (when (hash-table-p coverage) (maphash (lambda (path hits-list) (let ((lines (or (gethash path result) (make-hash-table :test 'eql))) (line-num 1)) (dolist (hits hits-list) (when (funcall predicate hits) (puthash line-num t lines)) (setq line-num (1+ line-num))) (puthash path lines result))) coverage))))) data) result)) (defun duet-coverage--parse-simplecov (file) "Return path -> (line -> t) hash for HIT lines (hits > 0) in FILE." (duet-coverage--collect file (lambda (h) (and (numberp h) (> h 0))))) (defun duet-coverage--executable-lines (file) "Return path -> (line -> t) hash for EXECUTABLE lines in FILE. Executable means the SimpleCov entry is a number (hit or unhit); null entries \(blank lines, comments) are excluded." (duet-coverage--collect file #'numberp)) (defun duet-coverage--line-count (table path) "Return the number of recorded lines for PATH in TABLE, or 0 when absent." (let ((lines (gethash path table))) (if (hash-table-p lines) (hash-table-count lines) 0))) (defun duet-coverage--find-key (table abs) "Return the key in TABLE matching ABS, or nil. Matches by expanded path first, then by basename, so a report whose paths are written in a slightly different but equivalent form still resolves." (let ((want-base (file-name-nondirectory abs)) (found nil)) (catch 'done (maphash (lambda (k _v) (when (or (string= (expand-file-name k) abs) (string= (file-name-nondirectory k) want-base)) (setq found k) (throw 'done k))) table)) found)) (defun duet-coverage--records (report-file source-files project-root) "Return per-file coverage records for SOURCE-FILES from REPORT-FILE. SOURCE-FILES are paths relative to PROJECT-ROOT. Each record is a plist: (:path REL :covered N :total N :present BOOL :percent FLOAT) A source absent from the report has :present nil, :covered 0, :total 0, and :percent 0.0 — it counts as 0%, not 100%. A present source with no executable lines is 100% (nothing left to cover)." (let ((covered (duet-coverage--parse-simplecov report-file)) (executable (duet-coverage--executable-lines report-file)) (root (file-name-as-directory (expand-file-name project-root)))) (mapcar (lambda (rel) (let* ((abs (expand-file-name rel root)) (key (duet-coverage--find-key executable abs)) (present (and key t)) (total (if key (duet-coverage--line-count executable key) 0)) (cov (if key (duet-coverage--line-count covered key) 0)) (percent (cond ((not present) 0.0) ((zerop total) 100.0) (t (/ (* 100.0 cov) total))))) (list :path rel :covered cov :total total :present present :percent percent))) source-files))) (defun duet-coverage-summary-text (report-file source-files project-root) "Return a coverage summary string for SOURCE-FILES from REPORT-FILE. SOURCE-FILES are paths relative to PROJECT-ROOT." (let* ((records (duet-coverage--records report-file source-files project-root)) (present (seq-filter (lambda (r) (plist-get r :present)) records)) (missing (seq-remove (lambda (r) (plist-get r :present)) records)) (total-cov (apply #'+ (mapcar (lambda (r) (plist-get r :covered)) present))) (total-lines (apply #'+ (mapcar (lambda (r) (plist-get r :total)) present))) (line-pct (if (> total-lines 0) (/ (* 100.0 total-cov) total-lines) 100.0)) (file-score (apply #'+ (mapcar (lambda (r) (plist-get r :percent)) records))) (file-pct (if records (/ file-score (length records)) 0.0)) (sorted (sort (copy-sequence records) (lambda (a b) (< (plist-get a :percent) (plist-get b :percent))))) (width (apply #'max 8 (mapcar (lambda (r) (length (plist-get r :path))) records))) (row-format (format " %%-%ds %%5d/%%-5d (%%5.1f%%%%)\n" width)) (miss-format (format " %%-%ds not in report (counts as 0%%%%)\n" width))) (with-temp-buffer (insert "Coverage Summary\n================\n\n") (insert "Per-file coverage (worst first):\n") (dolist (r sorted) (if (plist-get r :present) (insert (format row-format (plist-get r :path) (plist-get r :covered) (plist-get r :total) (plist-get r :percent))) (insert (format miss-format (plist-get r :path))))) (insert "\n") (insert (format "Line coverage: %d/%d lines (%.1f%%) across %d file%s in report\n" total-cov total-lines line-pct (length present) (if (= 1 (length present)) "" "s"))) (insert (format "Source coverage: %.1f%% (%d tracked, %d missing; missing counts as 0%%)\n" file-pct (length present) (length missing))) (when missing (insert (format "\nNot in coverage report: %d source%s\n" (length missing) (if (= 1 (length missing)) "" "s"))) (dolist (r missing) (insert (format " %s\n" (plist-get r :path))))) (buffer-string)))) (defun duet-coverage-print-summary (report-file source-files project-root) "Print the coverage summary for SOURCE-FILES to standard output. SOURCE-FILES are paths relative to PROJECT-ROOT. Entry point for `make coverage-summary'." (princ "\n") (princ (duet-coverage-summary-text report-file source-files project-root))) (provide 'duet-coverage-summary) ;;; coverage-summary.el ends here