diff --git a/src/main.rs b/src/main.rs index 93490f9..37a326c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use hostname::get as get_hostname; use notify_rust::Notification; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::cmp::Ordering as cmpOrdering; +use std::cmp::Ordering as CmpOrdering; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -35,6 +35,11 @@ static TIME_CACHE: Lazy> = Lazy::new(DashMap::new); // Constants (matching Python exactly) const TIME_OFFSET: Duration = Duration::hours(4); const CACHE_TTL_SECONDS: i64 = 60; +const THRESHOLD_CHECK_INTERVAL_SECS: u64 = 10; +const SERVER_CHECK_INTERVAL_SECS: u64 = 10; +const NEW_DAY_CHECK_INTERVAL_SECS: u64 = 5 * 60; // 5 minutes +const AFK_TIMEOUT_MINUTES: i64 = 5; +const NOTIFICATION_TIMEOUT_MS: i32 = 5000; /// Category aggregation mode #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -68,6 +73,26 @@ impl Default for AlertConfig { } } +impl AlertConfig { + /// Get the display label, falling back to category name if not set + pub fn display_label(&self) -> &str { + self.label.as_deref().unwrap_or(&self.category) + } + + /// Validate that thresholds are in ascending order + /// + /// Returns `true` if thresholds are properly ordered or if there are 0-1 thresholds. + /// For 0 or 1 threshold, there's nothing to validate ordering-wise. + pub fn validate_thresholds(&self) -> bool { + // Empty or single threshold is valid (nothing to order) + if self.thresholds_minutes.len() <= 1 { + return true; + } + // Check that each threshold is strictly less than the next + self.thresholds_minutes.windows(2).all(|w| w[0] < w[1]) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct NotificationConfig { pub alerts: Vec, @@ -112,6 +137,44 @@ impl Default for NotificationConfig { } } +impl NotificationConfig { + /// Validate the configuration + /// + /// Checks that all alert configurations have valid threshold orderings. + pub fn validate(&self) -> Result<()> { + for (idx, alert) in self.alerts.iter().enumerate() { + if alert.thresholds_minutes.is_empty() { + log::warn!( + "Alert #{} for category '{}' has no thresholds - it will never trigger", + idx + 1, // Use 1-based numbering for user-friendly output + alert.category + ); + } + if !alert.validate_thresholds() { + return Err(anyhow!( + "Alert for category '{}' has thresholds not in ascending order", + alert.category + )); + } + } + Ok(()) + } + + /// Check if any monitoring features are enabled + /// + /// Returns `true` if any feature is enabled (hourly checkins, new day greetings, + /// server monitoring, or at least one alert with valid thresholds). + pub fn has_any_monitoring_enabled(&self) -> bool { + self.hourly_checkins + || self.new_day_greetings + || self.server_monitoring + || self + .alerts + .iter() + .any(|alert| !alert.thresholds_minutes.is_empty()) + } +} + #[derive(Parser)] #[clap( name = "aw-notify", @@ -202,6 +265,9 @@ fn load_config() -> Result { let config: NotificationConfig = toml::from_str(&config_content) .map_err(|e| anyhow!("Failed to parse config file {:?}: {}", config_path, e))?; + // Validate the configuration + config.validate()?; + log::info!("Loaded configuration from {:?}", config_path); Ok(config) } @@ -235,15 +301,30 @@ fn main() -> Result<()> { let host = "127.0.0.1"; let client = match aw_client_rust::blocking::AwClient::new(host, port, "aw-notify") { Ok(client) => client, - Err(e) => return Err(anyhow!("Failed to create client: {}", e)), + Err(e) => { + return Err(anyhow!( + "Failed to create ActivityWatch client ({}:{}): {}. Is the ActivityWatch server running?", + host, + port, + e + )) + } }; // Wait for server to be ready (like Python's wait_for_start) - client.get_info()?; - - let hostname = get_hostname() - .map(|h| h.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); + client.get_info().map_err(|e| { + anyhow!( + "Failed to connect to ActivityWatch server at {}:{}. Error: {}", + host, + port, + e + ) + })?; + + let hostname = get_hostname().map_or_else( + |_| "unknown".to_string(), + |h| h.to_string_lossy().to_string(), + ); // Set global state AW_CLIENT.set(client).ok(); @@ -258,12 +339,20 @@ fn main() -> Result<()> { let client = match aw_client_rust::blocking::AwClient::new(host, port, "aw-notify-checkin") { Ok(client) => client, - Err(e) => return Err(anyhow!("Failed to create client: {}", e)), + Err(e) => { + return Err(anyhow!( + "Failed to create ActivityWatch client ({}:{}): {}. Is the ActivityWatch server running?", + host, + port, + e + )) + } }; - let hostname = get_hostname() - .map(|h| h.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); + let hostname = get_hostname().map_or_else( + |_| "unknown".to_string(), + |h| h.to_string_lossy().to_string(), + ); // Set global state AW_CLIENT.set(client).ok(); @@ -279,12 +368,20 @@ fn main() -> Result<()> { let client = match aw_client_rust::blocking::AwClient::new(host, port, "aw-notify-checkin") { Ok(client) => client, - Err(e) => return Err(anyhow!("Failed to create client: {}", e)), + Err(e) => { + return Err(anyhow!( + "Failed to create ActivityWatch client ({}:{}): {}. Is the ActivityWatch server running?", + host, + port, + e + )) + } }; - let hostname = get_hostname() - .map(|h| h.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); + let hostname = get_hostname().map_or_else( + |_| "unknown".to_string(), + |h| h.to_string_lossy().to_string(), + ); // Set global state AW_CLIENT.set(client).ok(); @@ -299,6 +396,11 @@ fn main() -> Result<()> { fn start_service(hostname: String, config: NotificationConfig) -> Result<()> { log::info!("Starting notification service..."); + // Validate configuration first + if !config.has_any_monitoring_enabled() { + log::warn!("All monitoring features are disabled in configuration. Service will idle."); + } + // Create shutdown channels for each thread let (shutdown_tx_main, shutdown_rx_main) = bounded::<()>(1); let (shutdown_tx_hourly, shutdown_rx_hourly) = bounded::<()>(1); @@ -372,7 +474,9 @@ fn start_service(hostname: String, config: NotificationConfig) -> Result<()> { result } -// CategoryAlert struct (exact copy of Python's CategoryAlert logic) +/// CategoryAlert tracks time spent in a category and sends notifications when thresholds are reached +/// +/// This struct mirrors the Python version's CategoryAlert class logic exactly. struct CategoryAlert { category: String, label: String, @@ -385,6 +489,14 @@ struct CategoryAlert { } impl CategoryAlert { + /// Create a new CategoryAlert with specified thresholds + /// + /// # Arguments + /// + /// * `category` - Category name to monitor + /// * `thresholds` - List of time thresholds for notifications + /// * `label` - Optional display label (defaults to category name) + /// * `positive` - If true, shows "Goal reached!", otherwise shows "Time spent" fn new(category: &str, thresholds: Vec, label: Option<&str>, positive: bool) -> Self { Self { category: category.to_string(), @@ -398,6 +510,7 @@ impl CategoryAlert { } } + /// Get list of thresholds that haven't been triggered yet fn thresholds_untriggered(&self) -> Vec { self.thresholds .iter() @@ -406,6 +519,7 @@ impl CategoryAlert { .collect() } + /// Calculate time remaining until the next threshold fn time_to_next_threshold(&self) -> Duration { let untriggered = self.thresholds_untriggered(); if untriggered.is_empty() { @@ -430,6 +544,7 @@ impl CategoryAlert { (min_threshold - self.time_spent).max(Duration::zero()) } + /// Update time spent in this category from ActivityWatch fn update(&mut self) { let now = Local::now(); let time_to_threshold = self.time_to_next_threshold(); @@ -450,6 +565,11 @@ impl CategoryAlert { } } + /// Check if any thresholds have been reached and send notifications + /// + /// # Arguments + /// + /// * `silent` - If true, suppresses notifications (useful for initial check) fn check(&mut self, silent: bool) { // Sort thresholds in descending order (like Python) let mut untriggered = self.thresholds_untriggered(); @@ -484,11 +604,16 @@ impl CategoryAlert { } } + /// Get current status string showing time spent fn status(&self) -> String { format!("{}: {}", self.label, to_hms(self.time_spent)) } } +/// Main threshold monitoring loop +/// +/// Continuously monitors category time thresholds and sends notifications when reached. +/// Runs until shutdown signal is received. fn threshold_alerts(shutdown_rx: Receiver<()>, alert_configs: Vec) -> Result<()> { log::info!("Starting threshold alerts monitoring..."); @@ -540,14 +665,13 @@ fn threshold_alerts(shutdown_rx: Receiver<()>, alert_configs: Vec) } // Wait for shutdown signal or timeout (10 seconds for normal monitoring) - match shutdown_rx.recv_timeout(time::Duration::from_secs(10)) { + match shutdown_rx.recv_timeout(time::Duration::from_secs(THRESHOLD_CHECK_INTERVAL_SECS)) { Ok(_) => { log::info!("Shutdown signal received, stopping threshold alerts monitoring"); break; } Err(crossbeam_channel::RecvTimeoutError::Timeout) => { // Normal timeout, continue monitoring - continue; } Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { log::warn!("Shutdown channel disconnected, stopping threshold alerts monitoring"); @@ -560,7 +684,14 @@ fn threshold_alerts(shutdown_rx: Receiver<()>, alert_configs: Vec) Ok(()) } -// Cache implementation (matching Python's @cache_ttl decorator) +/// Get time spent per category with caching +/// +/// Implements a TTL cache (60 seconds) to reduce server requests, matching Python's @cache_ttl. +/// +/// # Arguments +/// +/// * `date` - Optional date to query (defaults to today) +/// * `aggregation_mode` - How to aggregate hierarchical categories fn get_time( date: Option>, aggregation_mode: CategoryAggregation, @@ -587,6 +718,9 @@ fn get_time( Ok(result) } +/// Query ActivityWatch server for time data +/// +/// Builds and executes a canonical events query matching the Python implementation. fn query_activitywatch( date: Option>, aggregation_mode: CategoryAggregation, @@ -732,6 +866,7 @@ RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};"# } } +/// Send a checkin notification with top-level categories fn send_checkin(title: &str, date: Option>) -> Result<()> { log::info!("Sending checkin: {}", title); @@ -755,6 +890,7 @@ fn send_checkin(title: &str, date: Option>) -> Result<()> { Ok(()) } +/// Send a detailed checkin notification with all category levels fn send_detailed_checkin(title: &str, date: Option>) -> Result<()> { log::info!("Sending detailed checkin: {}", title); @@ -778,11 +914,15 @@ fn send_detailed_checkin(title: &str, date: Option>) -> Result<()> Ok(()) } +/// Send a checkin notification for yesterday's activity fn send_checkin_yesterday() -> Result<()> { let yesterday = Local::now().with_timezone(&Utc) - Duration::days(1); send_checkin("Time yesterday", Some(yesterday)) } +/// Start hourly checkin background thread +/// +/// Sends activity summaries at the top of each hour when the user is active. fn start_hourly(hostname: String, shutdown_rx: Receiver<()>) { thread::spawn(move || { log::info!("Starting hourly checkin thread"); @@ -839,6 +979,9 @@ fn start_hourly(hostname: String, shutdown_rx: Receiver<()>) { }); } +/// Start new day greeting background thread +/// +/// Sends a greeting notification when a new day starts and the user becomes active. fn start_new_day(hostname: String, shutdown_rx: Receiver<()>) { thread::spawn(move || { log::info!("Starting new day notification thread"); @@ -896,7 +1039,6 @@ fn start_new_day(hostname: String, shutdown_rx: Receiver<()>) { } Err(crossbeam_channel::RecvTimeoutError::Timeout) => { // Normal timeout, continue checking - continue; } Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { log::warn!( @@ -914,10 +1056,16 @@ fn start_new_day(hostname: String, shutdown_rx: Receiver<()>) { /// Calculate polling interval for new day detection /// - Always poll every 5 minutes for consistent checking fn calculate_new_day_polling_interval(_now: DateTime) -> time::Duration { - log::debug!("Using 5-minute polling for new day detection"); - time::Duration::from_secs(5 * 60) // 5 minutes + log::debug!( + "Using {}-minute polling for new day detection", + NEW_DAY_CHECK_INTERVAL_SECS / 60 + ); + time::Duration::from_secs(NEW_DAY_CHECK_INTERVAL_SECS) } +/// Start server monitoring background thread +/// +/// Monitors ActivityWatch server availability and sends alerts on status changes. fn start_server_monitor(shutdown_rx: Receiver<()>) { thread::spawn(move || { log::info!("Starting server monitor thread"); @@ -947,14 +1095,13 @@ fn start_server_monitor(shutdown_rx: Receiver<()>) { } // Wait for shutdown signal or timeout (10 seconds for monitoring) - match shutdown_rx.recv_timeout(time::Duration::from_secs(10)) { + match shutdown_rx.recv_timeout(time::Duration::from_secs(SERVER_CHECK_INTERVAL_SECS)) { Ok(_) => { log::info!("Shutdown signal received, stopping server monitor thread"); break; } Err(crossbeam_channel::RecvTimeoutError::Timeout) => { // Normal timeout, continue monitoring - continue; } Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { log::warn!("Shutdown channel disconnected, stopping server monitor thread"); @@ -967,6 +1114,13 @@ fn start_server_monitor(shutdown_rx: Receiver<()>) { }); } +/// Check if the user is currently active (not AFK) +/// +/// # Returns +/// +/// * `Ok(Some(true))` - User is active +/// * `Ok(Some(false))` - User is AFK +/// * `Ok(None)` - Cannot determine status (e.g., event too old) fn get_active_status(hostname: &str) -> Result> { let client = AW_CLIENT .get() @@ -983,7 +1137,7 @@ fn get_active_status(hostname: &str) -> Result> { let event_end = event.timestamp + event.duration; // Check if event is too old (like Python - 5 minutes) - if event_end < Local::now().with_timezone(&Utc) - Duration::minutes(5) { + if event_end < Local::now().with_timezone(&Utc) - Duration::minutes(AFK_TIMEOUT_MINUTES) { log::warn!("AFK event is too old, can't use to reliably determine AFK state"); return Ok(None); } @@ -997,6 +1151,7 @@ fn get_active_status(hostname: &str) -> Result> { Ok(None) } +/// Check if ActivityWatch server is available fn check_server_availability() -> bool { if let Some(client) = AW_CLIENT.get() { match client.get_info() { @@ -1008,6 +1163,11 @@ fn check_server_availability() -> bool { } } +/// Send a desktop notification +/// +/// On macOS, tries terminal-notifier first, then falls back to notify-rust. +/// On other platforms, uses notify-rust directly. +/// In output-only mode, prints to stdout instead. fn notify(title: &str, message: &str) -> Result<()> { let output_only = OUTPUT_ONLY.load(Ordering::Relaxed); @@ -1035,12 +1195,13 @@ fn notify(title: &str, message: &str) -> Result<()> { .summary(title) .body(message) .appname("ActivityWatch") - .timeout(5000) + .timeout(NOTIFICATION_TIMEOUT_MS) .show()?; Ok(()) } +/// Try sending notification via terminal-notifier on macOS #[cfg(target_os = "macos")] fn try_terminal_notifier(title: &str, message: &str) -> Result { use std::process::Command; @@ -1068,11 +1229,14 @@ fn try_terminal_notifier(title: &str, message: &str) -> Result { } } +/// Stub for terminal-notifier on non-macOS platforms #[cfg(not(target_os = "macos"))] +#[allow(dead_code)] fn try_terminal_notifier(_title: &str, _message: &str) -> Result { Ok(false) } +/// Format duration as human-readable string (e.g., "2h 30m") fn to_hms(duration: Duration) -> String { let days = duration.num_days(); let hours = duration.num_hours() % 24; @@ -1097,6 +1261,10 @@ fn to_hms(duration: Duration) -> String { parts.join(" ") } +/// Decode Unicode escape sequences in strings +/// +/// Simple implementation that currently just returns the string as-is. +/// Could be enhanced to handle actual Unicode escape sequences if needed. fn decode_unicode_escapes(s: &str) -> String { // Simple implementation for now - matches Python's decode_unicode_escapes // Could be enhanced to handle actual Unicode escape sequences @@ -1104,10 +1272,16 @@ fn decode_unicode_escapes(s: &str) -> String { } // === CATEGORY MATCHING AND PROCESSING FUNCTIONS === -//Get categorization classes from server with fallback to defaults +/// Get categorization classes from server with fallback to defaults fn get_server_classes() -> Vec<(CategoryId, CategorySpec)> { // Try to get classes from server (like old version) - let client = AW_CLIENT.get().unwrap(); + let client = match AW_CLIENT.get() { + Some(client) => client, + None => { + log::warn!("Client not initialized, using default classes"); + return default_classes(); + } + }; client .get_setting("classes") @@ -1248,6 +1422,14 @@ fn format_category_for_notification(category: &str) -> String { } /// Get top categories sorted by time spent with clean formatting +/// +/// Filters and sorts categories, returning only those above a minimum percentage threshold. +/// +/// # Arguments +/// +/// * `cat_time` - HashMap of category names to time spent in seconds +/// * `min_percent` - Minimum percentage of total time to include (e.g., 0.02 = 2%) +/// * `max_count` - Maximum number of categories to return fn get_top_categories( cat_time: &HashMap, min_percent: f64, @@ -1266,7 +1448,7 @@ fn get_top_categories( .collect(); // Sort by time spent (descending) - categories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(cmpOrdering::Equal)); + categories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(CmpOrdering::Equal)); // Limit to max_count and format durations categories @@ -1277,6 +1459,9 @@ fn get_top_categories( } /// Get top categories aggregated by top-level with emoji formatting for notifications +/// +/// First aggregates hierarchical categories to their top-level (e.g., "Work > Programming" → "Work"), +/// then filters, sorts, and formats them with appropriate emoji icons. fn get_top_level_categories_for_notifications( cat_time: &HashMap, min_percent: f64,