void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Progress observer implementation using indicatif progress bars.
//!
//! This module provides a [`ProgressObserver`] that implements the unified
//! [`VoidObserver`] trait and displays progress using indicatif progress bars.

use indicatif::{ProgressBar, ProgressStyle};
use void_core::support::events::{PipelineEvent, VoidEvent, VoidObserver, WorkspaceEvent};

/// Progress observer that displays progress using indicatif progress bars.
///
/// This observer handles events from void-core operations and updates
/// an indicatif progress bar to show progress to the user.
pub struct ProgressObserver {
    bar: ProgressBar,
}

impl ProgressObserver {
    /// Create a new progress observer with a spinner and message.
    ///
    /// # Arguments
    ///
    /// * `message` - Initial message to display alongside the progress bar.
    pub fn new(message: &str) -> Self {
        let bar = ProgressBar::new_spinner();
        bar.set_style(
            ProgressStyle::default_spinner()
                .template("{spinner:.cyan} {msg}")
                .unwrap_or_else(|_| ProgressStyle::default_spinner()),
        );
        bar.set_message(message.to_string());
        bar.enable_steady_tick(std::time::Duration::from_millis(100));
        Self { bar }
    }

    /// Create a hidden progress observer for JSON mode.
    ///
    /// This observer produces no visible output, suitable for use when
    /// the CLI is outputting structured JSON.
    pub fn new_hidden() -> Self {
        let bar = ProgressBar::hidden();
        Self { bar }
    }

    /// Finish the progress bar and clear the line.
    pub fn finish(&self) {
        self.bar.finish_and_clear();
    }

    /// Set the progress bar to determinate mode with a known length.
    ///
    /// # Arguments
    ///
    /// * `len` - Total number of items.
    pub fn set_length(&self, len: u64) {
        self.bar.set_length(len);
        self.bar.set_style(
            ProgressStyle::default_bar()
                .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
                .map(|s| s.progress_chars("=> "))
                .unwrap_or_else(|_| ProgressStyle::default_bar()),
        );
    }

    /// Set the current progress position.
    ///
    /// # Arguments
    ///
    /// * `pos` - Current position.
    pub fn set_position(&self, pos: u64) {
        self.bar.set_position(pos);
    }

    /// Increment the progress bar by one.
    pub fn inc(&self) {
        self.bar.inc(1);
    }

    /// Set the progress bar message.
    ///
    /// # Arguments
    ///
    /// * `message` - Message to display.
    pub fn set_message(&self, message: &str) {
        self.bar.set_message(message.to_string());
    }
}

