use std::io::IsTerminal;
use thiserror::Error;
use crate::manifest::Manifest;
#[derive(Debug, Error)]
pub enum GuardError {
#[error("missing required env var `{key}` (manifest expects `{expected}`); set it with: export {key}={expected}")]
EnvMissing { key: String, expected: String },
#[error("env var `{key}` = `{actual}` does not match manifest requirement `{expected}`")]
EnvMismatch {
key: String,
expected: String,
actual: String,
},
#[error("`{group}` requires confirmation but stdin is not a TTY; pass --yes to run non-interactively")]
NonInteractiveRefuse { group: String },
#[error("user declined to proceed with `{group} {extension}`")]
UserDeclined { group: String, extension: String },
}
pub fn print_banner(manifest: &Manifest) {
if let Some(banner) = &manifest.banner {
eprintln!("{banner}");
}
}
pub fn check_requires_env(manifest: &Manifest) -> Result<(), GuardError> {
for (key, expected) in &manifest.requires_env {
match std::env::var(key) {
Ok(actual) if actual == *expected => {}
Ok(actual) => {
return Err(GuardError::EnvMismatch {
key: key.clone(),
expected: expected.clone(),
actual,
})
}
Err(_) => {
return Err(GuardError::EnvMissing {
key: key.clone(),
expected: expected.clone(),
})
}
}
}
Ok(())
}
pub fn run_confirm(
manifest: &Manifest,
group: &str,
extension: &str,
assume_yes: bool,
prompt: &dyn ConfirmPrompt,
) -> Result<(), GuardError> {
if !manifest.confirm {
return Ok(());
}
if assume_yes {
return Ok(());
}
if !std::io::stdin().is_terminal() {
return Err(GuardError::NonInteractiveRefuse {
group: group.into(),
});
}
let message = format!("Run `qli {group} {extension}`?");
if prompt.ask(&message)? {
Ok(())
} else {
Err(GuardError::UserDeclined {
group: group.into(),
extension: extension.into(),
})
}
}
pub trait ConfirmPrompt {
fn ask(&self, message: &str) -> Result<bool, GuardError>;
}
#[derive(Debug, Default)]
pub struct TtyConfirm;
impl ConfirmPrompt for TtyConfirm {
fn ask(&self, message: &str) -> Result<bool, GuardError> {
match dialoguer::Confirm::new()
.with_prompt(message)
.default(false)
.interact()
{
Ok(answer) => Ok(answer),
Err(_) => Ok(false),
}
}
}
#[must_use]
pub fn tty_confirm() -> TtyConfirm {
TtyConfirm
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(banner: Option<&str>, confirm: bool, env: &[(&str, &str)]) -> Manifest {
Manifest {
schema_version: 1,
description: "test".into(),
banner: banner.map(str::to_owned),
requires_env: env
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
confirm,
audit_log: None,
secrets: Vec::new(),
}
}
#[test]
#[serial_test::serial]
fn check_requires_env_passes_when_match() {
std::env::set_var("QLI_TEST_GUARD_OK", "yes");
let m = manifest_with(None, false, &[("QLI_TEST_GUARD_OK", "yes")]);
check_requires_env(&m).unwrap();
std::env::remove_var("QLI_TEST_GUARD_OK");
}
#[test]
#[serial_test::serial]
fn check_requires_env_errors_when_missing() {
std::env::remove_var("QLI_TEST_GUARD_MISSING");
let m = manifest_with(None, false, &[("QLI_TEST_GUARD_MISSING", "yes")]);
let err = check_requires_env(&m).unwrap_err();
assert!(matches!(err, GuardError::EnvMissing { .. }));
}
#[test]
#[serial_test::serial]
fn check_requires_env_errors_when_mismatched() {
std::env::set_var("QLI_TEST_GUARD_MISMATCH", "no");
let m = manifest_with(None, false, &[("QLI_TEST_GUARD_MISMATCH", "yes")]);
let err = check_requires_env(&m).unwrap_err();
assert!(matches!(err, GuardError::EnvMismatch { .. }));
std::env::remove_var("QLI_TEST_GUARD_MISMATCH");
}
struct YesPrompt;
impl ConfirmPrompt for YesPrompt {
fn ask(&self, _message: &str) -> Result<bool, GuardError> {
Ok(true)
}
}
struct NoPrompt;
impl ConfirmPrompt for NoPrompt {
fn ask(&self, _message: &str) -> Result<bool, GuardError> {
Ok(false)
}
}
#[test]
fn run_confirm_skipped_when_disabled() {
let m = manifest_with(None, false, &[]);
run_confirm(&m, "dev", "hello", false, &YesPrompt).unwrap();
}
#[test]
fn run_confirm_skipped_when_assume_yes() {
let m = manifest_with(None, true, &[]);
run_confirm(&m, "dev", "hello", true, &NoPrompt).unwrap();
}
#[test]
fn run_confirm_declines_propagate() {
let m = manifest_with(None, true, &[]);
let err = run_confirm(&m, "dev", "hello", false, &NoPrompt).unwrap_err();
assert!(
matches!(
err,
GuardError::UserDeclined { .. } | GuardError::NonInteractiveRefuse { .. },
),
"unexpected variant: {err:?}",
);
}
}