1use std::path::{Path, PathBuf};
2
3use fallow_types::output_health::{
4 UntestedExportAction, UntestedExportActionType, UntestedFileAction, UntestedFileActionType,
5};
6use fallow_types::serde_path;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub struct UntestedFile {
12 #[serde(serialize_with = "serde_path::serialize")]
14 pub path: PathBuf,
15 pub value_export_count: usize,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22pub struct UntestedExport {
23 #[serde(serialize_with = "serde_path::serialize")]
25 pub path: PathBuf,
26 pub export_name: String,
28 pub line: u32,
30 pub col: u32,
32}
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct UntestedFileFinding {
43 #[serde(flatten)]
45 pub file: UntestedFile,
46 pub actions: Vec<UntestedFileAction>,
49}
50
51impl UntestedFileFinding {
52 #[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct UntestedExportFinding {
82 #[serde(flatten)]
84 pub export: UntestedExport,
85 pub actions: Vec<UntestedExportAction>,
88}
89
90impl UntestedExportFinding {
91 #[must_use]
93 pub fn with_actions(export: UntestedExport, root: &Path) -> Self {
94 Self::with_reach_actions(export, root, false)
95 }
96
97 #[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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct CoverageGapSummary {
139 pub runtime_files: usize,
141 pub covered_files: usize,
143 pub file_coverage_pct: f64,
145 pub untested_files: usize,
147 pub untested_exports: usize,
149}
150
151#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct CoverageGaps {
156 pub summary: CoverageGapSummary,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 #[cfg_attr(feature = "schema", schemars(default))]
162 pub files: Vec<UntestedFileFinding>,
163 #[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 #[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}