#![deny(missing_docs)]
#![deny(unsafe_code)]
#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![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);
#[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 {
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();
}
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();
}
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
}
}
fn default_progress_bar_style() -> ProgressStyle {
ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {msg}")
.progress_chars("##-")
}
#[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)
}
}
#[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);
}
}