openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Output formatting infrastructure.
//!
//! Cloned from openlatch-client `src/cli/output.rs` and extended for the
//! 4-mode dispatch that openlatch-provider exposes (`table` / `json` / `yaml`
//! / `sarif`).
//!
//! Internally `OutputConfig` keeps the openlatch-client `format: OutputFormat`
//! enum (Human/Json) so the cloned auth/init/etc. command bodies don't need
//! per-call adjustments. The 4-mode CLI flag is collapsed:
//!   - `table` → `Human`
//!   - `json` / `yaml` / `sarif` → `Json` (machine-readable; the actual
//!     serializer for yaml/sarif lives at the call site)
//!
//! Per `.claude/rules/cli-output.md`:
//!   - human/table mode is colorized when stdout is a TTY
//!   - JSON-shaped errors go to stderr (never stdout) in machine modes
//!   - spinners write to stderr in TTY mode, no-op in machine modes

use crate::cli::{GlobalArgs, OutputFormat as CliOutputFormat};
use crate::error::OlError;
use crate::ui::{color, tty};

/// Internal output format — narrowed from the 4-mode CLI flag for the
/// openlatch-client compatibility surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
    Human,
    Json,
}

#[derive(Debug, Clone)]
pub struct OutputConfig {
    pub format: OutputFormat,
    /// Original CLI flag — used when commands need yaml-vs-sarif-vs-json branching.
    pub cli_format: CliOutputFormat,
    pub verbose: bool,
    pub debug: bool,
    pub quiet: bool,
    pub color: bool,
    pub interactive: bool,
    pub yes: bool,
    pub dry_run: bool,
}

impl OutputConfig {
    /// Build from parsed [`GlobalArgs`] applying TTY / CI / NO_COLOR /
    /// FORCE_COLOR precedence.
    pub fn resolve(g: &GlobalArgs) -> Self {
        let cli_format = g.output.unwrap_or_else(|| {
            if tty::stdout_is_tty() && !tty::is_ci() {
                CliOutputFormat::Table
            } else {
                CliOutputFormat::Json
            }
        });
        let format = match cli_format {
            CliOutputFormat::Table => OutputFormat::Human,
            _ => OutputFormat::Json,
        };
        let color = match cli_format {
            CliOutputFormat::Table => color::is_color_enabled(g.no_color),
            _ => false,
        };
        Self {
            format,
            cli_format,
            verbose: g.verbose,
            debug: g.debug,
            quiet: g.quiet,
            color,
            interactive: tty::allow_interactive(g.non_interactive),
            yes: g.yes,
            dry_run: g.dry_run,
        }
    }

    pub fn is_human(&self) -> bool {
        self.format == OutputFormat::Human && !self.quiet
    }

    pub fn is_machine(&self) -> bool {
        self.format == OutputFormat::Json
    }

    pub fn is_quiet(&self) -> bool {
        self.quiet
    }

    /// Print a step-completion line (✓ <message>). No-op in JSON / quiet modes.
    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 bullet (· <message>). No-op in JSON / quiet modes.
    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 an informational message to stderr. No-op in JSON / quiet modes.
    pub fn print_info(&self, message: &str) {
        if self.quiet || self.format == OutputFormat::Json {
            return;
        }
        eprintln!("{message}");
    }

    /// Render a typed error. Human mode = multi-line panel; JSON = struct on stderr.
    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.code);
                if error.suggestion.is_some() {
                    eprintln!();
                    if let Some(ref s) = error.suggestion {
                        eprintln!("  Suggestion: {s}");
                    }
                }
                eprintln!("  Docs: {}", error.code.docs_url);
            }
            OutputFormat::Json => {
                let json = serde_json::json!({
                    "error": {
                        "code": error.code.code,
                        "message": error.message,
                        "suggestion": error.suggestion,
                        "docs_url": error.code.docs_url,
                        "context": error.context,
                    }
                });
                eprintln!(
                    "{}",
                    serde_json::to_string_pretty(&json).unwrap_or_default()
                );
            }
        }
    }

    /// Serialize `value` as pretty JSON (or YAML in `--output yaml` mode) to
    /// stdout. Errors are reported to stderr.
    pub fn print_json<T: serde::Serialize>(&self, value: &T) {
        if matches!(self.cli_format, CliOutputFormat::Yaml) {
            match serde_yaml::to_string(value) {
                Ok(s) => print!("{s}"),
                Err(e) => eprintln!("Error: failed to serialize YAML output: {e}"),
            }
            return;
        }
        match serde_json::to_string_pretty(value) {
            Ok(s) => println!("{s}"),
            Err(e) => eprintln!("Error: failed to serialize JSON output: {e}"),
        }
    }

    /// Indicatif spinner writing to stderr. None in non-interactive contexts.
    pub fn create_spinner(&self, message: &str) -> Option<indicatif::ProgressBar> {
        if self.quiet || self.format == OutputFormat::Json {
            return None;
        }
        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,
            cli_format: CliOutputFormat::Table,
            verbose: false,
            debug: false,
            quiet: false,
            color: false,
            interactive: true,
            yes: false,
            dry_run: false,
        }
    }

    fn json_config() -> OutputConfig {
        OutputConfig {
            format: OutputFormat::Json,
            cli_format: CliOutputFormat::Json,
            verbose: false,
            debug: false,
            quiet: false,
            color: false,
            interactive: false,
            yes: false,
            dry_run: false,
        }
    }

    #[test]
    fn explicit_json_overrides_tty_default() {
        let g = GlobalArgs {
            output: Some(CliOutputFormat::Json),
            ..Default::default()
        };
        let cfg = OutputConfig::resolve(&g);
        assert_eq!(cfg.format, OutputFormat::Json);
        assert!(cfg.is_machine());
    }

    #[test]
    fn create_spinner_returns_none_in_json_mode() {
        let cfg = json_config();
        assert!(cfg.create_spinner("doing work").is_none());
    }

    #[test]
    fn print_step_human_mode_does_not_panic() {
        let cfg = human_config();
        cfg.print_step("hello");
    }

    #[test]
    fn print_error_json_mode_does_not_panic() {
        let cfg = json_config();
        let err = OlError::new(crate::error::OL_4220_HMAC_FAILED, "test").with_suggestion("retry");
        cfg.print_error(&err);
    }
}