http_client_vcr/
cassette.rs

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    /// Traditional single YAML file format
15    #[default]
16    File,
17    /// Directory format with separate body files
18    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, // Default to file format
39        }
40    }
41
42    pub fn with_path(mut self, path: PathBuf) -> Self {
43        self.path = Some(path);
44        self
45    }
46
47    /// Explicitly set the cassette format (useful when creating new cassettes)
48    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        // Simple detection: if it's a directory, load as directory format, otherwise as file
55        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        // Load interactions metadata from interactions.yaml
78        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        // For directory format, we serialize a simplified structure
90        #[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            // Load request body if specified
123            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                    // Check if this is a base64 file based on extension
134                    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            // Load response body if specified
144            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                    // Check if this is a base64 file based on extension
155                    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        // Create the cassette directory and bodies subdirectory
221        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        // Create directory format structures for serialization
230        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            // Handle request body
263            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            // Handle response body
290            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        // Write the interactions.yaml file
336        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; // Mark as modified when recording new interactions
362        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}