Skip to main content

apcore_cli/
approval.rs

1// apcore-cli — Human-in-the-loop approval gate.
2// Protocol spec: FE-05 (check_approval, ApprovalError)
3
4use thiserror::Error;
5
6// ---------------------------------------------------------------------------
7// Error types  (Task 1: error-types)
8// ---------------------------------------------------------------------------
9
10/// Errors returned by the approval gate.
11/// All variants map to exit code 46 (EXIT_APPROVAL_DENIED).
12#[derive(Debug, Error)]
13pub enum ApprovalError {
14    /// The operator denied execution.
15    #[error("approval denied for module '{module_id}'")]
16    Denied { module_id: String },
17
18    /// No interactive TTY is available to prompt the user.
19    #[error("no interactive terminal available for module '{module_id}'")]
20    NonInteractive { module_id: String },
21
22    /// The approval prompt timed out.
23    #[error("approval timed out after {seconds}s for module '{module_id}'")]
24    Timeout { module_id: String, seconds: u64 },
25}
26
27// ---------------------------------------------------------------------------
28// Annotation extraction helpers  (Task 2: annotation-extraction)
29// ---------------------------------------------------------------------------
30
31/// Returns true only when `module_def["annotations"]["requires_approval"]`
32/// is exactly `Value::Bool(true)`. Strings, integers, and null all return false.
33fn get_requires_approval(module_def: &serde_json::Value) -> bool {
34    module_def
35        .get("annotations")
36        .and_then(|a| a.get("requires_approval"))
37        .and_then(|v| v.as_bool())
38        == Some(true)
39}
40
41/// Returns the custom approval message if present and non-empty, otherwise
42/// the default: "Module '{module_id}' requires approval to execute."
43fn get_approval_message(module_def: &serde_json::Value, module_id: &str) -> String {
44    module_def
45        .get("annotations")
46        .and_then(|a| a.get("approval_message"))
47        .and_then(|v| v.as_str())
48        .filter(|s| !s.is_empty())
49        .map(|s| s.to_string())
50        .unwrap_or_else(|| format!("Module '{module_id}' requires approval to execute."))
51}
52
53/// Returns `module_def["module_id"]` or `module_def["canonical_id"]` if
54/// either is a string, otherwise `"unknown"`.
55fn get_module_id(module_def: &serde_json::Value) -> String {
56    module_def
57        .get("module_id")
58        .or_else(|| module_def.get("canonical_id"))
59        .and_then(|v| v.as_str())
60        .unwrap_or("unknown")
61        .to_string()
62}
63
64// ---------------------------------------------------------------------------
65// Prompt with injectable reader  (Task 5: tty-prompt-timeout)
66// ---------------------------------------------------------------------------
67
68/// Internal prompt implementation with an injectable reader function.
69/// This enables unit testing without a real TTY.
70///
71/// `reader` is called on a blocking thread via `spawn_blocking`; it must
72/// read one line from whatever source is appropriate (real stdin in
73/// production, a mock in tests).
74///
75/// On timeout the blocking thread remains parked — this is acceptable
76/// because the process exits immediately with code 46 after this function
77/// returns `Err(ApprovalError::Timeout)`.
78async fn prompt_with_reader<F>(
79    module_id: &str,
80    message: &str,
81    timeout_secs: u64,
82    reader: F,
83) -> Result<(), ApprovalError>
84where
85    F: FnOnce() -> std::io::Result<String> + Send + 'static,
86{
87    // Display message and prompt to stderr.
88    eprint!("{}\nProceed? [y/N]: ", message);
89    // Flush stderr so the prompt appears before blocking.
90    use std::io::Write;
91    let _ = std::io::stderr().flush();
92
93    let module_id_owned = module_id.to_string();
94    let read_handle = tokio::task::spawn_blocking(reader);
95
96    tokio::select! {
97        result = read_handle => {
98            match result {
99                Ok(Ok(line)) => {
100                    let input = line.trim().to_lowercase();
101                    if input == "y" || input == "yes" {
102                        tracing::info!(
103                            "User approved execution of module '{}'.",
104                            module_id_owned
105                        );
106                        Ok(())
107                    } else {
108                        tracing::warn!(
109                            "Approval rejected by user for module '{}'.",
110                            module_id_owned
111                        );
112                        eprintln!("Error: Approval denied.");
113                        Err(ApprovalError::Denied { module_id: module_id_owned })
114                    }
115                }
116                Ok(Err(io_err)) => {
117                    // stdin closed (EOF) without input — treat as denial.
118                    tracing::warn!(
119                        "stdin read error for module '{}': {}",
120                        module_id_owned,
121                        io_err
122                    );
123                    eprintln!("Error: Approval denied.");
124                    Err(ApprovalError::Denied { module_id: module_id_owned })
125                }
126                Err(join_err) => {
127                    // spawn_blocking task panicked.
128                    tracing::error!("spawn_blocking panicked: {}", join_err);
129                    Err(ApprovalError::Denied { module_id: module_id_owned })
130                }
131            }
132        }
133        _ = tokio::time::sleep(tokio::time::Duration::from_secs(timeout_secs)) => {
134            tracing::warn!(
135                "Approval timed out after {}s for module '{}'.",
136                timeout_secs,
137                module_id_owned
138            );
139            eprintln!("Error: Approval prompt timed out after {} seconds.", timeout_secs);
140            Err(ApprovalError::Timeout {
141                module_id: module_id_owned,
142                seconds: timeout_secs,
143            })
144        }
145    }
146}
147
148/// Production prompt: uses real stdin with a 60-second timeout.
149async fn prompt_with_timeout(
150    module_id: &str,
151    message: &str,
152    timeout_secs: u64,
153) -> Result<(), ApprovalError> {
154    prompt_with_reader(module_id, message, timeout_secs, || {
155        let mut line = String::new();
156        std::io::stdin().read_line(&mut line)?;
157        Ok(line)
158    })
159    .await
160}
161
162// ---------------------------------------------------------------------------
163// check_approval_with_tty  (Tasks 3, 4, 5)
164// ---------------------------------------------------------------------------
165
166/// Default approval prompt timeout in seconds.
167pub const DEFAULT_APPROVAL_TIMEOUT_SECS: u64 = 60;
168
169/// Internal implementation accepting `is_tty` for testability.
170///
171/// Delegates to [`check_approval_with_tty_timeout`] with
172/// [`DEFAULT_APPROVAL_TIMEOUT_SECS`] so existing callers keep the 60-second
173/// default.
174pub async fn check_approval_with_tty(
175    module_def: &serde_json::Value,
176    auto_approve: bool,
177    is_tty: bool,
178) -> Result<(), ApprovalError> {
179    check_approval_with_tty_timeout(
180        module_def,
181        auto_approve,
182        is_tty,
183        DEFAULT_APPROVAL_TIMEOUT_SECS,
184    )
185    .await
186}
187
188/// Testable gate that honors a configurable timeout.
189///
190/// Decision order:
191/// 1. Skip entirely if `requires_approval` is not strict bool `true`.
192/// 2. Bypass if `auto_approve == true` (--yes flag).
193/// 3. Bypass if `APCORE_CLI_AUTO_APPROVE == "1"` (exact match).
194/// 4. Reject if `!is_tty` (NonInteractive).
195/// 5. Prompt interactively with `timeout_secs` timeout.
196pub async fn check_approval_with_tty_timeout(
197    module_def: &serde_json::Value,
198    auto_approve: bool,
199    is_tty: bool,
200    timeout_secs: u64,
201) -> Result<(), ApprovalError> {
202    if !get_requires_approval(module_def) {
203        return Ok(());
204    }
205
206    let module_id = get_module_id(module_def);
207
208    // Bypass: --yes flag (highest priority)
209    if auto_approve {
210        tracing::info!(
211            "Approval bypassed via --yes flag for module '{}'.",
212            module_id
213        );
214        return Ok(());
215    }
216
217    // Bypass: APCORE_CLI_AUTO_APPROVE env var
218    match std::env::var("APCORE_CLI_AUTO_APPROVE").as_deref() {
219        Ok("1") => {
220            tracing::info!(
221                "Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '{}'.",
222                module_id
223            );
224            return Ok(());
225        }
226        Ok("") | Err(_) => {
227            // Not set or empty — fall through silently.
228        }
229        Ok(val) => {
230            // D10-009 cross-SDK parity: emit to stderr (matching TS and
231            // Python) so the user-visible channel is consistent regardless
232            // of whether a tracing subscriber is configured. Spec at
233            // apcore-cli/docs/features/approval-gate.md:122 says "Log
234            // WARNING" which was ambiguous; the three SDKs now agree on
235            // stderr.
236            eprintln!(
237                "Warning: APCORE_CLI_AUTO_APPROVE is set to '{val}', expected '1'. Ignoring."
238            );
239        }
240    }
241
242    // Non-TTY rejection
243    if !is_tty {
244        eprintln!(
245            "Error: Module '{}' requires approval but no interactive terminal is available. \
246             Use --yes or set APCORE_CLI_AUTO_APPROVE=1 to bypass.",
247            module_id
248        );
249        tracing::error!(
250            "Non-interactive environment, no bypass provided for module '{}'.",
251            module_id
252        );
253        return Err(ApprovalError::NonInteractive { module_id });
254    }
255
256    // TTY prompt with caller-specified timeout.
257    let message = get_approval_message(module_def, &module_id);
258    prompt_with_timeout(&module_id, &message, timeout_secs).await
259}
260
261// ---------------------------------------------------------------------------
262// check_approval — public API  (Task 5)
263// ---------------------------------------------------------------------------
264
265/// Gate module execution behind an interactive approval prompt.
266///
267/// Returns `Ok(())` immediately if `requires_approval` is not `true`.
268/// Bypasses the prompt if `auto_approve` is `true` or the env var
269/// `APCORE_CLI_AUTO_APPROVE` is set to exactly `"1"`.
270/// Returns `Err(ApprovalError::NonInteractive)` if stdin is not a TTY.
271/// Otherwise prompts the user with a per-call timeout.
272///
273/// `timeout` accepts `Option<u64>` for cross-SDK parity with Python and TS,
274/// which both expose `(module_def, auto_approve, timeout)` 3-arg signatures.
275/// `None` falls back to [`DEFAULT_APPROVAL_TIMEOUT_SECS`]; `Some(n)` selects
276/// an explicit window. Internally delegates to
277/// [`check_approval_with_timeout`] (Rust convention: `_with_*` suffix for the
278/// concrete-parameter variant).
279///
280/// # Errors
281/// * `ApprovalError::NonInteractive` — stdin is not an interactive terminal
282/// * `ApprovalError::Denied`         — user typed anything other than `y`/`yes`
283/// * `ApprovalError::Timeout`        — prompt timed out
284pub async fn check_approval(
285    module_def: &serde_json::Value,
286    auto_approve: bool,
287    timeout: Option<u64>,
288) -> Result<(), ApprovalError> {
289    let secs = timeout.unwrap_or(DEFAULT_APPROVAL_TIMEOUT_SECS);
290    check_approval_with_timeout(module_def, auto_approve, secs).await
291}
292
293/// Configurable-timeout variant of [`check_approval`]. Resolve the timeout
294/// from the `--approval-timeout` CLI flag or `cli.approval_timeout` config
295/// before calling.
296pub async fn check_approval_with_timeout(
297    module_def: &serde_json::Value,
298    auto_approve: bool,
299    timeout_secs: u64,
300) -> Result<(), ApprovalError> {
301    use std::io::IsTerminal;
302    let is_tty = std::io::stdin().is_terminal();
303    check_approval_with_tty_timeout(module_def, auto_approve, is_tty, timeout_secs).await
304}
305
306// ---------------------------------------------------------------------------
307// ApprovalResult — apcore ApprovalHandler protocol shape (D10-006)
308// ---------------------------------------------------------------------------
309
310/// Outcome of an [`CliApprovalHandler::request_approval`] / `check_approval`
311/// invocation. Mirrors the apcore ApprovalHandler protocol shape that Python
312/// and TypeScript SDKs return as a `dict { status, approved_by | reason }`
313/// duck-typed against `ApprovalResult`.
314///
315/// Cross-SDK parity (D10-006, 2026-04-26): previously the Rust handler
316/// returned `Result<(), ApprovalError>`, which meant a Rust handler instance
317/// could not satisfy the apcore protocol callback signature. Callers of the
318/// standalone [`check_approval`] / [`check_approval_with_timeout`] still get
319/// the typed-error form for compose-friendly error chaining; the
320/// protocol-callback path goes through `CliApprovalHandler` and returns
321/// `ApprovalResult`.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum ApprovalStatus {
324    /// User (or a bypass mechanism) authorised the call.
325    Approved,
326    /// User denied, or no TTY was available.
327    Rejected,
328    /// The interactive prompt did not receive a response in time.
329    Timeout,
330}
331
332/// Result of an approval request. Equivalent to the Python/TS protocol shape
333/// `{ status, approved_by, reason }`.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct ApprovalResult {
336    /// Approval state.
337    pub status: ApprovalStatus,
338    /// Identifier of the approver when `status == Approved`. Standard values
339    /// match Python parity: `"auto_approve"` (--yes flag),
340    /// `"env_auto_approve"` (`APCORE_CLI_AUTO_APPROVE=1`), or `"tty_user"`
341    /// (interactive prompt). `None` for non-Approved results.
342    pub approved_by: Option<String>,
343    /// Human-readable reason when `status == Rejected` or `Timeout`.
344    /// `None` for `Approved` results.
345    pub reason: Option<String>,
346}
347
348impl ApprovalResult {
349    /// Convenience constructor for the approved-via-flag case.
350    pub fn approved_via(approved_by: impl Into<String>) -> Self {
351        Self {
352            status: ApprovalStatus::Approved,
353            approved_by: Some(approved_by.into()),
354            reason: None,
355        }
356    }
357
358    /// Convenience constructor for rejection.
359    pub fn rejected(reason: impl Into<String>) -> Self {
360        Self {
361            status: ApprovalStatus::Rejected,
362            approved_by: None,
363            reason: Some(reason.into()),
364        }
365    }
366
367    /// Convenience constructor for timeout.
368    pub fn timed_out(reason: impl Into<String>) -> Self {
369        Self {
370            status: ApprovalStatus::Timeout,
371            approved_by: None,
372            reason: Some(reason.into()),
373        }
374    }
375}
376
377// ---------------------------------------------------------------------------
378// CliApprovalHandler — ApprovalHandler protocol adapter
379// ---------------------------------------------------------------------------
380
381/// Implements the apcore ApprovalHandler protocol so SDK consumers can pass
382/// a CLI-backed handler to `executor.set_approval_handler(handler)`.
383///
384/// `request_approval` and `check_approval` return [`ApprovalResult`] for
385/// cross-SDK protocol parity (D10-006). The standalone module-level
386/// `check_approval` / `check_approval_with_timeout` continue to return
387/// `Result<(), ApprovalError>` for callers that prefer typed-error
388/// semantics in pure Rust code.
389pub struct CliApprovalHandler {
390    /// Auto-approve without prompting the user.
391    pub auto_approve: bool,
392    /// Maximum seconds to wait for interactive approval (0 = wait indefinitely).
393    pub timeout_secs: u64,
394}
395
396impl CliApprovalHandler {
397    /// Create a new handler.
398    pub fn new(auto_approve: bool, timeout_secs: u64) -> Self {
399        Self {
400            auto_approve,
401            timeout_secs,
402        }
403    }
404
405    /// Request approval for a module, using the CLI interactive prompt.
406    ///
407    /// Returns an [`ApprovalResult`] matching the Python/TS protocol shape:
408    /// `Approved/auto_approve` for the `--yes` flag bypass,
409    /// `Approved/env_auto_approve` for `APCORE_CLI_AUTO_APPROVE=1`,
410    /// `Approved/tty_user` for an interactive yes,
411    /// `Rejected` for non-TTY or user denial,
412    /// `Timeout` when the prompt window expires.
413    ///
414    /// Mirrors the bypass-priority and message logic of
415    /// [`check_approval_with_tty_timeout`] but folded into the protocol-shape
416    /// return path.
417    pub async fn request_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
418        let module_id = get_module_id(module_def);
419
420        // Skip if approval is not required.
421        if !get_requires_approval(module_def) {
422            return ApprovalResult::approved_via("not_required");
423        }
424
425        // Bypass: --yes flag (highest priority).
426        if self.auto_approve {
427            tracing::info!(
428                "Approval bypassed via --yes flag for module '{}'.",
429                module_id
430            );
431            return ApprovalResult::approved_via("auto_approve");
432        }
433
434        // Bypass: APCORE_CLI_AUTO_APPROVE env var.
435        match std::env::var("APCORE_CLI_AUTO_APPROVE").as_deref() {
436            Ok("1") => {
437                tracing::info!(
438                    "Approval bypassed via APCORE_CLI_AUTO_APPROVE for module '{}'.",
439                    module_id
440                );
441                return ApprovalResult::approved_via("env_auto_approve");
442            }
443            Ok("") | Err(_) => {}
444            Ok(val) => {
445                tracing::warn!(
446                    "APCORE_CLI_AUTO_APPROVE is set to '{}', expected '1'. Ignoring.",
447                    val
448                );
449            }
450        }
451
452        // Non-TTY rejection.
453        use std::io::IsTerminal;
454        if !std::io::stdin().is_terminal() {
455            tracing::error!(
456                "Non-interactive environment, no bypass provided for module '{}'.",
457                module_id
458            );
459            return ApprovalResult::rejected(format!(
460                "Module '{module_id}' requires approval but no interactive terminal is available. \
461                 Use --yes or set APCORE_CLI_AUTO_APPROVE=1 to bypass."
462            ));
463        }
464
465        // TTY prompt with caller-specified timeout.
466        let message = get_approval_message(module_def, &module_id);
467        match prompt_with_timeout(&module_id, &message, self.timeout_secs).await {
468            Ok(()) => ApprovalResult::approved_via("tty_user"),
469            Err(ApprovalError::Timeout { seconds, .. }) => ApprovalResult::timed_out(format!(
470                "Approval prompt timed out after {seconds} seconds."
471            )),
472            Err(_) => ApprovalResult::rejected("User denied approval".to_string()),
473        }
474    }
475
476    /// Alias for [`request_approval`] (matches the Python / TypeScript
477    /// `check_approval` method name on the handler).
478    pub async fn check_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
479        self.request_approval(module_def).await
480    }
481}
482
483// Type aliases so callers can match by variant-like name (parity with Python/TS).
484/// Alias for [`ApprovalError`] — the denial variant. Use `ApprovalError::Denied` to match.
485pub type ApprovalDeniedError = ApprovalError;
486/// Alias for [`ApprovalError`] — the timeout variant. Use `ApprovalError::Timeout` to match.
487pub type ApprovalTimeoutError = ApprovalError;
488
489// ---------------------------------------------------------------------------
490// Unit tests
491// ---------------------------------------------------------------------------
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use serde_json::json;
497    use std::sync::Mutex;
498
499    /// Global mutex serializes all tests that read or write env vars.
500    /// Env vars are process-global; parallel tokio tests will race without this.
501    static ENV_MUTEX: Mutex<()> = Mutex::new(());
502
503    // --- Task 1: error-types ---
504
505    #[test]
506    fn error_denied_display() {
507        let e = ApprovalError::Denied {
508            module_id: "my-module".into(),
509        };
510        assert_eq!(e.to_string(), "approval denied for module 'my-module'");
511    }
512
513    #[test]
514    fn error_non_interactive_display() {
515        let e = ApprovalError::NonInteractive {
516            module_id: "my-module".into(),
517        };
518        assert_eq!(
519            e.to_string(),
520            "no interactive terminal available for module 'my-module'"
521        );
522    }
523
524    #[test]
525    fn error_timeout_display() {
526        let e = ApprovalError::Timeout {
527            module_id: "my-module".into(),
528            seconds: 60,
529        };
530        assert_eq!(
531            e.to_string(),
532            "approval timed out after 60s for module 'my-module'"
533        );
534    }
535
536    #[test]
537    fn error_variants_are_debug() {
538        let d = format!(
539            "{:?}",
540            ApprovalError::Denied {
541                module_id: "x".into()
542            }
543        );
544        assert!(d.contains("Denied"));
545    }
546
547    // --- Task 2: annotation-extraction ---
548
549    #[test]
550    fn requires_approval_true_returns_true() {
551        let v = json!({"annotations": {"requires_approval": true}});
552        assert!(get_requires_approval(&v));
553    }
554
555    #[test]
556    fn requires_approval_false_returns_false() {
557        let v = json!({"annotations": {"requires_approval": false}});
558        assert!(!get_requires_approval(&v));
559    }
560
561    #[test]
562    fn requires_approval_string_true_returns_false() {
563        let v = json!({"annotations": {"requires_approval": "true"}});
564        assert!(!get_requires_approval(&v));
565    }
566
567    #[test]
568    fn requires_approval_int_one_returns_false() {
569        let v = json!({"annotations": {"requires_approval": 1}});
570        assert!(!get_requires_approval(&v));
571    }
572
573    #[test]
574    fn requires_approval_null_returns_false() {
575        let v = json!({"annotations": {"requires_approval": null}});
576        assert!(!get_requires_approval(&v));
577    }
578
579    #[test]
580    fn requires_approval_absent_returns_false() {
581        let v = json!({"annotations": {}});
582        assert!(!get_requires_approval(&v));
583    }
584
585    #[test]
586    fn requires_approval_no_annotations_returns_false() {
587        let v = json!({});
588        assert!(!get_requires_approval(&v));
589    }
590
591    #[test]
592    fn requires_approval_annotations_null_returns_false() {
593        let v = json!({"annotations": null});
594        assert!(!get_requires_approval(&v));
595    }
596
597    #[test]
598    fn approval_message_custom() {
599        let v = json!({"annotations": {"approval_message": "Please confirm."}});
600        assert_eq!(get_approval_message(&v, "mod-x"), "Please confirm.");
601    }
602
603    #[test]
604    fn approval_message_default_when_absent() {
605        let v = json!({"annotations": {}});
606        assert_eq!(
607            get_approval_message(&v, "mod-x"),
608            "Module 'mod-x' requires approval to execute."
609        );
610    }
611
612    #[test]
613    fn approval_message_default_when_not_string() {
614        let v = json!({"annotations": {"approval_message": 42}});
615        assert_eq!(
616            get_approval_message(&v, "mod-x"),
617            "Module 'mod-x' requires approval to execute."
618        );
619    }
620
621    #[test]
622    fn module_id_from_module_id_field() {
623        let v = json!({"module_id": "my-module"});
624        assert_eq!(get_module_id(&v), "my-module");
625    }
626
627    #[test]
628    fn module_id_from_canonical_id_field() {
629        let v = json!({"canonical_id": "canon-module"});
630        assert_eq!(get_module_id(&v), "canon-module");
631    }
632
633    #[test]
634    fn module_id_unknown_when_absent() {
635        let v = json!({});
636        assert_eq!(get_module_id(&v), "unknown");
637    }
638
639    // --- Task 3: bypass-logic ---
640
641    fn module(requires: bool) -> serde_json::Value {
642        json!({
643            "module_id": "test-module",
644            "annotations": { "requires_approval": requires }
645        })
646    }
647
648    #[tokio::test]
649    async fn skip_when_requires_approval_false() {
650        let result = check_approval(
651            &json!({"annotations": {"requires_approval": false}}),
652            false,
653            None,
654        )
655        .await;
656        assert!(result.is_ok());
657    }
658
659    #[tokio::test]
660    async fn skip_when_no_annotations() {
661        let result = check_approval(&json!({}), false, None).await;
662        assert!(result.is_ok());
663    }
664
665    #[tokio::test]
666    async fn skip_when_requires_approval_string_true() {
667        let result = check_approval(
668            &json!({"annotations": {"requires_approval": "true"}}),
669            false,
670            None,
671        )
672        .await;
673        assert!(result.is_ok());
674    }
675
676    #[tokio::test]
677    async fn bypass_auto_approve_true() {
678        let result = check_approval(&module(true), true, None).await;
679        assert!(result.is_ok(), "auto_approve=true must bypass");
680    }
681
682    #[tokio::test]
683    async fn explicit_timeout_some_delegates_to_with_timeout() {
684        // Some(0) is the strongest evidence the timeout argument is wired
685        // through to check_approval_with_timeout — a 0-second timeout would
686        // immediately time out an actual TTY prompt. Bypass via auto_approve
687        // so this test does not need a TTY.
688        let result = check_approval(&module(true), true, Some(0)).await;
689        assert!(
690            result.is_ok(),
691            "auto_approve must bypass before timeout matters"
692        );
693    }
694
695    #[test]
696    fn bypass_env_var_one() {
697        let _guard = ENV_MUTEX.lock().unwrap();
698        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
699        let rt = tokio::runtime::Runtime::new().unwrap();
700        let result = rt.block_on(check_approval(&module(true), false, None));
701        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
702        assert!(result.is_ok(), "APCORE_CLI_AUTO_APPROVE=1 must bypass");
703    }
704
705    #[test]
706    fn yes_flag_priority_over_env_var() {
707        let _guard = ENV_MUTEX.lock().unwrap();
708        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
709        let rt = tokio::runtime::Runtime::new().unwrap();
710        let result = rt.block_on(check_approval(&module(true), true, None));
711        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
712        assert!(result.is_ok());
713    }
714
715    // --- Task 4: non-tty-rejection ---
716
717    fn module_requiring_approval() -> serde_json::Value {
718        json!({
719            "module_id": "test-module",
720            "annotations": { "requires_approval": true }
721        })
722    }
723
724    #[test]
725    fn non_tty_no_bypass_returns_non_interactive_error() {
726        let _guard = ENV_MUTEX.lock().unwrap();
727        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
728        let rt = tokio::runtime::Runtime::new().unwrap();
729        let result = rt.block_on(check_approval_with_tty(
730            &module_requiring_approval(),
731            false,
732            false,
733        ));
734        match result {
735            Err(ApprovalError::NonInteractive { module_id }) => {
736                assert_eq!(module_id, "test-module");
737            }
738            other => panic!("expected NonInteractive error, got {:?}", other),
739        }
740    }
741
742    #[tokio::test]
743    async fn non_tty_with_yes_flag_bypasses_before_tty_check() {
744        let result = check_approval_with_tty(&module_requiring_approval(), true, false).await;
745        assert!(result.is_ok(), "auto_approve bypasses TTY check");
746    }
747
748    #[test]
749    fn non_tty_with_env_var_bypasses_before_tty_check() {
750        let _guard = ENV_MUTEX.lock().unwrap();
751        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
752        let rt = tokio::runtime::Runtime::new().unwrap();
753        let result = rt.block_on(check_approval_with_tty(
754            &module_requiring_approval(),
755            false,
756            false,
757        ));
758        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
759        assert!(result.is_ok(), "env var bypass happens before TTY check");
760    }
761
762    #[test]
763    fn non_tty_env_var_not_one_returns_non_interactive() {
764        let _guard = ENV_MUTEX.lock().unwrap();
765        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "true") };
766        let rt = tokio::runtime::Runtime::new().unwrap();
767        let result = rt.block_on(check_approval_with_tty(
768            &module_requiring_approval(),
769            false,
770            false,
771        ));
772        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
773        assert!(matches!(result, Err(ApprovalError::NonInteractive { .. })));
774    }
775
776    // --- Task 5: tty-prompt-timeout ---
777
778    #[tokio::test]
779    async fn user_types_y_returns_ok() {
780        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
781            Ok("y\n".to_string())
782        })
783        .await;
784        assert!(result.is_ok());
785    }
786
787    #[tokio::test]
788    async fn user_types_yes_returns_ok() {
789        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
790            Ok("yes\n".to_string())
791        })
792        .await;
793        assert!(result.is_ok());
794    }
795
796    #[tokio::test]
797    async fn user_types_yes_uppercase_returns_ok() {
798        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
799            Ok("YES\n".to_string())
800        })
801        .await;
802        assert!(result.is_ok());
803    }
804
805    #[tokio::test]
806    async fn user_types_n_returns_denied() {
807        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
808            Ok("n\n".to_string())
809        })
810        .await;
811        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
812    }
813
814    #[tokio::test]
815    async fn user_presses_enter_returns_denied() {
816        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
817            Ok("\n".to_string())
818        })
819        .await;
820        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
821    }
822
823    #[tokio::test]
824    async fn user_types_garbage_returns_denied() {
825        let result = prompt_with_reader("test-module", "Requires approval.", 60, || {
826            Ok("maybe\n".to_string())
827        })
828        .await;
829        assert!(matches!(result, Err(ApprovalError::Denied { .. })));
830    }
831
832    #[tokio::test]
833    async fn timeout_returns_timeout_error() {
834        let result = prompt_with_reader(
835            "test-module",
836            "Requires approval.",
837            0, // fires immediately
838            || {
839                // Simulate a slow/blocking read that never returns in time.
840                std::thread::sleep(std::time::Duration::from_secs(10));
841                Ok("y\n".to_string())
842            },
843        )
844        .await;
845        match result {
846            Err(ApprovalError::Timeout { module_id, seconds }) => {
847                assert_eq!(module_id, "test-module");
848                assert_eq!(seconds, 0);
849            }
850            other => panic!("expected Timeout, got {:?}", other),
851        }
852    }
853
854    #[tokio::test]
855    async fn check_approval_custom_message_displayed() {
856        let module_def = json!({
857            "module_id": "mod-custom",
858            "annotations": {
859                "requires_approval": true,
860                "approval_message": "Custom: please confirm."
861            }
862        });
863        // With auto_approve=true, bypass fires before TTY prompt.
864        let result = check_approval_with_tty(&module_def, true, true).await;
865        assert!(result.is_ok());
866    }
867
868    async fn check_approval_with_tty_timeout_honors_custom_value_before_prompt_inner() {
869        let module_def = json!({
870            "module_id": "mod-non-interactive",
871            "annotations": {"requires_approval": true}
872        });
873        // is_tty=false with non-default timeout should still return
874        // NonInteractive (the timeout only applies to the interactive
875        // prompt path).
876        let result = check_approval_with_tty_timeout(&module_def, false, false, 42).await;
877        match result {
878            Err(ApprovalError::NonInteractive { module_id }) => {
879                assert_eq!(module_id, "mod-non-interactive");
880            }
881            other => panic!("expected NonInteractive, got {other:?}"),
882        }
883    }
884
885    #[test]
886    fn check_approval_with_tty_timeout_honors_custom_value_before_prompt() {
887        // Closes the review finding: --approval-timeout was captured and
888        // discarded, prompt_with_timeout got a literal 60. The new
889        // _with_timeout variant must accept a caller-specified timeout and
890        // not break the pre-prompt decision order (requires_approval,
891        // --yes, env var, is_tty). Run on a dedicated runtime so the sync
892        // ENV_MUTEX guard is never held across an await point (clippy
893        // await_holding_lock).
894        let _guard = ENV_MUTEX.lock().unwrap();
895        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
896        let rt = tokio::runtime::Runtime::new().unwrap();
897        rt.block_on(check_approval_with_tty_timeout_honors_custom_value_before_prompt_inner());
898    }
899
900    #[tokio::test]
901    async fn check_approval_with_timeout_honors_auto_approve_bypass() {
902        let module_def = json!({
903            "module_id": "mod-bypass",
904            "annotations": {"requires_approval": true}
905        });
906        // --yes bypass must fire regardless of timeout setting.
907        let result = check_approval_with_timeout(&module_def, true, 7).await;
908        assert!(result.is_ok());
909    }
910
911    #[tokio::test]
912    async fn prompt_with_reader_timeout_respects_nonzero_value() {
913        // Directly verifies the timeout value is threaded through and
914        // surfaces in the ApprovalError::Timeout.seconds field — guards
915        // against regression where a hard-coded 60 overrides the caller's
916        // input (the exact bug the review flagged at approval.rs:230).
917        let result = prompt_with_reader("mod-threaded", "Needs approval.", 3, || {
918            std::thread::sleep(std::time::Duration::from_secs(30));
919            Ok("y\n".to_string())
920        })
921        .await;
922        match result {
923            Err(ApprovalError::Timeout { module_id, seconds }) => {
924                assert_eq!(module_id, "mod-threaded");
925                assert_eq!(seconds, 3, "timeout must propagate caller value, not 60");
926            }
927            other => panic!("expected Timeout with seconds=3, got {other:?}"),
928        }
929    }
930
931    // -----------------------------------------------------------------
932    // CliApprovalHandler — ApprovalResult shape (D10-006)
933    // -----------------------------------------------------------------
934
935    #[tokio::test]
936    async fn handler_returns_approved_via_auto_approve_for_yes_flag() {
937        let handler = CliApprovalHandler::new(true, 60);
938        let result = handler.request_approval(&module(true)).await;
939        assert_eq!(result.status, ApprovalStatus::Approved);
940        assert_eq!(result.approved_by.as_deref(), Some("auto_approve"));
941        assert!(result.reason.is_none());
942    }
943
944    #[tokio::test]
945    async fn handler_returns_approved_not_required_when_no_annotation() {
946        let handler = CliApprovalHandler::new(false, 60);
947        let result = handler.request_approval(&module(false)).await;
948        assert_eq!(result.status, ApprovalStatus::Approved);
949        assert_eq!(result.approved_by.as_deref(), Some("not_required"));
950    }
951
952    #[test]
953    fn handler_returns_approved_via_env_for_one_value() {
954        let _guard = ENV_MUTEX.lock().unwrap();
955        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
956        let rt = tokio::runtime::Runtime::new().unwrap();
957        let handler = CliApprovalHandler::new(false, 60);
958        let result = rt.block_on(handler.request_approval(&module(true)));
959        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
960        assert_eq!(result.status, ApprovalStatus::Approved);
961        assert_eq!(result.approved_by.as_deref(), Some("env_auto_approve"));
962    }
963
964    #[test]
965    fn handler_yes_flag_priority_over_env() {
966        let _guard = ENV_MUTEX.lock().unwrap();
967        unsafe { std::env::set_var("APCORE_CLI_AUTO_APPROVE", "1") };
968        let rt = tokio::runtime::Runtime::new().unwrap();
969        let handler = CliApprovalHandler::new(true, 60);
970        let result = rt.block_on(handler.request_approval(&module(true)));
971        unsafe { std::env::remove_var("APCORE_CLI_AUTO_APPROVE") };
972        // Both bypass paths qualify; --yes takes priority — must say
973        // "auto_approve" not "env_auto_approve".
974        assert_eq!(result.status, ApprovalStatus::Approved);
975        assert_eq!(result.approved_by.as_deref(), Some("auto_approve"));
976    }
977
978    #[test]
979    fn approval_result_constructors_set_status_and_fields() {
980        let approved = ApprovalResult::approved_via("tty_user");
981        assert_eq!(approved.status, ApprovalStatus::Approved);
982        assert_eq!(approved.approved_by.as_deref(), Some("tty_user"));
983        assert!(approved.reason.is_none());
984
985        let rejected = ApprovalResult::rejected("user said no");
986        assert_eq!(rejected.status, ApprovalStatus::Rejected);
987        assert!(rejected.approved_by.is_none());
988        assert_eq!(rejected.reason.as_deref(), Some("user said no"));
989
990        let timeout = ApprovalResult::timed_out("60s expired");
991        assert_eq!(timeout.status, ApprovalStatus::Timeout);
992        assert!(timeout.approved_by.is_none());
993        assert_eq!(timeout.reason.as_deref(), Some("60s expired"));
994    }
995
996    #[tokio::test]
997    async fn handler_check_approval_aliases_request_approval() {
998        let handler = CliApprovalHandler::new(true, 60);
999        let request_result = handler.request_approval(&module(true)).await;
1000        let check_result = handler.check_approval(&module(true)).await;
1001        assert_eq!(request_result, check_result);
1002    }
1003}