use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::Duration;
use crate::watcher::config::AppFocusConfig;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FocusEvent {
pub app_name: String,
pub window_title: Option<String>,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub duration_secs: u64,
}
impl FocusEvent {
pub fn to_memory_content(&self) -> String {
let window_part = self
.window_title
.as_deref()
.map(|t| format!(" ({})", t))
.unwrap_or_default();
format!(
"App focus: {}{} — {} seconds (from {} to {})",
self.app_name,
window_part,
self.duration_secs,
self.started_at.format("%Y-%m-%dT%H:%M:%SZ"),
self.ended_at.format("%Y-%m-%dT%H:%M:%SZ"),
)
}
}
#[derive(Debug)]
struct ActiveFocus {
app_name: String,
started_at: DateTime<Utc>,
}
pub struct AppFocusWatcher {
config: AppFocusConfig,
active: Option<ActiveFocus>,
completed: Vec<FocusEvent>,
command_runner: Box<dyn AppNameProvider + Send + Sync>,
}
impl std::fmt::Debug for AppFocusWatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppFocusWatcher")
.field("config", &self.config)
.field("active", &self.active)
.field("completed_count", &self.completed.len())
.finish()
}
}
impl AppFocusWatcher {
pub fn new(config: AppFocusConfig) -> Self {
Self {
config,
active: None,
completed: Vec::new(),
command_runner: Box::new(OsCommandRunner),
}
}
pub fn with_runner(
config: AppFocusConfig,
runner: Box<dyn AppNameProvider + Send + Sync>,
) -> Self {
Self {
config,
active: None,
completed: Vec::new(),
command_runner: runner,
}
}
pub fn tick(&mut self) -> bool {
if !self.config.enabled {
return false;
}
let now = Utc::now();
let current_app = match self.command_runner.current_app() {
Some(name) => name,
None => return false,
};
if self.is_excluded(¤t_app) {
self.active = None;
return false;
}
let mut new_event = false;
match self.active.take() {
None => {
self.active = Some(ActiveFocus {
app_name: current_app,
started_at: now,
});
}
Some(prev) if prev.app_name == current_app => {
self.active = Some(prev);
}
Some(prev) => {
let duration = now
.signed_duration_since(prev.started_at)
.num_seconds()
.max(0) as u64;
if duration >= self.config.min_focus_secs {
self.completed.push(FocusEvent {
app_name: prev.app_name,
window_title: None,
started_at: prev.started_at,
ended_at: now,
duration_secs: duration,
});
new_event = true;
}
self.active = Some(ActiveFocus {
app_name: current_app,
started_at: now,
});
}
}
new_event
}
pub fn drain_completed_events(&mut self) -> Vec<FocusEvent> {
std::mem::take(&mut self.completed)
}
pub fn current_app(&self) -> Option<&str> {
self.active.as_ref().map(|a| a.app_name.as_str())
}
pub fn poll_interval(&self) -> Duration {
Duration::from_secs(self.config.poll_interval_secs)
}
fn is_excluded(&self, app_name: &str) -> bool {
let lower = app_name.to_lowercase();
self.config
.exclude_apps
.iter()
.any(|ex| ex.to_lowercase() == lower)
}
}
pub trait AppNameProvider {
fn current_app(&self) -> Option<String>;
}
struct OsCommandRunner;
impl AppNameProvider for OsCommandRunner {
fn current_app(&self) -> Option<String> {
poll_foreground_app()
}
}
pub fn poll_foreground_app() -> Option<String> {
#[cfg(target_os = "macos")]
{
let output = Command::new("osascript")
.args([
"-e",
r#"tell application "System Events" to get name of first process whose frontmost is true"#,
])
.output()
.ok()?;
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
} else {
tracing::debug!(
stderr = %String::from_utf8_lossy(&output.stderr),
"osascript returned non-zero exit code"
);
None
}
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
struct MockRunner {
queue: Arc<Mutex<VecDeque<Option<String>>>>,
}
impl MockRunner {
fn from_names(names: Vec<Option<&str>>) -> Self {
let deque = names
.into_iter()
.map(|n| n.map(|s| s.to_string()))
.collect();
Self {
queue: Arc::new(Mutex::new(deque)),
}
}
}
impl AppNameProvider for MockRunner {
fn current_app(&self) -> Option<String> {
self.queue.lock().unwrap().pop_front().flatten()
}
}
fn default_config() -> AppFocusConfig {
AppFocusConfig {
enabled: true,
poll_interval_secs: 5,
min_focus_secs: 1,
exclude_apps: Vec::new(),
}
}
#[test]
fn test_first_tick_starts_tracking_no_event() {
let runner = MockRunner::from_names(vec![Some("Safari")]);
let mut watcher = AppFocusWatcher::with_runner(default_config(), Box::new(runner));
let emitted = watcher.tick();
assert!(!emitted, "first tick should not emit a completed event");
assert_eq!(watcher.current_app(), Some("Safari"));
assert!(watcher.drain_completed_events().is_empty());
}
#[test]
fn test_app_switch_emits_event() {
let mut config = default_config();
config.min_focus_secs = 0;
let runner = MockRunner::from_names(vec![Some("Safari"), Some("Terminal")]);
let mut watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
watcher.tick(); let emitted = watcher.tick();
assert!(emitted, "app switch should emit a completed event");
let events = watcher.drain_completed_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].app_name, "Safari");
assert_eq!(watcher.current_app(), Some("Terminal"));
}
#[test]
fn test_same_app_no_event() {
let runner = MockRunner::from_names(vec![Some("VSCode"), Some("VSCode"), Some("VSCode")]);
let mut watcher = AppFocusWatcher::with_runner(default_config(), Box::new(runner));
watcher.tick();
watcher.tick();
watcher.tick();
assert!(watcher.drain_completed_events().is_empty());
assert_eq!(watcher.current_app(), Some("VSCode"));
}
#[test]
fn test_sub_threshold_events_are_dropped() {
let mut config = default_config();
config.min_focus_secs = 3600;
let runner = MockRunner::from_names(vec![Some("App A"), Some("App B")]);
let mut watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
watcher.tick(); let emitted = watcher.tick();
assert!(!emitted, "short focus sessions should be dropped");
assert!(watcher.drain_completed_events().is_empty());
}
#[test]
fn test_excluded_apps_are_skipped() {
let mut config = default_config();
config.exclude_apps = vec!["Finder".to_string(), "loginwindow".to_string()];
config.min_focus_secs = 0;
let runner = MockRunner::from_names(vec![
Some("Finder"),
Some("Safari"),
Some("Finder"),
Some("Terminal"),
]);
let mut watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
watcher.tick(); watcher.tick(); watcher.tick(); watcher.tick();
let events = watcher.drain_completed_events();
for ev in &events {
assert_ne!(
ev.app_name.to_lowercase(),
"finder",
"excluded apps must not appear in events"
);
assert_ne!(
ev.app_name.to_lowercase(),
"loginwindow",
"excluded apps must not appear in events"
);
}
}
#[test]
fn test_disabled_watcher_never_emits() {
let mut config = default_config();
config.enabled = false;
let runner = MockRunner::from_names(vec![Some("Safari"), Some("Terminal")]);
let mut watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
let e1 = watcher.tick();
let e2 = watcher.tick();
assert!(!e1);
assert!(!e2);
assert!(
watcher.current_app().is_none(),
"disabled watcher should not track"
);
assert!(watcher.drain_completed_events().is_empty());
}
#[test]
fn test_focus_event_to_memory_content() {
let started = "2026-03-09T10:00:00Z".parse::<DateTime<Utc>>().unwrap();
let ended = "2026-03-09T10:05:30Z".parse::<DateTime<Utc>>().unwrap();
let duration = ended.signed_duration_since(started).num_seconds() as u64;
let event = FocusEvent {
app_name: "Xcode".to_string(),
window_title: Some("MyProject".to_string()),
started_at: started,
ended_at: ended,
duration_secs: duration,
};
let content = event.to_memory_content();
assert!(content.contains("Xcode"), "content should include app name");
assert!(
content.contains("MyProject"),
"content should include window title"
);
assert!(
content.contains("330"),
"content should include duration in seconds"
);
}
#[test]
fn test_none_app_name_is_no_op() {
let runner = MockRunner::from_names(vec![None, None]);
let mut watcher = AppFocusWatcher::with_runner(default_config(), Box::new(runner));
let e1 = watcher.tick();
let e2 = watcher.tick();
assert!(!e1);
assert!(!e2);
assert!(watcher.current_app().is_none());
assert!(watcher.drain_completed_events().is_empty());
}
#[test]
fn test_poll_interval() {
let mut config = default_config();
config.poll_interval_secs = 10;
let runner = MockRunner::from_names(vec![]);
let watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
assert_eq!(watcher.poll_interval(), Duration::from_secs(10));
}
#[test]
fn test_multiple_events_accumulate() {
let mut config = default_config();
config.min_focus_secs = 0;
let runner = MockRunner::from_names(vec![Some("App A"), Some("App B"), Some("App C")]);
let mut watcher = AppFocusWatcher::with_runner(config, Box::new(runner));
watcher.tick(); watcher.tick(); watcher.tick();
let events = watcher.drain_completed_events();
assert_eq!(events.len(), 2);
assert_eq!(events[0].app_name, "App A");
assert_eq!(events[1].app_name, "App B");
assert!(watcher.drain_completed_events().is_empty());
}
}