1use std::path::PathBuf;
13
14use fallow_config::HealthConfig;
15
16use crate::ProgrammaticError;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct CoverageInputs {
21 pub coverage: Option<PathBuf>,
24 pub coverage_root: Option<PathBuf>,
26}
27
28impl CoverageInputs {
29 #[must_use]
33 pub const fn needs_config_layer(explicit: &Self, env: &Self) -> bool {
34 (explicit.coverage.is_none() && env.coverage.is_none())
35 || (explicit.coverage_root.is_none() && env.coverage_root.is_none())
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CoverageInputSource {
42 Explicit,
44 Environment,
46 Config,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum CoverageInputError {
53 RelativeCoverageRoot {
56 source: CoverageInputSource,
58 message: String,
60 },
61}
62
63impl CoverageInputError {
64 #[must_use]
69 pub fn into_programmatic_error(self, explicit_context: &str) -> ProgrammaticError {
70 let Self::RelativeCoverageRoot { source, message } = self;
71 let context = match source {
72 CoverageInputSource::Explicit => explicit_context,
73 CoverageInputSource::Environment => "FALLOW_COVERAGE_ROOT",
74 CoverageInputSource::Config => "health.coverageRoot",
75 };
76 ProgrammaticError::new(message, 2)
77 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
78 .with_context(context)
79 }
80}
81
82impl std::fmt::Display for CoverageInputError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 let Self::RelativeCoverageRoot { message, .. } = self;
85 f.write_str(message)
86 }
87}
88
89impl std::error::Error for CoverageInputError {}
90
91pub fn resolve_coverage_inputs(
102 explicit: CoverageInputs,
103 env: CoverageInputs,
104 config_health: Option<&HealthConfig>,
105) -> Result<CoverageInputs, CoverageInputError> {
106 let coverage = layer(
107 explicit.coverage,
108 env.coverage,
109 config_health.and_then(|health| health.coverage.clone()),
110 );
111 let coverage_root = layer(
112 explicit.coverage_root,
113 env.coverage_root,
114 config_health.and_then(|health| health.coverage_root.clone()),
115 );
116 if let Some((path, source)) = &coverage_root
117 && let Err(message) = fallow_engine::health::validate_coverage_root_absolute(Some(path))
118 {
119 return Err(CoverageInputError::RelativeCoverageRoot {
120 source: *source,
121 message,
122 });
123 }
124 Ok(CoverageInputs {
125 coverage: coverage.map(|(path, _)| path),
126 coverage_root: coverage_root.map(|(path, _)| path),
127 })
128}
129
130fn layer(
131 explicit: Option<PathBuf>,
132 env: Option<PathBuf>,
133 config: Option<PathBuf>,
134) -> Option<(PathBuf, CoverageInputSource)> {
135 explicit
136 .map(|path| (path, CoverageInputSource::Explicit))
137 .or_else(|| env.map(|path| (path, CoverageInputSource::Environment)))
138 .or_else(|| config.map(|path| (path, CoverageInputSource::Config)))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn inputs(coverage: Option<&str>, coverage_root: Option<&str>) -> CoverageInputs {
146 CoverageInputs {
147 coverage: coverage.map(PathBuf::from),
148 coverage_root: coverage_root.map(PathBuf::from),
149 }
150 }
151
152 fn health(coverage: Option<&str>, coverage_root: Option<&str>) -> HealthConfig {
153 HealthConfig {
154 coverage: coverage.map(PathBuf::from),
155 coverage_root: coverage_root.map(PathBuf::from),
156 ..HealthConfig::default()
157 }
158 }
159
160 #[test]
161 fn explicit_beats_env_beats_config() {
162 let config = health(Some("config.json"), Some("/config"));
163 let resolved = resolve_coverage_inputs(
164 inputs(Some("flag.json"), Some("/flag")),
165 inputs(Some("env.json"), Some("/env")),
166 Some(&config),
167 )
168 .expect("absolute roots");
169 assert_eq!(resolved, inputs(Some("flag.json"), Some("/flag")));
170
171 let resolved = resolve_coverage_inputs(
172 CoverageInputs::default(),
173 inputs(Some("env.json"), Some("/env")),
174 Some(&config),
175 )
176 .expect("absolute roots");
177 assert_eq!(resolved, inputs(Some("env.json"), Some("/env")));
178
179 let resolved = resolve_coverage_inputs(
180 CoverageInputs::default(),
181 CoverageInputs::default(),
182 Some(&config),
183 )
184 .expect("absolute roots");
185 assert_eq!(resolved, inputs(Some("config.json"), Some("/config")));
186 }
187
188 #[test]
189 fn each_input_resolves_independently() {
190 let config = health(Some("config.json"), Some("/config"));
191 let resolved = resolve_coverage_inputs(
192 inputs(Some("flag.json"), None),
193 inputs(None, Some("/env")),
194 Some(&config),
195 )
196 .expect("absolute roots");
197 assert_eq!(resolved, inputs(Some("flag.json"), Some("/env")));
198
199 let resolved = resolve_coverage_inputs(
200 inputs(None, Some("/flag")),
201 inputs(Some("env.json"), None),
202 Some(&config),
203 )
204 .expect("absolute roots");
205 assert_eq!(resolved, inputs(Some("env.json"), Some("/flag")));
206 }
207
208 #[test]
209 fn empty_layers_leave_auto_detection_to_the_engine() {
210 let resolved =
211 resolve_coverage_inputs(CoverageInputs::default(), CoverageInputs::default(), None)
212 .expect("nothing to validate");
213 assert_eq!(resolved, CoverageInputs::default());
214
215 let config = HealthConfig::default();
216 let resolved = resolve_coverage_inputs(
217 CoverageInputs::default(),
218 CoverageInputs::default(),
219 Some(&config),
220 )
221 .expect("nothing to validate");
222 assert_eq!(resolved, CoverageInputs::default());
223 }
224
225 #[test]
226 fn needs_config_layer_only_while_an_input_is_unset() {
227 assert!(CoverageInputs::needs_config_layer(
228 &CoverageInputs::default(),
229 &CoverageInputs::default()
230 ));
231 assert!(CoverageInputs::needs_config_layer(
232 &inputs(Some("flag.json"), None),
233 &inputs(None, None)
234 ));
235 assert!(CoverageInputs::needs_config_layer(
236 &inputs(None, Some("/flag")),
237 &inputs(None, None)
238 ));
239 assert!(!CoverageInputs::needs_config_layer(
240 &inputs(Some("flag.json"), None),
241 &inputs(None, Some("/env"))
242 ));
243 assert!(!CoverageInputs::needs_config_layer(
244 &inputs(Some("flag.json"), Some("/flag")),
245 &CoverageInputs::default()
246 ));
247 }
248
249 #[test]
250 fn relative_root_is_rejected_and_names_its_layer() {
251 let config = health(None, Some("src"));
252 let err = resolve_coverage_inputs(
253 CoverageInputs::default(),
254 CoverageInputs::default(),
255 Some(&config),
256 )
257 .expect_err("relative config root");
258 assert!(matches!(
259 &err,
260 CoverageInputError::RelativeCoverageRoot {
261 source: CoverageInputSource::Config,
262 ..
263 }
264 ));
265 assert!(
266 err.to_string()
267 .contains("--coverage-root expects an absolute path prefix")
268 && err.to_string().contains("got 'src'"),
269 "{err}"
270 );
271
272 let err = resolve_coverage_inputs(
273 CoverageInputs::default(),
274 inputs(None, Some("./coverage")),
275 Some(&config),
276 )
277 .expect_err("relative env root wins over the config root");
278 assert!(matches!(
279 err,
280 CoverageInputError::RelativeCoverageRoot {
281 source: CoverageInputSource::Environment,
282 ..
283 }
284 ));
285
286 let err = resolve_coverage_inputs(
287 inputs(None, Some("a/b")),
288 inputs(None, Some("/env")),
289 Some(&config),
290 )
291 .expect_err("relative explicit root wins over the env root");
292 assert!(matches!(
293 err,
294 CoverageInputError::RelativeCoverageRoot {
295 source: CoverageInputSource::Explicit,
296 ..
297 }
298 ));
299 }
300
301 #[test]
302 fn absolute_roots_under_either_platform_convention_are_accepted() {
303 for root in ["/ci/workspace", r"C:\ci\workspace"] {
304 let config = health(None, Some(root));
305 let resolved = resolve_coverage_inputs(
306 CoverageInputs::default(),
307 CoverageInputs::default(),
308 Some(&config),
309 )
310 .expect("absolute root");
311 assert_eq!(resolved.coverage_root, Some(PathBuf::from(root)));
312 }
313 }
314
315 #[test]
316 fn programmatic_error_keeps_the_root_code_and_names_the_layer() {
317 let cases = [
318 (CoverageInputSource::Explicit, "audit.coverageRoot"),
319 (CoverageInputSource::Environment, "FALLOW_COVERAGE_ROOT"),
320 (CoverageInputSource::Config, "health.coverageRoot"),
321 ];
322 for (source, context) in cases {
323 let err = CoverageInputError::RelativeCoverageRoot {
324 source,
325 message: "relative".to_string(),
326 }
327 .into_programmatic_error("audit.coverageRoot");
328 assert_eq!(err.exit_code, 2);
329 assert_eq!(err.message, "relative");
330 assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
331 assert_eq!(err.context.as_deref(), Some(context));
332 }
333 }
334}