Skip to main content

fallow_cli/health_types/
coverage.rs

1use std::path::{Path, PathBuf};
2
3use fallow_types::output_health::{
4    UntestedExportAction, UntestedExportActionType, UntestedFileAction, UntestedFileActionType,
5};
6
7/// Runtime code that no test dependency path reaches.
8#[derive(Debug, Clone, serde::Serialize)]
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10pub struct UntestedFile {
11    /// Absolute file path.
12    pub path: PathBuf,
13    /// Number of value exports declared by the file.
14    pub value_export_count: usize,
15}
16
17/// Runtime export that no test-reachable module references.
18#[derive(Debug, Clone, serde::Serialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20pub struct UntestedExport {
21    /// Absolute file path.
22    pub path: PathBuf,
23    /// Export name.
24    pub export_name: String,
25    /// 1-based source line.
26    pub line: u32,
27    /// 0-based source column.
28    pub col: u32,
29}
30
31/// Wire-shape envelope for an [`UntestedFile`] finding. Carries the bare
32/// [`UntestedFile`] flattened in plus a typed `actions` array. The action
33/// vec is computed at construction time using a project-root-relative path
34/// so descriptions match `strip_root_prefix`'s post-pass output on the inner
35/// `path` field. Schemars derives the merged shape natively; this retires
36/// the `augment_finding_definition` graft for `UntestedFile`.
37#[derive(Debug, Clone, serde::Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct UntestedFileFinding {
40    /// The underlying coverage-gap entry.
41    #[serde(flatten)]
42    pub file: UntestedFile,
43    /// Suggested next steps: an `add-tests` primary and a `suppress-file`
44    /// secondary. Always emitted (possibly empty for forward-compat).
45    pub actions: Vec<UntestedFileAction>,
46}
47
48impl UntestedFileFinding {
49    /// Build the wrapper from a raw [`UntestedFile`] and the project root.
50    /// `root` is used to compute the relative path string embedded in action
51    /// descriptions; the inner `file.path` stays absolute and is converted to
52    /// the wire form by `strip_root_prefix` later in the JSON pipeline.
53    #[must_use]
54    pub fn with_actions(file: UntestedFile, root: &Path) -> Self {
55        let display_path = relative_display(&file.path, root);
56        let actions = vec![
57            UntestedFileAction {
58                kind: UntestedFileActionType::AddTests,
59                auto_fixable: false,
60                description: format!("Add test coverage for `{display_path}`"),
61                note: Some("No test dependency path reaches this runtime file".to_string()),
62                comment: None,
63            },
64            UntestedFileAction {
65                kind: UntestedFileActionType::SuppressFile,
66                auto_fixable: false,
67                description: format!("Suppress coverage gap reporting for `{display_path}`"),
68                note: None,
69                comment: Some("// fallow-ignore-file coverage-gaps".to_string()),
70            },
71        ];
72        Self { file, actions }
73    }
74}
75
76/// Wire-shape envelope for an [`UntestedExport`] finding. Same pattern as
77/// [`UntestedFileFinding`]: flattens the bare finding and carries a typed
78/// `actions` array computed at construction time.
79#[derive(Debug, Clone, serde::Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct UntestedExportFinding {
82    /// The underlying coverage-gap entry.
83    #[serde(flatten)]
84    pub export: UntestedExport,
85    /// Suggested next steps: an `add-test-import` primary and a
86    /// `suppress-file` secondary.
87    pub actions: Vec<UntestedExportAction>,
88}
89
90impl UntestedExportFinding {
91    /// Build the wrapper from a raw [`UntestedExport`] and the project root.
92    #[must_use]
93    pub fn with_actions(export: UntestedExport, root: &Path) -> Self {
94        let display_path = relative_display(&export.path, root);
95        let export_name = export.export_name.clone();
96        let actions = vec![
97            UntestedExportAction {
98                kind: UntestedExportActionType::AddTestImport,
99                auto_fixable: false,
100                description: format!("Import and test `{export_name}` from `{display_path}`"),
101                note: Some(
102                    "This export is runtime-reachable but no test-reachable module references it"
103                        .to_string(),
104                ),
105                comment: None,
106            },
107            UntestedExportAction {
108                kind: UntestedExportActionType::SuppressFile,
109                auto_fixable: false,
110                description: format!("Suppress coverage gap reporting for `{display_path}`"),
111                note: None,
112                comment: Some("// fallow-ignore-file coverage-gaps".to_string()),
113            },
114        ];
115        Self { export, actions }
116    }
117}
118
119fn relative_display(path: &Path, root: &Path) -> String {
120    path.strip_prefix(root)
121        .unwrap_or(path)
122        .display()
123        .to_string()
124}
125
126/// Aggregate coverage-gap counters for the current analysis scope.
127#[derive(Debug, Clone, Default, serde::Serialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129pub struct CoverageGapSummary {
130    /// Runtime-reachable files in scope.
131    pub runtime_files: usize,
132    /// Runtime-reachable files also reachable from tests.
133    pub covered_files: usize,
134    /// Percentage of runtime files that are test-reachable.
135    pub file_coverage_pct: f64,
136    /// Runtime files with no test dependency path.
137    pub untested_files: usize,
138    /// Runtime exports with no test-reachable reference chain.
139    pub untested_exports: usize,
140}
141
142/// Static test coverage gaps derived from the module graph. Shows runtime files
143/// and exports with no test dependency path.
144#[derive(Debug, Clone, Default, serde::Serialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146pub struct CoverageGaps {
147    /// Summary metrics for the current analysis scope.
148    pub summary: CoverageGapSummary,
149    /// Runtime files with no test dependency path. Each entry carries its
150    /// own `actions` array via [`UntestedFileFinding`].
151    #[serde(skip_serializing_if = "Vec::is_empty")]
152    #[cfg_attr(feature = "schema", schemars(default))]
153    pub files: Vec<UntestedFileFinding>,
154    /// Runtime exports with no test-reachable reference chain. Each entry
155    /// carries its own `actions` array via [`UntestedExportFinding`].
156    #[serde(skip_serializing_if = "Vec::is_empty")]
157    #[cfg_attr(feature = "schema", schemars(default))]
158    pub exports: Vec<UntestedExportFinding>,
159}
160
161impl CoverageGaps {
162    #[must_use]
163    pub fn is_empty(&self) -> bool {
164        self.files.is_empty() && self.exports.is_empty()
165    }
166}