use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::update_prefs::{UpdatePolicy, UpdatePreferences};
use crate::upgrade::{UpgradeFinding, UpgradeSource};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeNudge {
pub finding: UpgradeFinding,
pub message: String,
pub dismiss_key: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NudgeDecision {
pub auto_apply: Vec<UpgradeFinding>,
pub nudge: Option<UpgradeNudge>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NudgeState {
#[serde(default)]
pub last_nudge_secs: u64,
#[serde(default)]
pub dismissed: Vec<String>,
}
impl NudgeState {
pub fn default_path() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".car")
.join("nudge-state.json")
}
pub fn load_from(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_to(&self, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(
path,
serde_json::to_string_pretty(self).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
pub fn dismiss(&mut self, key: &str) {
if !self.dismissed.iter().any(|k| k == key) {
self.dismissed.push(key.to_string());
}
}
}
pub const DEFAULT_THROTTLE_SECS: u64 = 24 * 60 * 60;
fn dismiss_key(f: &UpgradeFinding) -> String {
format!("{}=>{}", f.from_id, f.to_id)
}
pub fn decide_nudge(
findings: &[UpgradeFinding],
prefs: &UpdatePreferences,
state: &NudgeState,
now_secs: u64,
throttle_secs: u64,
inference_active: bool,
) -> NudgeDecision {
if inference_active || matches!(prefs.policy, UpdatePolicy::Off) {
return NudgeDecision::default();
}
let auto_apply: Vec<UpgradeFinding> = if matches!(prefs.policy, UpdatePolicy::Auto) {
findings
.iter()
.filter(|f| f.source == UpgradeSource::Curated && f.target_pullable)
.cloned()
.collect()
} else {
Vec::new()
};
let auto_keys: Vec<String> = auto_apply.iter().map(dismiss_key).collect();
let mut notify_candidates: Vec<&UpgradeFinding> = findings
.iter()
.filter(|f| {
let k = dismiss_key(f);
!auto_keys.contains(&k) && !state.dismissed.contains(&k)
})
.collect();
let throttled = state.last_nudge_secs != 0
&& now_secs.saturating_sub(state.last_nudge_secs) < throttle_secs;
let nudge = if throttled || notify_candidates.is_empty() {
None
} else {
notify_candidates.sort_by(|a, b| {
curated_first(a.source)
.cmp(&curated_first(b.source))
.then(a.to_id.cmp(&b.to_id))
});
let f = notify_candidates[0].clone();
let message = nudge_message(&f);
let dismiss_key = dismiss_key(&f);
Some(UpgradeNudge {
finding: f,
message,
dismiss_key,
})
};
NudgeDecision { auto_apply, nudge }
}
fn curated_first(s: UpgradeSource) -> u8 {
match s {
UpgradeSource::Curated => 0,
UpgradeSource::Upstream => 1,
}
}
fn nudge_message(f: &UpgradeFinding) -> String {
match f.source {
UpgradeSource::Curated => format!(
"A newer model is available to replace {}: {} Switch?",
f.from_name, f.reason
),
UpgradeSource::Upstream => format!(
"{} has an update available. {} Refresh it?",
f.from_name, f.reason
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::TrustTier;
fn finding(from: &str, to: &str, source: UpgradeSource) -> UpgradeFinding {
UpgradeFinding {
from_id: from.into(),
from_name: from.into(),
to_id: to.into(),
to_name: to.into(),
reason: "newer line.".into(),
trust_tier: if source == UpgradeSource::Curated {
TrustTier::Curated
} else {
TrustTier::Community
},
source,
target_pullable: true,
}
}
fn prefs(policy: UpdatePolicy) -> UpdatePreferences {
UpdatePreferences {
policy,
..Default::default()
}
}
#[test]
fn active_inference_defers_everything() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, true);
assert!(d.nudge.is_none() && d.auto_apply.is_empty());
}
#[test]
fn off_policy_does_nothing() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Off), &NudgeState::default(), 100, 10, false);
assert!(d.nudge.is_none() && d.auto_apply.is_empty());
}
#[test]
fn notify_policy_nudges_but_never_auto_applies() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
assert!(d.auto_apply.is_empty(), "Notify never auto-applies");
assert!(d.nudge.is_some());
assert!(d.nudge.unwrap().message.contains("Switch?"));
}
#[test]
fn auto_policy_applies_curated_and_does_not_nudge_for_them() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
assert_eq!(d.auto_apply.len(), 1);
assert!(d.nudge.is_none(), "auto-applied curated upgrade isn't nudged");
}
#[test]
fn auto_policy_still_nudges_community_never_auto_applies_it() {
let f = [finding("a", "b", UpgradeSource::Upstream)];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Auto), &NudgeState::default(), 100, 10, false);
assert!(d.auto_apply.is_empty(), "community is never auto-applied");
assert!(d.nudge.is_some(), "community still notifies under Auto");
}
#[test]
fn throttle_suppresses_within_window() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let state = NudgeState {
last_nudge_secs: 100,
..Default::default()
};
let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 105, 10, false);
assert!(d.nudge.is_none());
let d2 = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 120, 10, false);
assert!(d2.nudge.is_some());
}
#[test]
fn dismissed_findings_are_never_nudged_again() {
let f = [finding("a", "b", UpgradeSource::Curated)];
let mut state = NudgeState::default();
state.dismiss("a=>b");
let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &state, 100, 10, false);
assert!(d.nudge.is_none(), "dismissed upgrade must not re-nudge");
}
#[test]
fn curated_is_preferred_over_upstream_in_the_single_nudge() {
let f = [
finding("x", "x", UpgradeSource::Upstream),
finding("a", "b", UpgradeSource::Curated),
];
let d = decide_nudge(&f, &prefs(UpdatePolicy::Notify), &NudgeState::default(), 100, 10, false);
let n = d.nudge.expect("a nudge");
assert_eq!(n.finding.source, UpgradeSource::Curated);
}
#[test]
fn state_round_trips_and_dedups_dismissals() {
let dir = std::env::temp_dir().join(format!("car-nudge-{}", std::process::id()));
let path = dir.join("nudge-state.json");
let mut s = NudgeState::default();
s.dismiss("a=>b");
s.dismiss("a=>b"); s.last_nudge_secs = 42;
s.save_to(&path).unwrap();
let back = NudgeState::load_from(&path);
assert_eq!(back.dismissed, vec!["a=>b".to_string()]);
assert_eq!(back.last_nudge_secs, 42);
let _ = std::fs::remove_dir_all(&dir);
}
}