datalab-cli 0.1.0

A powerful CLI for converting, extracting, and processing documents using the Datalab API
Documentation
use crate::error::DatalabError;
use colored::Colorize;
use serde::Serialize;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;

/// Controls whether progress output is enabled
static PROGRESS_ENABLED: AtomicBool = AtomicBool::new(true);

/// Progress event types for JSON streaming
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProgressEvent {
    Start {
        operation: String,
        #[serde(skip_serializing_if = "Option::is_none")]
        file: Option<String>,
    },
    Upload {
        bytes_sent: u64,
        total_bytes: u64,
    },
    Submit {
        request_id: String,
    },
    Poll {
        status: String,
        elapsed_secs: f64,
    },
    CacheHit {
        cache_key: String,
    },
    Complete {
        elapsed_secs: f64,
    },
    Error {
        code: String,
        message: String,
    },
}

/// Progress reporter that emits JSON events to stderr
#[derive(Clone)]
pub struct Progress {
    enabled: bool,
    start_time: Instant,
}

impl Progress {
    /// Create a new progress reporter
    pub fn new(quiet: bool, verbose: bool) -> Self {
        let is_tty = std::io::stderr().is_terminal();

        // Determine if progress should be enabled:
        // - Disabled if --quiet flag is set
        // - Enabled if --verbose flag is set (even when piped)
        // - Otherwise, enabled only on TTY
        let enabled = if quiet {
            false
        } else if verbose {
            true
        } else {
            is_tty
        };

        PROGRESS_ENABLED.store(enabled, Ordering::SeqCst);

        Self {
            enabled,
            start_time: Instant::now(),
        }
    }

    /// Check if progress is enabled
    #[allow(dead_code)]
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Emit a progress event
    pub fn emit(&self, event: ProgressEvent) {
        if !self.enabled {
            return;
        }

        if let Ok(json) = serde_json::to_string(&event) {
            let _ = writeln!(std::io::stderr(), "{}", json);
        }
    }

    /// Emit start event
    pub fn start(&self, operation: &str, file: Option<&str>) {
        self.emit(ProgressEvent::Start {
            operation: operation.to_string(),
            file: file.map(String::from),
        });
    }

    /// Emit upload progress event
    pub fn upload(&self, bytes_sent: u64, total_bytes: u64) {
        self.emit(ProgressEvent::Upload {
            bytes_sent,
            total_bytes,
        });
    }

    /// Emit submit event
    pub fn submit(&self, request_id: &str) {
        self.emit(ProgressEvent::Submit {
            request_id: request_id.to_string(),
        });
    }

    /// Emit poll event
    pub fn poll(&self, status: &str) {
        self.emit(ProgressEvent::Poll {
            status: status.to_string(),
            elapsed_secs: self.start_time.elapsed().as_secs_f64(),
        });
    }

    /// Emit cache hit event
    pub fn cache_hit(&self, cache_key: &str) {
        self.emit(ProgressEvent::CacheHit {
            cache_key: cache_key.to_string(),
        });
    }

    /// Emit complete event
    pub fn complete(&self) {
        self.emit(ProgressEvent::Complete {
            elapsed_secs: self.start_time.elapsed().as_secs_f64(),
        });
    }

    /// Emit error event
    pub fn error(&self, err: &DatalabError) {
        self.emit(ProgressEvent::Error {
            code: err.code().to_string(),
            message: err.to_string(),
        });
    }
}

/// Output handler for errors and messages
pub struct Output {
    is_tty: bool,
    use_color: bool,
}

impl Output {
    /// Create a new output handler
    pub fn new() -> Self {
        let is_tty = std::io::stderr().is_terminal();
        let use_color = is_tty && std::env::var("NO_COLOR").is_err();

        Self { is_tty, use_color }
    }

    /// Display an error appropriately based on context
    pub fn error(&self, err: &DatalabError) {
        if self.is_tty {
            self.print_colored_error(err);
        } else {
            eprintln!("{}", err.to_json());
        }
    }

    /// Print a colored error with suggestions
    fn print_colored_error(&self, err: &DatalabError) {
        // Error prefix
        if self.use_color {
            eprint!("{}: ", "error".red().bold());
        } else {
            eprint!("error: ");
        }

        // Error message
        eprintln!("{}", err);

        // Suggestion
        if let Some(suggestion) = err.suggestion() {
            eprintln!();
            if self.use_color {
                eprintln!("{}: {}", "hint".yellow().bold(), suggestion);
            } else {
                eprintln!("hint: {}", suggestion);
            }
        }

        // Help URL for certain errors
        if let Some(help_url) = err.help_url() {
            if self.use_color {
                eprintln!("{}: {}", "help".cyan().bold(), help_url);
            } else {
                eprintln!("help: {}", help_url);
            }
        }
    }

    /// Print an info message (only on TTY)
    #[allow(dead_code)]
    pub fn info(&self, message: &str) {
        if self.is_tty {
            if self.use_color {
                eprintln!("{}: {}", "info".blue().bold(), message);
            } else {
                eprintln!("info: {}", message);
            }
        }
    }

    /// Print a warning message
    #[allow(dead_code)]
    pub fn warn(&self, message: &str) {
        if self.is_tty {
            if self.use_color {
                eprintln!("{}: {}", "warning".yellow().bold(), message);
            } else {
                eprintln!("warning: {}", message);
            }
        }
    }

    /// Print a success message (only on TTY)
    #[allow(dead_code)]
    pub fn success(&self, message: &str) {
        if self.is_tty {
            if self.use_color {
                eprintln!("{}: {}", "success".green().bold(), message);
            } else {
                eprintln!("success: {}", message);
            }
        }
    }
}

impl Default for Output {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_progress_event_serialization() {
        let event = ProgressEvent::Start {
            operation: "convert".to_string(),
            file: Some("test.pdf".to_string()),
        };
        let json = serde_json::to_string(&event).unwrap();
        assert!(json.contains("\"type\":\"start\""));
        assert!(json.contains("\"operation\":\"convert\""));
    }

    #[test]
    fn test_progress_poll_event() {
        let event = ProgressEvent::Poll {
            status: "processing".to_string(),
            elapsed_secs: 1.5,
        };
        let json = serde_json::to_string(&event).unwrap();
        assert!(json.contains("\"type\":\"poll\""));
        assert!(json.contains("\"status\":\"processing\""));
    }
}