apiforge 0.4.0

Production-grade API release automation CLI. From merged code to healthy pods in production — one command.
Documentation
use std::sync::{Arc, Mutex};
use std::time::Duration;

use colored::Colorize;
use comfy_table::{ContentArrangement, Table};
use indicatif::{ProgressBar, ProgressStyle};

use crate::steps::{ProgressReporter, StepOutput, StepStatus};
use crate::utils::sanitize_message;

pub struct OutputManager {
    /// Spinner for the step currently running; cleared before final lines
    /// are printed so spinner frames never garble persistent output.
    active_spinner: Arc<Mutex<Option<ProgressBar>>>,
    /// Spinners only render on interactive terminals; in CI/pipes all output
    /// degrades to the plain line-based format.
    interactive: bool,
    /// Write human-readable lines to stderr instead of stdout. Used in
    /// machine-output modes (`--output json`) so stdout carries only the
    /// JSON document while progress remains visible on stderr.
    to_stderr: bool,
}

impl OutputManager {
    pub fn new() -> Self {
        Self {
            active_spinner: Arc::new(Mutex::new(None)),
            interactive: console::Term::stderr().is_term(),
            to_stderr: false,
        }
    }

    /// Output manager that writes all human-readable lines to stderr,
    /// keeping stdout clean for machine-readable output.
    pub fn stderr() -> Self {
        Self {
            to_stderr: true,
            ..Self::new()
        }
    }

    fn line(&self, content: String) {
        if self.to_stderr {
            eprintln!("{}", content);
        } else {
            println!("{}", content);
        }
    }

    /// Handle steps can use to stream live progress into the active spinner.
    pub fn progress_reporter(&self) -> Arc<dyn ProgressReporter> {
        Arc::new(SpinnerProgress {
            active_spinner: self.active_spinner.clone(),
        })
    }

    fn start_spinner(&self, message: String) {
        if !self.interactive {
            return;
        }
        let spinner = ProgressBar::new_spinner();
        spinner.set_style(
            ProgressStyle::with_template("  {spinner:.cyan} {msg}")
                .expect("static spinner template is valid"),
        );
        spinner.set_message(message);
        spinner.enable_steady_tick(Duration::from_millis(80));
        if let Ok(mut guard) = self.active_spinner.lock() {
            if let Some(old) = guard.take() {
                old.finish_and_clear();
            }
            *guard = Some(spinner);
        }
    }

    fn clear_spinner(&self) {
        if let Ok(mut guard) = self.active_spinner.lock() {
            if let Some(spinner) = guard.take() {
                spinner.finish_and_clear();
            }
        }
    }

    pub fn section(&self, title: &str) {
        self.clear_spinner();
        self.line(format!("\n{}", format!("{}", title).bold().cyan()));
    }

    pub fn step_status(&self, name: &str, status: &str) {
        if self.interactive {
            self.start_spinner(format!("{} {}", name, status));
        } else {
            self.line(format!("  {} {}", name.bold(), status.dimmed()));
        }
    }

    pub fn step_ok(&self, name: &str) {
        self.clear_spinner();
        self.line(format!("  {} {}", "".green().bold(), name));
    }

    pub fn step_done(&self, name: &str, output: &StepOutput) {
        self.clear_spinner();
        let icon = match output.status {
            StepStatus::Success => "".green().bold(),
            StepStatus::Skipped => "".yellow().bold(),
            StepStatus::Failed => "".red().bold(),
        };
        let timing = format!("({}ms)", output.duration_ms).dimmed();
        self.line(format!(
            "  {} {} {} {}",
            icon,
            name.bold(),
            output.message,
            timing
        ));

        // Display dry-run details if present
        if let Some(ref details) = output.dry_run_details {
            self.print_dry_run_details(details);
        }
    }

    fn print_dry_run_details(&self, details: &crate::steps::DryRunDetails) {
        // Print file changes
        for change in &details.file_changes {
            let op_icon = match change.operation {
                crate::steps::FileOperation::Create => "+",
                crate::steps::FileOperation::Modify => "~",
                crate::steps::FileOperation::Delete => "-",
            };
            self.line(format!(
                "    {} {} {} {}",
                "├─".dimmed(),
                op_icon.yellow(),
                change.path.dimmed(),
                format!("({:?})", change.operation).dimmed()
            ));
            if let Some(ref diff) = change.diff {
                for line in diff.lines() {
                    self.line(format!("    {} {}", "".dimmed(), line.cyan()));
                }
            }
        }

        // Print Docker preview
        if let Some(ref docker) = details.docker_preview {
            self.line(format!(
                "    {} {} {}",
                "├─".dimmed(),
                "📦".to_string().yellow(),
                format!("Docker image: {}", docker.image_name).dimmed()
            ));
            self.line(format!(
                "    {} {} {}",
                "".dimmed(),
                "🏷️".dimmed(),
                format!("Tags: {}", docker.tags.join(", ")).dimmed()
            ));
            if let Some(layers) = docker.layers_estimate {
                self.line(format!(
                    "    {} {} {}",
                    "".dimmed(),
                    "📚".dimmed(),
                    format!("Estimated layers: {}", layers).dimmed()
                ));
            }
        }

        // Print notes
        for note in &details.notes {
            self.line(format!(
                "    {} {} {}",
                "├─".dimmed(),
                "".dimmed(),
                note.dimmed()
            ));
        }
    }

    pub fn step_fail(&self, name: &str, error: &str) {
        self.clear_spinner();
        let safe_error = sanitize_message(error);
        self.line(format!(
            "  {} {} {}",
            "".red().bold(),
            name.bold(),
            safe_error.red()
        ));
    }

    pub fn blank_line(&self) {
        self.clear_spinner();
        self.line(String::new());
    }

    pub fn summary_table(&self, outputs: &[(&str, &StepOutput)]) {
        let mut table = Table::new();
        table.set_content_arrangement(ContentArrangement::Dynamic);
        table.set_header(vec!["Step", "Status", "Duration", "Message"]);

        for (name, out) in outputs {
            table.add_row(vec![
                name.to_string(),
                out.status.to_string(),
                format!("{}ms", out.duration_ms),
                out.message.clone(),
            ]);
        }

        self.line(format!("{table}"));
    }

    pub fn success(&self, msg: &str) {
        self.line(format!("\n{}", format!("{}", msg).green().bold()));
    }

    pub fn error(&self, msg: &str) {
        let safe_msg = sanitize_message(msg);
        self.line(format!("\n{}", format!("{}", safe_msg).red().bold()));
    }

    pub fn info(&self, msg: &str) {
        self.line(format!("  {}", msg));
    }

    pub fn warn(&self, msg: &str) {
        self.line(format!("  {}", format!("{}", msg).yellow()));
    }
}

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

/// Forwards step progress messages into the currently active spinner.
/// Messages are dropped when no spinner is active (non-interactive mode),
/// matching the "progress is best-effort" contract of `ProgressReporter`.
struct SpinnerProgress {
    active_spinner: Arc<Mutex<Option<ProgressBar>>>,
}

impl ProgressReporter for SpinnerProgress {
    fn set_message(&self, message: &str) {
        if let Ok(guard) = self.active_spinner.lock() {
            if let Some(ref spinner) = *guard {
                // Single-line spinner: keep messages terminal-friendly.
                let one_line = message.lines().next().unwrap_or("");
                let truncated: String = one_line.chars().take(120).collect();
                spinner.set_message(truncated);
            }
        }
    }
}