use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ProgressMode {
#[default]
Human,
Json,
}
impl ProgressMode {
pub fn as_str(self) -> &'static str {
match self {
ProgressMode::Human => "human",
ProgressMode::Json => "json",
}
}
}
impl std::str::FromStr for ProgressMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"human" => Ok(ProgressMode::Human),
"json" => Ok(ProgressMode::Json),
other => Err(format!(
"unknown progress mode '{other}' (expected 'human' or 'json')"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProgressErrorKind {
Network,
Disk,
Checksum,
Interrupted,
Other,
}
impl ProgressErrorKind {
pub fn exit_code(self) -> i32 {
match self {
ProgressErrorKind::Network => 69,
ProgressErrorKind::Disk => 74,
ProgressErrorKind::Checksum => 65,
ProgressErrorKind::Interrupted => 130,
ProgressErrorKind::Other => 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "phase", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProgressEvent {
Download {
file: String,
bytes_done: u64,
bytes_total: u64,
},
Quantize { file: String },
Verify { file: String },
Done { model_dir: String },
Error {
kind: ProgressErrorKind,
message: String,
},
}
impl ProgressEvent {
pub fn to_ndjson(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
"{\"phase\":\"error\",\"kind\":\"other\",\"message\":\"progress event serialization failed\"}"
.to_string()
})
}
}
static PROGRESS_MODE: AtomicU8 = AtomicU8::new(ProgressMode::Human as u8);
pub fn set_progress_mode(mode: ProgressMode) {
PROGRESS_MODE.store(mode as u8, Ordering::Relaxed);
}
pub fn progress_mode() -> ProgressMode {
match PROGRESS_MODE.load(Ordering::Relaxed) {
1 => ProgressMode::Json,
_ => ProgressMode::Human,
}
}
pub fn emit_progress_event(event: &ProgressEvent) {
if progress_mode() != ProgressMode::Json {
return;
}
use std::io::Write;
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{}", event.to_ndjson());
let _ = lock.flush();
}
#[cfg(feature = "net")]
pub fn classify_download_error(err: &anyhow::Error) -> ProgressErrorKind {
for cause in err.chain() {
if cause.downcast_ref::<reqwest::Error>().is_some() {
return ProgressErrorKind::Network;
}
if cause.downcast_ref::<std::io::Error>().is_some() {
return ProgressErrorKind::Disk;
}
}
let msg = format!("{err:#}");
if msg.contains("SHA-256 mismatch") {
return ProgressErrorKind::Checksum;
}
if msg.contains("HTTP ") {
return ProgressErrorKind::Network;
}
ProgressErrorKind::Other
}
#[cfg(feature = "net")]
pub(super) const JSON_PROGRESS_THROTTLE: std::time::Duration =
std::time::Duration::from_millis(200);
#[cfg(feature = "net")]
pub(super) struct ProgressSink {
pub(super) mode: ProgressMode,
pub(super) emit: Box<dyn Fn(&ProgressEvent) + Send + Sync>,
}
#[cfg(feature = "net")]
impl ProgressSink {
pub(super) fn global() -> Self {
Self {
mode: progress_mode(),
emit: Box::new(emit_progress_event),
}
}
#[cfg(test)]
pub(super) fn human() -> Self {
Self {
mode: ProgressMode::Human,
emit: Box::new(|_| {}),
}
}
#[cfg(test)]
pub(super) fn capturing() -> (Self, std::sync::Arc<std::sync::Mutex<Vec<ProgressEvent>>>) {
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink_log = std::sync::Arc::clone(&log);
(
Self {
mode: ProgressMode::Json,
emit: Box::new(move |e| {
if let Ok(mut guard) = sink_log.lock() {
guard.push(e.clone());
}
}),
},
log,
)
}
pub(super) fn event(&self, event: &ProgressEvent) {
(self.emit)(event);
}
}
#[cfg(feature = "net")]
pub(super) struct DownloadProgress {
total: u64,
pub(super) current: u64,
pub(super) last_percent: u8,
pub(super) last_json_emit: Option<std::time::Instant>,
json_final_emitted: bool,
}
#[cfg(feature = "net")]
impl DownloadProgress {
pub(super) fn new(total: u64) -> Self {
Self {
total,
current: 0,
last_percent: 0,
last_json_emit: None,
json_final_emitted: false,
}
}
pub(super) fn human_tick(&mut self) -> Option<String> {
let percent = (self.current * 100)
.checked_div(self.total)
.map(|p| p as u8)
.unwrap_or(0);
if percent == self.last_percent {
return None;
}
self.last_percent = percent;
Some(format!(
"\rDownloading... {percent}% ({:.1}MB / {:.1}MB)",
self.current as f64 / 1_048_576.0,
self.total as f64 / 1_048_576.0
))
}
pub(super) fn human_finish(&self) -> String {
format!(
"\rDownload complete ({:.1}MB) ",
self.current as f64 / 1_048_576.0
)
}
pub(super) fn update(&mut self, bytes: u64, sink: &ProgressSink, label: &str) {
self.current += bytes;
match sink.mode {
ProgressMode::Human => {
if let Some(line) = self.human_tick() {
eprint!("{line}");
}
}
ProgressMode::Json => {
let complete = self.total > 0 && self.current >= self.total;
let due = self
.last_json_emit
.is_none_or(|t| t.elapsed() >= JSON_PROGRESS_THROTTLE);
if (complete && !self.json_final_emitted) || (due && !complete) {
sink.event(&ProgressEvent::Download {
file: label.to_string(),
bytes_done: self.current,
bytes_total: self.total,
});
self.last_json_emit = Some(std::time::Instant::now());
if complete {
self.json_final_emitted = true;
}
}
}
}
}
pub(super) fn finish(&mut self, sink: &ProgressSink, label: &str) {
match sink.mode {
ProgressMode::Human => eprintln!("{}", self.human_finish()),
ProgressMode::Json => {
if !self.json_final_emitted {
self.json_final_emitted = true;
sink.event(&ProgressEvent::Download {
file: label.to_string(),
bytes_done: self.current,
bytes_total: self.total,
});
}
}
}
}
}