fallow_output/
health_coverage_gaps.rs1use 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)]
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)]
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)]
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)]
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 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#[derive(Debug, Clone, Default, serde::Serialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129pub struct CoverageGapSummary {
130 pub runtime_files: usize,
132 pub covered_files: usize,
134 pub file_coverage_pct: f64,
136 pub untested_files: usize,
138 pub untested_exports: usize,
140}
141
142#[derive(Debug, Clone, Default, serde::Serialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146pub struct CoverageGaps {
147 pub summary: CoverageGapSummary,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 #[cfg_attr(feature = "schema", schemars(default))]
153 pub files: Vec<UntestedFileFinding>,
154 #[serde(default, 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}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn untested_file_actions_use_relative_display_path() {
174 let root = Path::new("/repo");
175 let finding = UntestedFileFinding::with_actions(
176 UntestedFile {
177 path: PathBuf::from("/repo/src/api.ts"),
178 value_export_count: 2,
179 },
180 root,
181 );
182
183 assert_eq!(finding.actions.len(), 2);
184 assert!(finding.actions[0].description.contains("src/api.ts"));
185 }
186
187 #[test]
188 fn empty_coverage_gaps_reports_empty() {
189 assert!(CoverageGaps::default().is_empty());
190 }
191}