Skip to main content

ferrous_browser/
har.rs

1use serde::Serialize;
2use serde_json::{json, Value};
3use std::collections::HashMap;
4use std::sync::Arc;
5use tokio::sync::Mutex;
6
7use crate::cdp::CDPClient;
8use crate::error::Result;
9
10/// A single HTTP Archive (HAR) entry representing a network request/response pair.
11#[derive(Debug, Clone, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct HarEntry {
14    /// Unique page reference within the HAR
15    pub pageref: String,
16    /// ISO 8601 timestamp when the request started
17    pub started_date_time: String,
18    /// Total elapsed time in milliseconds
19    pub time: f64,
20    /// Request details
21    pub request: HarRequest,
22    /// Response details
23    pub response: HarResponse,
24    /// Cache state
25    pub cache: Value,
26    /// Timing breakdown
27    pub timings: HarTimings,
28    /// Server IP address (if available)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub server_ip_address: Option<String>,
31    /// Connection UUID (if available)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub connection: Option<String>,
34}
35
36/// HAR request object
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct HarRequest {
40    /// HTTP method
41    pub method: String,
42    /// Request URL
43    pub url: String,
44    /// HTTP version
45    pub http_version: String,
46    /// Request headers as name-value pairs
47    pub headers: Vec<HarHeader>,
48    /// Query string parameters
49    pub query_string: Vec<HarQueryParam>,
50    /// Size of request headers in bytes (-1 if unknown)
51    pub headers_size: i64,
52    /// Size of request body in bytes (-1 if unknown)
53    pub body_size: i64,
54    /// POST data (if applicable)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub post_data: Option<HarPostData>,
57}
58
59/// HAR response object
60#[derive(Debug, Clone, Serialize)]
61#[serde(rename_all = "camelCase")]
62pub struct HarResponse {
63    /// HTTP status code
64    pub status: i64,
65    /// HTTP status text
66    pub status_text: String,
67    /// HTTP version
68    pub http_version: String,
69    /// Response headers as name-value pairs
70    pub headers: Vec<HarHeader>,
71    /// Response cookies
72    pub cookies: Vec<HarCookie>,
73    /// Response content metadata
74    pub content: HarContent,
75    /// Redirect URL (empty if no redirect)
76    pub redirect_url: String,
77    /// Size of response headers in bytes (-1 if unknown)
78    pub headers_size: i64,
79    /// Size of response body in bytes (-1 if unknown)
80    pub body_size: i64,
81}
82
83/// HAR timing object (all values in milliseconds)
84#[derive(Debug, Clone, Serialize)]
85pub struct HarTimings {
86    /// Time spent in DNS lookup
87    pub dns: f64,
88    /// Time spent in TCP connection
89    pub connect: f64,
90    /// Time spent in TLS handshake
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub ssl: Option<f64>,
93    /// Time spent sending request
94    pub send: f64,
95    /// Time spent waiting for response
96    pub wait: f64,
97    /// Time spent receiving response
98    pub receive: f64,
99    /// Total blocked time
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub blocked: Option<f64>,
102}
103
104/// HAR header name-value pair
105#[derive(Debug, Clone, Serialize)]
106pub struct HarHeader {
107    /// Header name
108    pub name: String,
109    /// Header value
110    pub value: String,
111}
112
113/// HAR query string parameter
114#[derive(Debug, Clone, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub struct HarQueryParam {
117    /// Parameter name
118    pub name: String,
119    /// Parameter value
120    pub value: String,
121}
122
123/// HAR POST data
124#[derive(Debug, Clone, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub struct HarPostData {
127    /// MIME type of the POST data
128    pub mime_type: String,
129    /// POST body text
130    pub text: String,
131}
132
133/// HAR cookie
134#[derive(Debug, Clone, Serialize)]
135pub struct HarCookie {
136    /// Cookie name
137    pub name: String,
138    /// Cookie value
139    pub value: String,
140    /// Cookie path
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub path: Option<String>,
143    /// Cookie domain
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub domain: Option<String>,
146    /// Expiry timestamp
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub expires: Option<String>,
149    /// Whether cookie is HTTP-only
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub http_only: Option<bool>,
152    /// Whether cookie is secure-only
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub secure: Option<bool>,
155}
156
157/// HAR content metadata
158#[derive(Debug, Clone, Serialize)]
159#[serde(rename_all = "camelCase")]
160pub struct HarContent {
161    /// Size of the response body in bytes
162    pub size: i64,
163    /// MIME type of the response
164    pub mime_type: String,
165    /// Optional compression saving
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub compression: Option<i64>,
168}
169
170/// Full HAR log structure
171#[derive(Debug, Clone, Serialize)]
172pub struct HarLog {
173    /// HAR spec version (e.g. "1.2")
174    pub version: String,
175    /// Tool that created the HAR
176    pub creator: Value,
177    /// Array of captured request/response entries
178    pub entries: Vec<HarEntry>,
179}
180
181/// Top-level HAR structure matching the HTTP Archive spec
182#[derive(Debug, Clone, Serialize)]
183pub struct HarArchive {
184    /// The log container
185    pub log: HarLog,
186}
187
188// Internal state for building HAR entries from CDP network events.
189// We track pending requests by requestId until they complete or fail.
190#[derive(Debug, Clone)]
191struct PendingRequest {
192    method: String,
193    url: String,
194    http_version: String,
195    request_headers: Vec<HarHeader>,
196    query_string: Vec<HarQueryParam>,
197    post_data: Option<HarPostData>,
198    request_timestamp: f64,
199    status: Option<i64>,
200    status_text: Option<String>,
201    response_headers: Vec<HarHeader>,
202    response_cookies: Vec<HarCookie>,
203    mime_type: Option<String>,
204    redirect_url: String,
205    headers_size: i64,
206    body_size: i64,
207    server_ip_address: Option<String>,
208    connection: Option<String>,
209    timing_dns: f64,
210    timing_connect: f64,
211    timing_ssl: Option<f64>,
212    timing_send: f64,
213    timing_wait: f64,
214    timing_receive: f64,
215    timing_blocked: Option<f64>,
216}
217
218impl PendingRequest {
219    fn new(method: String, url: String, request_timestamp: f64) -> Self {
220        Self {
221            method,
222            url,
223            http_version: String::new(),
224            request_headers: Vec::new(),
225            query_string: Vec::new(),
226            post_data: None,
227            request_timestamp,
228            status: None,
229            status_text: None,
230            response_headers: Vec::new(),
231            response_cookies: Vec::new(),
232            mime_type: None,
233            redirect_url: String::new(),
234            headers_size: -1,
235            body_size: -1,
236            server_ip_address: None,
237            connection: None,
238            timing_dns: -1.0,
239            timing_connect: -1.0,
240            timing_ssl: None,
241            timing_send: -1.0,
242            timing_wait: -1.0,
243            timing_receive: -1.0,
244            timing_blocked: None,
245        }
246    }
247
248    fn finish(self, response_timestamp: f64) -> HarEntry {
249        let time_ms = ((response_timestamp - self.request_timestamp) * 1000.0).max(0.0);
250        let iso = iso_timestamp(self.request_timestamp);
251
252        let http_version = self.http_version;
253        let method = self.method;
254        let url = self.url;
255        let request_headers = self.request_headers;
256        let query_string = self.query_string;
257        let headers_size = self.headers_size;
258        let body_size = self.body_size;
259        let post_data = self.post_data;
260        let status = self.status.unwrap_or(0);
261        let status_text = self.status_text.unwrap_or_default();
262        let response_headers = self.response_headers;
263        let response_cookies = self.response_cookies;
264        let mime_type = self.mime_type.unwrap_or_default();
265        let redirect_url = self.redirect_url;
266        let server_ip_address = self.server_ip_address;
267        let connection = self.connection;
268        let timing_dns = self.timing_dns;
269        let timing_connect = self.timing_connect;
270        let timing_ssl = self.timing_ssl;
271        let timing_send = self.timing_send;
272        let timing_wait = self.timing_wait;
273        let timing_receive = self.timing_receive;
274        let timing_blocked = self.timing_blocked;
275
276        HarEntry {
277            pageref: "page_1".to_string(),
278            started_date_time: iso,
279            time: time_ms,
280            request: HarRequest {
281                method,
282                url,
283                http_version: http_version.clone(),
284                headers: request_headers,
285                query_string,
286                headers_size,
287                body_size,
288                post_data,
289            },
290            response: HarResponse {
291                status,
292                status_text,
293                http_version,
294                headers: response_headers,
295                cookies: response_cookies,
296                content: HarContent {
297                    size: body_size.max(0),
298                    mime_type,
299                    compression: None,
300                },
301                redirect_url,
302                headers_size,
303                body_size,
304            },
305            cache: json!({}),
306            timings: HarTimings {
307                dns: timing_dns,
308                connect: timing_connect,
309                ssl: timing_ssl,
310                send: timing_send,
311                wait: timing_wait,
312                receive: timing_receive,
313                blocked: timing_blocked,
314            },
315            server_ip_address,
316            connection,
317        }
318    }
319}
320
321struct CaptureState {
322    pending: HashMap<String, PendingRequest>,
323    entries: Vec<HarEntry>,
324}
325
326/// Captures HTTP Archive (HAR) data for a page by listening to Chrome's
327/// Network domain events. Start it via [`crate::Page::start_har_capture`].
328///
329/// # Example
330///
331/// ```no_run
332/// # use ferrous_browser::{Browser, WaitUntil};
333/// # #[tokio::main]
334/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
335/// let browser = Browser::launch_chrome(None).await?;
336/// let page = browser.new_page().await?;
337///
338/// let har = page.start_har_capture().await?;
339/// page.goto("https://example.com", WaitUntil::Load).await?;
340///
341/// let archive = har.export().await;
342/// let json = serde_json::to_string_pretty(&archive)?;
343/// std::fs::write("trace.har", json)?;
344/// # Ok(())
345/// # }
346/// ```
347pub struct HarCapture {
348    cdp: Arc<CDPClient>,
349    session_id: String,
350    state: Arc<Mutex<CaptureState>>,
351}
352
353impl HarCapture {
354    pub(crate) fn new(cdp: Arc<CDPClient>, session_id: String) -> Self {
355        Self {
356            cdp,
357            session_id,
358            state: Arc::new(Mutex::new(CaptureState {
359                pending: HashMap::new(),
360                entries: Vec::new(),
361            })),
362        }
363    }
364
365    /// Listen to CDP Network events and accumulate HAR entries.
366    /// Call this to start capturing. Events are processed in a background
367    /// task until `stop` or the `HarCapture` is dropped.
368    pub async fn start(&self) -> Result<()> {
369        // Enable Network domain — required for events
370        self.cdp
371            .send_command_with_session(&self.session_id, "Network.enable".to_string(), None)
372            .await?;
373
374        let mut rx = self.cdp.subscribe_events();
375        let session_id = self.session_id.clone();
376        let state = self.state.clone();
377
378        tokio::spawn(async move {
379            loop {
380                match rx.recv().await {
381                    Ok(msg) if msg.session_id.as_deref() == Some(&session_id) => {
382                        Self::handle_event(&state, msg.method.as_deref(), msg.params).await;
383                    }
384                    Ok(_) => {} // different session
385                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
386                    Err(_) => return, // channel closed
387                }
388            }
389        });
390
391        Ok(())
392    }
393
394    async fn handle_event(
395        state: &Arc<Mutex<CaptureState>>,
396        method: Option<&str>,
397        params: Option<Value>,
398    ) {
399        let Some(params) = params else { return };
400        match method {
401            Some("Network.requestWillBeSent") => {
402                Self::handle_request_will_be_sent(state, params).await;
403            }
404            Some("Network.responseReceived") => {
405                Self::handle_response_received(state, params).await;
406            }
407            Some("Network.loadingFinished") => {
408                Self::handle_loading_finished(state, params).await;
409            }
410            Some("Network.loadingFailed") => {
411                Self::handle_loading_failed(state, params).await;
412            }
413            _ => {}
414        }
415    }
416
417    async fn handle_request_will_be_sent(state: &Arc<Mutex<CaptureState>>, params: Value) {
418        let request_id = params
419            .get("requestId")
420            .and_then(|v| v.as_str())
421            .map(|s| s.to_string());
422        let request = match params.get("request") {
423            Some(r) => r,
424            None => return,
425        };
426        let method = match request.get("method").and_then(|v| v.as_str()) {
427            Some(m) => m.to_string(),
428            None => return,
429        };
430        let url = match request.get("url").and_then(|v| v.as_str()) {
431            Some(u) => u.to_string(),
432            None => return,
433        };
434        let ts = params
435            .get("timestamp")
436            .and_then(|v| v.as_f64())
437            .unwrap_or(0.0);
438
439        // Determine HTTP version before moving `url`
440        let http_version = {
441            if let Some(ver) = request.get("headers").and_then(|h| h.get(":version")) {
442                ver.as_str().unwrap_or("HTTP/2.0").to_string()
443            } else if url.starts_with("https") {
444                "HTTP/2.0".to_string()
445            } else {
446                "HTTP/1.1".to_string()
447            }
448        };
449
450        let mut pending = PendingRequest::new(method, url, ts);
451        pending.http_version = http_version;
452
453        // Request headers
454        if let Some(headers) = request.get("headers").and_then(|h| h.as_object()) {
455            for (name, value) in headers {
456                if let Some(val) = value.as_str() {
457                    if !name.starts_with(':') {
458                        pending.request_headers.push(HarHeader {
459                            name: name.clone(),
460                            value: val.to_string(),
461                        });
462                    }
463                }
464            }
465        }
466
467        // Query string
468        if let Some(qs) = request.get("queryString").and_then(|v| v.as_array()) {
469            for param in qs {
470                if let (Some(name), Some(value)) = (
471                    param.get("name").and_then(|v| v.as_str()),
472                    param.get("value").and_then(|v| v.as_str()),
473                ) {
474                    pending.query_string.push(HarQueryParam {
475                        name: name.to_string(),
476                        value: value.to_string(),
477                    });
478                }
479            }
480        }
481
482        // POST data
483        if let Some(post) = request.get("postData") {
484            if let (Some(text), mime) = (
485                post.get("text").and_then(|v| v.as_str()),
486                post.get("mimeType")
487                    .and_then(|v| v.as_str())
488                    .unwrap_or("application/octet-stream"),
489            ) {
490                pending.post_data = Some(HarPostData {
491                    mime_type: mime.to_string(),
492                    text: text.to_string(),
493                });
494            }
495        }
496
497        let mut guard = state.lock().await;
498        if let Some(id) = request_id {
499            guard.pending.insert(id, pending);
500        }
501    }
502
503    async fn handle_response_received(state: &Arc<Mutex<CaptureState>>, params: Value) {
504        let request_id = params
505            .get("requestId")
506            .and_then(|v| v.as_str())
507            .map(|s| s.to_string());
508        let response = match params.get("response") {
509            Some(r) => r,
510            None => return,
511        };
512
513        let status = response.get("status").and_then(|v| v.as_i64()).unwrap_or(0);
514        let status_text = response
515            .get("statusText")
516            .and_then(|v| v.as_str())
517            .unwrap_or("")
518            .to_string();
519        let mime_type = response
520            .get("mimeType")
521            .and_then(|v| v.as_str())
522            .unwrap_or("")
523            .to_string();
524        let remote_ip = response
525            .get("remoteIPAddress")
526            .and_then(|v| v.as_str())
527            .map(|s| s.to_string());
528        let connection_id = response
529            .get("connectionId")
530            .and_then(|v| v.as_str())
531            .map(|s| s.to_string());
532
533        // Response headers
534        let mut resp_headers = Vec::new();
535        if let Some(headers) = response.get("headers").and_then(|h| h.as_object()) {
536            for (name, value) in headers {
537                if let Some(val) = value.as_str() {
538                    if !name.starts_with(':') {
539                        resp_headers.push(HarHeader {
540                            name: name.clone(),
541                            value: val.to_string(),
542                        });
543                    }
544                }
545            }
546        }
547
548        // Cookies
549        let mut cookies = Vec::new();
550        if let Some(cookie_array) = response.get("cookies").and_then(|c| c.as_array()) {
551            for c in cookie_array {
552                let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
553                let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("");
554                cookies.push(HarCookie {
555                    name: name.to_string(),
556                    value: value.to_string(),
557                    path: c
558                        .get("path")
559                        .and_then(|v| v.as_str())
560                        .map(|s| s.to_string()),
561                    domain: c
562                        .get("domain")
563                        .and_then(|v| v.as_str())
564                        .map(|s| s.to_string()),
565                    expires: c
566                        .get("expires")
567                        .and_then(|v| v.as_str())
568                        .map(|s| s.to_string()),
569                    http_only: c.get("httpOnly").and_then(|v| v.as_bool()),
570                    secure: c.get("secure").and_then(|v| v.as_bool()),
571                });
572            }
573        }
574
575        // Timing
576        let timings = response.get("timing");
577        let dns = timings
578            .and_then(|t| t.get("dns"))
579            .and_then(|v| v.as_f64())
580            .unwrap_or(-1.0);
581        let connect = timings
582            .and_then(|t| t.get("connect"))
583            .and_then(|v| v.as_f64())
584            .unwrap_or(-1.0);
585        let ssl = timings
586            .and_then(|t| t.get("ssl"))
587            .and_then(|v| v.as_f64())
588            .filter(|&v| v >= 0.0);
589        let send = timings
590            .and_then(|t| t.get("send"))
591            .and_then(|v| v.as_f64())
592            .unwrap_or(-1.0);
593        let wait = timings
594            .and_then(|t| t.get("wait"))
595            .and_then(|v| v.as_f64())
596            .unwrap_or(-1.0);
597        let receive = timings
598            .and_then(|t| t.get("receive"))
599            .and_then(|v| v.as_f64())
600            .unwrap_or(-1.0);
601        let blocked = timings
602            .and_then(|t| t.get("blocked"))
603            .and_then(|v| v.as_f64())
604            .filter(|&v| v >= 0.0);
605
606        // Redirect URL
607        let redirect_url = response
608            .get("redirectURL")
609            .and_then(|v| v.as_str())
610            .unwrap_or("")
611            .to_string();
612
613        // Headers size from response
614        let headers_size = response
615            .get("headersSize")
616            .and_then(|v| v.as_i64())
617            .unwrap_or(-1);
618
619        let mut guard = state.lock().await;
620        if let Some(ref id) = request_id {
621            if let Some(p) = guard.pending.get_mut(id) {
622                p.status = Some(status);
623                p.status_text = Some(status_text);
624                p.mime_type = Some(mime_type);
625                p.server_ip_address = remote_ip;
626                p.connection = connection_id;
627                p.response_headers = resp_headers;
628                p.response_cookies = cookies;
629                p.timing_dns = dns;
630                p.timing_connect = connect;
631                p.timing_ssl = ssl;
632                p.timing_send = send;
633                p.timing_wait = wait;
634                p.timing_receive = receive;
635                p.timing_blocked = blocked;
636                p.redirect_url = redirect_url;
637                p.headers_size = headers_size;
638            }
639        }
640    }
641
642    async fn handle_loading_finished(state: &Arc<Mutex<CaptureState>>, params: Value) {
643        let request_id = params
644            .get("requestId")
645            .and_then(|v| v.as_str())
646            .map(|s| s.to_string());
647        let ts = params
648            .get("timestamp")
649            .and_then(|v| v.as_f64())
650            .unwrap_or(0.0);
651        let encoded_size = params
652            .get("encodedDataLength")
653            .and_then(|v| v.as_i64())
654            .unwrap_or(-1);
655
656        let mut guard = state.lock().await;
657        if let Some(id) = request_id {
658            if let Some(p) = guard.pending.remove(&id) {
659                let mut entry = p.finish(ts);
660                entry.response.content.size = encoded_size.max(0);
661                entry.response.body_size = encoded_size;
662                guard.entries.push(entry);
663            }
664        }
665    }
666
667    async fn handle_loading_failed(state: &Arc<Mutex<CaptureState>>, params: Value) {
668        let request_id = params
669            .get("requestId")
670            .and_then(|v| v.as_str())
671            .map(|s| s.to_string());
672        let ts = params
673            .get("timestamp")
674            .and_then(|v| v.as_f64())
675            .unwrap_or(0.0);
676
677        let mut guard = state.lock().await;
678        if let Some(id) = request_id {
679            if let Some(p) = guard.pending.remove(&id) {
680                let mut entry = p.finish(ts);
681                entry.response.status = 0;
682                entry.response.status_text = params
683                    .get("errorText")
684                    .and_then(|v| v.as_str())
685                    .unwrap_or("Failed")
686                    .to_string();
687                guard.entries.push(entry);
688            }
689        }
690    }
691
692    /// Stop the capture and return the complete HAR archive.
693    ///
694    /// This drains any remaining pending requests (for requests that were
695    /// sent but never received a response) and returns the full HAR.
696    pub async fn stop(&self) -> HarArchive {
697        let mut guard = self.state.lock().await;
698        let now = std::time::SystemTime::now()
699            .duration_since(std::time::UNIX_EPOCH)
700            .unwrap_or_default()
701            .as_secs_f64();
702
703        // Flush any dangling pending requests
704        let mut all_entries = std::mem::take(&mut guard.entries);
705        for (_, pending) in guard.pending.drain() {
706            all_entries.push(pending.finish(now));
707        }
708
709        HarArchive {
710            log: HarLog {
711                version: "1.2".to_string(),
712                creator: json!({
713                    "name": "ferrous-browser",
714                    "version": env!("CARGO_PKG_VERSION"),
715                }),
716                entries: all_entries,
717            },
718        }
719    }
720
721    /// Export the current HAR without stopping the capture.
722    pub async fn export(&self) -> HarArchive {
723        let guard = self.state.lock().await;
724        let now = std::time::SystemTime::now()
725            .duration_since(std::time::UNIX_EPOCH)
726            .unwrap_or_default()
727            .as_secs_f64();
728
729        let mut entries = guard.entries.clone();
730        for (_, pending) in guard.pending.iter() {
731            entries.push(pending.clone().finish(now));
732        }
733
734        HarArchive {
735            log: HarLog {
736                version: "1.2".to_string(),
737                creator: json!({
738                    "name": "ferrous-browser",
739                    "version": env!("CARGO_PKG_VERSION"),
740                }),
741                entries,
742            },
743        }
744    }
745
746    /// Clear all captured entries and pending requests.
747    pub async fn clear(&self) {
748        let mut guard = self.state.lock().await;
749        guard.pending.clear();
750        guard.entries.clear();
751    }
752}
753
754/// Convert a Unix timestamp (seconds since epoch) to ISO 8601 string.
755fn iso_timestamp(ts: f64) -> String {
756    let secs = ts as i64;
757    let subsec_nanos = ((ts - secs as f64) * 1_000_000_000.0).round() as u32;
758
759    // Decompose into year/month/day/hour/min/sec
760    let days = secs / 86400;
761    let time_secs = (secs % 86400).abs();
762    let hours = time_secs / 3600;
763    let minutes = (time_secs % 3600) / 60;
764    let seconds = time_secs % 60;
765
766    // Days since epoch to date (civil calendar)
767    let (year, month, day) = days_to_date(days);
768
769    format!(
770        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
771        year,
772        month,
773        day,
774        hours,
775        minutes,
776        seconds,
777        subsec_nanos / 1_000_000
778    )
779}
780
781/// Convert days since Unix epoch to (year, month, day).
782fn days_to_date(mut days: i64) -> (i64, u32, u32) {
783    // Algorithm from Howard Hinnant
784    days += 719468;
785    let era = if days >= 0 { days } else { days - 146096 } / 146097;
786    let doe = days - era * 146097;
787    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
788    let y = yoe + era * 400;
789    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
790    let mp = (5 * doy + 2) / 153;
791    let d = doy - (153 * mp + 2) / 5 + 1;
792    let m = if mp < 10 { mp + 3 } else { mp - 9 };
793    let y = if m <= 2 { y + 1 } else { y };
794    (y, m as u32, d as u32)
795}