git-vet 0.4.0

Git-based review gate for tracked file contents
Documentation
use std::io::{self, Write};
use std::time::Duration;

use console::Style;
use indicatif::{ProgressBar, ProgressStyle};

use crate::channel::{NotesRef, ReviewChannel};
use crate::error::AppError;
use crate::remote::RemoteName;
use crate::ui::interactive_stderr;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SyncStep {
    CheckRemote,
    CheckLocal,
    Fetch,
    Merge,
    Push,
    Cleanup,
}

impl SyncStep {
    const fn message(self) -> &'static str {
        match self {
            Self::CheckRemote => "Checking remote review notes",
            Self::CheckLocal => "Checking local review notes",
            Self::Fetch => "Fetching review notes",
            Self::Merge => "Merging review notes",
            Self::Push => "Pushing review notes",
            Self::Cleanup => "Cleaning up temporary notes ref",
        }
    }

    const fn gerund(self) -> &'static str {
        match self {
            Self::CheckRemote => "checking remote review notes",
            Self::CheckLocal => "checking local review notes",
            Self::Fetch => "fetching review notes",
            Self::Merge => "merging review notes",
            Self::Push => "pushing review notes",
            Self::Cleanup => "cleaning up temporary notes ref",
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SyncOutcome {
    FetchedMergedPushed,
    PushedLocalOnly,
    NothingToSync,
}

impl SyncOutcome {
    const fn details(self) -> &'static str {
        match self {
            Self::FetchedMergedPushed => "fetched, merged, and pushed",
            Self::PushedLocalOnly => "pushed local notes; remote ref did not exist",
            Self::NothingToSync => "nothing to sync",
        }
    }

    const fn summary_verb(self) -> &'static str {
        match self {
            Self::FetchedMergedPushed => "Synced",
            Self::PushedLocalOnly => "Pushed",
            Self::NothingToSync => "No",
        }
    }
}

#[derive(Debug)]
pub(crate) struct SyncContext<'a> {
    pub channel: &'a ReviewChannel,
    pub remote: &'a RemoteName,
    pub notes_ref: &'a NotesRef,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SyncReport {
    pub channel: ReviewChannel,
    pub remote: RemoteName,
    pub notes_ref: NotesRef,
    pub outcome: SyncOutcome,
}

pub(crate) trait SyncProgress {
    fn started(&mut self, context: &SyncContext<'_>) -> Result<(), AppError>;
    fn step_started(&mut self, step: SyncStep) -> Result<(), AppError>;
    fn step_finished(&mut self, step: SyncStep) -> Result<(), AppError>;
    fn step_failed(&mut self, step: SyncStep) -> Result<(), AppError>;
    fn finished(&mut self, report: &SyncReport) -> Result<(), AppError>;
}

pub(crate) enum SyncProgressReporter {
    Spinner(SpinnerSyncProgress),
    Plain(PlainSyncProgress),
}

impl SyncProgressReporter {
    pub(crate) fn from_environment(color: bool) -> Self {
        if interactive_stderr() {
            Self::Spinner(SpinnerSyncProgress::new(color))
        } else {
            Self::Plain(PlainSyncProgress::new(color))
        }
    }
}

impl SyncProgress for SyncProgressReporter {
    fn started(&mut self, context: &SyncContext<'_>) -> Result<(), AppError> {
        match self {
            Self::Spinner(progress) => progress.started(context),
            Self::Plain(progress) => progress.started(context),
        }
    }

    fn step_started(&mut self, step: SyncStep) -> Result<(), AppError> {
        match self {
            Self::Spinner(progress) => progress.step_started(step),
            Self::Plain(progress) => progress.step_started(step),
        }
    }

    fn step_finished(&mut self, step: SyncStep) -> Result<(), AppError> {
        match self {
            Self::Spinner(progress) => progress.step_finished(step),
            Self::Plain(progress) => progress.step_finished(step),
        }
    }

    fn step_failed(&mut self, step: SyncStep) -> Result<(), AppError> {
        match self {
            Self::Spinner(progress) => progress.step_failed(step),
            Self::Plain(progress) => progress.step_failed(step),
        }
    }

