use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const LOCAL_SUBDIR: &str = "local";
const SELECTION_FILE: &str = "selection.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalSelection {
pub provider: String,
pub model: String,
pub alias: Option<String>,
pub base_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ctx: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_alive: Option<String>,
pub switched_at: String,
}
impl LocalSelection {
pub fn now(
provider: impl Into<String>,
model: impl Into<String>,
alias: Option<String>,
base_url: impl Into<String>,
ctx: Option<u64>,
keep_alive: Option<String>,
) -> Self {
Self {
provider: provider.into(),
model: model.into(),
alias,
base_url: base_url.into(),
ctx,
keep_alive,
switched_at: harn_clock::system_now_rfc3339(),
}
}
}
pub fn local_state_dir(base_dir: &Path) -> PathBuf {
crate::runtime_paths::state_root(base_dir).join(LOCAL_SUBDIR)
}
pub fn ensure_local_state_dir(base_dir: &Path) -> Result<PathBuf, String> {
let dir = local_state_dir(base_dir);
fs::create_dir_all(&dir)
.map_err(|error| format!("failed to create {}: {error}", dir.display()))?;
Ok(dir)
}
pub fn write_selection(base_dir: &Path, selection: &LocalSelection) -> Result<(), String> {
let dir = ensure_local_state_dir(base_dir)?;
let path = dir.join(SELECTION_FILE);
let body = serde_json::to_vec_pretty(selection)
.map_err(|error| format!("failed to serialize local selection: {error}"))?;
fs::write(&path, body).map_err(|error| format!("failed to write {}: {error}", path.display()))
}
pub fn read_selection(base_dir: &Path) -> Result<Option<LocalSelection>, String> {
let path = local_state_dir(base_dir).join(SELECTION_FILE);
if !path.exists() {
return Ok(None);
}
let body =
fs::read(&path).map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let selection: LocalSelection = serde_json::from_slice(&body)
.map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
Ok(Some(selection))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn selection_roundtrip_persists_under_state_root() {
let dir = tempdir().expect("tempdir");
let selection = LocalSelection::now(
"ollama",
"qwen36:30b",
Some("qwen36-coder".to_string()),
"http://127.0.0.1:11434",
Some(32_768),
Some("30m".to_string()),
);
write_selection(dir.path(), &selection).expect("write selection");
let round = read_selection(dir.path())
.expect("read selection")
.expect("present");
assert_eq!(round.provider, "ollama");
assert_eq!(round.model, "qwen36:30b");
assert_eq!(round.alias.as_deref(), Some("qwen36-coder"));
assert_eq!(round.ctx, Some(32_768));
}
#[test]
fn read_selection_returns_none_when_missing() {
let dir = tempdir().expect("tempdir");
let result = read_selection(dir.path()).expect("ok");
assert!(result.is_none());
}
}