use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UpdateChannel {
#[default]
Stable,
Latest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UpdatePolicy {
Auto,
#[default]
Notify,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdatePreferences {
#[serde(default)]
pub channel: UpdateChannel,
#[serde(default)]
pub policy: UpdatePolicy,
#[serde(default)]
pub disk_budget_mb: Option<u64>,
#[serde(default = "default_true")]
pub keep_old_until_verified: bool,
}
fn default_true() -> bool {
true
}
fn project_override_path(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
let candidate = d.join(".car").join("update-prefs.json");
if candidate.exists() {
return Some(candidate);
}
dir = d.parent();
}
None
}
impl Default for UpdatePreferences {
fn default() -> Self {
UpdatePreferences {
channel: UpdateChannel::default(),
policy: UpdatePolicy::default(),
disk_budget_mb: None,
keep_old_until_verified: true,
}
}
}
impl UpdatePreferences {
pub fn default_path() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".car")
.join("update-prefs.json")
}
pub fn load_from(path: &Path) -> Result<Self, String> {
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
pub fn load() -> Result<Self, String> {
Self::load_from(&Self::default_path())
}
pub fn load_effective(cwd: &Path) -> Result<Self, String> {
match project_override_path(cwd) {
Some(p) => Self::load_from(&p),
None => Self::load(),
}
}
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())?;
}
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())
}
pub fn save(&self) -> Result<(), String> {
self.save_to(&Self::default_path())
}
pub fn may_auto_apply_community(&self) -> bool {
false
}
pub fn may_auto_apply_curated(&self) -> bool {
matches!(self.policy, UpdatePolicy::Auto)
}
pub fn checks_enabled(&self) -> bool {
!matches!(self.policy, UpdatePolicy::Off)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_conservative() {
let p = UpdatePreferences::default();
assert_eq!(p.channel, UpdateChannel::Stable);
assert_eq!(p.policy, UpdatePolicy::Notify);
assert!(p.keep_old_until_verified);
assert!(p.disk_budget_mb.is_none());
assert!(!p.may_auto_apply_curated());
assert!(!p.may_auto_apply_community());
assert!(p.checks_enabled());
}
#[test]
fn missing_file_yields_defaults() {
let p = UpdatePreferences::load_from(Path::new("/nonexistent/update-prefs.json")).unwrap();
assert_eq!(p, UpdatePreferences::default());
}
#[test]
fn partial_config_fills_defaults() {
let p: UpdatePreferences = serde_json::from_str(r#"{"policy":"auto"}"#).unwrap();
assert_eq!(p.policy, UpdatePolicy::Auto);
assert_eq!(p.channel, UpdateChannel::Stable);
assert!(p.keep_old_until_verified);
assert!(p.may_auto_apply_curated());
assert!(!p.may_auto_apply_community()); }
#[test]
fn off_disables_checks() {
let p = UpdatePreferences {
policy: UpdatePolicy::Off,
..Default::default()
};
assert!(!p.checks_enabled());
}
#[test]
fn round_trips_to_disk() {
let dir = std::env::temp_dir().join(format!("car-prefs-test-{}", std::process::id()));
let path = dir.join("update-prefs.json");
let prefs = UpdatePreferences {
channel: UpdateChannel::Latest,
policy: UpdatePolicy::Auto,
disk_budget_mb: Some(50_000),
keep_old_until_verified: false,
};
prefs.save_to(&path).unwrap();
let back = UpdatePreferences::load_from(&path).unwrap();
assert_eq!(prefs, back);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn project_override_wins_over_user_path() {
let root = std::env::temp_dir().join(format!("car-proj-{}", std::process::id()));
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let proj = UpdatePreferences {
policy: UpdatePolicy::Off,
..Default::default()
};
proj.save_to(&root.join(".car").join("update-prefs.json")).unwrap();
let loaded = UpdatePreferences::load_effective(&nested).unwrap();
assert_eq!(loaded.policy, UpdatePolicy::Off, "project file should win");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn corrupt_file_is_an_error_not_a_silent_default() {
let dir = std::env::temp_dir().join(format!("car-prefs-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("update-prefs.json");
std::fs::write(&path, "{ not json").unwrap();
assert!(UpdatePreferences::load_from(&path).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}