gproxy_transform/transform/
context.rs1use std::cell::RefCell;
2use std::sync::{Arc, Mutex};
3
4use crate::protocol::OperationKey;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TransformDiagnosticKind {
9 UnsupportedField,
11 LossyField,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TransformDiagnostic {
18 pub kind: TransformDiagnosticKind,
20 pub field: String,
22 pub reason: String,
24}
25
26impl TransformDiagnostic {
27 pub fn unsupported(field: impl Into<String>, reason: impl Into<String>) -> Self {
29 Self {
30 kind: TransformDiagnosticKind::UnsupportedField,
31 field: field.into(),
32 reason: reason.into(),
33 }
34 }
35
36 pub fn lossy(field: impl Into<String>, reason: impl Into<String>) -> Self {
38 Self {
39 kind: TransformDiagnosticKind::LossyField,
40 field: field.into(),
41 reason: reason.into(),
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TransformOutput<T> {
50 pub value: T,
52 pub diagnostics: Vec<TransformDiagnostic>,
54}
55
56impl<T> TransformOutput<T> {
57 pub fn new(value: T, diagnostics: Vec<TransformDiagnostic>) -> Self {
59 Self { value, diagnostics }
60 }
61
62 pub fn into_value(self) -> T {
64 self.value
65 }
66
67 pub fn into_parts(self) -> (T, Vec<TransformDiagnostic>) {
69 (self.value, self.diagnostics)
70 }
71}
72
73type DiagnosticSink = Arc<Mutex<Vec<TransformDiagnostic>>>;
74
75thread_local! {
76 static DIAGNOSTIC_SCOPES: RefCell<Vec<DiagnosticSink>> = const { RefCell::new(Vec::new()) };
77}
78
79#[derive(Debug, Clone)]
87pub struct TransformContext {
88 pub source: OperationKey,
89 pub target: OperationKey,
90 pub path: String,
91 pub query: Option<String>,
92 diagnostics: DiagnosticSink,
93}
94
95impl PartialEq for TransformContext {
96 fn eq(&self, other: &Self) -> bool {
97 self.source == other.source
98 && self.target == other.target
99 && self.path == other.path
100 && self.query == other.query
101 }
102}
103
104impl Eq for TransformContext {}
105
106impl TransformContext {
107 pub fn new(source: OperationKey, target: OperationKey) -> Self {
108 Self {
109 source,
110 target,
111 path: String::new(),
112 query: None,
113 diagnostics: Arc::new(Mutex::new(Vec::new())),
114 }
115 }
116
117 pub fn with_request(mut self, path: &str, query: Option<&str>) -> Self {
119 self.path = path.to_owned();
120 self.query = query.map(str::to_owned);
121 self
122 }
123
124 pub fn diagnostics(&self) -> Vec<TransformDiagnostic> {
126 self.diagnostics
127 .lock()
128 .expect("transform diagnostic mutex poisoned")
129 .clone()
130 }
131
132 pub fn take_diagnostics(&self) -> Vec<TransformDiagnostic> {
134 std::mem::take(
135 &mut *self
136 .diagnostics
137 .lock()
138 .expect("transform diagnostic mutex poisoned"),
139 )
140 }
141
142 pub(crate) fn isolated(&self) -> Self {
143 let mut isolated = self.clone();
144 isolated.diagnostics = Arc::new(Mutex::new(Vec::new()));
145 isolated
146 }
147
148 pub(crate) fn scope<T>(&self, f: impl FnOnce() -> T) -> T {
149 struct ScopeGuard;
150 impl Drop for ScopeGuard {
151 fn drop(&mut self) {
152 DIAGNOSTIC_SCOPES.with(|scopes| {
153 scopes.borrow_mut().pop();
154 });
155 }
156 }
157
158 DIAGNOSTIC_SCOPES.with(|scopes| scopes.borrow_mut().push(self.diagnostics.clone()));
159 let guard = ScopeGuard;
160 let output = f();
161 drop(guard);
162 output
163 }
164}
165
166pub(crate) fn report_diagnostic(diagnostic: TransformDiagnostic) {
167 let sink = DIAGNOSTIC_SCOPES.with(|scopes| scopes.borrow().last().cloned());
168 if let Some(sink) = sink {
169 sink.lock()
170 .expect("transform diagnostic mutex poisoned")
171 .push(diagnostic);
172 } else {
173 tracing::warn!(
174 kind = ?diagnostic.kind,
175 field = %diagnostic.field,
176 reason = %diagnostic.reason,
177 "semantic loss outside a diagnostic transform scope"
178 );
179 }
180}
181
182pub(crate) fn report_lossy(field: impl Into<String>, reason: impl Into<String>) {
183 report_diagnostic(TransformDiagnostic::lossy(field, reason));
184}
185
186pub(crate) fn report_unsupported(field: impl Into<String>, reason: impl Into<String>) {
187 report_diagnostic(TransformDiagnostic::unsupported(field, reason));
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::protocol::{ContentGenerationKind, Operation};
194
195 #[test]
196 fn diagnostic_scopes_are_isolated_and_structured() {
197 let key = OperationKey::content_generation(
198 Operation::GenerateContent,
199 ContentGenerationKind::OpenAiResponses,
200 );
201 let context = TransformContext::new(key, key);
202 let isolated = context.isolated();
203 isolated.scope(|| report_lossy("input.cache_control", "not representable"));
204
205 assert!(context.diagnostics().is_empty());
206 assert_eq!(
207 isolated.take_diagnostics(),
208 vec![TransformDiagnostic::lossy(
209 "input.cache_control",
210 "not representable"
211 )]
212 );
213 assert!(isolated.diagnostics().is_empty());
214 }
215}