use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CopilotContext {
pub current_app: String,
pub current_window_title: String,
pub recent_actions: Vec<String>,
pub clipboard: Option<String>,
pub time_in_current_app_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RuleCondition {
AppIs(String),
WindowTitleContains(String),
ClipboardContains(String),
IdleFor(u64),
RepeatedAction(String, u32),
Pattern(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SuggestedAction {
RunWorkflow(String),
TypeText(String),
OpenApp(String),
ShowTip(String),
RunScript(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopilotRule {
pub name: String,
pub condition: RuleCondition,
pub action: SuggestedAction,
pub priority: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suggestion {
pub rule_name: String,
pub action: SuggestedAction,
pub confidence: f64,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct DesktopCopilot {
context: CopilotContext,
rules: Vec<CopilotRule>,
}
impl DesktopCopilot {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_builtin_rules() -> Self {
let mut copilot = Self::new();
for rule in builtin_rules() {
copilot.add_rule(rule);
}
copilot
}
pub fn update_context(&mut self, ctx: CopilotContext) {
self.context = ctx;
}
pub fn add_rule(&mut self, rule: CopilotRule) {
self.rules.push(rule);
}
#[must_use]
pub fn evaluate(&self) -> Vec<Suggestion> {
let mut suggestions: Vec<Suggestion> = self
.rules
.iter()
.filter_map(|rule| self.try_match(rule))
.collect();
suggestions.sort_by(|a, b| {
let pa = self.priority_of(&a.rule_name);
let pb = self.priority_of(&b.rule_name);
pb.cmp(&pa)
});
suggestions
}
fn priority_of(&self, name: &str) -> u8 {
self.rules
.iter()
.find(|r| r.name == name)
.map_or(0, |r| r.priority)
}
fn try_match(&self, rule: &CopilotRule) -> Option<Suggestion> {
let (confidence, reason) = evaluate_condition(&rule.condition, &self.context)?;
Some(Suggestion {
rule_name: rule.name.clone(),
action: rule.action.clone(),
confidence,
reason,
})
}
}
fn evaluate_condition(condition: &RuleCondition, ctx: &CopilotContext) -> Option<(f64, String)> {
match condition {
RuleCondition::AppIs(app) => matches_app_is(app, ctx),
RuleCondition::WindowTitleContains(substr) => matches_window_title(substr, ctx),
RuleCondition::ClipboardContains(substr) => matches_clipboard(substr, ctx),
RuleCondition::IdleFor(threshold_ms) => matches_idle(*threshold_ms, ctx),
RuleCondition::RepeatedAction(action, min_count) => {
matches_repeated_action(action, *min_count, ctx)
}
RuleCondition::Pattern(pattern) => matches_pattern(pattern, ctx),
}
}
fn matches_app_is(app: &str, ctx: &CopilotContext) -> Option<(f64, String)> {
if ctx.current_app.to_lowercase() == app.to_lowercase() {
Some((1.0, format!("Active app is '{}'", ctx.current_app)))
} else {
None
}
}
fn matches_window_title(substr: &str, ctx: &CopilotContext) -> Option<(f64, String)> {
if ctx
.current_window_title
.to_lowercase()
.contains(&substr.to_lowercase())
{
Some((0.9, format!("Window title contains '{substr}'")))
} else {
None
}
}
fn matches_clipboard(substr: &str, ctx: &CopilotContext) -> Option<(f64, String)> {
let clip = ctx.clipboard.as_deref()?;
if clip.to_lowercase().contains(&substr.to_lowercase()) {
Some((0.85, format!("Clipboard contains '{substr}'")))
} else {
None
}
}
fn matches_idle(threshold_ms: u64, ctx: &CopilotContext) -> Option<(f64, String)> {
if ctx.time_in_current_app_ms >= threshold_ms {
Some((
0.7,
format!(
"Idle for {}ms (threshold {threshold_ms}ms)",
ctx.time_in_current_app_ms,
),
))
} else {
None
}
}
fn matches_repeated_action(
action: &str,
min_count: u32,
ctx: &CopilotContext,
) -> Option<(f64, String)> {
let count = u32::try_from(
ctx.recent_actions
.iter()
.filter(|a| a.to_lowercase() == action.to_lowercase())
.count(),
)
.unwrap_or(u32::MAX);
if count >= min_count {
Some((0.8, format!("Action '{action}' repeated {count}×")))
} else {
None
}
}
fn matches_pattern(pattern: &[String], ctx: &CopilotContext) -> Option<(f64, String)> {
if pattern.is_empty() {
return None;
}
let actions = &ctx.recent_actions;
if actions.len() < pattern.len() {
return None;
}
let tail = &actions[actions.len() - pattern.len()..];
let matched = tail
.iter()
.zip(pattern.iter())
.all(|(a, p)| a.to_lowercase() == p.to_lowercase());
if matched {
Some((0.95, format!("Action pattern {pattern:?} detected")))
} else {
None
}
}
#[must_use]
pub fn builtin_rules() -> Vec<CopilotRule> {
vec![
CopilotRule {
name: "clipboard_url_open_browser".to_owned(),
condition: RuleCondition::ClipboardContains("http".to_owned()),
action: SuggestedAction::OpenApp("Safari".to_owned()),
priority: 80,
},
CopilotRule {
name: "idle_suggest_break".to_owned(),
condition: RuleCondition::IdleFor(300_000), action: SuggestedAction::ShowTip(
"You have been in this app for 5 minutes — take a short break?".to_owned(),
),
priority: 30,
},
CopilotRule {
name: "repeated_copy_suggest_snippet".to_owned(),
condition: RuleCondition::RepeatedAction("copy".to_owned(), 3),
action: SuggestedAction::ShowTip(
"You copied this 3 times — consider saving it as a snippet.".to_owned(),
),
priority: 60,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with_app(app: &str) -> CopilotContext {
CopilotContext {
current_app: app.to_owned(),
current_window_title: String::new(),
recent_actions: vec![],
clipboard: None,
time_in_current_app_ms: 0,
}
}
fn app_rule(app: &str, priority: u8) -> CopilotRule {
CopilotRule {
name: format!("rule_{app}"),
condition: RuleCondition::AppIs(app.to_owned()),
action: SuggestedAction::ShowTip(format!("tip for {app}")),
priority,
}
}
#[test]
fn app_is_rule_triggers_on_exact_match() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("VS Code", 50));
copilot.update_context(ctx_with_app("VS Code"));
let suggestions = copilot.evaluate();
assert_eq!(suggestions.len(), 1);
assert_eq!(suggestions[0].rule_name, "rule_VS Code");
}
#[test]
fn app_is_rule_is_case_insensitive() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("safari", 50));
copilot.update_context(ctx_with_app("Safari"));
assert_eq!(copilot.evaluate().len(), 1);
}
#[test]
fn app_is_rule_does_not_trigger_for_different_app() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("Xcode", 50));
copilot.update_context(ctx_with_app("Finder"));
assert!(copilot.evaluate().is_empty());
}
#[test]
fn window_title_rule_triggers_on_substring() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "pr_review".to_owned(),
condition: RuleCondition::WindowTitleContains("PR".to_owned()),
action: SuggestedAction::ShowTip("reviewing a PR".to_owned()),
priority: 70,
});
let mut ctx = ctx_with_app("VS Code");
ctx.current_window_title = "Fix bug — PR #42".to_owned();
copilot.update_context(ctx);
let s = copilot.evaluate();
assert_eq!(s.len(), 1);
assert_eq!(s[0].rule_name, "pr_review");
}
#[test]
fn window_title_rule_does_not_trigger_on_absent_substring() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "todo".to_owned(),
condition: RuleCondition::WindowTitleContains("TODO".to_owned()),
action: SuggestedAction::ShowTip("you have a TODO".to_owned()),
priority: 40,
});
let mut ctx = ctx_with_app("Finder");
ctx.current_window_title = "Documents".to_owned();
copilot.update_context(ctx);
assert!(copilot.evaluate().is_empty());
}
#[test]
fn idle_rule_triggers_after_threshold_exceeded() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "take_break".to_owned(),
condition: RuleCondition::IdleFor(1_000),
action: SuggestedAction::ShowTip("Take a break".to_owned()),
priority: 50,
});
let mut ctx = ctx_with_app("Any");
ctx.time_in_current_app_ms = 2_000;
copilot.update_context(ctx);
assert_eq!(copilot.evaluate().len(), 1);
}
#[test]
fn idle_rule_does_not_trigger_before_threshold() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "idle".to_owned(),
condition: RuleCondition::IdleFor(5_000),
action: SuggestedAction::ShowTip("idle".to_owned()),
priority: 50,
});
let mut ctx = ctx_with_app("Any");
ctx.time_in_current_app_ms = 4_999;
copilot.update_context(ctx);
assert!(copilot.evaluate().is_empty());
}
#[test]
fn repeated_action_triggers_at_min_count() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "snippet_hint".to_owned(),
condition: RuleCondition::RepeatedAction("copy".to_owned(), 3),
action: SuggestedAction::ShowTip("save as snippet".to_owned()),
priority: 60,
});
let mut ctx = ctx_with_app("VS Code");
ctx.recent_actions = vec!["copy".to_owned(), "copy".to_owned(), "copy".to_owned()];
copilot.update_context(ctx);
assert_eq!(copilot.evaluate().len(), 1);
}
#[test]
fn repeated_action_does_not_trigger_below_min_count() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "snippet_hint".to_owned(),
condition: RuleCondition::RepeatedAction("copy".to_owned(), 3),
action: SuggestedAction::ShowTip("save as snippet".to_owned()),
priority: 60,
});
let mut ctx = ctx_with_app("VS Code");
ctx.recent_actions = vec!["copy".to_owned(), "paste".to_owned(), "copy".to_owned()];
copilot.update_context(ctx);
assert!(copilot.evaluate().is_empty());
}
#[test]
fn pattern_rule_triggers_when_recent_actions_end_with_pattern() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "edit_cycle".to_owned(),
condition: RuleCondition::Pattern(vec![
"open".to_owned(),
"edit".to_owned(),
"save".to_owned(),
]),
action: SuggestedAction::ShowTip("run tests?".to_owned()),
priority: 75,
});
let mut ctx = ctx_with_app("VS Code");
ctx.recent_actions = vec![
"focus".to_owned(),
"open".to_owned(),
"edit".to_owned(),
"save".to_owned(),
];
copilot.update_context(ctx);
assert_eq!(copilot.evaluate().len(), 1);
}
#[test]
fn pattern_rule_does_not_trigger_when_tail_does_not_match() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "open_save".to_owned(),
condition: RuleCondition::Pattern(vec!["open".to_owned(), "save".to_owned()]),
action: SuggestedAction::ShowTip("hint".to_owned()),
priority: 50,
});
let mut ctx = ctx_with_app("VS Code");
ctx.recent_actions = vec!["edit".to_owned(), "save".to_owned()];
copilot.update_context(ctx);
assert!(copilot.evaluate().is_empty());
}
#[test]
fn suggestions_sorted_by_priority_descending() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(CopilotRule {
name: "low_prio".to_owned(),
condition: RuleCondition::AppIs("App".to_owned()),
action: SuggestedAction::ShowTip("low".to_owned()),
priority: 10,
});
copilot.add_rule(CopilotRule {
name: "high_prio".to_owned(),
condition: RuleCondition::AppIs("App".to_owned()),
action: SuggestedAction::ShowTip("high".to_owned()),
priority: 90,
});
copilot.update_context(ctx_with_app("App"));
let suggestions = copilot.evaluate();
assert_eq!(suggestions.len(), 2);
assert_eq!(suggestions[0].rule_name, "high_prio");
assert_eq!(suggestions[1].rule_name, "low_prio");
}
#[test]
fn no_suggestions_when_no_rules_match() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("Terminal", 50));
copilot.update_context(ctx_with_app("Finder"));
assert!(copilot.evaluate().is_empty());
}
#[test]
fn empty_copilot_returns_no_suggestions() {
let mut copilot = DesktopCopilot::new();
copilot.update_context(ctx_with_app("Safari"));
assert!(copilot.evaluate().is_empty());
}
#[test]
fn builtin_clipboard_url_rule_triggers_on_http_url() {
let mut copilot = DesktopCopilot::with_builtin_rules();
let mut ctx = ctx_with_app("Finder");
ctx.clipboard = Some("https://example.com/path?q=1".to_owned());
copilot.update_context(ctx);
let suggestions = copilot.evaluate();
assert!(suggestions
.iter()
.any(|s| s.rule_name == "clipboard_url_open_browser"));
}
#[test]
fn builtin_clipboard_url_rule_does_not_trigger_without_http() {
let mut copilot = DesktopCopilot::with_builtin_rules();
let mut ctx = ctx_with_app("Finder");
ctx.clipboard = Some("just some text".to_owned());
copilot.update_context(ctx);
let suggestions = copilot.evaluate();
assert!(!suggestions
.iter()
.any(|s| s.rule_name == "clipboard_url_open_browser"));
}
#[test]
fn suggestion_confidence_is_in_unit_interval() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("Safari", 50));
copilot.update_context(ctx_with_app("Safari"));
let suggestions = copilot.evaluate();
for s in &suggestions {
assert!((0.0..=1.0).contains(&s.confidence));
}
}
#[test]
fn suggestion_reason_is_non_empty() {
let mut copilot = DesktopCopilot::new();
copilot.add_rule(app_rule("Terminal", 50));
copilot.update_context(ctx_with_app("Terminal"));
let suggestions = copilot.evaluate();
assert!(!suggestions[0].reason.is_empty());
}
#[test]
fn builtin_rules_returns_non_empty_slice() {
assert!(!builtin_rules().is_empty());
}
#[test]
fn builtin_rules_all_have_non_empty_names() {
for rule in builtin_rules() {
assert!(!rule.name.is_empty(), "rule name must not be empty");
}
}
}