openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Output formatting infrastructure for the `openlatch` CLI.
//!
//! [`OutputConfig`] is the single source of truth for how any command should
//! produce output. It captures format (human vs JSON), verbosity, debug, quiet,
//! and color settings, and provides helper methods that apply the correct output
//! strategy for each case.
//!
//! ## Design rules
//!
//! - D-01: `print_step` emits checkmark-prefixed lines in human mode; silent in JSON mode
//! - D-04: `print_substep` is indented below a parent step (bullet point)
//! - CLI-11: Progress spinners write to stderr, not stdout
//! - T-02-04: `--debug` mode never prints tokens or secrets

use crate::cli::color;
use crate::error::OlError;

/// Output format selection.
#[derive(Debug, Clone, PartialEq)]
pub enum OutputFormat {
    /// Human-readable, colorized if TTY (default)
    Human,
    /// Pure JSON — one object per logical result, to stdout
    Json,
}

/// Resolved output configuration for a CLI invocation.
///
/// Created once at startup via [`crate::cli::build_output_config`] and passed
/// down through command handlers.
#[derive(Debug, Clone)]
pub struct OutputConfig {
    /// Output format (human or JSON)
    pub format: OutputFormat,
    /// Whether verbose output is enabled (includes `--debug`)
    pub verbose: bool,
    /// Whether debug output is enabled (superset of verbose)
    pub debug: bool,
    /// Whether quiet mode is active (suppress info/step output)
    pub quiet: bool,
    /// Whether ANSI color codes are allowed
    pub color: bool,
}

impl OutputConfig {
    /// Print a step completion line in human mode.
    ///
    /// Per D-01: each step that completes prints a checkmark prefix + message.
    /// In JSON mode, this is a no-op (JSON commands emit a single JSON object at end).
    /// In quiet mode, this is also suppressed.
    pub fn print_step(&self, message: &str) {
        if self.format == OutputFormat::Json || self.quiet {
            return;
        }
        let mark = color::checkmark(self.color);
        eprintln!("{mark} {message}");
    }

    /// Print an indented substep line in human mode.
    ///
    /// Per D-04: substeps are indented bullet points under a parent step.
    /// Silent in JSON mode and quiet mode.
    pub fn print_substep(&self, message: &str) {
        if self.format == OutputFormat::Json || self.quiet {
            return;
        }
        let dot = color::bullet(self.color);
        eprintln!("{dot} {message}");
    }

    /// Print a formatted [`OlError`] to stderr.
    ///
    /// In human mode: structured multi-line error with OL code, suggestion, and docs URL.
    /// In JSON mode: JSON object on stderr with `{"error": {...}}`.
    pub fn print_error(&self, error: &OlError) {
        match self.format {
            OutputFormat::Human => {
                let prefix = color::red("Error:", self.color);
                eprintln!("{prefix} {} ({})", error.message, error.code);
                if error.suggestion.is_some() || error.docs_url.is_some() {
                    eprintln!();
                    if let Some(ref s) = error.suggestion {
                        eprintln!("  Suggestion: {s}");
                    }
                    if let Some(ref url) = error.docs_url {
                        eprintln!("  Docs: {url}");
                    }
                }
            }
            OutputFormat::Json => {
                let json = serde_json::json!({
                    "error": {
                        "code": error.code,
                        "message": error.message,
                        "suggestion": error.suggestion,
                        "docs_url": error.docs_url,
                    }
                });
                eprintln!(
                    "{}",
                    serde_json::to_string_pretty(&json).unwrap_or_default()
                );
            }
        }
    }

    /// Print an informational message to stderr.
    ///
    /// Silent in quiet mode and JSON mode.
    pub fn print_info(&self, message: &str) {
        if self.quiet || self.format == OutputFormat::Json {
            return;
        }
        eprintln!("{message}");
    }

    /// Serialize a value as pretty JSON to stdout.
    ///
    /// Used by commands to emit their JSON output. Silently skips serialization
    /// errors rather than panicking (logs a debug message instead).
    pub fn print_json<T: serde::Serialize>(&self, value: &T) {
        match serde_json::to_string_pretty(value) {
            Ok(s) => println!("{s}"),
            Err(e) => {
                // SECURITY: Never log raw event content or token values here
                eprintln!("Error: failed to serialize JSON output: {e}");
            }
        }
    }

