Skip to main content

datalab_cli/
output.rs

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
8/// Controls whether progress output is enabled
9static PROGRESS_ENABLED: AtomicBool = AtomicBool::new(true);
10
11/// Progress event types for JSON streaming
12#[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/// Progress reporter that emits JSON events to stderr
44#[derive(Clone)]
45pub struct Progress {
46    enabled: bool,
47    start_time: Instant,
48}
49
50impl Progress {
51    /// Create a new progress reporter
52    pub fn new(quiet: bool, verbose: bool) -> Self {
53        let is_tty = std::io::stderr().is_terminal();
54
55        // Determine if progress should be enabled:
56        // - Disabled if --quiet flag is set
57        // - Enabled if --verbose flag is set (even when piped)
58        // - Otherwise, enabled only on TTY
59        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    /// Check if progress is enabled
76    #[allow(dead_code)]
77    pub fn is_enabled(&self) -> bool {
78        self.enabled
79    }
80
81    /// Emit a progress event
82    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    /// Emit start event
93    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    /// Emit upload progress event
101    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    /// Emit submit event
109    pub fn submit(&self, request_id: &str) {
110        self.emit(ProgressEvent::Submit {
111            request_id: request_id.to_string(),
112        });
113    }
114
115    /// Emit poll event
116    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    /// Emit cache hit event
124    pub fn cache_hit(&self, cache_key: &str) {
125        self.emit(ProgressEvent::CacheHit {
126            cache_key: cache_key.to_string(),
127        });
128    }
129
130    /// Emit complete event
131    pub fn complete(&self) {
132        self.emit(ProgressEvent::Complete {
133            elapsed_secs: self.start_time.elapsed().as_secs_f64(),
134        });
135    }
136
137    /// Emit error event
138    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
146/// Output handler for errors and messages
147pub struct Output {
148    is_tty: bool,
149    use_color: bool,
150}
151
152impl Output {
153    /// Create a new output handler
154    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    /// Display an error appropriately based on context
162    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    /// Print a colored error with suggestions
171    fn print_colored_error(&self, err: &DatalabError) {
172        // Error prefix
173        if self.use_color {
174            eprint!("{}: ", "error".red().bold());
175        } else {
176            eprint!("error: ");
177        }
178
179        // Error message
180        eprintln!("{}", err);
181
182        // Suggestion
183        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        // Help URL for certain errors
193        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    /// Print an info message (only on TTY)
203    #[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    /// Print a warning message
215    #[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    /// Print a success message (only on TTY)
227    #[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}