Skip to main content

qli_ext/
guard.rs

1//! Pre-spawn guards: banner, `requires_env`, confirm prompt.
2//!
3//! Each function corresponds to one numbered step in the Phase 1F dispatch
4//! sequence. They are split out so unit tests can exercise each gate
5//! independently and so failure paths return typed errors that the
6//! dispatcher surfaces uniformly.
7
8use std::io::IsTerminal;
9
10use thiserror::Error;
11
12use crate::manifest::Manifest;
13
14/// Errors raised by pre-spawn guard checks.
15#[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
31/// Print the manifest banner to stderr, if any. Step 1 of the guard chain.
32pub fn print_banner(manifest: &Manifest) {
33    if let Some(banner) = &manifest.banner {
34        eprintln!("{banner}");
35    }
36}
37
38/// Step 2: enforce every `requires_env` entry.
39pub 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
61/// Step 3: ask for confirmation if the manifest demands it.
62///
63/// `assume_yes` short-circuits the prompt (used by `--yes`). When stdin is
64/// not a TTY and `assume_yes` is false, the dispatcher refuses rather than
65/// silently proceeding.
66///
67/// `prompt` is injected so tests can drive a deterministic answer. Production
68/// callers pass [`tty_confirm`] which uses [`dialoguer::Confirm`].
69pub 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
98/// Confirm-prompt strategy. Production code uses [`tty_confirm`]; tests pass
99/// a stubbed implementation that returns a pre-set answer without touching
100/// stdin/stderr.
101pub trait ConfirmPrompt {
102    /// Ask the user the question and return `true` for affirmative.
103    /// Returning `Err` aborts the dispatch with a guard error.
104    fn ask(&self, message: &str) -> Result<bool, GuardError>;
105}
106
107/// TTY-backed implementation of [`ConfirmPrompt`] using `dialoguer`.
108#[derive(Debug, Default)]
109pub struct TtyConfirm;
110
111impl ConfirmPrompt for TtyConfirm {
112    fn ask(&self, message: &str) -> Result<bool, GuardError> {
113        // dialoguer prints to stderr and reads from stdin — both correct
114        // for a CLI tool whose stdout is reserved for data.
115        match dialoguer::Confirm::new()
116            .with_prompt(message)
117            .default(false)
118            .interact()
119        {
120            Ok(answer) => Ok(answer),
121            // dialoguer maps EOF / closed stdin to an error. Treat that as a
122            // decline so the dispatcher refuses instead of surfacing an
123            // I/O error. The non-TTY path is already gated above; this
124            // branch handles "TTY went away mid-prompt".
125            Err(_) => Ok(false),
126        }
127    }
128}
129
130/// Convenience constructor used by the dispatcher.
131#[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        // NoPrompt would fail; --yes must short-circuit before asking.
207        run_confirm(&m, "dev", "hello", true, &NoPrompt).unwrap();
208    }
209
210    #[test]
211    fn run_confirm_declines_propagate() {
212        // This test runs only when stdin is a TTY (i.e., locally, not in CI).
213        // When stdin is not a TTY, the function returns NonInteractiveRefuse
214        // before consulting the prompt, so we accept either decline path.
215        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}