1use thiserror::Error;
5
6#[derive(Debug, Error)]
13pub enum ApprovalError {
14 #[error("approval denied for module '{module_id}'")]
16 Denied { module_id: String },
17
18 #[error("no interactive terminal available for module '{module_id}'")]
20 NonInteractive { module_id: String },
21
22 #[error("approval timed out after {seconds}s for module '{module_id}'")]
24 Timeout { module_id: String, seconds: u64 },
25}
26
27fn 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
41fn 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
53fn 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
64async 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 eprint!("{}\nProceed? [y/N]: ", message);
89 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 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 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
148async 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
162pub const DEFAULT_APPROVAL_TIMEOUT_SECS: u64 = 60;
168
169pub 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
188pub 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 if auto_approve {
210 tracing::info!(
211 "Approval bypassed via --yes flag for module '{}'.",
212 module_id
213 );
214 return Ok(());
215 }
216
217 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 }
229 Ok(val) => {
230 eprintln!(
237 "Warning: APCORE_CLI_AUTO_APPROVE is set to '{val}', expected '1'. Ignoring."
238 );
239 }
240 }
241
242 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 let message = get_approval_message(module_def, &module_id);
258 prompt_with_timeout(&module_id, &message, timeout_secs).await
259}
260
261pub 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
293pub 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#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum ApprovalStatus {
324 Approved,
326 Rejected,
328 Timeout,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct ApprovalResult {
336 pub status: ApprovalStatus,
338 pub approved_by: Option<String>,
343 pub reason: Option<String>,
346}
347
348impl ApprovalResult {
349 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 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 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
377pub struct CliApprovalHandler {
390 pub auto_approve: bool,
392 pub timeout_secs: u64,
394}
395
396impl CliApprovalHandler {
397 pub fn new(auto_approve: bool, timeout_secs: u64) -> Self {
399 Self {
400 auto_approve,
401 timeout_secs,
402 }
403 }
404
405 pub async fn request_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
418 let module_id = get_module_id(module_def);
419
420 if !get_requires_approval(module_def) {
422 return ApprovalResult::approved_via("not_required");
423 }
424
425 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 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 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 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 pub async fn check_approval(&self, module_def: &serde_json::Value) -> ApprovalResult {
479 self.request_approval(module_def).await
480 }
481}
482
483pub type ApprovalDeniedError = ApprovalError;
486pub type ApprovalTimeoutError = ApprovalError;
488
489#[cfg(test)]
494mod tests {
495 use super::*;
496 use serde_json::json;
497 use std::sync::Mutex;
498
499 static ENV_MUTEX: Mutex<()> = Mutex::new(());
502
503 #[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 #[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 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 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 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 #[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, || {
839 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 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 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 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 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 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 #[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 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}