    /// Returns true if quiet mode is active.
    pub fn is_quiet(&self) -> bool {
        self.quiet
    }

    /// Create an indicatif progress spinner writing to stderr.
    ///
    /// Per CLI-11: progress spinners write to stderr, not stdout, so they don't
    /// contaminate machine-parseable stdout output.
    ///
    /// Returns `None` if quiet mode or JSON mode is active (no spinners in non-interactive
    /// or machine-readable contexts).
    pub fn create_spinner(&self, message: &str) -> Option<indicatif::ProgressBar> {
        if self.quiet || self.format == OutputFormat::Json {
            return None;
        }

        // Write spinner to stderr so stdout stays clean for JSON/piped output
        let pb = indicatif::ProgressBar::new_spinner();
        pb.set_draw_target(indicatif::ProgressDrawTarget::stderr());
        pb.set_message(message.to_string());
        pb.enable_steady_tick(std::time::Duration::from_millis(80));
        Some(pb)
    }
}

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

    fn human_config() -> OutputConfig {
        OutputConfig {
            format: OutputFormat::Human,
            verbose: false,
            debug: false,
            quiet: false,
            color: false,
        }
    }

    fn json_config() -> OutputConfig {
        OutputConfig {
            format: OutputFormat::Json,
            verbose: false,
            debug: false,
            quiet: false,
            color: false,
        }
    }

    fn quiet_config() -> OutputConfig {
        OutputConfig {
            format: OutputFormat::Human,
            verbose: false,
            debug: false,
            quiet: true,
            color: false,
        }
    }

    #[test]
    fn test_is_quiet_true_when_quiet_flag() {
        let cfg = quiet_config();
        assert!(cfg.is_quiet());
    }

    #[test]
    fn test_is_quiet_false_in_normal_mode() {
        let cfg = human_config();
        assert!(!cfg.is_quiet());
    }

    #[test]
    fn test_print_json_writes_valid_json() {
        // Capture stdout via a temporary buffer to verify JSON output
        // We verify the method doesn't panic with a valid serializable value
        let cfg = json_config();
        let value = serde_json::json!({"status": "ok", "version": "0.0.0"});
        // This should not panic
        cfg.print_json(&value);
    }

    #[test]
    fn test_create_spinner_returns_none_in_json_mode() {
        let cfg = json_config();
        let spinner = cfg.create_spinner("doing work...");
        assert!(spinner.is_none(), "Spinner should be None in JSON mode");
    }

    #[test]
    fn test_create_spinner_returns_none_in_quiet_mode() {
        let cfg = quiet_config();
        let spinner = cfg.create_spinner("doing work...");
        assert!(spinner.is_none(), "Spinner should be None in quiet mode");
    }

    #[test]
    fn test_output_format_equality() {
        assert_eq!(OutputFormat::Human, OutputFormat::Human);
        assert_eq!(OutputFormat::Json, OutputFormat::Json);
        assert_ne!(OutputFormat::Human, OutputFormat::Json);
    }

    /// Verify print_error in JSON mode produces valid JSON structure.
    ///
    /// We call print_error with a known error and verify the logic branches work
    /// without panicking (actual stderr capture requires process-level work).
    #[test]
    fn test_print_error_json_mode_no_panic() {
        let cfg = json_config();
        let err = OlError::new(crate::error::ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB")
            .with_suggestion("Split the payload")
            .with_docs("https://docs.openlatch.ai/errors/OL-1002");
        // Should not panic
        cfg.print_error(&err);
    }

    #[test]
    fn test_print_error_human_mode_no_panic() {
        let cfg = human_config();
        let err = OlError::new(crate::error::ERR_PORT_IN_USE, "Port 7443 in use");
        // Should not panic
        cfg.print_error(&err);
    }

    /// Verify that print_step is a no-op in JSON mode by checking no panic occurs.
    /// The actual "no stdout write" behavior is enforced by the implementation
    /// (returns early before any write in JSON mode).
    #[test]
    fn test_print_step_json_mode_is_silent() {
        let cfg = json_config();
        // Should not panic and should write nothing to stdout
        cfg.print_step("Installing hooks");
    }
}