car-inference 0.56.1

Local model inference for CAR — Candle backend with Qwen3 models
Documentation
//! User preferences for how CAR keeps models up to date.
//!
//! Persisted at `update-prefs.json` under the CAR state root
//! (`~/.car/update-prefs.json`; `CAR_HOME` moves it) 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 {
    /// Keep the local model world right in the background and say so after
    /// the fact: apply curated upgrades (never `Community`-tier ones — those
    /// always notify), and retire CAR-owned weights the maintenance pass finds
    /// useless here, replaced, or idle under disk pressure (see
    /// [`crate::maintenance`]), plus abandoned partial downloads.
    /// The default, when the user has not chosen a policy.
    #[default]
    Auto,
    /// Surface a single, dismissible notification; never act without the user.
    Notify,
    /// Don't check, don't notify, don't maintain: no retirement, no partial
    /// cleanup, nothing.
    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,
    /// Whether `policy` is the user's explicit choice. Set by
    /// [`UpdatePreferences::from_user_params`] when a save names a policy. A
    /// user file without it (every file written before the default became
    /// `Auto` serialized `"notify"` whether or not anyone chose it) reads an
    /// unchosen `notify` as the new default, `auto`; any other value was a
    /// choice. A project `.car/update-prefs.json` is always a team's choice.
    #[serde(default)]
    pub policy_chosen: 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,
            policy_chosen: false,
        }
    }
}

impl UpdatePreferences {
    /// The canonical user path: `update-prefs.json` under the CAR state root —
    /// `~/.car/update-prefs.json` unless `CAR_HOME` moves the root, in which
    /// case the user preferences move with it. Only this user-level file
    /// follows the state root; the project-level
    /// [`project_override_path`] walk-up is unchanged and still wins where a
    /// team-shared `.car/update-prefs.json` exists.
    pub fn default_path() -> PathBuf {
        car_home::root_or_relative().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. A file written before
    /// [`Self::policy_chosen`] existed counts as chosen (see there).
    pub fn load() -> Result<Self, String> {
        Self::load_user_from(&Self::default_path())
    }

    fn load_user_from(path: &Path) -> Result<Self, String> {
        let mut prefs = Self::load_from(path)?;
        if path.exists() {
            let legacy = std::fs::read_to_string(path)
                .ok()
                .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
                .is_some_and(|value| value.get("policy_chosen").is_none());
            if legacy {
                prefs.policy_chosen = true;
            }
        }
        Ok(prefs)
    }

    /// Preferences a user is saving: `params` replaces them whole. The
    /// policy counts as chosen when `params` names one that differs from
    /// `current`'s, or `current`'s was already chosen — a client that reads
    /// the preferences and writes them back to change something else re-sends
    /// the policy in force, and that is not a decision.
    pub fn from_user_params(params: &serde_json::Value, current: &Self) -> Result<Self, String> {
        let mut prefs: Self = serde_json::from_value(params.clone())
            .map_err(|e| format!("invalid preferences: {e}"))?;
        prefs.policy_chosen = params.get("policy").is_some()
            && (prefs.policy != current.policy || current.policy_chosen);
        Ok(prefs)
    }

    /// 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).map(|mut prefs| {
                prefs.policy_chosen = true;
                prefs
            }),
            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_keep_models_current_but_never_community() {
        let p = UpdatePreferences::default();
        assert_eq!(p.channel, UpdateChannel::Stable);
        assert_eq!(p.policy, UpdatePolicy::Auto);
        assert!(!p.policy_chosen);
        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(),
            "community is never auto-applied"
        );
        assert!(p.checks_enabled());
    }

    /// A file an earlier version wrote was an explicit save: its policy
    /// stays. Only a machine with no file gets the default.
    #[test]
    fn a_legacy_file_keeps_its_policy() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("update-prefs.json");
        std::fs::write(&path, r#"{"channel":"stable","policy":"notify"}"#).unwrap();
        let legacy = UpdatePreferences::load_user_from(&path).unwrap();
        assert_eq!(legacy.policy, UpdatePolicy::Notify);
        assert!(legacy.policy_chosen);
        let missing = UpdatePreferences::load_user_from(&dir.path().join("none.json")).unwrap();
        assert_eq!(missing.policy, UpdatePolicy::Auto);
        assert!(!missing.policy_chosen);
    }

    /// Naming a different policy is a choice; re-sending the one in force
    /// (a read-modify-write of another field) is not.
    #[test]
    fn only_a_changed_policy_is_a_choice() {
        let default = UpdatePreferences::default();
        let pick = |params: serde_json::Value, current: &UpdatePreferences| {
            UpdatePreferences::from_user_params(&params, current).unwrap()
        };
        assert!(pick(serde_json::json!({"policy": "notify"}), &default).policy_chosen);
        let round_trip = pick(
            serde_json::json!({"policy": "auto", "channel": "latest"}),
            &default,
        );
        assert!(!round_trip.policy_chosen, "re-sent the default");
        assert!(!pick(serde_json::json!({"channel": "latest"}), &default).policy_chosen);
        let chosen = UpdatePreferences {
            policy_chosen: true,
            ..Default::default()
        };
        assert!(pick(serde_json::json!({"policy": "auto"}), &chosen).policy_chosen);
    }

    #[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,
            policy_chosen: true,
        };
        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");
        // A team's committed `notify` is a decision, never the legacy flip.
        let team = UpdatePreferences {
            policy: UpdatePolicy::Notify,
            ..Default::default()
        };
        team.save_to(&root.join(".car").join("update-prefs.json"))
            .unwrap();
        let loaded = UpdatePreferences::load_effective(&nested).unwrap();
        assert_eq!(loaded.policy, UpdatePolicy::Notify);
        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);
    }
}