Skip to main content

jugar_probar/
har.rs

1//! HAR (HTTP Archive) Recording and Playback (Feature G.2)
2//!
3//! Implements HAR 1.2 format for recording and replaying HTTP traffic.
4//! This enables reproducible E2E tests by capturing network interactions.
5//!
6//! ## EXTREME TDD: Tests written FIRST per Popperian falsification
7//!
8//! ## Toyota Way Application
9//!
10//! - **Mieruka**: HAR files make network interactions visible and auditable
11//! - **Poka-Yoke**: Type-safe HAR structures prevent invalid recordings
12//! - **Jidoka**: Immediate feedback on HAR parsing/validation errors
13
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17// =============================================================================
18// HAR 1.2 Format Structures
19// =============================================================================
20
21/// HAR file root structure (HAR 1.2 specification)
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Har {
24    /// HAR log container
25    pub log: HarLog,
26}
27
28impl Har {
29    /// Create a new empty HAR file
30    #[must_use]
31    pub fn new() -> Self {
32        Self { log: HarLog::new() }
33    }
34
35    /// Parse HAR from JSON string
36    ///
37    /// # Errors
38    ///
39    /// Returns error if JSON parsing fails
40    pub fn from_json(json: &str) -> Result<Self, HarError> {
41        serde_json::from_str(json).map_err(|e| HarError::ParseError(e.to_string()))
42    }
43
44    /// Serialize HAR to JSON string
45    ///
46    /// # Errors
47    ///
48    /// Returns error if serialization fails
49    pub fn to_json(&self) -> Result<String, HarError> {
50        serde_json::to_string_pretty(self).map_err(|e| HarError::SerializeError(e.to_string()))
51    }
52
53    /// Get number of entries
54    #[must_use]
55    pub fn entry_count(&self) -> usize {
56        self.log.entries.len()
57    }
58
59    /// Add an entry
60    pub fn add_entry(&mut self, entry: HarEntry) {
61        self.log.entries.push(entry);
62    }
63
64    /// Find entry by URL
65    #[must_use]
66    pub fn find_by_url(&self, url: &str) -> Option<&HarEntry> {
67        self.log.entries.iter().find(|e| e.request.url == url)
68    }
69
70    /// Find entries matching URL pattern (glob-style)
71    #[must_use]
72    pub fn find_matching(&self, pattern: &str) -> Vec<&HarEntry> {
73        self.log
74            .entries
75            .iter()
76            .filter(|e| url_matches_pattern(&e.request.url, pattern))
77            .collect()
78    }
79}
80
81impl Default for Har {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87/// HAR log structure
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct HarLog {
90    /// HAR format version (always "1.2")
91    pub version: String,
92    /// Creator application info
93    pub creator: HarCreator,
94    /// Browser info (optional)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub browser: Option<HarBrowser>,
97    /// List of recorded entries
98    pub entries: Vec<HarEntry>,
99    /// Optional comment
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub comment: Option<String>,
102}
103
104impl HarLog {
105    /// Create a new HAR log
106    #[must_use]
107    pub fn new() -> Self {
108        Self {
109            version: "1.2".to_string(),
110            creator: HarCreator::probar(),
111            browser: None,
112            entries: Vec::new(),
113            comment: None,
114        }
115    }
116}
117
118impl Default for HarLog {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124/// Creator information
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct HarCreator {
127    /// Creator name
128    pub name: String,
129    /// Creator version
130    pub version: String,
131    /// Optional comment
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub comment: Option<String>,
134}
135
136impl HarCreator {
137    /// Create Probar creator info
138    #[must_use]
139    pub fn probar() -> Self {
140        Self {
141            name: "Probar".to_string(),
142            version: env!("CARGO_PKG_VERSION").to_string(),
143            comment: None,
144        }
145    }
146}
147
148/// Browser information
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct HarBrowser {
151    /// Browser name
152    pub name: String,
153    /// Browser version
154    pub version: String,
155    /// Optional comment
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub comment: Option<String>,
158}
159
160impl HarBrowser {
161    /// Create browser info
162    #[must_use]
163    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
164        Self {
165            name: name.into(),
166            version: version.into(),
167            comment: None,
168        }
169    }
170}
171
172/// A single HAR entry (request/response pair)
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct HarEntry {
175    /// Start time (ISO 8601)
176    #[serde(rename = "startedDateTime")]
177    pub started_date_time: String,
178    /// Total time in milliseconds
179    pub time: f64,
180    /// Request details
181    pub request: HarRequest,
182    /// Response details
183    pub response: HarResponse,
184    /// Cache details
185    pub cache: HarCache,
186    /// Timing details
187    pub timings: HarTimings,
188    /// Server IP address (optional)
189    #[serde(rename = "serverIPAddress", skip_serializing_if = "Option::is_none")]
190    pub server_ip_address: Option<String>,
191    /// Connection ID (optional)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub connection: Option<String>,
194    /// Optional comment
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub comment: Option<String>,
197}
198
199impl HarEntry {
200    /// Create a new entry
201    #[must_use]
202    pub fn new(request: HarRequest, response: HarResponse) -> Self {
203        Self {
204            started_date_time: chrono_now_iso(),
205            time: 0.0,
206            request,
207            response,
208            cache: HarCache::default(),
209            timings: HarTimings::default(),
210            server_ip_address: None,
211            connection: None,
212            comment: None,
213        }
214    }
215
216    /// Set timing in milliseconds
217    #[must_use]
218    pub fn with_time(mut self, time_ms: f64) -> Self {
219        self.time = time_ms;
220        self
221    }
222
223    /// Set server IP
224    #[must_use]
225    pub fn with_server_ip(mut self, ip: impl Into<String>) -> Self {
226        self.server_ip_address = Some(ip.into());
227        self
228    }
229}
230
231/// HTTP request in HAR format
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct HarRequest {
234    /// HTTP method
235    pub method: String,
236    /// Request URL
237    pub url: String,
238    /// HTTP version
239    #[serde(rename = "httpVersion")]
240    pub http_version: String,
241    /// Cookies
242    pub cookies: Vec<HarCookie>,
243    /// Headers
244    pub headers: Vec<HarHeader>,
245    /// Query string parameters
246    #[serde(rename = "queryString")]
247    pub query_string: Vec<HarQueryParam>,
248    /// POST data (optional)
249    #[serde(rename = "postData", skip_serializing_if = "Option::is_none")]
250    pub post_data: Option<HarPostData>,
251    /// Headers size in bytes (-1 if unknown)
252    #[serde(rename = "headersSize")]
253    pub headers_size: i64,
254    /// Body size in bytes (-1 if unknown)
255    #[serde(rename = "bodySize")]
256    pub body_size: i64,
257    /// Optional comment
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub comment: Option<String>,
260}
261
262impl HarRequest {
263    /// Create a GET request
264    #[must_use]
265    pub fn get(url: impl Into<String>) -> Self {
266        Self::new("GET", url)
267    }
268
269    /// Create a POST request
270    #[must_use]
271    pub fn post(url: impl Into<String>) -> Self {
272        Self::new("POST", url)
273    }
274
275    /// Create a new request
276    #[must_use]
277    pub fn new(method: impl Into<String>, url: impl Into<String>) -> Self {
278        Self {
279            method: method.into(),
280            url: url.into(),
281            http_version: "HTTP/1.1".to_string(),
282            cookies: Vec::new(),
283            headers: Vec::new(),
284            query_string: Vec::new(),
285            post_data: None,
286            headers_size: -1,
287            body_size: -1,
288            comment: None,
289        }
290    }
291
292    /// Add a header
293    #[must_use]
294    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
295        self.headers.push(HarHeader::new(name, value));
296        self
297    }
298
299    /// Add POST data
300    #[must_use]
301    pub fn with_post_data(mut self, data: HarPostData) -> Self {
302        self.post_data = Some(data);
303        self
304    }
305}
306
307/// HTTP response in HAR format
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct HarResponse {
310    /// HTTP status code
311    pub status: u16,
312    /// Status text
313    #[serde(rename = "statusText")]
314    pub status_text: String,
315    /// HTTP version
316    #[serde(rename = "httpVersion")]
317    pub http_version: String,
318    /// Cookies
319    pub cookies: Vec<HarCookie>,
320    /// Headers
321    pub headers: Vec<HarHeader>,
322    /// Response content
323    pub content: HarContent,
324    /// Redirect URL (if any)
325    #[serde(rename = "redirectURL")]
326    pub redirect_url: String,
327    /// Headers size in bytes (-1 if unknown)
328    #[serde(rename = "headersSize")]
329    pub headers_size: i64,
330    /// Body size in bytes (-1 if unknown)
331    #[serde(rename = "bodySize")]
332    pub body_size: i64,
333    /// Optional comment
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub comment: Option<String>,
336}
337
338impl HarResponse {
339    /// Create a successful response
340    #[must_use]
341    pub fn ok() -> Self {
342        Self::new(200, "OK")
343    }
344
345    /// Create a not found response
346    #[must_use]
347    pub fn not_found() -> Self {
348        Self::new(404, "Not Found")
349    }
350
351    /// Create a new response
352    #[must_use]
353    pub fn new(status: u16, status_text: impl Into<String>) -> Self {
354        Self {
355            status,
356            status_text: status_text.into(),
357            http_version: "HTTP/1.1".to_string(),
358            cookies: Vec::new(),
359            headers: Vec::new(),
360            content: HarContent::default(),
361            redirect_url: String::new(),
362            headers_size: -1,
363            body_size: -1,
364            comment: None,
365        }
366    }
367
368    /// Add a header
369    #[must_use]
370    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
371        self.headers.push(HarHeader::new(name, value));
372        self
373    }
374
375    /// Set response content
376    #[must_use]
377    pub fn with_content(mut self, content: HarContent) -> Self {
378        self.content = content;
379        self
380    }
381
382    /// Set JSON body
383    #[must_use]
384    pub fn with_json(mut self, body: impl Into<String>) -> Self {
385        self.content = HarContent::json(body);
386        self.headers
387            .push(HarHeader::new("Content-Type", "application/json"));
388        self
389    }
390}
391
392/// HTTP header
393#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct HarHeader {
395    /// Header name
396    pub name: String,
397    /// Header value
398    pub value: String,
399    /// Optional comment
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub comment: Option<String>,
402}
403
404impl HarHeader {
405    /// Create a new header
406    #[must_use]
407    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
408        Self {
409            name: name.into(),
410            value: value.into(),
411            comment: None,
412        }
413    }
414}
415
416/// Cookie
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct HarCookie {
419    /// Cookie name
420    pub name: String,
421    /// Cookie value
422    pub value: String,
423    /// Path (optional)
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub path: Option<String>,
426    /// Domain (optional)
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub domain: Option<String>,
429    /// Expires (optional)
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub expires: Option<String>,
432    /// HTTP only flag
433    #[serde(rename = "httpOnly", skip_serializing_if = "Option::is_none")]
434    pub http_only: Option<bool>,
435    /// Secure flag
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub secure: Option<bool>,
438    /// Optional comment
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub comment: Option<String>,
441}
442
443impl HarCookie {
444    /// Create a new cookie
445    #[must_use]
446    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
447        Self {
448            name: name.into(),
449            value: value.into(),
450            path: None,
451            domain: None,
452            expires: None,
453            http_only: None,
454            secure: None,
455            comment: None,
456        }
457    }
458}
459
460/// Query string parameter
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct HarQueryParam {
463    /// Parameter name
464    pub name: String,
465    /// Parameter value
466    pub value: String,
467    /// Optional comment
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub comment: Option<String>,
470}
471
472impl HarQueryParam {
473    /// Create a new query parameter
474    #[must_use]
475    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
476        Self {
477            name: name.into(),
478            value: value.into(),
479            comment: None,
480        }
481    }
482}
483
484/// POST data
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct HarPostData {
487    /// MIME type
488    #[serde(rename = "mimeType")]
489    pub mime_type: String,
490    /// Form parameters (for urlencoded)
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub params: Vec<HarPostParam>,
493    /// Raw text content
494    pub text: String,
495    /// Optional comment
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub comment: Option<String>,
498}
499
500impl HarPostData {
501    /// Create JSON POST data
502    #[must_use]
503    pub fn json(body: impl Into<String>) -> Self {
504        Self {
505            mime_type: "application/json".to_string(),
506            params: Vec::new(),
507            text: body.into(),
508            comment: None,
509        }
510    }
511
512    /// Create form-urlencoded POST data
513    #[must_use]
514    pub fn form(params: Vec<HarPostParam>) -> Self {
515        Self {
516            mime_type: "application/x-www-form-urlencoded".to_string(),
517            params,
518            text: String::new(),
519            comment: None,
520        }
521    }
522}
523
524/// POST parameter
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct HarPostParam {
527    /// Parameter name
528    pub name: String,
529    /// Parameter value (optional for file uploads)
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub value: Option<String>,
532    /// File name (for file uploads)
533    #[serde(rename = "fileName", skip_serializing_if = "Option::is_none")]
534    pub file_name: Option<String>,
535    /// Content type (for file uploads)
536    #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")]
537    pub content_type: Option<String>,
538    /// Optional comment
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub comment: Option<String>,
541}
542
543impl HarPostParam {
544    /// Create a new POST parameter
545    #[must_use]
546    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
547        Self {
548            name: name.into(),
549            value: Some(value.into()),
550            file_name: None,
551            content_type: None,
552            comment: None,
553        }
554    }
555}
556
557/// Response content
558#[derive(Debug, Clone, Default, Serialize, Deserialize)]
559pub struct HarContent {
560    /// Content size in bytes
561    pub size: i64,
562    /// Compression size (optional)
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub compression: Option<i64>,
565    /// MIME type
566    #[serde(rename = "mimeType")]
567    pub mime_type: String,
568    /// Response text (optional)
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub text: Option<String>,
571    /// Encoding (e.g., "base64")
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub encoding: Option<String>,
574    /// Optional comment
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub comment: Option<String>,
577}
578
579impl HarContent {
580    /// Create JSON content
581    #[must_use]
582    pub fn json(body: impl Into<String>) -> Self {
583        let text = body.into();
584        let size = text.len() as i64;
585        Self {
586            size,
587            compression: None,
588            mime_type: "application/json".to_string(),
589            text: Some(text),
590            encoding: None,
591            comment: None,
592        }
593    }
594
595    /// Create text content
596    #[must_use]
597    pub fn text(body: impl Into<String>) -> Self {
598        let text = body.into();
599        let size = text.len() as i64;
600        Self {
601            size,
602            compression: None,
603            mime_type: "text/plain".to_string(),
604            text: Some(text),
605            encoding: None,
606            comment: None,
607        }
608    }
609
610    /// Create HTML content
611    #[must_use]
612    pub fn html(body: impl Into<String>) -> Self {
613        let text = body.into();
614        let size = text.len() as i64;
615        Self {
616            size,
617            compression: None,
618            mime_type: "text/html".to_string(),
619            text: Some(text),
620            encoding: None,
621            comment: None,
622        }
623    }
624}
625
626/// Cache details
627#[derive(Debug, Clone, Default, Serialize, Deserialize)]
628pub struct HarCache {
629    /// Before request cache state (optional)
630    #[serde(rename = "beforeRequest", skip_serializing_if = "Option::is_none")]
631    pub before_request: Option<HarCacheState>,
632    /// After request cache state (optional)
633    #[serde(rename = "afterRequest", skip_serializing_if = "Option::is_none")]
634    pub after_request: Option<HarCacheState>,
635    /// Optional comment
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub comment: Option<String>,
638}
639
640/// Cache state
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct HarCacheState {
643    /// Expiry time (optional)
644    #[serde(skip_serializing_if = "Option::is_none")]
645    pub expires: Option<String>,
646    /// Last access time (optional)
647    #[serde(rename = "lastAccess", skip_serializing_if = "Option::is_none")]
648    pub last_access: Option<String>,
649    /// ETag (optional)
650    #[serde(rename = "eTag", skip_serializing_if = "Option::is_none")]
651    pub etag: Option<String>,
652    /// Hit count (optional)
653    #[serde(rename = "hitCount", skip_serializing_if = "Option::is_none")]
654    pub hit_count: Option<u32>,
655    /// Optional comment
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub comment: Option<String>,
658}
659
660/// Timing details
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct HarTimings {
663    /// Time spent in blocked queue (-1 if not applicable)
664    pub blocked: f64,
665    /// DNS resolution time (-1 if not applicable)
666    pub dns: f64,
667    /// Time to establish connection (-1 if not applicable)
668    pub connect: f64,
669    /// Time to send request
670    pub send: f64,
671    /// Time waiting for response
672    pub wait: f64,
673    /// Time to receive response
674    pub receive: f64,
675    /// SSL/TLS negotiation time (-1 if not applicable)
676    pub ssl: f64,
677    /// Optional comment
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub comment: Option<String>,
680}
681
682impl Default for HarTimings {
683    fn default() -> Self {
684        Self {
685            blocked: -1.0,
686            dns: -1.0,
687            connect: -1.0,
688            send: 0.0,
689            wait: 0.0,
690            receive: 0.0,
691            ssl: -1.0,
692            comment: None,
693        }
694    }
695}
696
697impl HarTimings {
698    /// Create new timings with defaults
699    #[must_use]
700    pub fn new() -> Self {
701        Self::default()
702    }
703
704    /// Total time
705    #[must_use]
706    pub fn total(&self) -> f64 {
707        let mut total = 0.0;
708        if self.blocked > 0.0 {
709            total += self.blocked;
710        }
711        if self.dns > 0.0 {
712            total += self.dns;
713        }
714        if self.connect > 0.0 {
715            total += self.connect;
716        }
717        total += self.send;
718        total += self.wait;
719        total += self.receive;
720        total
721    }
722}
723
724// =============================================================================
725// HAR Recording and Playback
726// =============================================================================
727
728/// HAR recording options
729#[derive(Debug, Clone)]
730pub struct HarOptions {
731    /// Behavior when request not found in HAR
732    pub not_found: NotFoundBehavior,
733    /// Update HAR with new requests
734    pub update: bool,
735    /// URL pattern to match (glob-style)
736    pub url_pattern: Option<String>,
737}
738
739impl Default for HarOptions {
740    fn default() -> Self {
741        Self {
742            not_found: NotFoundBehavior::Fallback,
743            update: false,
744            url_pattern: None,
745        }
746    }
747}
748
749impl HarOptions {
750    /// Create new options with abort on not found
751    #[must_use]
752    pub fn abort_on_not_found() -> Self {
753        Self {
754            not_found: NotFoundBehavior::Abort,
755            ..Default::default()
756        }
757    }
758
759    /// Create new options with fallback on not found
760    #[must_use]
761    pub fn fallback_on_not_found() -> Self {
762        Self {
763            not_found: NotFoundBehavior::Fallback,
764            ..Default::default()
765        }
766    }
767
768    /// Enable update mode
769    #[must_use]
770    pub fn with_update(mut self, update: bool) -> Self {
771        self.update = update;
772        self
773    }
774
775    /// Set URL pattern filter
776    #[must_use]
777    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
778        self.url_pattern = Some(pattern.into());
779        self
780    }
781}
782
783/// Behavior when request not found in HAR
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
785pub enum NotFoundBehavior {
786    /// Abort the request
787    Abort,
788    /// Fall back to real network
789    Fallback,
790}
791
792/// HAR recorder for capturing network traffic
793#[derive(Debug)]
794pub struct HarRecorder {
795    /// Recorded HAR data
796    har: Har,
797    /// Output path
798    path: PathBuf,
799    /// Whether recording is active
800    active: bool,
801    /// URL filter pattern
802    filter: Option<String>,
803}
804
805impl HarRecorder {
806    /// Create a new HAR recorder
807    #[must_use]
808    pub fn new(path: impl Into<PathBuf>) -> Self {
809        Self {
810            har: Har::new(),
811            path: path.into(),
812            active: false,
813            filter: None,
814        }
815    }
816
817    /// Start recording
818    pub fn start(&mut self) {
819        self.active = true;
820    }
821
822    /// Stop recording
823    pub fn stop(&mut self) {
824        self.active = false;
825    }
826
827    /// Check if recording is active
828    #[must_use]
829    pub fn is_active(&self) -> bool {
830        self.active
831    }
832
833    /// Set URL filter pattern
834    pub fn set_filter(&mut self, pattern: impl Into<String>) {
835        self.filter = Some(pattern.into());
836    }
837
838    /// Record a request/response pair
839    pub fn record(&mut self, entry: HarEntry) {
840        if !self.active {
841            return;
842        }
843
844        // Apply filter if set
845        if let Some(ref pattern) = self.filter {
846            if !url_matches_pattern(&entry.request.url, pattern) {
847                return;
848            }
849        }
850
851        self.har.add_entry(entry);
852    }
853
854    /// Get recorded HAR
855    #[must_use]
856    pub fn har(&self) -> &Har {
857        &self.har
858    }
859
860    /// Get entry count
861    #[must_use]
862    pub fn entry_count(&self) -> usize {
863        self.har.entry_count()
864    }
865
866    /// Save HAR to file
867    ///
868    /// # Errors
869    ///
870    /// Returns error if file writing fails
871    pub fn save(&self) -> Result<(), HarError> {
872        let json = self.har.to_json()?;
873        std::fs::write(&self.path, json).map_err(|e| HarError::IoError(e.to_string()))
874    }
875}
876
877/// HAR player for replaying recorded traffic
878#[derive(Debug)]
879pub struct HarPlayer {
880    /// HAR data to replay
881    har: Har,
882    /// Options for playback
883    options: HarOptions,
884}
885
886impl HarPlayer {
887    /// Create a new HAR player
888    #[must_use]
889    pub fn new(har: Har, options: HarOptions) -> Self {
890        Self { har, options }
891    }
892
893    /// Load HAR from file
894    ///
895    /// # Errors
896    ///
897    /// Returns error if file reading or parsing fails
898    pub fn from_file(path: impl Into<PathBuf>, options: HarOptions) -> Result<Self, HarError> {
899        let path = path.into();
900        let content =
901            std::fs::read_to_string(&path).map_err(|e| HarError::IoError(e.to_string()))?;
902        let har = Har::from_json(&content)?;
903        Ok(Self::new(har, options))
904    }
905
906    /// Find matching response for a request
907    #[must_use]
908    pub fn find_response(&self, method: &str, url: &str) -> Option<&HarResponse> {
909        // Check URL pattern filter
910        if let Some(ref pattern) = self.options.url_pattern {
911            if !url_matches_pattern(url, pattern) {
912                return None;
913            }
914        }
915
916        // Find matching entry
917        self.har.log.entries.iter().find_map(|entry| {
918            if entry.request.method == method && entry.request.url == url {
919                Some(&entry.response)
920            } else {
921                None
922            }
923        })
924    }
925
926    /// Get behavior for not found requests
927    #[must_use]
928    pub fn not_found_behavior(&self) -> NotFoundBehavior {
929        self.options.not_found
930    }
931
932    /// Get entry count
933    #[must_use]
934    pub fn entry_count(&self) -> usize {
935        self.har.entry_count()
936    }
937}
938
939// =============================================================================
940// Errors
941// =============================================================================
942
943/// HAR-related errors
944#[derive(Debug, Clone, PartialEq, Eq)]
945pub enum HarError {
946    /// JSON parsing error
947    ParseError(String),
948    /// JSON serialization error
949    SerializeError(String),
950    /// I/O error
951    IoError(String),
952    /// Request not found in HAR
953    NotFound(String),
954}
955
956impl std::fmt::Display for HarError {
957    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958        match self {
959            Self::ParseError(msg) => write!(f, "HAR parse error: {msg}"),
960            Self::SerializeError(msg) => write!(f, "HAR serialize error: {msg}"),
961            Self::IoError(msg) => write!(f, "HAR I/O error: {msg}"),
962            Self::NotFound(url) => write!(f, "Request not found in HAR: {url}"),
963        }
964    }
965}
966
967impl std::error::Error for HarError {}
968
969// =============================================================================
970// Helpers
971// =============================================================================
972
973/// Generate current ISO 8601 timestamp
974fn chrono_now_iso() -> String {
975    // Simple implementation without chrono dependency
976    "2024-01-01T00:00:00.000Z".to_string()
977}
978
979/// Check if URL matches pattern (simple contains match)
980fn url_matches_pattern(url: &str, pattern: &str) -> bool {
981    // Simple contains matching for now
982    // Strip glob wildcards for basic matching
983    let clean_pattern = pattern
984        .replace("**", "")
985        .replace('*', "")
986        .trim_matches('/')
987        .to_string();
988
989    if clean_pattern.is_empty() {
990        return true; // Empty pattern matches everything
991    }
992
993    url.contains(&clean_pattern)
994}
995
996// =============================================================================
997// Tests
998// =============================================================================
999
1000#[cfg(test)]
1001#[allow(clippy::unwrap_used, clippy::expect_used)]
1002mod tests {
1003    use super::*;
1004
1005    // =========================================================================
1006    // H₀-HAR-01 to H₀-HAR-10: HAR Structure Tests
1007    // =========================================================================
1008
1009    #[test]
1010    fn h0_har_01_new_creates_empty_har() {
1011        let har = Har::new();
1012        assert_eq!(har.log.version, "1.2");
1013        assert_eq!(har.entry_count(), 0);
1014    }
1015
1016    #[test]
1017    fn h0_har_02_log_has_probar_creator() {
1018        let har = Har::new();
1019        assert_eq!(har.log.creator.name, "Probar");
1020    }
1021
1022    #[test]
1023    fn h0_har_03_add_entry() {
1024        let mut har = Har::new();
1025        let entry = HarEntry::new(HarRequest::get("http://example.com"), HarResponse::ok());
1026        har.add_entry(entry);
1027        assert_eq!(har.entry_count(), 1);
1028    }
1029
1030    #[test]
1031    fn h0_har_04_find_by_url() {
1032        let mut har = Har::new();
1033        har.add_entry(HarEntry::new(
1034            HarRequest::get("http://example.com/api"),
1035            HarResponse::ok(),
1036        ));
1037        assert!(har.find_by_url("http://example.com/api").is_some());
1038        assert!(har.find_by_url("http://other.com").is_none());
1039    }
1040
1041    #[test]
1042    fn h0_har_05_serialization_roundtrip() {
1043        let mut har = Har::new();
1044        har.add_entry(HarEntry::new(
1045            HarRequest::get("http://test.com"),
1046            HarResponse::ok(),
1047        ));
1048        let json = har.to_json().unwrap();
1049        let parsed = Har::from_json(&json).unwrap();
1050        assert_eq!(parsed.entry_count(), 1);
1051    }
1052
1053    #[test]
1054    fn h0_har_06_request_get() {
1055        let req = HarRequest::get("http://test.com");
1056        assert_eq!(req.method, "GET");
1057        assert_eq!(req.url, "http://test.com");
1058    }
1059
1060    #[test]
1061    fn h0_har_07_request_post() {
1062        let req = HarRequest::post("http://test.com");
1063        assert_eq!(req.method, "POST");
1064    }
1065
1066    #[test]
1067    fn h0_har_08_request_with_header() {
1068        let req = HarRequest::get("http://test.com").with_header("Accept", "application/json");
1069        assert_eq!(req.headers.len(), 1);
1070        assert_eq!(req.headers[0].name, "Accept");
1071    }
1072
1073    #[test]
1074    fn h0_har_09_response_ok() {
1075        let resp = HarResponse::ok();
1076        assert_eq!(resp.status, 200);
1077        assert_eq!(resp.status_text, "OK");
1078    }
1079
1080    #[test]
1081    fn h0_har_10_response_not_found() {
1082        let resp = HarResponse::not_found();
1083        assert_eq!(resp.status, 404);
1084    }
1085
1086    // =========================================================================
1087    // H₀-HAR-11 to H₀-HAR-20: Content and Data Tests
1088    // =========================================================================
1089
1090    #[test]
1091    fn h0_har_11_response_with_json() {
1092        let resp = HarResponse::ok().with_json(r#"{"key": "value"}"#);
1093        assert_eq!(resp.content.mime_type, "application/json");
1094        assert!(resp.content.text.is_some());
1095    }
1096
1097    #[test]
1098    fn h0_har_12_content_json() {
1099        let content = HarContent::json(r#"{"test": true}"#);
1100        assert_eq!(content.mime_type, "application/json");
1101        assert!(content.size > 0);
1102    }
1103
1104    #[test]
1105    fn h0_har_13_content_text() {
1106        let content = HarContent::text("Hello, World!");
1107        assert_eq!(content.mime_type, "text/plain");
1108    }
1109
1110    #[test]
1111    fn h0_har_14_content_html() {
1112        let content = HarContent::html("<html></html>");
1113        assert_eq!(content.mime_type, "text/html");
1114    }
1115
1116    #[test]
1117    fn h0_har_15_post_data_json() {
1118        let data = HarPostData::json(r#"{"name": "test"}"#);
1119        assert_eq!(data.mime_type, "application/json");
1120    }
1121
1122    #[test]
1123    fn h0_har_16_post_data_form() {
1124        let data = HarPostData::form(vec![HarPostParam::new("field", "value")]);
1125        assert_eq!(data.mime_type, "application/x-www-form-urlencoded");
1126        assert_eq!(data.params.len(), 1);
1127    }
1128
1129    #[test]
1130    fn h0_har_17_cookie_creation() {
1131        let cookie = HarCookie::new("session", "abc123");
1132        assert_eq!(cookie.name, "session");
1133        assert_eq!(cookie.value, "abc123");
1134    }
1135
1136    #[test]
1137    fn h0_har_18_header_creation() {
1138        let header = HarHeader::new("X-Custom", "value");
1139        assert_eq!(header.name, "X-Custom");
1140        assert_eq!(header.value, "value");
1141    }
1142
1143    #[test]
1144    fn h0_har_19_query_param() {
1145        let param = HarQueryParam::new("page", "1");
1146        assert_eq!(param.name, "page");
1147        assert_eq!(param.value, "1");
1148    }
1149
1150    #[test]
1151    fn h0_har_20_entry_with_time() {
1152        let entry =
1153            HarEntry::new(HarRequest::get("http://test.com"), HarResponse::ok()).with_time(150.0);
1154        assert!((entry.time - 150.0).abs() < f64::EPSILON);
1155    }
1156
1157    // =========================================================================
1158    // H₀-HAR-21 to H₀-HAR-30: Recording Tests
1159    // =========================================================================
1160
1161    #[test]
1162    fn h0_har_21_recorder_new() {
1163        let recorder = HarRecorder::new("test.har");
1164        assert!(!recorder.is_active());
1165        assert_eq!(recorder.entry_count(), 0);
1166    }
1167
1168    #[test]
1169    fn h0_har_22_recorder_start_stop() {
1170        let mut recorder = HarRecorder::new("test.har");
1171        recorder.start();
1172        assert!(recorder.is_active());
1173        recorder.stop();
1174        assert!(!recorder.is_active());
1175    }
1176
1177    #[test]
1178    fn h0_har_23_recorder_record_when_active() {
1179        let mut recorder = HarRecorder::new("test.har");
1180        recorder.start();
1181        recorder.record(HarEntry::new(
1182            HarRequest::get("http://test.com"),
1183            HarResponse::ok(),
1184        ));
1185        assert_eq!(recorder.entry_count(), 1);
1186    }
1187
1188    #[test]
1189    fn h0_har_24_recorder_skip_when_inactive() {
1190        let mut recorder = HarRecorder::new("test.har");
1191        recorder.record(HarEntry::new(
1192            HarRequest::get("http://test.com"),
1193            HarResponse::ok(),
1194        ));
1195        assert_eq!(recorder.entry_count(), 0);
1196    }
1197
1198    #[test]
1199    fn h0_har_25_recorder_filter() {
1200        let mut recorder = HarRecorder::new("test.har");
1201        recorder.start();
1202        recorder.set_filter("/api/");
1203        recorder.record(HarEntry::new(
1204            HarRequest::get("http://test.com/api/users"),
1205            HarResponse::ok(),
1206        ));
1207        recorder.record(HarEntry::new(
1208            HarRequest::get("http://test.com/static/image.png"),
1209            HarResponse::ok(),
1210        ));
1211        // Only API request recorded (filter uses contains match)
1212        assert_eq!(recorder.entry_count(), 1);
1213    }
1214
1215    #[test]
1216    fn h0_har_26_options_default() {
1217        let options = HarOptions::default();
1218        assert_eq!(options.not_found, NotFoundBehavior::Fallback);
1219        assert!(!options.update);
1220    }
1221
1222    #[test]
1223    fn h0_har_27_options_abort_on_not_found() {
1224        let options = HarOptions::abort_on_not_found();
1225        assert_eq!(options.not_found, NotFoundBehavior::Abort);
1226    }
1227
1228    #[test]
1229    fn h0_har_28_options_with_update() {
1230        let options = HarOptions::default().with_update(true);
1231        assert!(options.update);
1232    }
1233
1234    #[test]
1235    fn h0_har_29_options_with_pattern() {
1236        let options = HarOptions::default().with_pattern("**/api/**");
1237        assert!(options.url_pattern.is_some());
1238    }
1239
1240    #[test]
1241    fn h0_har_30_recorder_har_access() {
1242        let recorder = HarRecorder::new("test.har");
1243        let har = recorder.har();
1244        assert_eq!(har.log.version, "1.2");
1245    }
1246
1247    // =========================================================================
1248    // H₀-HAR-31 to H₀-HAR-40: Playback Tests
1249    // =========================================================================
1250
1251    #[test]
1252    fn h0_har_31_player_new() {
1253        let har = Har::new();
1254        let player = HarPlayer::new(har, HarOptions::default());
1255        assert_eq!(player.entry_count(), 0);
1256    }
1257
1258    #[test]
1259    fn h0_har_32_player_find_response() {
1260        let mut har = Har::new();
1261        har.add_entry(HarEntry::new(
1262            HarRequest::get("http://test.com/api"),
1263            HarResponse::ok().with_json(r#"{"found": true}"#),
1264        ));
1265        let player = HarPlayer::new(har, HarOptions::default());
1266        let resp = player.find_response("GET", "http://test.com/api");
1267        assert!(resp.is_some());
1268        assert_eq!(resp.unwrap().status, 200);
1269    }
1270
1271    #[test]
1272    fn h0_har_33_player_not_found() {
1273        let har = Har::new();
1274        let player = HarPlayer::new(har, HarOptions::default());
1275        let resp = player.find_response("GET", "http://missing.com");
1276        assert!(resp.is_none());
1277    }
1278
1279    #[test]
1280    fn h0_har_34_player_not_found_behavior() {
1281        let player = HarPlayer::new(Har::new(), HarOptions::abort_on_not_found());
1282        assert_eq!(player.not_found_behavior(), NotFoundBehavior::Abort);
1283    }
1284
1285    #[test]
1286    fn h0_har_35_timings_default() {
1287        let timings = HarTimings::default();
1288        assert!(timings.blocked < 0.0);
1289        assert!(timings.dns < 0.0);
1290    }
1291
1292    #[test]
1293    fn h0_har_36_timings_total() {
1294        let mut timings = HarTimings::new();
1295        timings.send = 10.0;
1296        timings.wait = 50.0;
1297        timings.receive = 20.0;
1298        assert!((timings.total() - 80.0).abs() < f64::EPSILON);
1299    }
1300
1301    #[test]
1302    fn h0_har_37_browser_info() {
1303        let browser = HarBrowser::new("Chromium", "120.0");
1304        assert_eq!(browser.name, "Chromium");
1305        assert_eq!(browser.version, "120.0");
1306    }
1307
1308    #[test]
1309    fn h0_har_38_entry_with_server_ip() {
1310        let entry = HarEntry::new(HarRequest::get("http://test.com"), HarResponse::ok())
1311            .with_server_ip("192.168.1.1");
1312        assert_eq!(entry.server_ip_address, Some("192.168.1.1".to_string()));
1313    }
1314
1315    #[test]
1316    fn h0_har_39_error_display() {
1317        let err = HarError::ParseError("invalid json".to_string());
1318        assert!(format!("{err}").contains("parse error"));
1319    }
1320
1321    #[test]
1322    fn h0_har_40_error_not_found() {
1323        let err = HarError::NotFound("http://missing.com".to_string());
1324        assert!(format!("{err}").contains("not found"));
1325    }
1326
1327    // =========================================================================
1328    // H₀-HAR-41 to H₀-HAR-50: Advanced Tests
1329    // =========================================================================
1330
1331    #[test]
1332    fn h0_har_41_find_matching_empty() {
1333        let har = Har::new();
1334        let matches = har.find_matching("**/api/**");
1335        assert!(matches.is_empty());
1336    }
1337
1338    #[test]
1339    fn h0_har_42_cache_default() {
1340        let cache = HarCache::default();
1341        assert!(cache.before_request.is_none());
1342        assert!(cache.after_request.is_none());
1343    }
1344
1345    #[test]
1346    fn h0_har_43_response_with_header() {
1347        let resp = HarResponse::ok().with_header("X-Request-Id", "123");
1348        assert_eq!(resp.headers.len(), 1);
1349    }
1350
1351    #[test]
1352    fn h0_har_44_request_with_post_data() {
1353        let req = HarRequest::post("http://test.com").with_post_data(HarPostData::json(r#"{}"#));
1354        assert!(req.post_data.is_some());
1355    }
1356
1357    #[test]
1358    fn h0_har_45_response_with_content() {
1359        let resp = HarResponse::ok().with_content(HarContent::text("body"));
1360        assert_eq!(resp.content.text, Some("body".to_string()));
1361    }
1362
1363    #[test]
1364    fn h0_har_46_parse_error() {
1365        let result = Har::from_json("invalid json");
1366        assert!(result.is_err());
1367    }
1368
1369    #[test]
1370    fn h0_har_47_har_default() {
1371        let har = Har::default();
1372        assert_eq!(har.log.version, "1.2");
1373    }
1374
1375    #[test]
1376    fn h0_har_48_log_default() {
1377        let log = HarLog::default();
1378        assert!(log.entries.is_empty());
1379    }
1380
1381    #[test]
1382    fn h0_har_49_timings_new() {
1383        let timings = HarTimings::new();
1384        assert!(timings.ssl < 0.0);
1385    }
1386
1387    #[test]
1388    fn h0_har_50_content_default() {
1389        let content = HarContent::default();
1390        assert!(content.text.is_none());
1391        assert_eq!(content.size, 0);
1392    }
1393
1394    // =========================================================================
1395    // H₀-HAR-51 to H₀-HAR-70: Additional Coverage Tests
1396    // =========================================================================
1397
1398    #[test]
1399    fn h0_har_51_error_serialize_display() {
1400        let err = HarError::SerializeError("failed to serialize".to_string());
1401        let msg = format!("{err}");
1402        assert!(msg.contains("serialize error"));
1403        assert!(msg.contains("failed to serialize"));
1404    }
1405
1406    #[test]
1407    fn h0_har_52_error_io_display() {
1408        let err = HarError::IoError("file not found".to_string());
1409        let msg = format!("{err}");
1410        assert!(msg.contains("I/O error"));
1411        assert!(msg.contains("file not found"));
1412    }
1413
1414    #[test]
1415    fn h0_har_53_options_fallback_on_not_found() {
1416        let options = HarOptions::fallback_on_not_found();
1417        assert_eq!(options.not_found, NotFoundBehavior::Fallback);
1418        assert!(!options.update);
1419        assert!(options.url_pattern.is_none());
1420    }
1421
1422    #[test]
1423    fn h0_har_54_player_find_response_with_pattern_no_match() {
1424        let mut har = Har::new();
1425        har.add_entry(HarEntry::new(
1426            HarRequest::get("http://test.com/users"),
1427            HarResponse::ok(),
1428        ));
1429        let options = HarOptions::default().with_pattern("/api/");
1430        let player = HarPlayer::new(har, options);
1431        // URL doesn't match pattern, should return None
1432        let resp = player.find_response("GET", "http://test.com/users");
1433        assert!(resp.is_none());
1434    }
1435
1436    #[test]
1437    fn h0_har_55_player_find_response_with_pattern_match() {
1438        let mut har = Har::new();
1439        har.add_entry(HarEntry::new(
1440            HarRequest::get("http://test.com/api/users"),
1441            HarResponse::ok().with_json(r#"{"users": []}"#),
1442        ));
1443        let options = HarOptions::default().with_pattern("/api/");
1444        let player = HarPlayer::new(har, options);
1445        let resp = player.find_response("GET", "http://test.com/api/users");
1446        assert!(resp.is_some());
1447        assert_eq!(resp.unwrap().status, 200);
1448    }
1449
1450    #[test]
1451    fn h0_har_56_player_find_response_method_mismatch() {
1452        let mut har = Har::new();
1453        har.add_entry(HarEntry::new(
1454            HarRequest::get("http://test.com/api"),
1455            HarResponse::ok(),
1456        ));
1457        let player = HarPlayer::new(har, HarOptions::default());
1458        // Wrong method
1459        let resp = player.find_response("POST", "http://test.com/api");
1460        assert!(resp.is_none());
1461    }
1462
1463    #[test]
1464    fn h0_har_57_timings_total_with_positive_values() {
1465        let mut timings = HarTimings::new();
1466        timings.blocked = 5.0;
1467        timings.dns = 10.0;
1468        timings.connect = 15.0;
1469        timings.send = 2.0;
1470        timings.wait = 100.0;
1471        timings.receive = 50.0;
1472        // Total should be 5 + 10 + 15 + 2 + 100 + 50 = 182
1473        assert!((timings.total() - 182.0).abs() < f64::EPSILON);
1474    }
1475
1476    #[test]
1477    fn h0_har_58_timings_total_with_mixed_values() {
1478        let mut timings = HarTimings::new();
1479        // blocked and dns remain -1 (not applicable)
1480        timings.connect = 20.0;
1481        timings.send = 5.0;
1482        timings.wait = 30.0;
1483        timings.receive = 10.0;
1484        // Total = 20 + 5 + 30 + 10 = 65 (blocked/dns excluded)
1485        assert!((timings.total() - 65.0).abs() < f64::EPSILON);
1486    }
1487
1488    #[test]
1489    fn h0_har_59_find_matching_with_matches() {
1490        let mut har = Har::new();
1491        har.add_entry(HarEntry::new(
1492            HarRequest::get("http://test.com/api/users"),
1493            HarResponse::ok(),
1494        ));
1495        har.add_entry(HarEntry::new(
1496            HarRequest::get("http://test.com/api/posts"),
1497            HarResponse::ok(),
1498        ));
1499        har.add_entry(HarEntry::new(
1500            HarRequest::get("http://test.com/static/image.png"),
1501            HarResponse::ok(),
1502        ));
1503        let matches = har.find_matching("/api/");
1504        assert_eq!(matches.len(), 2);
1505    }
1506
1507    #[test]
1508    fn h0_har_60_url_matches_pattern_glob_wildcards() {
1509        // Pattern with ** glob
1510        assert!(url_matches_pattern(
1511            "http://test.com/api/users",
1512            "**/api/**"
1513        ));
1514        // Pattern with * glob
1515        assert!(url_matches_pattern("http://test.com/api/test", "*/api/*"));
1516        // Exact substring
1517        assert!(url_matches_pattern("http://example.com/path", "example"));
1518        // No match
1519        assert!(!url_matches_pattern("http://other.com", "example"));
1520    }
1521
1522    #[test]
1523    fn h0_har_61_url_matches_pattern_empty() {
1524        // Empty pattern after stripping globs matches everything
1525        assert!(url_matches_pattern("http://any.com/path", "**"));
1526        assert!(url_matches_pattern("http://any.com/path", "*"));
1527        assert!(url_matches_pattern("http://any.com/path", ""));
1528    }
1529
1530    #[test]
1531    fn h0_har_62_recorder_save_error() {
1532        let recorder = HarRecorder::new("/nonexistent/path/that/cannot/be/written/test.har");
1533        let result = recorder.save();
1534        assert!(result.is_err());
1535        if let Err(HarError::IoError(msg)) = result {
1536            assert!(!msg.is_empty());
1537        } else {
1538            panic!("Expected IoError");
1539        }
1540    }
1541
1542    #[test]
1543    fn h0_har_63_player_from_file_not_found() {
1544        let result = HarPlayer::from_file("/nonexistent/file.har", HarOptions::default());
1545        assert!(result.is_err());
1546        if let Err(HarError::IoError(msg)) = result {
1547            assert!(!msg.is_empty());
1548        } else {
1549            panic!("Expected IoError");
1550        }
1551    }
1552
1553    #[test]
1554    fn h0_har_64_player_from_file_invalid_json() {
1555        // Create a temp file with invalid JSON
1556        let temp_path = std::env::temp_dir().join("test_invalid_har.json");
1557        std::fs::write(&temp_path, "not valid json").unwrap();
1558        let result = HarPlayer::from_file(&temp_path, HarOptions::default());
1559        std::fs::remove_file(&temp_path).ok();
1560        assert!(result.is_err());
1561        if let Err(HarError::ParseError(msg)) = result {
1562            assert!(!msg.is_empty());
1563        } else {
1564            panic!("Expected ParseError");
1565        }
1566    }
1567
1568    #[test]
1569    fn h0_har_65_recorder_save_and_load_roundtrip() {
1570        let temp_path = std::env::temp_dir().join("test_har_roundtrip.har");
1571        let mut recorder = HarRecorder::new(&temp_path);
1572        recorder.start();
1573        recorder.record(HarEntry::new(
1574            HarRequest::get("http://test.com/api").with_header("Accept", "application/json"),
1575            HarResponse::ok().with_json(r#"{"status": "ok"}"#),
1576        ));
1577        recorder.stop();
1578        recorder.save().expect("Save should succeed");
1579
1580        let player = HarPlayer::from_file(&temp_path, HarOptions::default()).unwrap();
1581        assert_eq!(player.entry_count(), 1);
1582        let resp = player.find_response("GET", "http://test.com/api");
1583        assert!(resp.is_some());
1584        assert_eq!(resp.unwrap().status, 200);
1585
1586        std::fs::remove_file(&temp_path).ok();
1587    }
1588
1589    #[test]
1590    fn h0_har_66_har_error_implements_error_trait() {
1591        let err: Box<dyn std::error::Error> = Box::new(HarError::NotFound("test".to_string()));
1592        // Just verify it compiles and can be used as Box<dyn Error>
1593        assert!(!err.to_string().is_empty());
1594    }
1595
1596    #[test]
1597    fn h0_har_67_request_new_custom_method() {
1598        let req = HarRequest::new("DELETE", "http://test.com/resource/123");
1599        assert_eq!(req.method, "DELETE");
1600        assert_eq!(req.url, "http://test.com/resource/123");
1601        assert_eq!(req.http_version, "HTTP/1.1");
1602        assert!(req.cookies.is_empty());
1603        assert!(req.headers.is_empty());
1604        assert!(req.query_string.is_empty());
1605        assert!(req.post_data.is_none());
1606        assert_eq!(req.headers_size, -1);
1607        assert_eq!(req.body_size, -1);
1608    }
1609
1610    #[test]
1611    fn h0_har_68_response_new_custom_status() {
1612        let resp = HarResponse::new(201, "Created");
1613        assert_eq!(resp.status, 201);
1614        assert_eq!(resp.status_text, "Created");
1615        assert_eq!(resp.http_version, "HTTP/1.1");
1616        assert!(resp.cookies.is_empty());
1617        assert!(resp.headers.is_empty());
1618        assert!(resp.redirect_url.is_empty());
1619        assert_eq!(resp.headers_size, -1);
1620        assert_eq!(resp.body_size, -1);
1621    }
1622
1623    #[test]
1624    fn h0_har_69_chrono_now_iso_format() {
1625        // Verify the timestamp format is valid ISO 8601
1626        let timestamp = chrono_now_iso();
1627        assert!(timestamp.ends_with('Z'));
1628        assert!(timestamp.contains('T'));
1629        assert!(timestamp.contains('-'));
1630    }
1631
1632    #[test]
1633    fn h0_har_70_find_by_url_multiple_entries() {
1634        let mut har = Har::new();
1635        har.add_entry(HarEntry::new(
1636            HarRequest::get("http://test.com/first"),
1637            HarResponse::new(200, "First"),
1638        ));
1639        har.add_entry(HarEntry::new(
1640            HarRequest::get("http://test.com/second"),
1641            HarResponse::new(201, "Second"),
1642        ));
1643        har.add_entry(HarEntry::new(
1644            HarRequest::get("http://test.com/first"),
1645            HarResponse::new(202, "First Again"),
1646        ));
1647        // find_by_url returns the first match
1648        let entry = har.find_by_url("http://test.com/first");
1649        assert!(entry.is_some());
1650        assert_eq!(entry.unwrap().response.status, 200);
1651    }
1652
1653    #[test]
1654    fn h0_har_71_har_log_browser_and_comment() {
1655        let mut log = HarLog::new();
1656        log.browser = Some(HarBrowser::new("Firefox", "115.0"));
1657        log.comment = Some("Test HAR log".to_string());
1658        assert!(log.browser.is_some());
1659        assert_eq!(log.browser.as_ref().unwrap().name, "Firefox");
1660        assert!(log.comment.is_some());
1661    }
1662
1663    #[test]
1664    fn h0_har_72_cookie_optional_fields() {
1665        let mut cookie = HarCookie::new("session", "abc123");
1666        cookie.path = Some("/".to_string());
1667        cookie.domain = Some("example.com".to_string());
1668        cookie.expires = Some("2025-01-01T00:00:00Z".to_string());
1669        cookie.http_only = Some(true);
1670        cookie.secure = Some(true);
1671        cookie.comment = Some("Session cookie".to_string());
1672
1673        assert_eq!(cookie.path, Some("/".to_string()));
1674        assert_eq!(cookie.domain, Some("example.com".to_string()));
1675        assert_eq!(cookie.http_only, Some(true));
1676        assert_eq!(cookie.secure, Some(true));
1677    }
1678
1679    #[test]
1680    fn h0_har_73_post_param_file_upload() {
1681        let mut param = HarPostParam::new("file", "");
1682        param.value = None;
1683        param.file_name = Some("document.pdf".to_string());
1684        param.content_type = Some("application/pdf".to_string());
1685        param.comment = Some("Uploaded file".to_string());
1686
1687        assert!(param.value.is_none());
1688        assert_eq!(param.file_name, Some("document.pdf".to_string()));
1689        assert_eq!(param.content_type, Some("application/pdf".to_string()));
1690    }
1691
1692    #[test]
1693    fn h0_har_74_content_with_encoding() {
1694        let mut content = HarContent::json(r#"{"data": "test"}"#);
1695        content.encoding = Some("base64".to_string());
1696        content.compression = Some(100);
1697        content.comment = Some("Compressed content".to_string());
1698
1699        assert_eq!(content.encoding, Some("base64".to_string()));
1700        assert_eq!(content.compression, Some(100));
1701    }
1702
1703    #[test]
1704    fn h0_har_75_cache_state_fields() {
1705        let state = HarCacheState {
1706            expires: Some("2025-12-31T23:59:59Z".to_string()),
1707            last_access: Some("2024-01-01T00:00:00Z".to_string()),
1708            etag: Some("abc123".to_string()),
1709            hit_count: Some(42),
1710            comment: Some("Cache hit".to_string()),
1711        };
1712
1713        assert_eq!(state.hit_count, Some(42));
1714        assert!(state.etag.is_some());
1715    }
1716
1717    #[test]
1718    fn h0_har_76_cache_with_states() {
1719        let mut cache = HarCache::default();
1720        cache.before_request = Some(HarCacheState {
1721            expires: None,
1722            last_access: None,
1723            etag: Some("before".to_string()),
1724            hit_count: Some(1),
1725            comment: None,
1726        });
1727        cache.after_request = Some(HarCacheState {
1728            expires: None,
1729            last_access: None,
1730            etag: Some("after".to_string()),
1731            hit_count: Some(2),
1732            comment: None,
1733        });
1734        cache.comment = Some("Cache test".to_string());
1735
1736        assert!(cache.before_request.is_some());
1737        assert!(cache.after_request.is_some());
1738        assert_eq!(
1739            cache.before_request.as_ref().unwrap().etag,
1740            Some("before".to_string())
1741        );
1742    }
1743
1744    #[test]
1745    fn h0_har_77_timings_with_comment() {
1746        let mut timings = HarTimings::new();
1747        timings.comment = Some("Timing comment".to_string());
1748        assert!(timings.comment.is_some());
1749    }
1750
1751    #[test]
1752    fn h0_har_78_entry_optional_fields() {
1753        let mut entry = HarEntry::new(HarRequest::get("http://test.com"), HarResponse::ok());
1754        entry.connection = Some("1234".to_string());
1755        entry.comment = Some("Entry comment".to_string());
1756
1757        assert_eq!(entry.connection, Some("1234".to_string()));
1758        assert!(entry.comment.is_some());
1759    }
1760
1761    #[test]
1762    fn h0_har_79_browser_with_comment() {
1763        let mut browser = HarBrowser::new("Chrome", "120.0");
1764        browser.comment = Some("Browser comment".to_string());
1765        assert!(browser.comment.is_some());
1766    }
1767
1768    #[test]
1769    fn h0_har_80_creator_has_version() {
1770        let creator = HarCreator::probar();
1771        assert_eq!(creator.name, "Probar");
1772        // Version should be cargo package version
1773        assert!(!creator.version.is_empty());
1774        assert!(creator.comment.is_none());
1775    }
1776
1777    #[test]
1778    fn h0_har_81_header_with_comment() {
1779        let mut header = HarHeader::new("Content-Type", "application/json");
1780        header.comment = Some("Header comment".to_string());
1781        assert!(header.comment.is_some());
1782    }
1783
1784    #[test]
1785    fn h0_har_82_query_param_with_comment() {
1786        let mut param = HarQueryParam::new("page", "1");
1787        param.comment = Some("Pagination".to_string());
1788        assert!(param.comment.is_some());
1789    }
1790
1791    #[test]
1792    fn h0_har_83_post_data_with_comment() {
1793        let mut data = HarPostData::json(r#"{"test": true}"#);
1794        data.comment = Some("POST body".to_string());
1795        assert!(data.comment.is_some());
1796    }
1797
1798    #[test]
1799    fn h0_har_84_request_with_comment() {
1800        let mut req = HarRequest::get("http://test.com");
1801        req.comment = Some("Test request".to_string());
1802        assert!(req.comment.is_some());
1803    }
1804
1805    #[test]
1806    fn h0_har_85_response_with_comment() {
1807        let mut resp = HarResponse::ok();
1808        resp.comment = Some("Test response".to_string());
1809        assert!(resp.comment.is_some());
1810    }
1811
1812    #[test]
1813    fn h0_har_86_serialization_with_all_optional_fields() {
1814        let mut har = Har::new();
1815        har.log.browser = Some(HarBrowser::new("Chrome", "120.0"));
1816        har.log.comment = Some("Test log".to_string());
1817
1818        let mut entry = HarEntry::new(HarRequest::get("http://test.com"), HarResponse::ok())
1819            .with_server_ip("127.0.0.1")
1820            .with_time(100.0);
1821        entry.connection = Some("conn-1".to_string());
1822        entry.comment = Some("Entry".to_string());
1823
1824        har.add_entry(entry);
1825
1826        // Serialize and deserialize
1827        let json = har.to_json().unwrap();
1828        let parsed = Har::from_json(&json).unwrap();
1829
1830        assert!(parsed.log.browser.is_some());
1831        assert!(parsed.log.comment.is_some());
1832        assert_eq!(parsed.entry_count(), 1);
1833    }
1834
1835    #[test]
1836    fn h0_har_87_timings_zero_values() {
1837        let mut timings = HarTimings::new();
1838        timings.blocked = 0.0; // Zero, not negative
1839        timings.dns = 0.0;
1840        timings.connect = 0.0;
1841        timings.send = 0.0;
1842        timings.wait = 0.0;
1843        timings.receive = 0.0;
1844        // Zero values for blocked/dns/connect should NOT be added (only > 0)
1845        assert!((timings.total() - 0.0).abs() < f64::EPSILON);
1846    }
1847
1848    #[test]
1849    fn h0_har_88_url_pattern_with_slashes() {
1850        // Pattern with leading/trailing slashes should be trimmed
1851        assert!(url_matches_pattern(
1852            "http://test.com/api/v1/users",
1853            "/api/v1/"
1854        ));
1855        assert!(url_matches_pattern(
1856            "http://test.com/api/v1/users",
1857            "api/v1"
1858        ));
1859    }
1860
1861    #[test]
1862    fn h0_har_89_find_matching_partial_pattern() {
1863        let mut har = Har::new();
1864        har.add_entry(HarEntry::new(
1865            HarRequest::get("http://example.com/users/123"),
1866            HarResponse::ok(),
1867        ));
1868        har.add_entry(HarEntry::new(
1869            HarRequest::get("http://other.com/posts"),
1870            HarResponse::ok(),
1871        ));
1872        // Pattern "users" should match first entry
1873        let matches = har.find_matching("users");
1874        assert_eq!(matches.len(), 1);
1875        assert_eq!(matches[0].request.url, "http://example.com/users/123");
1876    }
1877
1878    #[test]
1879    fn h0_har_90_not_found_behavior_equality() {
1880        assert_eq!(NotFoundBehavior::Abort, NotFoundBehavior::Abort);
1881        assert_eq!(NotFoundBehavior::Fallback, NotFoundBehavior::Fallback);
1882        assert_ne!(NotFoundBehavior::Abort, NotFoundBehavior::Fallback);
1883    }
1884}