    fn finished(&mut self, report: &SyncReport) -> Result<(), AppError> {
        match self {
            Self::Spinner(progress) => progress.finished(report),
            Self::Plain(progress) => progress.finished(report),
        }
    }
}

pub(crate) struct SpinnerSyncProgress {
    spinner: ProgressBar,
    color: bool,
}

impl SpinnerSyncProgress {
    fn new(color: bool) -> Self {
        let spinner = ProgressBar::new_spinner().with_style(ProgressStyle::default_spinner());
        spinner.enable_steady_tick(Duration::from_millis(80));
        Self { spinner, color }
    }
}

impl SyncProgress for SpinnerSyncProgress {
    fn started(&mut self, context: &SyncContext<'_>) -> Result<(), AppError> {
        self.spinner.set_message(format!(
            "Syncing {} for channel {} via {}",
            context.notes_ref, context.channel, context.remote
        ));
        Ok(())
    }

    fn step_started(&mut self, step: SyncStep) -> Result<(), AppError> {
        self.spinner
            .set_message(paint(step.message(), UiStyle::Yellow, self.color));
        Ok(())
    }

    fn step_finished(&mut self, _step: SyncStep) -> Result<(), AppError> {
        Ok(())
    }

    fn step_failed(&mut self, step: SyncStep) -> Result<(), AppError> {
        self.spinner.finish_with_message(format!(
            "{} Failed while {}",
            paint("✗", UiStyle::Red, self.color),
            step.gerund()
        ));
        Ok(())
    }

    fn finished(&mut self, report: &SyncReport) -> Result<(), AppError> {
        self.spinner
            .finish_with_message(final_summary(report, self.color));
        Ok(())
    }
}

pub(crate) struct PlainSyncProgress {
    color: bool,
}

impl PlainSyncProgress {
    const fn new(color: bool) -> Self {
        Self { color }
    }
}

impl SyncProgress for PlainSyncProgress {
    fn started(&mut self, _context: &SyncContext<'_>) -> Result<(), AppError> {
        Ok(())
    }

    fn step_started(&mut self, _step: SyncStep) -> Result<(), AppError> {
        Ok(())
    }

    fn step_finished(&mut self, _step: SyncStep) -> Result<(), AppError> {
        Ok(())
    }

    fn step_failed(&mut self, step: SyncStep) -> Result<(), AppError> {
        let mut stderr = io::stderr().lock();
        writeln!(
            stderr,
            "{} Failed while {}",
            paint("✗", UiStyle::Red, self.color),
            step.gerund()
        )?;
        Ok(())
    }

    fn finished(&mut self, report: &SyncReport) -> Result<(), AppError> {
        let mut stderr = io::stderr().lock();
        writeln!(stderr, "{}", final_summary(report, self.color))?;
        writeln!(stderr, "  ref: {}", report.notes_ref)?;
        writeln!(stderr, "  result: {}", report.outcome.details())?;
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum UiStyle {
    Green,
    Yellow,
    Red,
}

impl UiStyle {
    const fn console_style(self) -> Style {
        match self {
            Self::Green => Style::new().green(),
            Self::Yellow => Style::new().yellow(),
            Self::Red => Style::new().red(),
        }
    }
}

fn final_summary(report: &SyncReport, color_enabled: bool) -> String {
    match report.outcome {
        SyncOutcome::NothingToSync => format!(
            "{} No review notes to sync for channel {} via {}",
            paint("✓", UiStyle::Green, color_enabled),
            report.channel,
            report.remote
        ),
        SyncOutcome::FetchedMergedPushed | SyncOutcome::PushedLocalOnly => format!(
            "{} {} review notes for channel {} via {}",
            paint("✓", UiStyle::Green, color_enabled),
            report.outcome.summary_verb(),
            report.channel,
            report.remote
        ),
    }
}

fn paint(text: &str, style: UiStyle, enabled: bool) -> String {
    style
        .console_style()
        .for_stderr()
        .force_styling(enabled)
        .apply_to(text)
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::channel::ReviewChannel;
    use crate::remote::RemoteNameSource;

    fn report(outcome: SyncOutcome) -> SyncReport {
        let channel = ReviewChannel::new("default").expect("valid channel");
        SyncReport {
            notes_ref: channel.notes_ref().clone(),
            channel,
            remote: RemoteName::new("origin", RemoteNameSource::Cli).expect("valid remote"),
            outcome,
        }
    }

    #[test]
    fn final_summary_describes_completed_sync() {
        assert_eq!(
            final_summary(&report(SyncOutcome::FetchedMergedPushed), false),
            "✓ Synced review notes for channel default via origin"
        );
    }

    #[test]
    fn final_summary_describes_noop_sync() {
        assert_eq!(
            final_summary(&report(SyncOutcome::NothingToSync), false),
            "✓ No review notes to sync for channel default via origin"
        );
    }
}