Skip to main content

devboy_storage/
ci.rs

1//! Explicit CI-mode detection per [ADR-021] §8 ("CI mode (explicit,
2//! not heuristic)").
3//!
4//! ADR-021 deliberately separates *selection* from *detection*: CI
5//! behaviour is selected (env var, context flag, or `--ci`), and CI
6//! signals are merely *observed* and reported via `doctor`. This
7//! avoids two failure modes the previous draft suffered:
8//!
9//! - A developer running a CI script locally with `CI=1` exported
10//!   silently switching to env-store routing.
11//! - A CI runner that does not set the expected variables silently
12//!   staying on interactive routing and failing on the first
13//!   keychain-unlock prompt.
14//!
15//! This module is the data layer:
16//!
17//! - [`detect_ci_mode`] reads the explicit signals + the heuristic
18//!   signals and returns a [`CiDetection`].
19//! - [`CiPolicy`] expresses the *behavioural* rules CI mode flips on
20//!   (env-store first, skip `NotInstalled` silently, refuse
21//!   local-vault unlock, refuse `BIOMETRIC_PROMPT`).
22//!
23//! The actual orchestration that consumes the policy lives one
24//! layer up (the router, P11+). Splitting detection from
25//! orchestration keeps both halves trivial to unit test.
26//!
27//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
28
29/// Env var that explicitly opts into CI mode. Accepts `1` /
30/// `true` (case-insensitive) as truthy; `0` / `false` (or unset)
31/// as falsy.
32pub const DEVBOY_CI_ENV: &str = "DEVBOY_CI";
33
34/// Common env vars CI runners set. Their presence is *only* a
35/// heuristic signal — the router does not flip CI mode on them.
36/// Per ADR-021 §8 the only effect of these is the `doctor` notice
37/// when none of the explicit triggers fire.
38pub const CI_HEURISTIC_VARS: &[&str] = &["CI", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"];
39
40// =============================================================================
41// Detection result
42// =============================================================================
43
44/// How CI mode was activated. `None` when CI mode is inactive.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum CiActivation {
47    /// `--ci` was passed on the CLI.
48    CliFlag,
49    /// `DEVBOY_CI=<truthy>` was set in the environment.
50    EnvVar {
51        /// The verbatim value of `DEVBOY_CI` so logs can echo it.
52        value: String,
53    },
54    /// The active context declares `[runtime] ci = true`.
55    ContextConfig,
56}
57
58/// Outcome of [`detect_ci_mode`].
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CiDetection {
61    /// `true` iff CI mode is selected.
62    pub active: bool,
63    /// How CI mode was activated. `None` when inactive.
64    pub activation: Option<CiActivation>,
65    /// Heuristic signals (`CI`, `GITLAB_CI`, …) observed in the
66    /// environment. Reported regardless of the active state so
67    /// `doctor` can show "we saw CI signals, but you didn't opt
68    /// in."
69    pub heuristic_signals: Vec<String>,
70}
71
72impl CiDetection {
73    /// `true` when heuristic CI signals were observed but the user
74    /// did *not* opt in via any explicit trigger. Drives the
75    /// `doctor` notice.
76    pub fn heuristic_without_explicit(&self) -> bool {
77        !self.active && !self.heuristic_signals.is_empty()
78    }
79
80    /// Human-readable warning for `doctor` when heuristic signals
81    /// fire without an explicit opt-in. Returns `None` otherwise.
82    pub fn doctor_notice(&self) -> Option<String> {
83        if !self.heuristic_without_explicit() {
84            return None;
85        }
86        Some(format!(
87            "CI signals detected ({signals}) — but `{env}` is not set; routing falls back to interactive defaults. \
88             Pass `--ci` or export `{env}=1` to switch to CI routing.",
89            signals = self.heuristic_signals.join(", "),
90            env = DEVBOY_CI_ENV,
91        ))
92    }
93}
94
95// =============================================================================
96// Detection
97// =============================================================================
98
99/// Detect whether CI mode is active.
100///
101/// Priority:
102///
103/// 1. `cli_flag` — `--ci` on the command line.
104/// 2. `DEVBOY_CI=<truthy>` in the environment.
105/// 3. `context_ci` — `[runtime] ci = true` from the active context.
106///
107/// Heuristic signals (`CI`, `GITLAB_CI`, `GITHUB_ACTIONS`,
108/// `BUILDKITE`) are recorded in the result but *do not* flip CI
109/// mode on their own. This is the explicit/heuristic split the
110/// ADR insists on.
111///
112/// `cli_flag = false` and `context_ci = None` are the "no opinion"
113/// neutral inputs; CLI / context loaders pass concrete values
114/// when available.
115pub fn detect_ci_mode(cli_flag: bool, context_ci: Option<bool>) -> CiDetection {
116    let heuristic_signals = collect_heuristic_signals();
117    let env_active = read_explicit_env_value();
118    let activation = if cli_flag {
119        Some(CiActivation::CliFlag)
120    } else if let Some(value) = env_active {
121        Some(CiActivation::EnvVar { value })
122    } else if context_ci.unwrap_or(false) {
123        Some(CiActivation::ContextConfig)
124    } else {
125        None
126    };
127    CiDetection {
128        active: activation.is_some(),
129        activation,
130        heuristic_signals,
131    }
132}
133
134/// Read `DEVBOY_CI` and return the verbatim value when truthy,
135/// `None` otherwise.
136fn read_explicit_env_value() -> Option<String> {
137    let raw = std::env::var(DEVBOY_CI_ENV).ok()?;
138    if raw.is_empty() {
139        return None;
140    }
141    let lower = raw.to_lowercase();
142    if lower == "1" || lower == "true" {
143        Some(raw)
144    } else {
145        None
146    }
147}
148
149/// Collect heuristic CI vars currently set in the environment.
150fn collect_heuristic_signals() -> Vec<String> {
151    let mut out = Vec::new();
152    for var in CI_HEURISTIC_VARS {
153        if let Ok(v) = std::env::var(var)
154            && is_truthy(&v)
155        {
156            out.push((*var).to_owned());
157        }
158    }
159    out
160}
161
162fn is_truthy(s: &str) -> bool {
163    if s.is_empty() {
164        return false;
165    }
166    let lower = s.to_lowercase();
167    !(lower == "0" || lower == "false")
168}
169
170// =============================================================================
171// Policy
172// =============================================================================
173
174/// Behavioural rules CI mode flips on, per ADR-021 §8.
175///
176/// The router (P11+) consumes this and applies each flag at the
177/// matching decision point: which source to consult first, whether
178/// to emit a fallback warning, whether to invoke an unlock UI, etc.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct CiPolicy {
181    /// Promote `env-store` to the front of the resolution chain
182    /// regardless of the routing table.
183    pub prefer_env_store: bool,
184    /// Skip sources whose `is_available()` returns
185    /// [`SourceStatus::NotInstalled`](crate::source::SourceStatus::NotInstalled)
186    /// without surfacing the fallback as a warning. In interactive
187    /// mode the same case logs a `doctor` notice.
188    pub skip_not_installed_silently: bool,
189    /// Refuse to invoke the `local-vault` unlock UI — no PIN or
190    /// passphrase prompts in CI.
191    pub refuse_local_vault_unlock: bool,
192    /// Skip sources that declare
193    /// [`Capabilities::BIOMETRIC_PROMPT`](crate::source::Capabilities::BIOMETRIC_PROMPT)
194    /// — biometric unlock makes no sense in headless CI.
195    pub refuse_biometric_sources: bool,
196    /// Emit routing decisions at `info` level so a CI pipeline can
197    /// grep for surprises. Interactive mode uses `debug` so the
198    /// developer's terminal stays quiet.
199    pub emit_decisions_as_info: bool,
200}
201
202impl CiPolicy {
203    /// Policy when CI mode is active. Every field is the strict
204    /// CI behaviour from ADR-021 §8.
205    pub fn active() -> Self {
206        Self {
207            prefer_env_store: true,
208            skip_not_installed_silently: true,
209            refuse_local_vault_unlock: true,
210            refuse_biometric_sources: true,
211            emit_decisions_as_info: true,
212        }
213    }
214
215    /// Policy when CI mode is inactive — interactive defaults.
216    pub fn inactive() -> Self {
217        Self {
218            prefer_env_store: false,
219            skip_not_installed_silently: false,
220            refuse_local_vault_unlock: false,
221            refuse_biometric_sources: false,
222            emit_decisions_as_info: false,
223        }
224    }
225}
226
227impl From<&CiDetection> for CiPolicy {
228    fn from(d: &CiDetection) -> Self {
229        if d.active {
230            Self::active()
231        } else {
232            Self::inactive()
233        }
234    }
235}
236
237// =============================================================================
238// Tests
239// =============================================================================
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    /// Run `f` with all three explicit triggers off and the
246    /// heuristic vars cleared, so each test starts from a known
247    /// neutral environment.
248    fn neutral_env<F: FnOnce()>(f: F) {
249        let clears: Vec<(&str, Option<&str>)> = std::iter::once((DEVBOY_CI_ENV, None))
250            .chain(CI_HEURISTIC_VARS.iter().map(|v| (*v, None)))
251            .collect();
252        temp_env::with_vars(clears, f);
253    }
254
255    fn with_env<F: FnOnce()>(extra: Vec<(&str, Option<&str>)>, f: F) {
256        let mut all: Vec<(&str, Option<&str>)> = std::iter::once((DEVBOY_CI_ENV, None))
257            .chain(CI_HEURISTIC_VARS.iter().map(|v| (*v, None)))
258            .collect();
259        all.extend(extra);
260        temp_env::with_vars(all, f);
261    }
262
263    // -- Inactive baseline -----------------------------------------
264
265    #[test]
266    fn empty_environment_is_inactive_with_no_heuristics() {
267        neutral_env(|| {
268            let d = detect_ci_mode(false, None);
269            assert!(!d.active);
270            assert!(d.activation.is_none());
271            assert!(d.heuristic_signals.is_empty());
272            assert!(!d.heuristic_without_explicit());
273            assert!(d.doctor_notice().is_none());
274        });
275    }
276
277    // -- Explicit triggers -----------------------------------------
278
279    #[test]
280    fn devboy_ci_eq_1_activates() {
281        with_env(vec![(DEVBOY_CI_ENV, Some("1"))], || {
282            let d = detect_ci_mode(false, None);
283            assert!(d.active);
284            assert_eq!(
285                d.activation,
286                Some(CiActivation::EnvVar { value: "1".into() })
287            );
288        });
289    }
290
291    #[test]
292    fn devboy_ci_eq_true_case_insensitive_activates() {
293        for v in &["true", "TRUE", "True"] {
294            with_env(vec![(DEVBOY_CI_ENV, Some(v))], || {
295                let d = detect_ci_mode(false, None);
296                assert!(d.active, "expected active for DEVBOY_CI={v:?}");
297            });
298        }
299    }
300
301    #[test]
302    fn devboy_ci_eq_0_or_false_does_not_activate() {
303        for v in &["0", "false", "FALSE", ""] {
304            with_env(vec![(DEVBOY_CI_ENV, Some(v))], || {
305                let d = detect_ci_mode(false, None);
306                assert!(
307                    !d.active,
308                    "expected inactive for DEVBOY_CI={v:?}, got {d:?}"
309                );
310            });
311        }
312    }
313
314    #[test]
315    fn cli_flag_activates() {
316        neutral_env(|| {
317            let d = detect_ci_mode(true, None);
318            assert!(d.active);
319            assert_eq!(d.activation, Some(CiActivation::CliFlag));
320        });
321    }
322
323    #[test]
324    fn context_config_activates() {
325        neutral_env(|| {
326            let d = detect_ci_mode(false, Some(true));
327            assert!(d.active);
328            assert_eq!(d.activation, Some(CiActivation::ContextConfig));
329        });
330    }
331
332    #[test]
333    fn context_config_false_does_not_activate() {
334        neutral_env(|| {
335            let d = detect_ci_mode(false, Some(false));
336            assert!(!d.active);
337        });
338    }
339
340    // -- Priority --------------------------------------------------
341
342    #[test]
343    fn cli_flag_wins_over_env_var() {
344        with_env(vec![(DEVBOY_CI_ENV, Some("1"))], || {
345            let d = detect_ci_mode(true, None);
346            assert_eq!(d.activation, Some(CiActivation::CliFlag));
347        });
348    }
349
350    #[test]
351    fn env_var_wins_over_context() {
352        with_env(vec![(DEVBOY_CI_ENV, Some("1"))], || {
353            let d = detect_ci_mode(false, Some(true));
354            assert_eq!(
355                d.activation,
356                Some(CiActivation::EnvVar { value: "1".into() })
357            );
358        });
359    }
360
361    // -- Heuristic detection ---------------------------------------
362
363    #[test]
364    fn ci_eq_true_alone_is_heuristic_signal_only() {
365        with_env(vec![("CI", Some("true"))], || {
366            let d = detect_ci_mode(false, None);
367            assert!(!d.active, "CI alone must NOT flip CI mode");
368            assert_eq!(d.heuristic_signals, vec!["CI".to_owned()]);
369            assert!(d.heuristic_without_explicit());
370            let notice = d.doctor_notice().unwrap();
371            assert!(notice.contains("CI signals detected"));
372            assert!(notice.contains("CI"));
373            assert!(notice.contains(DEVBOY_CI_ENV));
374        });
375    }
376
377    #[test]
378    fn ci_eq_false_does_not_count_as_signal() {
379        with_env(vec![("CI", Some("false"))], || {
380            let d = detect_ci_mode(false, None);
381            assert!(d.heuristic_signals.is_empty());
382        });
383    }
384
385    #[test]
386    fn each_heuristic_var_is_recognised() {
387        for var in CI_HEURISTIC_VARS {
388            with_env(vec![(var, Some("1"))], || {
389                let d = detect_ci_mode(false, None);
390                assert!(
391                    d.heuristic_signals.contains(&(*var).to_owned()),
392                    "expected {var} to be a recognised heuristic, got signals {:?}",
393                    d.heuristic_signals,
394                );
395            });
396        }
397    }
398
399    #[test]
400    fn explicit_trigger_silences_doctor_notice_even_with_heuristics() {
401        with_env(
402            vec![
403                (DEVBOY_CI_ENV, Some("1")),
404                ("CI", Some("true")),
405                ("GITHUB_ACTIONS", Some("true")),
406            ],
407            || {
408                let d = detect_ci_mode(false, None);
409                assert!(d.active);
410                // Heuristics are still listed for transparency...
411                assert!(d.heuristic_signals.contains(&"CI".into()));
412                assert!(d.heuristic_signals.contains(&"GITHUB_ACTIONS".into()));
413                // ...but the doctor notice fires only when there's
414                // no explicit trigger.
415                assert!(!d.heuristic_without_explicit());
416                assert!(d.doctor_notice().is_none());
417            },
418        );
419    }
420
421    // -- Policy ----------------------------------------------------
422
423    #[test]
424    fn ci_policy_active_flips_every_rule() {
425        let p = CiPolicy::active();
426        assert!(p.prefer_env_store);
427        assert!(p.skip_not_installed_silently);
428        assert!(p.refuse_local_vault_unlock);
429        assert!(p.refuse_biometric_sources);
430        assert!(p.emit_decisions_as_info);
431    }
432
433    #[test]
434    fn ci_policy_inactive_is_all_off() {
435        let p = CiPolicy::inactive();
436        assert!(!p.prefer_env_store);
437        assert!(!p.skip_not_installed_silently);
438        assert!(!p.refuse_local_vault_unlock);
439        assert!(!p.refuse_biometric_sources);
440        assert!(!p.emit_decisions_as_info);
441    }
442
443    #[test]
444    fn ci_policy_from_detection_picks_active_when_active() {
445        let active = CiDetection {
446            active: true,
447            activation: Some(CiActivation::CliFlag),
448            heuristic_signals: vec![],
449        };
450        assert_eq!(CiPolicy::from(&active), CiPolicy::active());
451    }
452
453    #[test]
454    fn ci_policy_from_detection_picks_inactive_when_inactive() {
455        let inactive = CiDetection {
456            active: false,
457            activation: None,
458            heuristic_signals: vec!["CI".into()],
459        };
460        assert_eq!(CiPolicy::from(&inactive), CiPolicy::inactive());
461    }
462}