1use crate::serializable::{SerializableRequest, SerializableResponse};
2use http_client::Error;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Interaction {
8 pub request: SerializableRequest,
9 pub response: SerializableResponse,
10}
11
12#[derive(Debug, Clone, Default)]
13pub enum CassetteFormat {
14 #[default]
16 File,
17 Directory,
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct Cassette {
23 pub interactions: Vec<Interaction>,
24 #[serde(skip)]
25 pub path: Option<PathBuf>,
26 #[serde(skip)]
27 pub modified_since_load: bool,
28 #[serde(skip)]
29 pub format: CassetteFormat,
30}
31
32impl Cassette {
33 pub fn new() -> Self {
34 Self {
35 interactions: Vec::new(),
36 path: None,
37 modified_since_load: false,
38 format: CassetteFormat::File, }
40 }
41
42 pub fn with_path(mut self, path: PathBuf) -> Self {
43 self.path = Some(path);
44 self
45 }
46
47 pub fn with_format(mut self, format: CassetteFormat) -> Self {
49 self.format = format;
50 self
51 }
52
53 pub async fn load_from_file(path: PathBuf) -> Result<Self, Error> {
54 if path.is_dir() {
56 Self::load_from_directory(path).await
57 } else {
58 Self::load_from_single_file(path).await
59 }
60 }
61
62 async fn load_from_single_file(path: PathBuf) -> Result<Self, Error> {
63 let content = std::fs::read_to_string(&path)
64 .map_err(|e| Error::from_str(500, format!("Failed to read cassette file: {e}")))?;
65
66 let mut cassette: Cassette = serde_yaml::from_str(&content)
67 .map_err(|e| Error::from_str(500, format!("Failed to parse cassette YAML: {e}")))?;
68
69 cassette.path = Some(path);
70 cassette.format = CassetteFormat::File;
71 cassette.modified_since_load = false;
72
73 Ok(cassette)
74 }
75
76 async fn load_from_directory(path: PathBuf) -> Result<Self, Error> {
77 let interactions_file = path.join("interactions.yaml");
79 if !interactions_file.exists() {
80 return Err(Error::from_str(
81 404,
82 format!("Directory cassette missing interactions.yaml: {path:?}"),
83 ));
84 }
85
86 let content = std::fs::read_to_string(&interactions_file)
87 .map_err(|e| Error::from_str(500, format!("Failed to read interactions.yaml: {e}")))?;
88
89 #[derive(Deserialize)]
91 struct DirectoryInteraction {
92 request: DirectorySerializableRequest,
93 response: DirectorySerializableResponse,
94 }
95
96 #[derive(Deserialize)]
97 struct DirectorySerializableRequest {
98 method: String,
99 url: String,
100 headers: std::collections::HashMap<String, Vec<String>>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 body_file: Option<String>,
103 version: String,
104 }
105
106 #[derive(Deserialize)]
107 struct DirectorySerializableResponse {
108 status: u16,
109 headers: std::collections::HashMap<String, Vec<String>>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 body_file: Option<String>,
112 version: String,
113 }
114
115 let dir_interactions: Vec<DirectoryInteraction> = serde_yaml::from_str(&content)
116 .map_err(|e| Error::from_str(500, format!("Failed to parse interactions.yaml: {e}")))?;
117
118 let bodies_dir = path.join("bodies");
119 let mut interactions = Vec::new();
120
121 for dir_interaction in dir_interactions {
122 let (request_body, request_body_base64) =
124 if let Some(ref body_file) = dir_interaction.request.body_file {
125 let body_path = bodies_dir.join(body_file);
126 let content = std::fs::read_to_string(&body_path).map_err(|e| {
127 Error::from_str(
128 500,
129 format!("Failed to read request body file {body_file}: {e}"),
130 )
131 })?;
132
133 if body_file.ends_with(".b64") {
135 (None, Some(content))
136 } else {
137 (Some(content), None)
138 }
139 } else {
140 (None, None)
141 };
142
143 let (response_body, response_body_base64) =
145 if let Some(ref body_file) = dir_interaction.response.body_file {
146 let body_path = bodies_dir.join(body_file);
147 let content = std::fs::read_to_string(&body_path).map_err(|e| {
148 Error::from_str(
149 500,
150 format!("Failed to read response body file {body_file}: {e}"),
151 )
152 })?;
153
154 if body_file.ends_with(".b64") {
156 (None, Some(content))
157 } else {
158 (Some(content), None)
159 }
160 } else {
161 (None, None)
162 };
163
164 let interaction = Interaction {
165 request: SerializableRequest {
166 method: dir_interaction.request.method,
167 url: dir_interaction.request.url,
168 headers: dir_interaction.request.headers,
169 body: request_body,
170 body_base64: request_body_base64,
171 version: dir_interaction.request.version,
172 },
173 response: SerializableResponse {
174 status: dir_interaction.response.status,
175 headers: dir_interaction.response.headers,
176 body: response_body,
177 body_base64: response_body_base64,
178 version: dir_interaction.response.version,
179 },
180 };
181
182 interactions.push(interaction);
183 }
184
185 Ok(Cassette {
186 interactions,
187 path: Some(path),
188 format: CassetteFormat::Directory,
189 modified_since_load: false,
190 })
191 }
192
193 pub async fn save_to_file(&self) -> Result<(), Error> {
194 if let Some(path) = &self.path {
195 match self.format {
196 CassetteFormat::File => self.save_to_single_file(path).await,
197 CassetteFormat::Directory => self.save_to_directory(path).await,
198 }
199 } else {
200 Err(Error::from_str(400, "No path specified for cassette"))
201 }
202 }
203
204 async fn save_to_single_file(&self, path: &PathBuf) -> Result<(), Error> {
205 let yaml = serde_yaml::to_string(self)
206 .map_err(|e| Error::from_str(500, format!("Failed to serialize cassette: {e}")))?;
207
208 if let Some(parent) = path.parent() {
209 std::fs::create_dir_all(parent)
210 .map_err(|e| Error::from_str(500, format!("Failed to create directory: {e}")))?;
211 }
212
213 std::fs::write(path, yaml)
214 .map_err(|e| Error::from_str(500, format!("Failed to write cassette file: {e}")))?;
215
216 Ok(())
217 }
218
219 async fn save_to_directory(&self, path: &PathBuf) -> Result<(), Error> {
220 std::fs::create_dir_all(path).map_err(|e| {
222 Error::from_str(500, format!("Failed to create cassette directory: {e}"))
223 })?;
224
225 let bodies_dir = path.join("bodies");
226 std::fs::create_dir_all(&bodies_dir)
227 .map_err(|e| Error::from_str(500, format!("Failed to create bodies directory: {e}")))?;
228
229 use serde::Serialize;
231
232 #[derive(Serialize)]
233 struct DirectoryInteraction {
234 request: DirectorySerializableRequest,
235 response: DirectorySerializableResponse,
236 }
237
238 #[derive(Serialize)]
239 struct DirectorySerializableRequest {
240 method: String,
241 url: String,
242 headers: std::collections::HashMap<String, Vec<String>>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 body_file: Option<String>,
245 version: String,
246 }
247
248 #[derive(Serialize)]
249 struct DirectorySerializableResponse {
250 status: u16,
251 headers: std::collections::HashMap<String, Vec<String>>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 body_file: Option<String>,
254 version: String,
255 }
256
257 let mut dir_interactions = Vec::new();
258
259 for (i, interaction) in self.interactions.iter().enumerate() {
260 let interaction_num = format!("{:03}", i + 1);
261
262 let request_body_file = if let Some(ref body) = interaction.request.body {
264 if !body.is_empty() {
265 let filename = format!("req_{interaction_num}.txt");
266 let body_path = bodies_dir.join(&filename);
267 std::fs::write(&body_path, body).map_err(|e| {
268 Error::from_str(500, format!("Failed to write request body file: {e}"))
269 })?;
270 Some(filename)
271 } else {
272 None
273 }
274 } else if let Some(ref body_base64) = interaction.request.body_base64 {
275 if !body_base64.is_empty() {
276 let filename = format!("req_{interaction_num}.b64");
277 let body_path = bodies_dir.join(&filename);
278 std::fs::write(&body_path, body_base64).map_err(|e| {
279 Error::from_str(500, format!("Failed to write request body file: {e}"))
280 })?;
281 Some(filename)
282 } else {
283 None
284 }
285 } else {
286 None
287 };
288
289 let response_body_file = if let Some(ref body) = interaction.response.body {
291 if !body.is_empty() {
292 let filename = format!("resp_{interaction_num}.txt");
293 let body_path = bodies_dir.join(&filename);
294 std::fs::write(&body_path, body).map_err(|e| {
295 Error::from_str(500, format!("Failed to write response body file: {e}"))
296 })?;
297 Some(filename)
298 } else {
299 None
300 }
301 } else if let Some(ref body_base64) = interaction.response.body_base64 {
302 if !body_base64.is_empty() {
303 let filename = format!("resp_{interaction_num}.b64");
304 let body_path = bodies_dir.join(&filename);
305 std::fs::write(&body_path, body_base64).map_err(|e| {
306 Error::from_str(500, format!("Failed to write response body file: {e}"))
307 })?;
308 Some(filename)
309 } else {
310 None
311 }
312 } else {
313 None
314 };
315
316 let dir_interaction = DirectoryInteraction {
317 request: DirectorySerializableRequest {
318 method: interaction.request.method.clone(),
319 url: interaction.request.url.clone(),
320 headers: interaction.request.headers.clone(),
321 body_file: request_body_file,
322 version: interaction.request.version.clone(),
323 },
324 response: DirectorySerializableResponse {
325 status: interaction.response.status,
326 headers: interaction.response.headers.clone(),
327 body_file: response_body_file,
328 version: interaction.response.version.clone(),
329 },
330 };
331
332 dir_interactions.push(dir_interaction);
333 }
334
335 let interactions_yaml = serde_yaml::to_string(&dir_interactions)
337 .map_err(|e| Error::from_str(500, format!("Failed to serialize interactions: {e}")))?;
338
339 let interactions_file = path.join("interactions.yaml");
340 std::fs::write(&interactions_file, interactions_yaml)
341 .map_err(|e| Error::from_str(500, format!("Failed to write interactions.yaml: {e}")))?;
342
343 Ok(())
344 }
345
346 pub fn clear(&mut self) {
347 self.interactions.clear();
348 }
349
350 pub async fn record_interaction(
351 &mut self,
352 serializable_request: SerializableRequest,
353 serializable_response: SerializableResponse,
354 ) -> Result<(), Error> {
355 let interaction = Interaction {
356 request: serializable_request,
357 response: serializable_response,
358 };
359
360 self.interactions.push(interaction);
361 self.modified_since_load = true; Ok(())
363 }
364
365 pub fn len(&self) -> usize {
366 self.interactions.len()
367 }
368
369 pub fn is_empty(&self) -> bool {
370 self.interactions.is_empty()
371 }
372}
373
374impl Default for Cassette {
375 fn default() -> Self {
376 Self::new()
377 }
378}