Skip to main content

car_inference/
update_prefs.rs

1//! User preferences for how CAR keeps models up to date.
2//!
3//! Persisted at `~/.car/update-prefs.json` and overridable by a team-shared
4//! `.car/update-prefs.json` discovered by walking up from the cwd (same spirit
5//! as the rest of the `.car/` project directory). Consumed by the proactive
6//! upgrade nudge: the preference decides whether an available upgrade is
7//! applied in the background, surfaced as a one-time notification, or ignored.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13/// Which models CAR considers when checking for upgrades.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum UpdateChannel {
17    /// Only project-curated, verified replacements. The safe default.
18    #[default]
19    Stable,
20    /// Also consider newer upstream revisions discovered on the Hub
21    /// (`trust_tier: Community`). Newer, less vetted.
22    Latest,
23}
24
25/// What CAR does when a newer model is available.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum UpdatePolicy {
29    /// Apply curated upgrades in the background, then notify after the fact.
30    /// Never auto-applies `Community`-tier upgrades — those always notify.
31    Auto,
32    /// Surface a single, dismissible notification; never act without the user.
33    /// The default.
34    #[default]
35    Notify,
36    /// Don't check, don't notify.
37    Off,
38}
39
40/// How CAR keeps models current. Every field has a sensible default, so a
41/// missing or partial config file is fine.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UpdatePreferences {
44    #[serde(default)]
45    pub channel: UpdateChannel,
46    #[serde(default)]
47    pub policy: UpdatePolicy,
48    /// Cap on total on-disk model storage in MB. `None` = no cap. An upgrade
49    /// that would push past the cap is offered with a "free space first" note
50    /// rather than applied.
51    #[serde(default)]
52    pub disk_budget_mb: Option<u64>,
53    /// Keep the old model until the new one has pulled *and* answered a smoke
54    /// prompt — so an upgrade never leaves the user with a broken/missing
55    /// model. Wires through the registry's `remove_old_after_available`.
56    #[serde(default = "default_true")]
57    pub keep_old_until_verified: bool,
58}
59
60fn default_true() -> bool {
61    true
62}
63
64/// Walk up from `start` looking for a project-level
65/// `.car/update-prefs.json`. Returns the first one found, or `None`.
66fn project_override_path(start: &Path) -> Option<PathBuf> {
67    let mut dir = Some(start);
68    while let Some(d) = dir {
69        let candidate = d.join(".car").join("update-prefs.json");
70        if candidate.exists() {
71            return Some(candidate);
72        }
73        dir = d.parent();
74    }
75    None
76}
77
78impl Default for UpdatePreferences {
79    fn default() -> Self {
80        UpdatePreferences {
81            channel: UpdateChannel::default(),
82            policy: UpdatePolicy::default(),
83            disk_budget_mb: None,
84            keep_old_until_verified: true,
85        }
86    }
87}
88
89impl UpdatePreferences {
90    /// The canonical user path: `~/.car/update-prefs.json`.
91    pub fn default_path() -> PathBuf {
92        std::env::var("HOME")
93            .map(PathBuf::from)
94            .unwrap_or_else(|_| PathBuf::from("."))
95            .join(".car")
96            .join("update-prefs.json")
97    }
98
99    /// Load from `path`, falling back to defaults if it doesn't exist. A
100    /// present-but-corrupt file is an error (so a typo isn't silently ignored).
101    pub fn load_from(path: &Path) -> Result<Self, String> {
102        if !path.exists() {
103            return Ok(Self::default());
104        }
105        let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
106        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
107    }
108
109    /// Load from the canonical user path.
110    pub fn load() -> Result<Self, String> {
111        Self::load_from(&Self::default_path())
112    }
113
114    /// Load the *effective* preferences: a team-shared project
115    /// `.car/update-prefs.json` (found by walking up from `cwd`) takes
116    /// precedence over the user `~/.car/update-prefs.json`. This is an
117    /// override, not a merge — if a project file exists, it wins wholesale,
118    /// matching how the rest of the `.car/` project directory behaves.
119    pub fn load_effective(cwd: &Path) -> Result<Self, String> {
120        match project_override_path(cwd) {
121            Some(p) => Self::load_from(&p),
122            None => Self::load(),
123        }
124    }
125
126    /// Write to `path`, creating the parent directory if needed.
127    pub fn save_to(&self, path: &Path) -> Result<(), String> {
128        if let Some(parent) = path.parent() {
129            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
130        }
131        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
132        std::fs::write(path, json).map_err(|e| e.to_string())
133    }
134
135    /// Write to the canonical user path.
136    pub fn save(&self) -> Result<(), String> {
137        self.save_to(&Self::default_path())
138    }
139
140    /// Whether a `Community`-tier (unverified) upgrade may be auto-applied.
141    /// Never — community upgrades always require explicit user action,
142    /// regardless of policy.
143    pub fn may_auto_apply_community(&self) -> bool {
144        false
145    }
146
147    /// Whether a curated upgrade may be applied in the background.
148    pub fn may_auto_apply_curated(&self) -> bool {
149        matches!(self.policy, UpdatePolicy::Auto)
150    }
151
152    /// Whether CAR should check/notify at all.
153    pub fn checks_enabled(&self) -> bool {
154        !matches!(self.policy, UpdatePolicy::Off)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn defaults_are_conservative() {
164        let p = UpdatePreferences::default();
165        assert_eq!(p.channel, UpdateChannel::Stable);
166        assert_eq!(p.policy, UpdatePolicy::Notify);
167        assert!(p.keep_old_until_verified);
168        assert!(p.disk_budget_mb.is_none());
169        // Notify is not auto-apply, and community is never auto-applied.
170        assert!(!p.may_auto_apply_curated());
171        assert!(!p.may_auto_apply_community());
172        assert!(p.checks_enabled());
173    }
174
175    #[test]
176    fn missing_file_yields_defaults() {
177        let p = UpdatePreferences::load_from(Path::new("/nonexistent/update-prefs.json")).unwrap();
178        assert_eq!(p, UpdatePreferences::default());
179    }
180
181    #[test]
182    fn partial_config_fills_defaults() {
183        // Only policy specified — the rest must default.
184        let p: UpdatePreferences = serde_json::from_str(r#"{"policy":"auto"}"#).unwrap();
185        assert_eq!(p.policy, UpdatePolicy::Auto);
186        assert_eq!(p.channel, UpdateChannel::Stable);
187        assert!(p.keep_old_until_verified);
188        assert!(p.may_auto_apply_curated());
189        assert!(!p.may_auto_apply_community()); // still never
190    }
191
192    #[test]
193    fn off_disables_checks() {
194        let p = UpdatePreferences {
195            policy: UpdatePolicy::Off,
196            ..Default::default()
197        };
198        assert!(!p.checks_enabled());
199    }
200
201    #[test]
202    fn round_trips_to_disk() {
203        let dir = std::env::temp_dir().join(format!("car-prefs-test-{}", std::process::id()));
204        let path = dir.join("update-prefs.json");
205        let prefs = UpdatePreferences {
206            channel: UpdateChannel::Latest,
207            policy: UpdatePolicy::Auto,
208            disk_budget_mb: Some(50_000),
209            keep_old_until_verified: false,
210        };
211        prefs.save_to(&path).unwrap();
212        let back = UpdatePreferences::load_from(&path).unwrap();
213        assert_eq!(prefs, back);
214        let _ = std::fs::remove_dir_all(&dir);
215    }
216
217    #[test]
218    fn project_override_wins_over_user_path() {
219        // A project .car/update-prefs.json found by walking up should be the
220        // one loaded by load_effective.
221        let root = std::env::temp_dir().join(format!("car-proj-{}", std::process::id()));
222        let nested = root.join("a").join("b");
223        std::fs::create_dir_all(&nested).unwrap();
224        let proj = UpdatePreferences {
225            policy: UpdatePolicy::Off,
226            ..Default::default()
227        };
228        proj.save_to(&root.join(".car").join("update-prefs.json")).unwrap();
229
230        let loaded = UpdatePreferences::load_effective(&nested).unwrap();
231        assert_eq!(loaded.policy, UpdatePolicy::Off, "project file should win");
232        let _ = std::fs::remove_dir_all(&root);
233    }
234
235    #[test]
236    fn corrupt_file_is_an_error_not_a_silent_default() {
237        let dir = std::env::temp_dir().join(format!("car-prefs-bad-{}", std::process::id()));
238        std::fs::create_dir_all(&dir).unwrap();
239        let path = dir.join("update-prefs.json");
240        std::fs::write(&path, "{ not json").unwrap();
241        assert!(UpdatePreferences::load_from(&path).is_err());
242        let _ = std::fs::remove_dir_all(&dir);
243    }
244}