heartbit_core/
execution_context.rs1use std::future::Future;
10use std::path::PathBuf;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use crate::error::Error;
15
16#[derive(Clone, Default)]
18pub struct ExecutionContext {
19 pub tenant_id: Option<String>,
21 pub user_id: Option<String>,
23 pub workspace: Option<PathBuf>,
25 pub credentials: Option<Arc<dyn CredentialResolver>>,
27 pub audit_sink: Option<Arc<dyn AuditSink>>,
29}
30
31impl std::fmt::Debug for ExecutionContext {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.debug_struct("ExecutionContext")
34 .field("tenant_id", &self.tenant_id)
35 .field("user_id", &self.user_id)
36 .field("workspace", &self.workspace)
37 .field(
38 "credentials",
39 &self.credentials.as_ref().map(|_| "<resolver>"),
40 )
41 .field("audit_sink", &self.audit_sink.as_ref().map(|_| "<sink>"))
42 .finish()
43 }
44}
45
46pub trait CredentialResolver: Send + Sync {
48 fn resolve(
50 &self,
51 name: &str,
52 ) -> Pin<Box<dyn Future<Output = Result<Secret, Error>> + Send + '_>>;
53}
54
55pub trait AuditSink: Send + Sync {
57 fn record(
59 &self,
60 record: serde_json::Value,
61 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>;
62}
63
64#[derive(Clone)]
66pub struct Secret(String);
67
68impl Secret {
69 pub fn new(value: impl Into<String>) -> Self {
71 Self(value.into())
72 }
73
74 pub fn expose(&self) -> &str {
76 &self.0
77 }
78}
79
80impl std::fmt::Debug for Secret {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 write!(f, "Secret(<redacted>)")
83 }
84}
85
86impl std::fmt::Display for Secret {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 write!(f, "<redacted>")
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn execution_context_default_has_no_identity() {
98 let ctx = ExecutionContext::default();
99 assert!(ctx.tenant_id.is_none());
100 assert!(ctx.user_id.is_none());
101 assert!(ctx.workspace.is_none());
102 assert!(ctx.credentials.is_none());
103 assert!(ctx.audit_sink.is_none());
104 }
105
106 #[test]
107 fn execution_context_clone_preserves_fields() {
108 let ctx = ExecutionContext {
109 tenant_id: Some("tenant-1".into()),
110 user_id: Some("user-2".into()),
111 workspace: Some(PathBuf::from("/tmp/ws")),
112 credentials: None,
113 audit_sink: None,
114 };
115 let cloned = ctx.clone();
116 assert_eq!(cloned.tenant_id.as_deref(), Some("tenant-1"));
117 assert_eq!(cloned.user_id.as_deref(), Some("user-2"));
118 assert_eq!(cloned.workspace, Some(PathBuf::from("/tmp/ws")));
119 }
120
121 #[test]
122 fn secret_debug_redacts() {
123 let s = Secret::new("super-secret-token");
124 let debug = format!("{:?}", s);
125 assert!(!debug.contains("super-secret-token"));
126 assert!(debug.contains("<redacted>"));
127 }
128
129 #[test]
130 fn secret_display_redacts() {
131 let s = Secret::new("super-secret-token");
132 let display = format!("{}", s);
133 assert!(!display.contains("super-secret-token"));
134 assert!(display.contains("<redacted>"));
135 }
136
137 #[test]
138 fn secret_expose_returns_inner() {
139 let s = Secret::new("super-secret-token");
140 assert_eq!(s.expose(), "super-secret-token");
141 }
142
143 #[test]
144 fn execution_context_debug_does_not_leak_resolver_internals() {
145 struct DummyResolver;
146 impl CredentialResolver for DummyResolver {
147 fn resolve(
148 &self,
149 _name: &str,
150 ) -> Pin<Box<dyn Future<Output = Result<Secret, Error>> + Send + '_>> {
151 Box::pin(async { Ok(Secret::new("x")) })
152 }
153 }
154
155 let ctx = ExecutionContext {
156 credentials: Some(Arc::new(DummyResolver)),
157 ..ExecutionContext::default()
158 };
159 let debug = format!("{:?}", ctx);
160 assert!(debug.contains("<resolver>"));
161 assert!(!debug.contains("DummyResolver"));
162 }
163}