Skip to main content

fallow_api/
coverage.rs

1//! Istanbul coverage input precedence shared by every surface that feeds CRAP
2//! scoring: the explicit flag or tool parameter, then `FALLOW_COVERAGE` /
3//! `FALLOW_COVERAGE_ROOT`, then `health.coverage` / `health.coverageRoot` from
4//! the project config. Engine auto-detection of `coverage/coverage-final.json`
5//! applies only when every layer leaves the coverage path unset.
6//!
7//! [`resolve_coverage_inputs`] is pure: adapters read the environment and load
8//! config at their own boundary and pass the layers in, so the CLI and the MCP
9//! typed route share one order (#2359, #2368) without the API reading process
10//! state. NAPI keeps explicit options and does not call it.
11
12use std::path::PathBuf;
13
14use fallow_config::HealthConfig;
15
16use crate::ProgrammaticError;
17
18/// Istanbul coverage inputs from one resolution layer, or the resolved result.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct CoverageInputs {
21    /// Path to an Istanbul `coverage-final.json`, or a directory holding one.
22    /// Relative paths resolve against the analysis root.
23    pub coverage: Option<PathBuf>,
24    /// Absolute path prefix the coverage map recorded its files under.
25    pub coverage_root: Option<PathBuf>,
26}
27
28impl CoverageInputs {
29    /// Whether the config layer still has to be consulted: at least one input
30    /// stays unset after layering `explicit` over `env`. Callers that load
31    /// config lazily skip the load when this is `false`.
32    #[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/// The layer that supplied a resolved coverage input.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CoverageInputSource {
42    /// A CLI flag or a tool parameter.
43    Explicit,
44    /// `FALLOW_COVERAGE` / `FALLOW_COVERAGE_ROOT`.
45    Environment,
46    /// `health.coverage` / `health.coverageRoot` in the project config.
47    Config,
48}
49
50/// Shape errors in resolved coverage inputs.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum CoverageInputError {
53    /// The winning `coverage_root` is not an absolute prefix from the
54    /// coverage data.
55    RelativeCoverageRoot {
56        /// The layer that supplied the rejected value.
57        source: CoverageInputSource,
58        /// The engine's rejection message, naming the value.
59        message: String,
60    },
61}
62
63impl CoverageInputError {
64    /// Convert into the structured `FALLOW_INVALID_COVERAGE_ROOT` error
65    /// (exit 2). `explicit_context` names the adapter's own input for the
66    /// explicit layer; the environment and config layers name
67    /// `FALLOW_COVERAGE_ROOT` and `health.coverageRoot`.
68    #[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
91/// Resolve coverage inputs with the shared precedence: `explicit`, then
92/// `env`, then `config_health`. Each input resolves independently, so an
93/// explicit coverage path pairs with an environment or config root and vice
94/// versa. `config_health` is the `health` section of the project config when
95/// the caller loaded one; see [`CoverageInputs::needs_config_layer`].
96///
97/// # Errors
98///
99/// Returns [`CoverageInputError::RelativeCoverageRoot`] when the winning
100/// `coverage_root` is not absolute under Unix or Windows conventions.
101pub 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}