1use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21use crate::update_prefs::{UpdatePolicy, UpdatePreferences};
22use crate::upgrade::{UpgradeFinding, UpgradeSource};
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct UpgradeNudge {
27 pub finding: UpgradeFinding,
29 pub message: String,
31 pub dismiss_key: String,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct NudgeDecision {
38 pub auto_apply: Vec<UpgradeFinding>,
40 pub nudge: Option<UpgradeNudge>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct NudgeState {
47 #[serde(default)]
48 pub last_nudge_secs: u64,
49 #[serde(default)]
55 pub last_concierge_secs: u64,
56 #[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 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
98pub const DEFAULT_THROTTLE_SECS: u64 = 24 * 60 * 60;
100
101fn dismiss_key(f: &UpgradeFinding) -> String {
105 format!("{}=>{}", f.from_id, f.to_id)
106}
107
108pub 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 if inference_active || matches!(prefs.policy, UpdatePolicy::Off) {
125 return NudgeDecision::default();
126 }
127
128 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 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 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 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
183fn 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 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 let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 105, 10, false);
276 assert!(d.nudge.is_none());
277 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"); 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}