1use crate::error::DatalabError;
2use colored::Colorize;
3use serde::Serialize;
4use std::io::{IsTerminal, Write};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Instant;
7
8static PROGRESS_ENABLED: AtomicBool = AtomicBool::new(true);
10
11#[derive(Debug, Clone, Serialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ProgressEvent {
15 Start {
16 operation: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 file: Option<String>,
19 },
20 Upload {
21 bytes_sent: u64,
22 total_bytes: u64,
23 },
24 Submit {
25 request_id: String,
26 },
27 Poll {
28 status: String,
29 elapsed_secs: f64,
30 },
31 CacheHit {
32 cache_key: String,
33 },
34 Complete {
35 elapsed_secs: f64,
36 },
37 Error {
38 code: String,
39 message: String,
40 },
41}
42
43#[derive(Clone)]
45pub struct Progress {
46 enabled: bool,
47 start_time: Instant,
48}
49
50impl Progress {
51 pub fn new(quiet: bool, verbose: bool) -> Self {
53 let is_tty = std::io::stderr().is_terminal();
54
55 let enabled = if quiet {
60 false
61 } else if verbose {
62 true
63 } else {
64 is_tty
65 };
66
67 PROGRESS_ENABLED.store(enabled, Ordering::SeqCst);
68
69 Self {
70 enabled,
71 start_time: Instant::now(),
72 }
73 }
74
75 #[allow(dead_code)]
77 pub fn is_enabled(&self) -> bool {
78 self.enabled
79 }
80
81 pub fn emit(&self, event: ProgressEvent) {
83 if !self.enabled {
84 return;
85 }
86
87 if let Ok(json) = serde_json::to_string(&event) {
88 let _ = writeln!(std::io::stderr(), "{}", json);
89 }
90 }
91
92 pub fn start(&self, operation: &str, file: Option<&str>) {
94 self.emit(ProgressEvent::Start {
95 operation: operation.to_string(),
96 file: file.map(String::from),
97 });
98 }
99
100 pub fn upload(&self, bytes_sent: u64, total_bytes: u64) {
102 self.emit(ProgressEvent::Upload {
103 bytes_sent,
104 total_bytes,
105 });
106 }
107
108 pub fn submit(&self, request_id: &str) {
110 self.emit(ProgressEvent::Submit {
111 request_id: request_id.to_string(),
112 });
113 }
114
115 pub fn poll(&self, status: &str) {
117 self.emit(ProgressEvent::Poll {
118 status: status.to_string(),
119 elapsed_secs: self.start_time.elapsed().as_secs_f64(),
120 });
121 }
122
123 pub fn cache_hit(&self, cache_key: &str) {
125 self.emit(ProgressEvent::CacheHit {
126 cache_key: cache_key.to_string(),
127 });
128 }
129
130 pub fn complete(&self) {
132 self.emit(ProgressEvent::Complete {
133 elapsed_secs: self.start_time.elapsed().as_secs_f64(),
134 });
135 }
136
137 pub fn error(&self, err: &DatalabError) {
139 self.emit(ProgressEvent::Error {
140 code: err.code().to_string(),
141 message: err.to_string(),
142 });
143 }
144}
145
146pub struct Output {
148 is_tty: bool,
149 use_color: bool,
150}
151
152impl Output {
153 pub fn new() -> Self {
155 let is_tty = std::io::stderr().is_terminal();
156 let use_color = is_tty && std::env::var("NO_COLOR").is_err();
157
158 Self { is_tty, use_color }
159 }
160
161 pub fn error(&self, err: &DatalabError) {
163 if self.is_tty {
164 self.print_colored_error(err);
165 } else {
166 eprintln!("{}", err.to_json());
167 }
168 }
169
170 fn print_colored_error(&self, err: &DatalabError) {
172 if self.use_color {
174 eprint!("{}: ", "error".red().bold());
175 } else {
176 eprint!("error: ");
177 }
178
179 eprintln!("{}", err);
181
182 if let Some(suggestion) = err.suggestion() {
184 eprintln!();
185 if self.use_color {
186 eprintln!("{}: {}", "hint".yellow().bold(), suggestion);
187 } else {
188 eprintln!("hint: {}", suggestion);
189 }
190 }
191
192 if let Some(help_url) = err.help_url() {
194 if self.use_color {
195 eprintln!("{}: {}", "help".cyan().bold(), help_url);
196 } else {
197 eprintln!("help: {}", help_url);
198 }
199 }
200 }
201
202 #[allow(dead_code)]
204 pub fn info(&self, message: &str) {
205 if self.is_tty {
206 if self.use_color {
207 eprintln!("{}: {}", "info".blue().bold(), message);
208 } else {
209 eprintln!("info: {}", message);
210 }
211 }
212 }
213
214 #[allow(dead_code)]
216 pub fn warn(&self, message: &str) {
217 if self.is_tty {
218 if self.use_color {
219 eprintln!("{}: {}", "warning".yellow().bold(), message);
220 } else {
221 eprintln!("warning: {}", message);
222 }
223 }
224 }
225
226 #[allow(dead_code)]
228 pub fn success(&self, message: &str) {
229 if self.is_tty {
230 if self.use_color {
231 eprintln!("{}: {}", "success".green().bold(), message);
232 } else {
233 eprintln!("success: {}", message);
234 }
235 }
236 }
237}
238
239impl Default for Output {
240 fn default() -> Self {
241 Self::new()
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_progress_event_serialization() {
251 let event = ProgressEvent::Start {
252 operation: "convert".to_string(),
253 file: Some("test.pdf".to_string()),
254 };
255 let json = serde_json::to_string(&event).unwrap();
256 assert!(json.contains("\"type\":\"start\""));
257 assert!(json.contains("\"operation\":\"convert\""));
258 }
259
260 #[test]
261 fn test_progress_poll_event() {
262 let event = ProgressEvent::Poll {
263 status: "processing".to_string(),
264 elapsed_secs: 1.5,
265 };
266 let json = serde_json::to_string(&event).unwrap();
267 assert!(json.contains("\"type\":\"poll\""));
268 assert!(json.contains("\"status\":\"processing\""));
269 }
270}