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};
6use fallow_types::serde_path;
7
8/// Runtime code that no test dependency path reaches.
9#[derive(Debug, Clone, serde::Serialize)]
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)]
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)]
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 (possibly empty for forward-compat).
48    pub actions: Vec<UntestedFileAction>,
49}
50
51impl UntestedFileFinding {
52    /// Build the wrapper from a raw [`UntestedFile`] and the project root.
53    /// `root` is used to compute the relative path string embedded in action
54    /// descriptions; the inner `file.path` stays absolute and is converted to
55    /// the wire form by `strip_root_prefix` later in the JSON pipeline.
56    #[must_use]
57    pub fn with_actions(file: UntestedFile, root: &Path) -> Self {
58        let display_path = relative_display(&file.path, root);
59        let actions = vec![
60            UntestedFileAction {
61                kind: UntestedFileActionType::AddTests,
62                auto_fixable: false,
63                description: format!("Add test coverage for `{display_path}`"),
64                note: Some("No test dependency path reaches this runtime file".to_string()),
65                comment: None,
66            },
67            UntestedFileAction {
68                kind: UntestedFileActionType::SuppressFile,
69                auto_fixable: false,
70                description: format!("Suppress coverage gap reporting for `{display_path}`"),
71                note: None,
72                comment: Some("// fallow-ignore-file coverage-gaps".to_string()),
73            },
74        ];
75        Self { file, actions }
76    }
77}
78
79/// Wire-shape envelope for an [`UntestedExport`] finding. Same pattern as
80/// [`UntestedFileFinding`]: flattens the bare finding and carries a typed
81/// `actions` array computed at construction time.
82#[derive(Debug, Clone, serde::Serialize)]
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84pub struct UntestedExportFinding {
85    /// The underlying coverage-gap entry.
86    #[serde(flatten)]
87    pub export: UntestedExport,
88    /// Suggested next steps: an `add-test-import` primary and a
89    /// `suppress-file` secondary.
90    pub actions: Vec<UntestedExportAction>,
91}
92
93impl UntestedExportFinding {
94    /// Build the wrapper from a raw [`UntestedExport`] and the project root.
95    #[must_use]
96    pub fn with_actions(export: UntestedExport, root: &Path) -> Self {
97        let display_path = relative_display(&export.path, root);
98        let export_name = export.export_name.clone();
99        let actions = vec![
100            UntestedExportAction {
101                kind: UntestedExportActionType::AddTestImport,
102                auto_fixable: false,
103                description: format!("Import and test `{export_name}` from `{display_path}`"),
104                note: Some(
105                    "This export is runtime-reachable but no test-reachable module references it"
106                        .to_string(),
107                ),
108                comment: None,
109            },
110            UntestedExportAction {
111                kind: UntestedExportActionType::SuppressFile,
112                auto_fixable: false,
113                description: format!("Suppress coverage gap reporting for `{display_path}`"),
114                note: None,
115                comment: Some("// fallow-ignore-file coverage-gaps".to_string()),
116            },
117        ];
118        Self { export, actions }
119    }
120}
121
122fn relative_display(path: &Path, root: &Path) -> String {
123    path.strip_prefix(root)
124        .unwrap_or(path)
125        .display()
126        .to_string()
127}
128
129/// Aggregate coverage-gap counters for the current analysis scope.
130#[derive(Debug, Clone, Default, serde::Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct CoverageGapSummary {
133    /// Runtime-reachable files in scope.
134    pub runtime_files: usize,
135    /// Runtime-reachable files also reachable from tests.
136    pub covered_files: usize,
137    /// Percentage of runtime files that are test-reachable.
138    pub file_coverage_pct: f64,
139    /// Runtime files with no test dependency path.
140    pub untested_files: usize,
141    /// Runtime exports with no test-reachable reference chain.
142    pub untested_exports: usize,
143}
144
145/// Static test coverage gaps derived from the module graph. Shows runtime files
146/// and exports with no test dependency path.
147#[derive(Debug, Clone, Default, serde::Serialize)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149pub struct CoverageGaps {
150    /// Summary metrics for the current analysis scope.
151    pub summary: CoverageGapSummary,
152    /// Runtime files with no test dependency path. Each entry carries its
153    /// own `actions` array via [`UntestedFileFinding`].
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    #[cfg_attr(feature = "schema", schemars(default))]
156    pub files: Vec<UntestedFileFinding>,
157    /// Runtime exports with no test-reachable reference chain. Each entry
158    /// carries its own `actions` array via [`UntestedExportFinding`].
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    #[cfg_attr(feature = "schema", schemars(default))]
161    pub exports: Vec<UntestedExportFinding>,
162}
163
164impl CoverageGaps {
165    #[must_use]
166    pub fn is_empty(&self) -> bool {
167        self.files.is_empty() && self.exports.is_empty()
168    }
169}