Skip to main content

fallow_output/
health_coverage_gaps.rs

1use std::path::{Path, PathBuf};
2
3use fallow_types::output_health::{
4    UntestedExportAction, UntestedExportActionType, UntestedFileAction, UntestedFileActionType,
5};
6use fallow_types::serde_path;
7
8/// Runtime code that no test dependency path reaches.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub struct UntestedFile {
12    /// Absolute file path.
13    #[serde(serialize_with = "serde_path::serialize")]
14    pub path: PathBuf,
15    /// Number of value exports declared by the file.
16    pub value_export_count: usize,
17}
18
19/// Runtime export that no test-reachable module references.
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22pub struct UntestedExport {
23    /// Absolute file path.
24    #[serde(serialize_with = "serde_path::serialize")]
25    pub path: PathBuf,
26    /// Export name.
27    pub export_name: String,
28    /// 1-based source line.
29    pub line: u32,
30    /// 0-based source column.
31    pub col: u32,
32}
33
34/// Wire-shape envelope for an [`UntestedFile`] finding. Carries the bare
35/// [`UntestedFile`] flattened in plus a typed `actions` array. The action
36/// vec is computed at construction time using a project-root-relative path
37/// so descriptions match `strip_root_prefix`'s post-pass output on the inner
38/// `path` field. Schemars derives the merged shape natively; this retires
39/// the `augment_finding_definition` graft for `UntestedFile`.
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct UntestedFileFinding {
43    /// The underlying coverage-gap entry.
44    #[serde(flatten)]
45    pub file: UntestedFile,
46    /// Suggested next steps: an `add-tests` primary and a `suppress-file`
47    /// secondary. Always emitted for the current wire contract.
48    pub actions: Vec<UntestedFileAction>,
49}
50
51impl UntestedFileFinding {
52    /// Build the wrapper from a raw [`UntestedFile`] and the project root.
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, serde::Deserialize)]
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        Self::with_reach_actions(export, root, false)
95    }
96
97    /// Build the wrapper and say in the primary note when only tests that
98    /// mock the module reference the export.
99    #[must_use]
100    pub fn with_reach_actions(export: UntestedExport, root: &Path, mocked_only: bool) -> Self {
101        let display_path = relative_display(&export.path, root);
102        let export_name = export.export_name.clone();
103        let note = if mocked_only {
104            "This export is runtime-reachable, but only tests that mock this module reference it"
105        } else {
106            "This export is runtime-reachable but no test-reachable module references it"
107        };
108        let actions = vec![
109            UntestedExportAction {
110                kind: UntestedExportActionType::AddTestImport,
111                auto_fixable: false,
112                description: format!("Import and test `{export_name}` from `{display_path}`"),
113                note: Some(note.to_string()),
114                comment: None,
115            },
116            UntestedExportAction {
117                kind: UntestedExportActionType::SuppressFile,
118                auto_fixable: false,
119                description: format!("Suppress coverage gap reporting for `{display_path}`"),
120                note: None,
121                comment: Some("// fallow-ignore-file coverage-gaps".to_string()),
122            },
123        ];
124        Self { export, actions }
125    }
126}
127
128fn relative_display(path: &Path, root: &Path) -> String {
129    path.strip_prefix(root)
130        .unwrap_or(path)
131        .display()
132        .to_string()
133}
134
135/// Aggregate coverage-gap counters for the current analysis scope.
136#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct CoverageGapSummary {
139    /// Runtime-reachable files in scope.
140    pub runtime_files: usize,
141    /// Runtime-reachable files also reachable from tests.
142    pub covered_files: usize,
143    /// Percentage of runtime files that are test-reachable.
144    pub file_coverage_pct: f64,
145    /// Runtime files with no test dependency path.
146    pub untested_files: usize,
147    /// Runtime exports with no test-reachable reference chain.
148    pub untested_exports: usize,
149}
150
151/// Static test coverage gaps derived from the module graph. Shows runtime files
152/// and exports with no test dependency path.
153#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct CoverageGaps {
156    /// Summary metrics for the current analysis scope.
157    pub summary: CoverageGapSummary,
158    /// Runtime files with no test dependency path. Each entry carries its
159    /// own `actions` array via [`UntestedFileFinding`].
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    #[cfg_attr(feature = "schema", schemars(default))]
162    pub files: Vec<UntestedFileFinding>,
163    /// Runtime exports with no test-reachable reference chain. Each entry
164    /// carries its own `actions` array via [`UntestedExportFinding`].
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    #[cfg_attr(feature = "schema", schemars(default))]
167    pub exports: Vec<UntestedExportFinding>,
168}
169
170impl CoverageGaps {
171    /// True when no untested files or exports were found; callers omit the
172    /// section from output in that case.
173    #[must_use]
174    pub fn is_empty(&self) -> bool {
175        self.files.is_empty() && self.exports.is_empty()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn untested_file_actions_use_relative_display_path() {
185        let root = Path::new("/repo");
186        let finding = UntestedFileFinding::with_actions(
187            UntestedFile {
188                path: PathBuf::from("/repo/src/api.ts"),
189                value_export_count: 2,
190            },
191            root,
192        );
193
194        assert_eq!(finding.actions.len(), 2);
195        assert!(finding.actions[0].description.contains("src/api.ts"));
196    }
197
198    #[test]
199    fn mocked_only_export_note_names_the_mock() {
200        let export = || UntestedExport {
201            path: PathBuf::from("/repo/src/queue.ts"),
202            export_name: "enqueue".to_string(),
203            line: 1,
204            col: 0,
205        };
206        let root = Path::new("/repo");
207        let mocked = UntestedExportFinding::with_reach_actions(export(), root, true);
208        let plain = UntestedExportFinding::with_actions(export(), root);
209
210        assert_eq!(
211            mocked.actions[0].note.as_deref(),
212            Some(
213                "This export is runtime-reachable, but only tests that mock this module reference it"
214            )
215        );
216        assert_eq!(
217            plain.actions[0].note.as_deref(),
218            Some("This export is runtime-reachable but no test-reachable module references it")
219        );
220    }
221
222    #[test]
223    fn empty_coverage_gaps_reports_empty() {
224        assert!(CoverageGaps::default().is_empty());
225    }
226}