car-inference 0.22.0

Local model inference for CAR — Candle backend with Qwen3 models
Documentation
//! User preferences for how CAR keeps models up to date.
//!
//! Persisted at `~/.car/update-prefs.json` and overridable by a team-shared
//! `.car/update-prefs.json` discovered by walking up from the cwd (same spirit
//! as the rest of the `.car/` project directory). Consumed by the proactive
//! upgrade nudge: the preference decides whether an available upgrade is
//! applied in the background, surfaced as a one-time notification, or ignored.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// Which models CAR considers when checking for upgrades.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UpdateChannel {
    /// Only project-curated, verified replacements. The safe default.
    #[default]
    Stable,
    /// Also consider newer upstream revisions discovered on the Hub
    /// (`trust_tier: Community`). Newer, less vetted.
    Latest,
}

/// What CAR does when a newer model is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UpdatePolicy {
    /// Apply curated upgrades in the background, then notify after the fact.
    /// Never auto-applies `Community`-tier upgrades — those always notify.
    Auto,
    /// Surface a single, dismissible notification; never act without the user.
    /// The default.
    #[default]
    Notify,
    /// Don't check, don't notify.
    Off,
}

/// How CAR keeps models current. Every field has a sensible default, so a
/// missing or partial config file is fine.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdatePreferences {
    #[serde(default)]
    pub channel: UpdateChannel,
    #[serde(default)]
    pub policy: UpdatePolicy,
    /// Cap on total on-disk model storage in MB. `None` = no cap. An upgrade
    /// that would push past the cap is offered with a "free space first" note
    /// rather than applied.
    #[serde(default)]
    pub disk_budget_mb: Option<u64>,
    /// Keep the old model until the new one has pulled *and* answered a smoke
    /// prompt — so an upgrade never leaves the user with a broken/missing
    /// model. Wires through the registry's `remove_old_after_available`.
    #[serde(default = "default_true")]
    pub keep_old_until_verified: bool,
}

fn default_true() -> bool {
    true
}

/// Walk up from `start` looking for a project-level
/// `.car/update-prefs.json`. Returns the first one found, or `None`.
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 {
    /// The canonical user path: `~/.car/update-prefs.json`.
    pub fn default_path() -> PathBuf {
        std::env::var("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(".car")
            .join("update-prefs.json")
    }

    /// Load from `path`, falling back to defaults if it doesn't exist. A
    /// present-but-corrupt file is an error (so a typo isn't silently ignored).
    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()))
    }

    /// Load from the canonical user path.
    pub fn load() -> Result<Self, String> {
        Self::load_from(&Self::default_path())
    }

    /// Load the *effective* preferences: a team-shared project
    /// `.car/update-prefs.json` (found by walking up from `cwd`) takes
    /// precedence over the user `~/.car/update-prefs.json`. This is an
    /// override, not a merge — if a project file exists, it wins wholesale,
    /// matching how the rest of the `.car/` project directory behaves.
    pub fn load_effective(cwd: &Path) -> Result<Self, String> {
        match project_override_path(cwd) {
            Some(p) => Self::load_from(&p),
            None => Self::load(),
        }
    }

    /// Write to `path`, creating the parent directory if needed.
    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())
    }

    /// Write to the canonical user path.
    pub fn save(&self) -> Result<(), String> {
        self.save_to(&Self::default_path())
    }

    /// Whether a `Community`-tier (unverified) upgrade may be auto-applied.
    /// Never — community upgrades always require explicit user action,
    /// regardless of policy.
    pub fn may_auto_apply_community(&self) -> bool {
        false
    }

    /// Whether a curated upgrade may be applied in the background.
    pub fn may_auto_apply_curated(&self) -> bool {
        matches!(self.policy, UpdatePolicy::Auto)
    }

    /// Whether CAR should check/notify at all.
    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());
        // Notify is not auto-apply, and community is never auto-applied.
        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() {
        // Only policy specified — the rest must default.
        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()); // still never
    }

    #[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() {
        // A project .car/update-prefs.json found by walking up should be the
        // one loaded by load_effective.
        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);
    }
}