impl VoidObserver for ProgressObserver {
    fn on_event(&self, event: &VoidEvent) {
        match event {
            VoidEvent::Workspace(workspace_event) => match workspace_event {
                WorkspaceEvent::FileStaged { path } => {
                    self.inc();
                    self.set_message(path);
                }
                WorkspaceEvent::FileUnstaged { path: _ } => {
                    self.inc();
                }
                WorkspaceEvent::FileCheckedOut { path } => {
                    self.inc();
                    self.set_message(path);
                }
                WorkspaceEvent::FileSkipped { path, reason: _ } => {
                    self.inc();
                    self.set_message(path);
                }
                WorkspaceEvent::Progress {
                    stage: _,
                    current,
                    total,
                } => {
                    if self.bar.length() != Some(*total) {
                        self.set_length(*total);
                    }
                    self.set_position(*current);
                }
            },
            VoidEvent::Pipeline(pipeline_event) => match pipeline_event {
                PipelineEvent::FileDiscovered { path, size: _ } => {
                    self.set_message(path);
                }
                PipelineEvent::FileProcessed {
                    path: _,
                    shard_id: _,
                } => {
                    self.inc();
                }
                PipelineEvent::ShardCreated {
                    cid,
                    size,
                    file_count,
                } => {
                    let msg = format!(
                        "shard {} ({} files, {} bytes)",
                        &cid[..12.min(cid.len())],
                        file_count,
                        size
                    );
                    self.set_message(&msg);
                }
                PipelineEvent::ShardFetched { cid, source: _ } => {
                    self.inc();
                    let msg = format!("fetched {}", &cid[..12.min(cid.len())]);
                    self.set_message(&msg);
                }
                PipelineEvent::Progress {
                    stage: _,
                    current,
                    total,
                } => {
                    if self.bar.length() != Some(*total) {
                        self.set_length(*total);
                    }
                    self.set_position(*current);
                }
                PipelineEvent::Warning { message } => {
                    self.bar.println(format!("warning: {}", message));
                }
                PipelineEvent::Error {
                    message,
                    recoverable: _,
                } => {
                    self.bar.println(format!("error: {}", message));
                }
            },
            VoidEvent::Ops(ops_event) => {
                use void_core::support::events::OpsEvent;
                match ops_event {
                    OpsEvent::ObjectChecked { cid, object_type } => {
                        self.inc();
                        let msg = format!("checked {} {}", object_type, &cid[..12.min(cid.len())]);
                        self.set_message(&msg);
                    }
                    OpsEvent::IssueFound {
                        cid,
                        message,
                        severity: _,
                    } => {
                        self.bar.println(format!(
                            "issue at {}: {}",
                            &cid[..12.min(cid.len())],
                            message
                        ));
                    }
                    OpsEvent::MergeProgress {
                        stage,
                        conflicts_resolved,
                        conflicts_total,
                    } => {
                        let msg = format!(
                            "{}: {}/{} conflicts resolved",
                            stage, conflicts_resolved, conflicts_total
                        );
                        self.set_message(&msg);
                    }
                    OpsEvent::Progress {
                        stage: _,
                        current,
                        total,
                    } => {
                        if self.bar.length() != Some(*total) {
                            self.set_length(*total);
                        }
                        self.set_position(*current);
                    }
                }
            }
            VoidEvent::P2P(_) => {
                // P2P events are typically handled by a dedicated P2P observer
                // or logged separately; we ignore them in the progress bar.
            }
        }
    }
}

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

    #[test]
    fn new_creates_spinner() {
        let observer = ProgressObserver::new("Testing...");
        observer.finish();
    }

    #[test]
    fn new_hidden_creates_hidden_bar() {
        let observer = ProgressObserver::new_hidden();
        observer.finish();
    }

    #[test]
    fn handles_workspace_file_staged() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Workspace(WorkspaceEvent::FileStaged {
            path: "src/main.rs".into(),
        }));
        observer.finish();
    }

    #[test]
    fn handles_workspace_file_unstaged() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Workspace(WorkspaceEvent::FileUnstaged {
            path: "src/lib.rs".into(),
        }));
        observer.finish();
    }

    #[test]
    fn handles_workspace_progress() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Workspace(WorkspaceEvent::Progress {
            stage: "staging".into(),
            current: 5,
            total: 10,
        }));
        observer.finish();
    }

    #[test]
    fn handles_pipeline_file_discovered() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Pipeline(PipelineEvent::FileDiscovered {
            path: "README.md".into(),
            size: 1024,
        }));
        observer.finish();
    }

    #[test]
    fn handles_pipeline_file_processed() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Pipeline(PipelineEvent::FileProcessed {
            path: "src/lib.rs".into(),
            shard_id: 1,
        }));
        observer.finish();
    }

    #[test]
    fn handles_pipeline_shard_created() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Pipeline(PipelineEvent::ShardCreated {
            cid: "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku".into(),
            size: 4096,
            file_count: 3,
        }));
        observer.finish();
    }

    #[test]
    fn handles_pipeline_progress() {
        let observer = ProgressObserver::new_hidden();
        observer.on_event(&VoidEvent::Pipeline(PipelineEvent::Progress {
            stage: "sealing".into(),
            current: 3,
            total: 10,
        }));
        observer.finish();
    }
}