1#![cfg_attr(docsrs, feature(doc_cfg))]
56
57use std::path::Path;
58
59use serde::{Deserialize, Serialize};
60
61pub mod recorder;
62pub use recorder::{DEFAULT_REDACT_HEADERS, Recorder, RecorderConfig};
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66#[non_exhaustive]
67pub struct RecordedExchange {
68 pub method: String,
70 pub path: String,
72 pub status: u16,
74 #[serde(default)]
78 pub request: Option<serde_json::Value>,
79 pub response: serde_json::Value,
82 #[serde(default)]
85 pub headers: Vec<(String, String)>,
86}
87
88impl RecordedExchange {
89 #[must_use]
92 pub fn new(
93 method: impl Into<String>,
94 path: impl Into<String>,
95 status: u16,
96 response: serde_json::Value,
97 ) -> Self {
98 Self {
99 method: method.into(),
100 path: path.into(),
101 status,
102 request: None,
103 response,
104 headers: Vec::new(),
105 }
106 }
107
108 #[must_use]
110 pub fn with_request(mut self, body: serde_json::Value) -> Self {
111 self.request = Some(body);
112 self
113 }
114
115 #[must_use]
117 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
118 self.headers.push((name.into(), value.into()));
119 self
120 }
121}
122
123#[derive(Debug, Clone, Default)]
126pub struct Cassette {
127 exchanges: Vec<RecordedExchange>,
128 skip_request_match: bool,
129}
130
131impl Cassette {
132 #[must_use]
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 #[must_use]
141 pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
142 Self {
143 exchanges,
144 skip_request_match: false,
145 }
146 }
147
148 pub async fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
152 let text = tokio::fs::read_to_string(path).await?;
153 Self::parse_jsonl(&text).map_err(std::io::Error::other)
154 }
155
156 pub fn from_path_sync(path: impl AsRef<Path>) -> std::io::Result<Self> {
159 let text = std::fs::read_to_string(path)?;
160 Self::parse_jsonl(&text).map_err(std::io::Error::other)
161 }
162
163 pub fn parse_jsonl(jsonl: &str) -> serde_json::Result<Self> {
166 let mut exchanges = Vec::new();
167 for (line_no, line) in jsonl.lines().enumerate() {
168 let trimmed = line.trim();
169 if trimmed.is_empty() || trimmed.starts_with('#') {
170 continue;
171 }
172 let exchange: RecordedExchange = serde_json::from_str(trimmed).map_err(|e| {
173 let msg = format!("cassette parse failed at line {}: {}", line_no + 1, e);
174 serde::de::Error::custom(msg)
175 })?;
176 exchanges.push(exchange);
177 }
178 Ok(Self {
179 exchanges,
180 skip_request_match: false,
181 })
182 }
183
184 pub fn push(&mut self, exchange: RecordedExchange) -> &mut Self {
186 self.exchanges.push(exchange);
187 self
188 }
189
190 #[must_use]
194 pub fn skip_request_match(mut self) -> Self {
195 self.skip_request_match = true;
196 self
197 }
198
199 #[must_use]
201 pub fn exchanges(&self) -> &[RecordedExchange] {
202 &self.exchanges
203 }
204
205 #[must_use]
207 pub fn len(&self) -> usize {
208 self.exchanges.len()
209 }
210
211 #[must_use]
213 pub fn is_empty(&self) -> bool {
214 self.exchanges.is_empty()
215 }
216
217 pub fn to_jsonl(&self) -> serde_json::Result<String> {
219 let mut out = String::new();
220 for ex in &self.exchanges {
221 out.push_str(&serde_json::to_string(ex)?);
222 out.push('\n');
223 }
224 Ok(out)
225 }
226}
227
228pub async fn mount_cassette(server: &wiremock::MockServer, cassette: &Cassette) {
236 use wiremock::matchers::{body_json, method, path};
237 use wiremock::{Mock, ResponseTemplate};
238
239 for ex in &cassette.exchanges {
240 let mut response = ResponseTemplate::new(ex.status).set_body_json(ex.response.clone());
241 for (k, v) in &ex.headers {
242 response = response.insert_header(k.as_str(), v.as_str());
243 }
244
245 let mock_builder = Mock::given(method(ex.method.as_str())).and(path(ex.path.as_str()));
246 let mock = match (&ex.request, cassette.skip_request_match) {
247 (Some(body), false) => mock_builder.and(body_json(body)).respond_with(response),
248 _ => mock_builder.respond_with(response),
249 };
250 mock.mount(server).await;
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use serde_json::json;
258
259 #[test]
260 fn parse_jsonl_round_trips() {
261 let jsonl = r#"
262# leading comment, ignored
263{"method":"POST","path":"/v1/messages","status":200,"request":{"model":"x"},"response":{"id":"msg_1"}}
264{"method":"GET","path":"/v1/models","status":200,"request":null,"response":{"data":[]}}
265"#;
266 let c = Cassette::parse_jsonl(jsonl).unwrap();
267 assert_eq!(c.len(), 2);
268 assert_eq!(c.exchanges()[0].method, "POST");
269 assert_eq!(c.exchanges()[1].path, "/v1/models");
270
271 let serialized = c.to_jsonl().unwrap();
272 let again = Cassette::parse_jsonl(&serialized).unwrap();
273 assert_eq!(again.len(), 2);
274 }
275
276 #[test]
277 fn empty_cassette_is_empty() {
278 let c = Cassette::new();
279 assert!(c.is_empty());
280 assert_eq!(c.len(), 0);
281 }
282
283 #[test]
284 fn cassette_parse_error_includes_line_number() {
285 let jsonl = "not-json\n";
286 let err = Cassette::parse_jsonl(jsonl).unwrap_err();
287 assert!(format!("{err}").contains("line 1"));
288 }
289
290 #[test]
291 fn skip_request_match_flag_is_set() {
292 let c = Cassette::new().skip_request_match();
293 assert!(c.skip_request_match);
294 }
295
296 #[test]
297 fn from_exchanges_constructs_directly() {
298 let ex = RecordedExchange {
299 method: "POST".into(),
300 path: "/v1/x".into(),
301 status: 200,
302 request: Some(json!({"k": 1})),
303 response: json!({"ok": true}),
304 headers: vec![("request-id".into(), "req_1".into())],
305 };
306 let c = Cassette::from_exchanges(vec![ex]);
307 assert_eq!(c.len(), 1);
308 }
309}