1use std::io::IsTerminal;
9
10use thiserror::Error;
11
12use crate::manifest::Manifest;
13
14#[derive(Debug, Error)]
16pub enum GuardError {
17 #[error("missing required env var `{key}` (manifest expects `{expected}`); set it with: export {key}={expected}")]
18 EnvMissing { key: String, expected: String },
19 #[error("env var `{key}` = `{actual}` does not match manifest requirement `{expected}`")]
20 EnvMismatch {
21 key: String,
22 expected: String,
23 actual: String,
24 },
25 #[error("`{group}` requires confirmation but stdin is not a TTY; pass --yes to run non-interactively")]
26 NonInteractiveRefuse { group: String },
27 #[error("user declined to proceed with `{group} {extension}`")]
28 UserDeclined { group: String, extension: String },
29}
30
31pub fn print_banner(manifest: &Manifest) {
33 if let Some(banner) = &manifest.banner {
34 eprintln!("{banner}");
35 }
36}
37
38pub fn check_requires_env(manifest: &Manifest) -> Result<(), GuardError> {
40 for (key, expected) in &manifest.requires_env {
41 match std::env::var(key) {
42 Ok(actual) if actual == *expected => {}
43 Ok(actual) => {
44 return Err(GuardError::EnvMismatch {
45 key: key.clone(),
46 expected: expected.clone(),
47 actual,
48 })
49 }
50 Err(_) => {
51 return Err(GuardError::EnvMissing {
52 key: key.clone(),
53 expected: expected.clone(),
54 })
55 }
56 }
57 }
58 Ok(())
59}
60
61pub fn run_confirm(
70 manifest: &Manifest,
71 group: &str,
72 extension: &str,
73 assume_yes: bool,
74 prompt: &dyn ConfirmPrompt,
75) -> Result<(), GuardError> {
76 if !manifest.confirm {
77 return Ok(());
78 }
79 if assume_yes {
80 return Ok(());
81 }
82 if !std::io::stdin().is_terminal() {
83 return Err(GuardError::NonInteractiveRefuse {
84 group: group.into(),
85 });
86 }
87 let message = format!("Run `qli {group} {extension}`?");
88 if prompt.ask(&message)? {
89 Ok(())
90 } else {
91 Err(GuardError::UserDeclined {
92 group: group.into(),
93 extension: extension.into(),
94 })
95 }
96}
97
98pub trait ConfirmPrompt {
102 fn ask(&self, message: &str) -> Result<bool, GuardError>;
105}
106
107#[derive(Debug, Default)]
109pub struct TtyConfirm;
110
111impl ConfirmPrompt for TtyConfirm {
112 fn ask(&self, message: &str) -> Result<bool, GuardError> {
113 match dialoguer::Confirm::new()
116 .with_prompt(message)
117 .default(false)
118 .interact()
119 {
120 Ok(answer) => Ok(answer),
121 Err(_) => Ok(false),
126 }
127 }
128}
129
130#[must_use]
132pub fn tty_confirm() -> TtyConfirm {
133 TtyConfirm
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn manifest_with(banner: Option<&str>, confirm: bool, env: &[(&str, &str)]) -> Manifest {
141 Manifest {
142 schema_version: 1,
143 description: "test".into(),
144 banner: banner.map(str::to_owned),
145 requires_env: env
146 .iter()
147 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
148 .collect(),
149 confirm,
150 audit_log: None,
151 secrets: Vec::new(),
152 }
153 }
154
155 #[test]
156 #[serial_test::serial]
157 fn check_requires_env_passes_when_match() {
158 std::env::set_var("QLI_TEST_GUARD_OK", "yes");
159 let m = manifest_with(None, false, &[("QLI_TEST_GUARD_OK", "yes")]);
160 check_requires_env(&m).unwrap();
161 std::env::remove_var("QLI_TEST_GUARD_OK");
162 }
163
164 #[test]
165 #[serial_test::serial]
166 fn check_requires_env_errors_when_missing() {
167 std::env::remove_var("QLI_TEST_GUARD_MISSING");
168 let m = manifest_with(None, false, &[("QLI_TEST_GUARD_MISSING", "yes")]);
169 let err = check_requires_env(&m).unwrap_err();
170 assert!(matches!(err, GuardError::EnvMissing { .. }));
171 }
172
173 #[test]
174 #[serial_test::serial]
175 fn check_requires_env_errors_when_mismatched() {
176 std::env::set_var("QLI_TEST_GUARD_MISMATCH", "no");
177 let m = manifest_with(None, false, &[("QLI_TEST_GUARD_MISMATCH", "yes")]);
178 let err = check_requires_env(&m).unwrap_err();
179 assert!(matches!(err, GuardError::EnvMismatch { .. }));
180 std::env::remove_var("QLI_TEST_GUARD_MISMATCH");
181 }
182
183 struct YesPrompt;
184 impl ConfirmPrompt for YesPrompt {
185 fn ask(&self, _message: &str) -> Result<bool, GuardError> {
186 Ok(true)
187 }
188 }
189
190 struct NoPrompt;
191 impl ConfirmPrompt for NoPrompt {
192 fn ask(&self, _message: &str) -> Result<bool, GuardError> {
193 Ok(false)
194 }
195 }
196
197 #[test]
198 fn run_confirm_skipped_when_disabled() {
199 let m = manifest_with(None, false, &[]);
200 run_confirm(&m, "dev", "hello", false, &YesPrompt).unwrap();
201 }
202
203 #[test]
204 fn run_confirm_skipped_when_assume_yes() {
205 let m = manifest_with(None, true, &[]);
206 run_confirm(&m, "dev", "hello", true, &NoPrompt).unwrap();
208 }
209
210 #[test]
211 fn run_confirm_declines_propagate() {
212 let m = manifest_with(None, true, &[]);
216 let err = run_confirm(&m, "dev", "hello", false, &NoPrompt).unwrap_err();
217 assert!(
218 matches!(
219 err,
220 GuardError::UserDeclined { .. } | GuardError::NonInteractiveRefuse { .. },
221 ),
222 "unexpected variant: {err:?}",
223 );
224 }
225}