car_inference/
update_prefs.rs1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum UpdateChannel {
17 #[default]
19 Stable,
20 Latest,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum UpdatePolicy {
29 Auto,
32 #[default]
35 Notify,
36 Off,
38}
39
40#[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 #[serde(default)]
52 pub disk_budget_mb: Option<u64>,
53 #[serde(default = "default_true")]
57 pub keep_old_until_verified: bool,
58}
59
60fn default_true() -> bool {
61 true
62}
63
64fn 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 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 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 pub fn load() -> Result<Self, String> {
111 Self::load_from(&Self::default_path())
112 }
113
114 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 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 pub fn save(&self) -> Result<(), String> {
137 self.save_to(&Self::default_path())
138 }
139
140 pub fn may_auto_apply_community(&self) -> bool {
144 false
145 }
146
147 pub fn may_auto_apply_curated(&self) -> bool {
149 matches!(self.policy, UpdatePolicy::Auto)
150 }
151
152 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 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 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()); }
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 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}