time_me 0.1.2

terminal based timer
Documentation
//! `time_me` lib

#![deny(missing_docs)]
#![deny(unsafe_code)]
#![deny(clippy::all)]
#![warn(clippy::pedantic)]
// TypedBuilder generates code that fails these lints
#![allow(clippy::empty_enum)]
#![allow(clippy::used_underscore_binding)]

use std::{error::Error, thread, time::Duration};

use indicatif::{ProgressBar, ProgressStyle};
use notify_rust::{Notification, Timeout};
use typed_builder::TypedBuilder;

const ONE_SECOND: Duration = Duration::from_secs(1);
const FIFTEEN_MINUTES: Duration = Duration::from_secs(15 * 60);
const DEFAULT_NOTIFICATION_TIMEOUT: Timeout = Timeout::Milliseconds(10000);

/// used to build a timer with different options:
/// progress bar, duration, and notification
#[derive(Clone, Debug, TypedBuilder)]
pub struct Timer {
    #[builder(default = default_progress_bar_style() )]
    progress_bar_style: ProgressStyle,
    #[builder(default = FIFTEEN_MINUTES)]
    duration: Duration,
    #[builder(default = None)]
    task: Option<String>,
    #[builder(default = false)]
    sticky_notification: bool,
}

impl Timer {
    /// starts the timer
    /// this will show a progress bar while running
    /// a notification will be sent out after timer finishes
    ///
    /// # Example
    /// ```
    /// # use time_me::Timer;
    /// # use std::time::Duration;
    ///
    /// Timer::builder()
    ///     .duration(Duration::from_secs(1))
    ///     .build()
    ///     .start()
    /// // waits 1 second
    /// // sends "time!" -> notification
    /// ```
    pub fn start(&self) {
        self.wait();
        self.send_notification();
    }

    #[allow(clippy::unused_self)]
    fn send_notification(&self) {
        println!("time!");

        let timeout = if self.sticky_notification {
            Timeout::Never
        } else {
            DEFAULT_NOTIFICATION_TIMEOUT
        };

        let _ = Notification::new()
            .summary("time!")
            .body(&format_notification_message(&self.duration, &self.task))
            .timeout(timeout)
            .show();
    }

    /// main waiting loop for timer
    ///
    /// this includes the progress bar updating
    fn wait(&self) {
        let progress_bar = self.create_progress_bar();
        for _ in 0..self.duration.as_secs() {
            thread::sleep(ONE_SECOND);
            progress_bar.inc(1);
        }
        progress_bar.finish();
    }

    /// creates a `ProgressBar` based on duration of `Timer`
    fn create_progress_bar(&self) -> ProgressBar {
        let progress_bar = ProgressBar::new(self.duration.as_secs());
        progress_bar.set_style(default_progress_bar_style());
        progress_bar
    }
}

/// basic fallback progress bar style
fn default_progress_bar_style() -> ProgressStyle {
    ProgressStyle::default_bar()
        .template("[{elapsed_precise}] {bar:40.cyan/blue} {msg}")
        .progress_chars("##-")
}

/// format timer information into nice notification message
#[must_use]
pub fn format_notification_message(duration: &Duration, task: &Option<String>) -> String {
    let minutes_elapsed = duration.as_secs() / 60;

    if let Some(task) = task {
        format!("{} minutes spent on `{}`", minutes_elapsed, task)
    } else {
        format!("{} minutes elapsed", minutes_elapsed)
    }
}

/// parses a string containing a number as minutes
/// decimals are parsed as seconds
///
/// # Example
/// ```
/// # use time_me::parse_as_minutes;
/// # use std::time::Duration;
///
/// let five_minutes = Duration::from_secs(5 * 60);
/// let parsed_as_minutes = parse_as_minutes("5").unwrap();
///
/// assert_eq!(five_minutes, parsed_as_minutes);
/// ```
///
/// # Errors
/// - if the provided string isn't a number
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn parse_as_minutes(unparsed: &str) -> Result<Duration, impl Error> {
    unparsed
        .trim()
        .parse::<f64>()
        .map(|minutes| (minutes * 60.0).floor() as u64)
        .map(Duration::from_secs)
}

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

    type TestResult = Result<(), Box<dyn Error>>;

    #[test]
    fn parses_single_number_as_minutes() -> TestResult {
        let expected_duration = Duration::from_secs(42 * 60);
        let parsed_duration = parse_as_minutes("42")?;

        assert_eq!(expected_duration, parsed_duration);
        Ok(())
    }

    #[test]
    fn parses_number_with_extra_whitespace() -> TestResult {
        let expected_duration = Duration::from_secs(25 * 60);
        let parsed_duration = parse_as_minutes(" 25  ")?;

        assert_eq!(expected_duration, parsed_duration);
        Ok(())
    }

    #[test]
    fn parses_decimal_as_seconds() -> TestResult {
        let expected_duration = Duration::from_secs(6);
        let parsed_duration = parse_as_minutes(".1")?;

        assert_eq!(expected_duration, parsed_duration);
        Ok(())
    }

    #[test]
    fn fails_to_parse_a_non_number() {
        assert!(parse_as_minutes("word").is_err());
    }

    #[test]
    fn adds_duration_to_notification_message() {
        let duration = Duration::from_secs(600);

        let actual_message = format_notification_message(&duration, &None);
        let expected_message = "10 minutes elapsed";

        assert_eq!(expected_message, actual_message);
    }

    #[test]
    fn adds_task_to_notification_message() {
        let task = "code".to_string();
        let duration = Duration::from_secs(600);

        let actual_message = format_notification_message(&duration, &Some(task));
        let expected_message = "10 minutes spent on `code`";

        assert_eq!(expected_message, actual_message);
    }
}