Skip to main content

hyperdb_bootstrap/
scrape.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Best-effort scraper for the public Hyper releases page.
5//!
6//! When no version pin is supplied, [`crate::scrape::scrape_latest`] fetches
7//! `https://tableau.github.io/hyper-db/docs/releases` and parses out the
8//! most recent version + build id for the given platform. This bypasses
9//! the compile-time pin and can lag or break when the page layout changes
10//! — prefer an explicit [`crate::VersionSource::Builtin`] or
11//! [`crate::VersionSource::TomlFile`] in production.
12
13use regex::Regex;
14use std::collections::HashMap;
15
16use crate::platform::Platform;
17use crate::release::PinnedRelease;
18use crate::Error;
19
20const RELEASES_URL: &str = "https://tableau.github.io/hyper-db/docs/releases";
21
22/// Fetches the public releases page and returns the newest `PinnedRelease`
23/// that has a C++ download for `platform`.
24///
25/// The returned `PinnedRelease` has an empty SHA-256 map — scraping only
26/// recovers the version + build id, never a digest.
27///
28/// # Errors
29///
30/// - [`Error::Http`] on network / TLS failure.
31/// - [`Error::HttpStatus`] on a non-success HTTP response.
32/// - [`Error::ScrapeFailed`] when the page layout no longer matches the
33///   expected structure.
34pub fn scrape_latest(platform: Platform) -> Result<PinnedRelease, Error> {
35    tracing::info!(url = RELEASES_URL, "scraping latest release");
36    let client = reqwest::blocking::Client::builder()
37        .user_agent(concat!("hyperd-bootstrap/", env!("CARGO_PKG_VERSION")))
38        .build()
39        .map_err(Error::Http)?;
40    let resp = client.get(RELEASES_URL).send().map_err(Error::Http)?;
41    if !resp.status().is_success() {
42        return Err(Error::HttpStatus {
43            url: RELEASES_URL.to_string(),
44            status: resp.status().as_u16(),
45        });
46    }
47    let html = resp.text().map_err(Error::Http)?;
48    parse_latest(&html, platform)
49}
50
51fn parse_latest(html: &str, platform: Platform) -> Result<PinnedRelease, Error> {
52    // The releases page lists entries in reverse-chronological order as
53    // `<h3>VERSION [DATE]</h3>`. The first match is the newest.
54    let h3_re =
55        Regex::new(r"<h3[^>]*>\s*([0-9]+(?:\.[0-9]+){1,3})\s*\[[^\]]+\]").expect("valid regex");
56    let version = h3_re
57        .captures(html)
58        .and_then(|c| c.get(1))
59        .map(|m| m.as_str().to_string())
60        .ok_or(Error::ScrapeFailed(
61            "no <h3>VERSION [DATE]</h3> heading found",
62        ))?;
63
64    // For that version, find the C++ zip for the requested platform to
65    // recover the build id.
66    let href_re = Regex::new(&format!(
67        r"tableauhyperapi-cxx-{plat}-release-main\.{ver}\.(rc[a-z0-9]+)\.zip",
68        plat = regex::escape(platform.slug()),
69        ver = regex::escape(&version),
70    ))
71    .expect("valid regex");
72    let build_id = href_re
73        .captures(html)
74        .and_then(|c| c.get(1))
75        .map(|m| m.as_str().to_string())
76        .ok_or(Error::ScrapeFailed(
77            "no matching cxx zip href for scraped version",
78        ))?;
79
80    Ok(PinnedRelease {
81        version,
82        build_id,
83        sha256: HashMap::new(),
84    })
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn parse_real_page_snippet() {
93        let html = r#"
94            <h3>0.0.24457 [February 12 2026]</h3>
95            <ul><li>Release notes</li></ul>
96            <a href="https://downloads.tableau.com/tssoftware//tableauhyperapi-cxx-macos-arm64-release-main.0.0.24457.rc36858b6.zip">C++ (macOS arm64)</a>
97            <a href="https://downloads.tableau.com/tssoftware//tableauhyperapi-cxx-linux-x86_64-release-main.0.0.24457.rc36858b6.zip">C++ (Linux)</a>
98            <h3>0.0.20000 [January 1 2025]</h3>
99        "#;
100        let release = parse_latest(html, Platform::MacosArm64).unwrap();
101        assert_eq!(release.version, "0.0.24457");
102        assert_eq!(release.build_id, "rc36858b6");
103    }
104
105    #[test]
106    fn parse_errors_when_no_heading() {
107        let html = "<p>nothing here</p>";
108        assert!(matches!(
109            parse_latest(html, Platform::LinuxX86_64),
110            Err(Error::ScrapeFailed(_))
111        ));
112    }
113
114    #[test]
115    fn parse_errors_when_no_matching_href() {
116        let html = r"<h3>0.0.24457 [Feb 12 2026]</h3>";
117        assert!(matches!(
118            parse_latest(html, Platform::WindowsX86_64),
119            Err(Error::ScrapeFailed(_))
120        ));
121    }
122}