1pub const DEVBOY_CI_ENV: &str = "DEVBOY_CI";
33
34pub const CI_HEURISTIC_VARS: &[&str] = &["CI", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"];
39
40#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum CiActivation {
47 CliFlag,
49 EnvVar {
51 value: String,
53 },
54 ContextConfig,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct CiDetection {
61 pub active: bool,
63 pub activation: Option<CiActivation>,
65 pub heuristic_signals: Vec<String>,
70}
71
72impl CiDetection {
73 pub fn heuristic_without_explicit(&self) -> bool {
77 !self.active && !self.heuristic_signals.is_empty()
78 }
79
80 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
95pub 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
134fn 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
149fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct CiPolicy {
181 pub prefer_env_store: bool,
184 pub skip_not_installed_silently: bool,
189 pub refuse_local_vault_unlock: bool,
192 pub refuse_biometric_sources: bool,
196 pub emit_decisions_as_info: bool,
200}
201
202impl CiPolicy {
203 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 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#[cfg(test)]
242mod tests {
243 use super::*;
244
245 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 #[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 #[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 #[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 #[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 assert!(d.heuristic_signals.contains(&"CI".into()));
412 assert!(d.heuristic_signals.contains(&"GITHUB_ACTIONS".into()));
413 assert!(!d.heuristic_without_explicit());
416 assert!(d.doctor_notice().is_none());
417 },
418 );
419 }
420
421 #[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}