car-inference 0.24.1

Local model inference for CAR — Candle backend with Qwen3 models
//! Lane defaults — the user/concierge's chosen default model per
//! use-case lane, optionally scoped to a project (Phase D1).
//!
//! There was no per-use-case default-model config anywhere before this;
//! routing was fully adaptive. The concierge's "set it up" action needs
//! a durable place to record "use model X for the coding lane (in this
//! project)", and routing can consult it as a strong preference. A
//! global default (`project = None`) applies everywhere; a project entry
//! overrides it for that project — model fit is frequently
//! project-specific (a heavy coder for a big Rust repo, something light
//! for notes).
//!
//! Persisted at `~/.car/lane-defaults.json`. Non-secret config. Pure
//! over its inputs; the path I/O is at the edges.

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

use serde::{Deserialize, Serialize};

use crate::intent::UseCase;

/// File name under `~/.car/`.
pub const LANE_DEFAULTS_FILE: &str = "lane-defaults.json";

/// One default: model for a lane, optionally scoped to a project.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LaneDefault {
    /// Project/workspace id this applies to; `None` is the global default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub use_case: UseCase,
    pub model_id: String,
    /// When it was set (unix secs) — for audit / last-write-wins display.
    #[serde(default)]
    pub set_at: u64,
}

/// The whole `lane-defaults.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaneDefaults {
    #[serde(default)]
    pub defaults: Vec<LaneDefault>,
}

impl LaneDefaults {
    /// Resolve the default model for `(project, use_case)`: a matching
    /// project entry wins; otherwise the global (`None`) entry; otherwise
    /// `None` (fall back to adaptive routing).
    pub fn resolve(&self, project: Option<&str>, use_case: UseCase) -> Option<&str> {
        // Project-specific first.
        if let Some(p) = project {
            if let Some(d) = self
                .defaults
                .iter()
                .find(|d| d.use_case == use_case && d.project.as_deref() == Some(p))
            {
                return Some(&d.model_id);
            }
        }
        // Global fallback.
        self.defaults
            .iter()
            .find(|d| d.use_case == use_case && d.project.is_none())
            .map(|d| d.model_id.as_str())
    }

    /// Set (or replace) the default for `(project, use_case)`.
    pub fn set(&mut self, project: Option<String>, use_case: UseCase, model_id: String, now: u64) {
        self.defaults
            .retain(|d| !(d.use_case == use_case && d.project == project));
        self.defaults.push(LaneDefault {
            project,
            use_case,
            model_id,
            set_at: now,
        });
    }

    /// Remove the default for `(project, use_case)`. Returns whether one
    /// was removed.
    pub fn clear(&mut self, project: Option<&str>, use_case: UseCase) -> bool {
        let before = self.defaults.len();
        self.defaults
            .retain(|d| !(d.use_case == use_case && d.project.as_deref() == project));
        self.defaults.len() != before
    }
}

/// Default path: `~/.car/lane-defaults.json`.
pub fn default_path() -> PathBuf {
    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(".car").join(LANE_DEFAULTS_FILE)
}

/// Load, treating a missing/garbage file as empty (config is best-effort;
/// a corrupt file must not break routing).
pub fn load_from(path: &Path) -> LaneDefaults {
    match std::fs::read_to_string(path) {
        Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
        Err(_) => LaneDefaults::default(),
    }
}

/// Save atomically (temp + rename), creating parent dirs.
pub fn save_to(path: &Path, defaults: &LaneDefaults) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(defaults)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_overrides_global_then_falls_back() {
        let mut d = LaneDefaults::default();
        d.set(None, UseCase::Coding, "global-coder".into(), 1);
        d.set(Some("repoA".into()), UseCase::Coding, "repoA-coder".into(), 2);

        // Project-specific wins.
        assert_eq!(d.resolve(Some("repoA"), UseCase::Coding), Some("repoA-coder"));
        // Unknown project → global fallback.
        assert_eq!(d.resolve(Some("repoB"), UseCase::Coding), Some("global-coder"));
        // No project → global.
        assert_eq!(d.resolve(None, UseCase::Coding), Some("global-coder"));
        // Unset lane → None (adaptive).
        assert_eq!(d.resolve(None, UseCase::Assistant), None);
    }

    #[test]
    fn set_replaces_and_clear_removes() {
        let mut d = LaneDefaults::default();
        d.set(None, UseCase::Assistant, "a1".into(), 1);
        d.set(None, UseCase::Assistant, "a2".into(), 2); // replace
        assert_eq!(d.defaults.len(), 1);
        assert_eq!(d.resolve(None, UseCase::Assistant), Some("a2"));

        assert!(d.clear(None, UseCase::Assistant));
        assert!(!d.clear(None, UseCase::Assistant)); // already gone
        assert_eq!(d.resolve(None, UseCase::Assistant), None);
    }

    #[test]
    fn roundtrip_to_disk() {
        let dir = std::env::temp_dir().join("car-lane-defaults-test");
        let _ = std::fs::remove_dir_all(&dir);
        let path = dir.join(LANE_DEFAULTS_FILE);
        let mut d = LaneDefaults::default();
        d.set(Some("p".into()), UseCase::Coding, "m".into(), 5);
        save_to(&path, &d).unwrap();
        let loaded = load_from(&path);
        assert_eq!(loaded.resolve(Some("p"), UseCase::Coding), Some("m"));
        let _ = std::fs::remove_dir_all(&dir);
    }
}