use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TransitionTrigger {
UserSwitch,
Automation,
Notification,
#[default]
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppState {
pub name: String,
pub focused: bool,
pub last_action: Option<String>,
pub focus_count: u64,
pub total_time_ms: u64,
}
impl AppState {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
focused: false,
last_action: None,
focus_count: 0,
total_time_ms: 0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppTransition {
pub from_app: String,
pub to_app: String,
pub timestamp: u64,
pub trigger: TransitionTrigger,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedWorkflow {
pub name: String,
pub apps: Vec<String>,
pub pattern: Vec<AppTransition>,
pub frequency: u32,
pub avg_duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AutomationStep {
pub app: String,
pub description: String,
pub step_index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CrossAppStats {
pub total_transitions: usize,
pub distinct_apps: usize,
pub top_app: Option<String>,
pub top_transition: Option<(String, String)>,
}
#[derive(Debug, Default)]
pub struct CrossAppTracker {
apps: HashMap<String, AppState>,
transitions: Vec<AppTransition>,
current_app: Option<String>,
current_focus_start_ms: u64,
}
impl CrossAppTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_focus(&mut self, app: &str, trigger: TransitionTrigger) {
let now = now_ms();
self.flush_dwell_time(now);
let from_app = self.current_app.clone().unwrap_or_default();
let is_first = self.current_app.is_none();
if let Some(prev) = &from_app
.is_empty()
.then_some(())
.map(|_| None)
.unwrap_or(Some(from_app.clone()))
{
if let Some(state) = self.apps.get_mut(prev) {
state.focused = false;
}
}
let state = self
.apps
.entry(app.to_owned())
.or_insert_with(|| AppState::new(app));
state.focused = true;
state.focus_count += 1;
if !is_first {
self.transitions.push(AppTransition {
from_app: from_app.clone(),
to_app: app.to_owned(),
timestamp: now,
trigger,
});
}
self.current_app = Some(app.to_owned());
self.current_focus_start_ms = now;
}
pub fn set_last_action(&mut self, app: &str, action: impl Into<String>) {
if let Some(state) = self.apps.get_mut(app) {
state.last_action = Some(action.into());
}
}
#[must_use]
pub fn app_states(&self) -> Vec<&AppState> {
self.apps.values().collect()
}
#[must_use]
pub fn transitions(&self) -> &[AppTransition] {
&self.transitions
}
#[must_use]
pub fn detect_workflows(&self, min_frequency: u32) -> Vec<DetectedWorkflow> {
if self.transitions.len() < 2 {
return vec![];
}
let app_seq = self.full_app_chain();
if app_seq.len() < 3 {
return vec![];
}
let app_seq_refs: Vec<&str> = app_seq.iter().map(String::as_str).collect();
let max_window = app_seq_refs.len().min(6);
let mut results: Vec<DetectedWorkflow> = vec![];
for window in 3..=max_window {
let candidates = count_subsequences(&app_seq_refs, window);
let mut sorted: Vec<_> = candidates.into_iter().collect();
sorted.sort_by(|(a, ca), (b, cb)| cb.cmp(ca).then_with(|| a.cmp(b)));
for (seq, freq) in sorted {
if freq < min_frequency {
continue;
}
if is_subsequence_of_existing(&seq, &results) {
continue;
}
let workflow = self.build_workflow(seq, freq);
results.push(workflow);
}
}
results.sort_by(|a, b| b.frequency.cmp(&a.frequency));
results
}
fn full_app_chain(&self) -> Vec<String> {
let Some(first) = self.transitions.first() else {
return vec![];
};
let mut chain = Vec::with_capacity(self.transitions.len() + 1);
chain.push(first.from_app.clone());
for t in &self.transitions {
chain.push(t.to_app.clone());
}
chain
}
#[must_use]
pub fn suggest_automation(workflow: &DetectedWorkflow) -> Vec<AutomationStep> {
workflow
.apps
.iter()
.enumerate()
.map(|(i, app)| AutomationStep {
app: app.clone(),
description: automation_description(app, i, workflow.apps.len()),
step_index: i,
})
.collect()
}
#[must_use]
pub fn stats(&self) -> CrossAppStats {
let top_app = self
.apps
.values()
.max_by_key(|s| s.focus_count)
.map(|s| s.name.clone());
let top_transition = top_transition_pair(&self.transitions);
CrossAppStats {
total_transitions: self.transitions.len(),
distinct_apps: self.apps.len(),
top_app,
top_transition,
}
}
fn flush_dwell_time(&mut self, now_ms: u64) {
if let Some(app) = &self.current_app.clone() {
if let Some(state) = self.apps.get_mut(app) {
let elapsed = now_ms.saturating_sub(self.current_focus_start_ms);
state.total_time_ms = state.total_time_ms.saturating_add(elapsed);
}
}
}
fn build_workflow(&self, seq: Vec<&str>, frequency: u32) -> DetectedWorkflow {
let apps: Vec<String> = seq.iter().map(|s| s.to_string()).collect();
let name = apps.join(" → ");
let pattern = self.find_first_occurrence(&apps);
let avg_duration_ms = self.avg_workflow_duration(&apps);
DetectedWorkflow {
name,
apps,
pattern,
frequency,
avg_duration_ms,
}
}
fn find_first_occurrence(&self, apps: &[String]) -> Vec<AppTransition> {
let n = apps.len();
if n < 2 || self.transitions.len() < n - 1 {
return vec![];
}
let window_len = n - 1;
self.transitions
.windows(window_len)
.find(|w| {
let first_app = w[0].from_app.as_str();
let rest: Vec<&str> = w.iter().map(|t| t.to_app.as_str()).collect();
let full: Vec<&str> = std::iter::once(first_app).chain(rest).collect();
full.iter().zip(apps.iter()).all(|(a, b)| *a == b.as_str())
})
.map(|w| w.to_vec())
.unwrap_or_default()
}
fn avg_workflow_duration(&self, apps: &[String]) -> u64 {
let n = apps.len();
if n < 2 || self.transitions.len() < n - 1 {
return 0;
}
let mut total: u64 = 0;
let mut count: u64 = 0;
let window_len = n - 1;
for window in self.transitions.windows(window_len) {
let first_app = window[0].from_app.as_str();
let rest: Vec<&str> = window.iter().map(|t| t.to_app.as_str()).collect();
let full: Vec<&str> = std::iter::once(first_app).chain(rest).collect();
if full.iter().zip(apps.iter()).all(|(a, b)| *a == b.as_str()) {
let duration = window
.last()
.and_then(|last| {
window
.first()
.map(|first| last.timestamp.saturating_sub(first.timestamp))
})
.unwrap_or(0);
total = total.saturating_add(duration);
count += 1;
}
}
if count == 0 {
0
} else {
total / count
}
}
}
fn count_subsequences<'a>(seq: &[&'a str], window: usize) -> HashMap<Vec<&'a str>, u32> {
let mut counts: HashMap<Vec<&str>, u32> = HashMap::new();
for chunk in seq.windows(window) {
*counts.entry(chunk.to_vec()).or_insert(0) += 1;
}
counts
}
fn is_subsequence_of_existing(seq: &[&str], existing: &[DetectedWorkflow]) -> bool {
existing.iter().any(|wf| {
let wf_apps: Vec<&str> = wf.apps.iter().map(String::as_str).collect();
wf_apps.windows(seq.len()).any(|w| w == seq)
})
}
fn automation_description(app: &str, i: usize, total: usize) -> String {
match i {
0 => format!("Read/extract data from {app}"),
n if n == total - 1 => format!("Write/inject result into {app}"),
_ => format!("Transform and pass context through {app}"),
}
}
fn top_transition_pair(transitions: &[AppTransition]) -> Option<(String, String)> {
let mut counts: HashMap<(&str, &str), u32> = HashMap::new();
for t in transitions {
*counts
.entry((t.from_app.as_str(), t.to_app.as_str()))
.or_insert(0) += 1;
}
counts
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|((from, to), _)| (from.to_owned(), to.to_owned()))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn push_abc(tracker: &mut CrossAppTracker, n: u32) {
for _ in 0..n {
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.record_focus("AppB", TransitionTrigger::UserSwitch);
tracker.record_focus("AppC", TransitionTrigger::UserSwitch);
}
}
#[test]
fn transition_trigger_default_is_unknown() {
assert_eq!(TransitionTrigger::default(), TransitionTrigger::Unknown);
}
#[test]
fn record_focus_three_apps_creates_three_states() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("Safari", TransitionTrigger::UserSwitch);
tracker.record_focus("Slack", TransitionTrigger::UserSwitch);
tracker.record_focus("VSCode", TransitionTrigger::UserSwitch);
let stats = tracker.stats();
assert_eq!(stats.distinct_apps, 3);
let vscode = tracker.apps.get("VSCode").unwrap();
assert!(vscode.focused);
}
#[test]
fn record_focus_increments_focus_count_per_app() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.record_focus("AppB", TransitionTrigger::UserSwitch);
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.record_focus("AppB", TransitionTrigger::UserSwitch);
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
assert_eq!(tracker.apps["AppA"].focus_count, 3);
assert_eq!(tracker.apps["AppB"].focus_count, 2);
}
#[test]
fn record_focus_first_app_generates_no_transition() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
assert_eq!(tracker.transitions().len(), 0);
}
#[test]
fn record_focus_second_app_generates_one_transition() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("From", TransitionTrigger::UserSwitch);
tracker.record_focus("To", TransitionTrigger::Automation);
let transitions = tracker.transitions();
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].from_app, "From");
assert_eq!(transitions[0].to_app, "To");
assert_eq!(transitions[0].trigger, TransitionTrigger::Automation);
}
#[test]
fn record_focus_only_current_app_has_focused_true() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("First", TransitionTrigger::UserSwitch);
tracker.record_focus("Second", TransitionTrigger::UserSwitch);
assert!(!tracker.apps["First"].focused);
assert!(tracker.apps["Second"].focused);
}
#[test]
fn set_last_action_stores_label_on_known_app() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.set_last_action("AppA", "editing component");
assert_eq!(
tracker.apps["AppA"].last_action.as_deref(),
Some("editing component")
);
}
#[test]
fn set_last_action_on_unknown_app_is_noop() {
let mut tracker = CrossAppTracker::new();
tracker.set_last_action("Ghost", "whatever");
assert!(!tracker.apps.contains_key("Ghost"));
}
#[test]
fn detect_workflows_empty_tracker_returns_empty() {
let tracker = CrossAppTracker::new();
assert!(tracker.detect_workflows(1).is_empty());
}
#[test]
fn detect_workflows_fewer_than_three_transitions_returns_empty() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("A", TransitionTrigger::UserSwitch);
tracker.record_focus("B", TransitionTrigger::UserSwitch);
assert!(tracker.detect_workflows(1).is_empty());
}
#[test]
fn detect_workflows_abc_repeated_twice_detected() {
let mut tracker = CrossAppTracker::new();
push_abc(&mut tracker, 2);
let workflows = tracker.detect_workflows(2);
assert!(!workflows.is_empty());
let wf = &workflows[0];
assert_eq!(wf.apps, vec!["AppA", "AppB", "AppC"]);
assert_eq!(wf.frequency, 2);
}
#[test]
fn detect_workflows_min_frequency_filters_rare_patterns() {
let mut tracker = CrossAppTracker::new();
push_abc(&mut tracker, 3);
tracker.record_focus("AppD", TransitionTrigger::UserSwitch);
tracker.record_focus("AppE", TransitionTrigger::UserSwitch);
tracker.record_focus("AppF", TransitionTrigger::UserSwitch);
let workflows = tracker.detect_workflows(3);
assert_eq!(workflows.len(), 1);
assert_eq!(workflows[0].apps[0], "AppA");
}
#[test]
fn detect_workflows_name_derived_from_app_sequence() {
let mut tracker = CrossAppTracker::new();
push_abc(&mut tracker, 3);
let workflows = tracker.detect_workflows(2);
assert_eq!(workflows[0].name, "AppA → AppB → AppC");
}
#[test]
fn detect_workflows_pattern_contains_representative_transitions() {
let mut tracker = CrossAppTracker::new();
push_abc(&mut tracker, 3);
let workflows = tracker.detect_workflows(2);
assert!(!workflows.is_empty());
assert_eq!(workflows[0].pattern.len(), 2);
}
#[test]
fn suggest_automation_returns_one_step_per_app() {
let wf = DetectedWorkflow {
name: "A → B → C → D".into(),
apps: vec!["A".into(), "B".into(), "C".into(), "D".into()],
pattern: vec![],
frequency: 3,
avg_duration_ms: 0,
};
let steps = CrossAppTracker::suggest_automation(&wf);
assert_eq!(steps.len(), 4);
assert_eq!(steps[0].step_index, 0);
assert_eq!(steps[3].step_index, 3);
}
#[test]
fn suggest_automation_first_step_is_read_last_is_write() {
let wf = DetectedWorkflow {
name: "Figma → Linear → VSCode".into(),
apps: vec!["Figma".into(), "Linear".into(), "VSCode".into()],
pattern: vec![],
frequency: 5,
avg_duration_ms: 0,
};
let steps = CrossAppTracker::suggest_automation(&wf);
assert!(
steps[0].description.contains("Read"),
"got: {}",
steps[0].description
);
assert!(
steps[2].description.contains("Write"),
"got: {}",
steps[2].description
);
}
#[test]
fn suggest_automation_middle_step_is_transform() {
let wf = DetectedWorkflow {
name: "A → B → C → D".into(),
apps: vec!["A".into(), "B".into(), "C".into(), "D".into()],
pattern: vec![],
frequency: 2,
avg_duration_ms: 0,
};
let steps = CrossAppTracker::suggest_automation(&wf);
assert!(
steps[1].description.contains("Transform"),
"got: {}",
steps[1].description
);
assert!(
steps[2].description.contains("Transform"),
"got: {}",
steps[2].description
);
}
#[test]
fn stats_empty_tracker_returns_zero_totals() {
let tracker = CrossAppTracker::new();
let stats = tracker.stats();
assert_eq!(stats.total_transitions, 0);
assert_eq!(stats.distinct_apps, 0);
assert!(stats.top_app.is_none());
assert!(stats.top_transition.is_none());
}
#[test]
fn stats_top_app_is_most_focused() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.record_focus("AppB", TransitionTrigger::UserSwitch);
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
tracker.record_focus("AppB", TransitionTrigger::UserSwitch);
tracker.record_focus("AppA", TransitionTrigger::UserSwitch);
let stats = tracker.stats();
assert_eq!(stats.top_app.as_deref(), Some("AppA"));
}
#[test]
fn stats_top_transition_is_most_frequent_pair() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("A", TransitionTrigger::UserSwitch); tracker.record_focus("B", TransitionTrigger::UserSwitch); tracker.record_focus("A", TransitionTrigger::UserSwitch); tracker.record_focus("B", TransitionTrigger::UserSwitch); tracker.record_focus("A", TransitionTrigger::UserSwitch); tracker.record_focus("B", TransitionTrigger::UserSwitch); tracker.record_focus("A", TransitionTrigger::UserSwitch); tracker.record_focus("B", TransitionTrigger::UserSwitch); tracker.record_focus("A", TransitionTrigger::UserSwitch); tracker.record_focus("B", TransitionTrigger::UserSwitch); tracker.record_focus("C", TransitionTrigger::UserSwitch);
let stats = tracker.stats();
let top = stats.top_transition.unwrap();
assert_eq!(top.0, "A");
assert_eq!(top.1, "B");
}
#[test]
fn stats_counts_all_distinct_apps() {
let mut tracker = CrossAppTracker::new();
tracker.record_focus("X", TransitionTrigger::UserSwitch);
tracker.record_focus("Y", TransitionTrigger::Notification);
tracker.record_focus("Z", TransitionTrigger::Automation);
assert_eq!(tracker.stats().distinct_apps, 3);
}
}