Skip to main content

faucet_core/
check.rs

1//! Preflight check types for `faucet doctor` (#126).
2//!
3//! A connector's `check()` returns a [`CheckReport`] of [`Probe`]s. Probe-level
4//! failures are [`ProbeStatus::Fail`] inside an `Ok(report)`; an `Err` from
5//! `check()` means "couldn't run any probe" and is rendered as a single failure.
6
7use serde::Serialize;
8use std::time::Duration;
9
10/// Inputs a probe may need. The doctor command enforces `timeout` on the whole
11/// `check()` call; connectors may also use it to bound their own client calls.
12#[derive(Debug, Clone)]
13pub struct CheckContext {
14    /// Wall-clock budget for a single `check()` invocation.
15    pub timeout: Duration,
16}
17
18impl Default for CheckContext {
19    fn default() -> Self {
20        Self {
21            timeout: Duration::from_secs(10),
22        }
23    }
24}
25
26/// Outcome of a single probe.
27#[derive(Debug, Clone, Serialize)]
28#[serde(tag = "status", rename_all = "snake_case")]
29pub enum ProbeStatus {
30    /// The probe succeeded.
31    Pass,
32    /// The probe ran and the target is unhealthy / unreachable / misconfigured.
33    Fail { reason: String },
34    /// The probe was not applicable (no check implemented, optional target absent).
35    Skip { reason: String },
36}
37
38/// One named probe within a [`CheckReport`] (e.g. `"read"`, `"auth"`,
39/// `"network"`, `"permissions"`, `"schema"`, `"io"`, `"sentinel"`).
40#[derive(Debug, Clone, Serialize)]
41pub struct Probe {
42    /// Short, stable probe name.
43    pub name: &'static str,
44    /// The outcome; serialized inline as `status` (+ `reason` on fail/skip).
45    #[serde(flatten)]
46    pub status: ProbeStatus,
47    /// Wall-clock time the probe took.
48    pub elapsed_ms: u64,
49    /// Remediation hint shown on failure. Must never contain secrets.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub hint: Option<String>,
52}
53
54impl Probe {
55    /// A passing probe.
56    pub fn pass(name: &'static str, elapsed: Duration) -> Self {
57        Self {
58            name,
59            status: ProbeStatus::Pass,
60            elapsed_ms: elapsed.as_millis() as u64,
61            hint: None,
62        }
63    }
64
65    /// A failing probe with a reason and no hint.
66    pub fn fail(name: &'static str, elapsed: Duration, reason: impl Into<String>) -> Self {
67        Self {
68            name,
69            status: ProbeStatus::Fail {
70                reason: reason.into(),
71            },
72            elapsed_ms: elapsed.as_millis() as u64,
73            hint: None,
74        }
75    }
76
77    /// A failing probe with a reason and a remediation hint.
78    pub fn fail_hint(
79        name: &'static str,
80        elapsed: Duration,
81        reason: impl Into<String>,
82        hint: impl Into<String>,
83    ) -> Self {
84        Self {
85            name,
86            status: ProbeStatus::Fail {
87                reason: reason.into(),
88            },
89            elapsed_ms: elapsed.as_millis() as u64,
90            hint: Some(hint.into()),
91        }
92    }
93
94    /// A skipped (not-applicable) probe.
95    pub fn skip(name: &'static str, reason: impl Into<String>) -> Self {
96        Self {
97            name,
98            status: ProbeStatus::Skip {
99                reason: reason.into(),
100            },
101            elapsed_ms: 0,
102            hint: None,
103        }
104    }
105}
106
107/// A connector's full preflight report.
108#[derive(Debug, Clone, Serialize, Default)]
109pub struct CheckReport {
110    /// The probes run, in order.
111    pub probes: Vec<Probe>,
112}
113
114impl CheckReport {
115    /// A report with a single probe.
116    pub fn single(probe: Probe) -> Self {
117        Self {
118            probes: vec![probe],
119        }
120    }
121
122    /// The default report for connectors that don't implement a probe.
123    pub fn not_implemented() -> Self {
124        Self::single(Probe::skip("check", "no check implemented"))
125    }
126
127    /// Number of `Fail` probes (used to compute the doctor exit code).
128    pub fn failed_count(&self) -> usize {
129        self.probes
130            .iter()
131            .filter(|p| matches!(p.status, ProbeStatus::Fail { .. }))
132            .count()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::time::Duration;
140
141    #[test]
142    fn pass_and_fail_constructors_set_status_and_elapsed() {
143        let p = Probe::pass("read", Duration::from_millis(42));
144        assert_eq!(p.name, "read");
145        assert!(matches!(p.status, ProbeStatus::Pass));
146        assert_eq!(p.elapsed_ms, 42);
147        assert!(p.hint.is_none());
148
149        let f = Probe::fail_hint("auth", Duration::from_millis(5), "bad token", "set TOKEN");
150        assert!(matches!(f.status, ProbeStatus::Fail { .. }));
151        assert_eq!(f.hint.as_deref(), Some("set TOKEN"));
152    }
153
154    #[test]
155    fn not_implemented_is_a_single_skip() {
156        let r = CheckReport::not_implemented();
157        assert_eq!(r.probes.len(), 1);
158        assert!(matches!(r.probes[0].status, ProbeStatus::Skip { .. }));
159        assert_eq!(r.failed_count(), 0);
160    }
161
162    #[test]
163    fn failed_count_counts_only_fail() {
164        let r = CheckReport {
165            probes: vec![
166                Probe::pass("a", Duration::ZERO),
167                Probe::fail("b", Duration::ZERO, "x"),
168                Probe::skip("c", "n/a"),
169                Probe::fail("d", Duration::ZERO, "y"),
170            ],
171        };
172        assert_eq!(r.failed_count(), 2);
173    }
174
175    #[test]
176    fn probe_serializes_status_inline() {
177        let p = Probe::fail("auth", Duration::from_millis(1), "nope");
178        let v = serde_json::to_value(&p).unwrap();
179        assert_eq!(v["name"], "auth");
180        assert_eq!(v["status"], "fail");
181        assert_eq!(v["reason"], "nope");
182        assert_eq!(v["elapsed_ms"], 1);
183    }
184}