Skip to main content

car_inference/
nudge.rs

1//! Proactive upgrade nudge — the decision logic that turns "an upgrade exists"
2//! into "tell the user once, nicely, and never nag."
3//!
4//! This is the brain of the push-based upgrade story; the daemon supplies the
5//! body (a periodic check that calls [`decide_nudge`] and broadcasts the
6//! resulting [`UpgradeNudge`] over WebSocket, plus a dismiss RPC). Keeping the
7//! decision here makes it pure and unit-testable: throttling, dismissals, and
8//! the policy split (auto-apply vs notify vs off) all have golden tests.
9//!
10//! Rules:
11//! - `policy == Off` → do nothing.
12//! - Curated upgrades under `policy == Auto` are returned for background
13//!   application; everything else becomes a single notification.
14//! - Community (upstream) findings are *never* auto-applied — they only notify.
15//! - At most one nudge per `throttle_secs` (default once/day), and a finding
16//!   the user dismissed is never nudged again.
17
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21use crate::update_prefs::{UpdatePolicy, UpdatePreferences};
22use crate::upgrade::{UpgradeFinding, UpgradeSource};
23
24/// A single, plain-language upgrade notification for the user.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct UpgradeNudge {
27    /// The upgrade being offered (the highest-priority one this round).
28    pub finding: UpgradeFinding,
29    /// One-line, jargon-free message: "A newer … is available — switch?".
30    pub message: String,
31    /// Stable key the client echoes back to dismiss this nudge.
32    pub dismiss_key: String,
33}
34
35/// What the periodic check decided to do this round.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct NudgeDecision {
38    /// Curated upgrades to apply in the background (only under `Auto` policy).
39    pub auto_apply: Vec<UpgradeFinding>,
40    /// The single notification to surface, if any.
41    pub nudge: Option<UpgradeNudge>,
42}
43
44/// Persisted nudge bookkeeping: when we last nudged, and what's been dismissed.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct NudgeState {
47    #[serde(default)]
48    pub last_nudge_secs: u64,
49    /// When the proactive concierge ([`crate::concierge`]) last surfaced an
50    /// acquisition suggestion. A SEPARATE field from `last_nudge_secs` so the
51    /// concierge and the upgrade nudge throttle independently — sharing one
52    /// field let whichever ran first each tick permanently starve the other.
53    /// `#[serde(default)]` → old state files load with 0 (never suggested).
54    #[serde(default)]
55    pub last_concierge_secs: u64,
56    /// Dismiss keys the user has waved away; never nudged again. Shared between
57    /// the upgrade nudge (`from=>to`) and the concierge (`concierge:...`);
58    /// their key namespaces are disjoint by construction.
59    #[serde(default)]
60    pub dismissed: Vec<String>,
61}
62
63impl NudgeState {
64    pub fn default_path() -> PathBuf {
65        std::env::var("HOME")
66            .map(PathBuf::from)
67            .unwrap_or_else(|_| PathBuf::from("."))
68            .join(".car")
69            .join("nudge-state.json")
70    }
71
72    pub fn load_from(path: &Path) -> Self {
73        std::fs::read_to_string(path)
74            .ok()
75            .and_then(|s| serde_json::from_str(&s).ok())
76            .unwrap_or_default()
77    }
78
79    pub fn save_to(&self, path: &Path) -> Result<(), String> {
80        if let Some(parent) = path.parent() {
81            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
82        }
83        std::fs::write(
84            path,
85            serde_json::to_string_pretty(self).map_err(|e| e.to_string())?,
86        )
87        .map_err(|e| e.to_string())
88    }
89
90    /// Record that the user dismissed a nudge key.
91    pub fn dismiss(&mut self, key: &str) {
92        if !self.dismissed.iter().any(|k| k == key) {
93            self.dismissed.push(key.to_string());
94        }
95    }
96}
97
98/// Default throttle: at most one nudge per day.
99pub const DEFAULT_THROTTLE_SECS: u64 = 24 * 60 * 60;
100
101/// A stable dismiss key for a finding — from→to identifies the specific
102/// upgrade, so re-running the same upgrade won't re-nudge once dismissed, but a
103/// *different* target later will.
104fn dismiss_key(f: &UpgradeFinding) -> String {
105    format!("{}=>{}", f.from_id, f.to_id)
106}
107
108/// Decide what to do given the current findings, prefs, and state. Pure: the
109/// caller injects `now_secs` and `throttle_secs`, so throttling is testable.
110///
111/// Does **not** mutate state — the caller updates `last_nudge_secs` when it
112/// actually surfaces the returned nudge (so a dropped notification can re-fire).
113pub fn decide_nudge(
114    findings: &[UpgradeFinding],
115    prefs: &UpdatePreferences,
116    state: &NudgeState,
117    now_secs: u64,
118    throttle_secs: u64,
119    inference_active: bool,
120) -> NudgeDecision {
121    // Never interrupt active inference — defer the whole decision (nudge *and*
122    // background auto-apply, which would contend for memory/compute) to a later
123    // tick when the machine is idle. The daemon supplies this signal.
124    if inference_active || matches!(prefs.policy, UpdatePolicy::Off) {
125        return NudgeDecision::default();
126    }
127
128    // Under Auto, curated upgrades apply in the background; community never.
129    let auto_apply: Vec<UpgradeFinding> = if matches!(prefs.policy, UpdatePolicy::Auto) {
130        findings
131            .iter()
132            .filter(|f| f.source == UpgradeSource::Curated && f.target_pullable)
133            .cloned()
134            .collect()
135    } else {
136        Vec::new()
137    };
138
139    // Candidates to *notify* about = findings not being auto-applied and not
140    // previously dismissed.
141    let auto_keys: Vec<String> = auto_apply.iter().map(dismiss_key).collect();
142    let mut notify_candidates: Vec<&UpgradeFinding> = findings
143        .iter()
144        .filter(|f| {
145            let k = dismiss_key(f);
146            !auto_keys.contains(&k) && !state.dismissed.contains(&k)
147        })
148        .collect();
149
150    // Throttle: at most one nudge per window.
151    let throttled = state.last_nudge_secs != 0
152        && now_secs.saturating_sub(state.last_nudge_secs) < throttle_secs;
153
154    let nudge = if throttled || notify_candidates.is_empty() {
155        None
156    } else {
157        // Curated first (verified), then upstream; stable within tier by id.
158        notify_candidates.sort_by(|a, b| {
159            curated_first(a.source)
160                .cmp(&curated_first(b.source))
161                .then(a.to_id.cmp(&b.to_id))
162        });
163        let f = notify_candidates[0].clone();
164        let message = nudge_message(&f);
165        let dismiss_key = dismiss_key(&f);
166        Some(UpgradeNudge {
167            finding: f,
168            message,
169            dismiss_key,
170        })
171    };
172
173    NudgeDecision { auto_apply, nudge }
174}
175
176fn curated_first(s: UpgradeSource) -> u8 {
177    match s {
178        UpgradeSource::Curated => 0,
179        UpgradeSource::Upstream => 1,
180    }
181}
182
183/// One plain-language line. No model ids, quant, or repos.
184fn nudge_message(f: &UpgradeFinding) -> String {
185    match f.source {
186        UpgradeSource::Curated => format!(
187            "A newer model is available to replace {}: {} Switch?",
188            f.from_name, f.reason
189        ),
190        UpgradeSource::Upstream => format!(
191            "{} has an update available. {} Refresh it?",
192            f.from_name, f.reason
193        ),
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::schema::TrustTier;
201
202    fn finding(from: &str, to: &str, source: UpgradeSource) -> UpgradeFinding {
203        UpgradeFinding {
204            from_id: from.into(),
205            from_name: from.into(),
206            to_id: to.into(),
207            to_name: to.into(),
208            reason: "newer line.".into(),
209            trust_tier: if source == UpgradeSource::Curated {
210                TrustTier::Curated
211            } else {
212                TrustTier::Community
213            },
214            source,
215            target_pullable: true,
216        }
217    }
218
219    fn prefs(policy: UpdatePolicy) -> UpdatePreferences {
220        UpdatePreferences {
221            policy,
222            ..Default::default()
223        }
224    }
225
226    #[test]
227    fn active_inference_defers_everything() {
228        // Even with an auto-applicable curated upgrade, an active inference
229        // suppresses both the nudge and background auto-apply.
230        let f = [finding("a", "b", UpgradeSource::Curated)];
231        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, true);
232        assert!(d.nudge.is_none() && d.auto_apply.is_empty());
233    }
234
235    #[test]
236    fn off_policy_does_nothing() {
237        let f = [finding("a", "b", UpgradeSource::Curated)];
238        let d = decide_nudge(&f, &prefs(UpdatePolicy::Off), &NudgeState::default(), 100, 10, false);
239        assert!(d.nudge.is_none() && d.auto_apply.is_empty());
240    }
241
242    #[test]
243    fn notify_policy_nudges_but_never_auto_applies() {
244        let f = [finding("a", "b", UpgradeSource::Curated)];
245        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
246        assert!(d.auto_apply.is_empty(), "Notify never auto-applies");
247        assert!(d.nudge.is_some());
248        assert!(d.nudge.unwrap().message.contains("Switch?"));
249    }
250
251    #[test]
252    fn auto_policy_applies_curated_and_does_not_nudge_for_them() {
253        let f = [finding("a", "b", UpgradeSource::Curated)];
254        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
255        assert_eq!(d.auto_apply.len(), 1);
256        assert!(d.nudge.is_none(), "auto-applied curated upgrade isn't nudged");
257    }
258
259    #[test]
260    fn auto_policy_still_nudges_community_never_auto_applies_it() {
261        let f = [finding("a", "b", UpgradeSource::Upstream)];
262        let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
263        assert!(d.auto_apply.is_empty(), "community is never auto-applied");
264        assert!(d.nudge.is_some(), "community still notifies under Auto");
265    }
266
267    #[test]
268    fn throttle_suppresses_within_window() {
269        let f = [finding("a", "b", UpgradeSource::Curated)];
270        let state = NudgeState {
271            last_nudge_secs: 100,
272            ..Default::default()
273        };
274        // 105 is within a 10s window of the last nudge → suppressed.
275        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 105, 10, false);
276        assert!(d.nudge.is_none());
277        // 120 is past the window → allowed again.
278        let d2 = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 120, 10, false);
279        assert!(d2.nudge.is_some());
280    }
281
282    #[test]
283    fn dismissed_findings_are_never_nudged_again() {
284        let f = [finding("a", "b", UpgradeSource::Curated)];
285        let mut state = NudgeState::default();
286        state.dismiss("a=>b");
287        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 100, 10, false);
288        assert!(d.nudge.is_none(), "dismissed upgrade must not re-nudge");
289    }
290
291    #[test]
292    fn curated_is_preferred_over_upstream_in_the_single_nudge() {
293        let f = [
294            finding("x", "x", UpgradeSource::Upstream),
295            finding("a", "b", UpgradeSource::Curated),
296        ];
297        let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
298        let n = d.nudge.expect("a nudge");
299        assert_eq!(n.finding.source, UpgradeSource::Curated);
300    }
301
302    #[test]
303    fn state_round_trips_and_dedups_dismissals() {
304        let dir = std::env::temp_dir().join(format!("car-nudge-{}", std::process::id()));
305        let path = dir.join("nudge-state.json");
306        let mut s = NudgeState::default();
307        s.dismiss("a=>b");
308        s.dismiss("a=>b"); // dedup
309        s.last_nudge_secs = 42;
310        s.save_to(&path).unwrap();
311        let back = NudgeState::load_from(&path);
312        assert_eq!(back.dismissed, vec!["a=>b".to_string()]);
313        assert_eq!(back.last_nudge_secs, 42);
314        let _ = std::fs::remove_dir_all(&dir);
315    }
316}