use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::datasets::http::{DatasetClient, DatasetClientConfig};
use crate::datasets::wikidata::error::{Result, WikidataError};
const USER_AGENT: &str = "kglite-datasets-wikidata/1";
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const PROGRESS_INTERVAL: Duration = Duration::from_secs(10);
const CHUNK_SIZE: usize = 64 * 1024;
#[derive(Debug, Clone, Default)]
pub struct RemoteMeta {
pub last_modified: Option<DateTime<Utc>>,
}
#[derive(Clone)]
pub struct WikidataClient {
inner: DatasetClient,
}
impl WikidataClient {
pub fn new() -> Result<Self> {
let inner = DatasetClient::new(DatasetClientConfig {
user_agent: USER_AGENT.to_string(),
connect_timeout: CONNECT_TIMEOUT,
overall_timeout: None,
rate_per_sec: None,
retry_count: 0,
base_backoff_ms: 0,
});
Ok(Self { inner })
}
pub fn head(&self, url: &str) -> Result<RemoteMeta> {
let resp = self.call(self.inner.agent().head(url), url)?;
let last_modified = resp.header("Last-Modified").and_then(parse_http_date);
Ok(RemoteMeta { last_modified })
}
pub fn download_resumable(&self, url: &str, dest: &Path, verbose: bool) -> Result<()> {
let start = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
let mut req = self.inner.agent().get(url);
if start > 0 {
req = req.set("Range", &format!("bytes={start}-"));
}
let resp = self.call(req, url)?;
let resuming = start > 0 && resp.status() == 206;
let content_len = resp
.header("Content-Length")
.and_then(|v| v.parse::<u64>().ok());
let total = content_len.map(|c| c + if resuming { start } else { 0 });
let mut file = if resuming {
OpenOptions::new().append(true).open(dest)?
} else {
File::create(dest)?
};
let mut written = if resuming { start } else { 0 };
let mut last_report = Instant::now();
if verbose {
eprintln!(
" {} {} ...",
if resuming { "Resuming" } else { "Downloading" },
fmt_bytes(total)
);
}
let mut reader = resp.into_reader();
let mut buf = vec![0u8; CHUNK_SIZE];
loop {
let n = reader
.read(&mut buf)
.map_err(|e| WikidataError::Http(e.to_string()))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
written += n as u64;
if verbose && last_report.elapsed() >= PROGRESS_INTERVAL {
eprintln!(" {} / {}", fmt_bytes(Some(written)), fmt_bytes(total));
last_report = Instant::now();
}
}
file.flush()?;
if verbose {
eprintln!(" Download complete: {}", fmt_bytes(Some(written)));
}
Ok(())
}
fn call(&self, req: ureq::Request, url: &str) -> Result<ureq::Response> {
match req.call() {
Ok(resp) => Ok(resp),
Err(ureq::Error::Status(code, _resp)) => Err(WikidataError::BadStatus {
status: code,
url: url.to_string(),
}),
Err(ureq::Error::Transport(t)) => Err(WikidataError::Http(t.to_string())),
}
}
}
fn parse_http_date(s: &str) -> Option<DateTime<Utc>> {
chrono::NaiveDateTime::parse_from_str(s.trim(), "%a, %d %b %Y %H:%M:%S GMT")
.ok()
.map(|ndt| ndt.and_utc())
}
fn fmt_bytes(n: Option<u64>) -> String {
let Some(n) = n else {
return "?".to_string();
};
const KB: f64 = 1024.0;
let b = n as f64;
if b < KB * KB {
format!("{:.1} KB", b / KB)
} else if b < KB * KB * KB {
format!("{:.1} MB", b / (KB * KB))
} else if b < KB * KB * KB * KB {
format!("{:.2} GB", b / (KB * KB * KB))
} else {
format!("{:.2} TB", b / (KB * KB * KB * KB))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_constructs() {
assert!(WikidataClient::new().is_ok());
}
#[test]
fn http_date_parses() {
let dt = parse_http_date("Wed, 21 Oct 2015 07:28:00 GMT").unwrap();
assert_eq!(dt.to_rfc3339(), "2015-10-21T07:28:00+00:00");
assert!(parse_http_date("not a date").is_none());
}
#[test]
fn byte_formatting() {
assert_eq!(fmt_bytes(None), "?");
assert_eq!(fmt_bytes(Some(2048)), "2.0 KB");
assert_eq!(fmt_bytes(Some(5 * 1024 * 1024)), "5.0 MB");
}
#[test]
fn resumable_download_appends_via_range() {
if std::env::var("WIKIDATA_LIVE_TEST").is_err() {
eprintln!("skipping resumable_download_appends_via_range — set WIKIDATA_LIVE_TEST=1");
return;
}
let url = "https://raw.githubusercontent.com/git/git/master/README.md";
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("range.bin");
let client = WikidataClient::new().unwrap();
client.download_resumable(url, &dest, false).unwrap();
let full = std::fs::read(&dest).unwrap();
assert!(full.len() > 1000, "expected a non-trivial file body");
let cut = 1000;
std::fs::write(&dest, &full[..cut]).unwrap();
client.download_resumable(url, &dest, false).unwrap();
let resumed = std::fs::read(&dest).unwrap();
assert_eq!(resumed, full, "resumed file must byte-match the original");
}
}