use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Slot {
pub index: u8,
pub name: String,
pub model_path: Option<PathBuf>,
pub temp: Option<f32>,
pub top_p: Option<f32>,
pub min_p: Option<f32>,
pub top_k: Option<u32>,
pub repeat_penalty: Option<f32>,
pub load_on_startup: bool,
pub max_output_tokens: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GlobalSettings {
pub host: Option<String>,
pub port: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Preset {
pub name: String,
pub path: PathBuf,
pub global: GlobalSettings,
pub slots: Vec<Slot>,
}
fn parse_slot_section(
section_name: &str,
values: &HashMap<String, String>,
) -> Result<Slot, color_eyre::Report> {
let (index_str, name) = match section_name.split_once('-') {
Some((idx, name_str)) => (idx, name_str.to_string()),
None => ("0", section_name.to_string()),
};
let name = name.trim().to_string();
if name.is_empty() {
return Err(color_eyre::eyre::eyre!("slot section has no name"));
}
let index = index_str
.parse()
.map_err(|_| color_eyre::eyre::eyre!("invalid slot index: {}", index_str))?;
let load_on_startup = values
.get("load-on-startup")
.map(|v| v.trim() == "true")
.unwrap_or(false);
Ok(Slot {
index,
name,
model_path: values.get("model").map(PathBuf::from),
temp: values.get("temp").and_then(|v| v.trim().parse().ok()),
top_p: values.get("top-p").and_then(|v| v.trim().parse().ok()),
min_p: values.get("min-p").and_then(|v| v.trim().parse().ok()),
top_k: values.get("top-k").and_then(|v| v.trim().parse().ok()),
repeat_penalty: values
.get("repeat-penalty")
.and_then(|v| v.trim().parse().ok()),
load_on_startup,
max_output_tokens: values
.get("max-output-tokens")
.and_then(|v| v.trim().parse().ok()),
})
}
fn parse_global_section(values: &HashMap<String, String>) -> GlobalSettings {
GlobalSettings {
host: values.get("host").cloned(),
port: values.get("port").and_then(|v| v.trim().parse().ok()),
}
}
pub fn parse_preset(path: &Path) -> Result<Preset, color_eyre::Report> {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| color_eyre::eyre::eyre!("invalid preset filename"))?
.to_string();
let content = fs::read_to_string(path)?;
let mut global_values: HashMap<String, String> = HashMap::new();
let mut current_section = String::from("*");
let mut sections: Vec<(String, HashMap<String, String>)> = Vec::new();
let mut current_values: HashMap<String, String> = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let next_section = trimmed[1..trimmed.len() - 1].to_string();
if current_section == "*" {
global_values = std::mem::take(&mut current_values);
} else {
sections.push((current_section, std::mem::take(&mut current_values)));
}
current_section = next_section;
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim().to_string();
let value = trimmed[eq_pos + 1..].trim().to_string();
current_values.insert(key, value);
}
}
if current_section == "*" {
global_values = std::mem::take(&mut current_values);
} else {
sections.push((current_section, current_values));
}
let mut slots: Vec<Slot> = Vec::new();
for (sec_name, sec_vals) in sections {
if sec_name != "*" {
let slot = parse_slot_section(&sec_name, &sec_vals)?;
slots.push(slot);
}
}
slots.sort_by_key(|s| s.index);
Ok(Preset {
name,
path: path.to_path_buf(),
global: parse_global_section(&global_values),
slots,
})
}
pub fn list_presets() -> Result<Vec<Preset>, color_eyre::Report> {
let home = std::env::var("HOME")?;
let presets_dir = PathBuf::from(&home).join(".config/muthr/provider.d/mlxcel");
if !presets_dir.exists() {
return Ok(Vec::new());
}
let mut presets: Vec<Preset> = Vec::new();
for entry in fs::read_dir(presets_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "ini") {
match parse_preset(&path) {
Ok(preset) => presets.push(preset),
Err(e) => eprintln!("warning: failed to parse {:?}: {}", path, e),
}
}
}
presets.sort_by(|a, b| a.name.cmp(&b.name));
Ok(presets)
}
pub fn resolve_preset(name: &str) -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
let mut selector = name.trim();
if let Some(stripped) = selector.strip_prefix("mlxcel/") {
selector = stripped;
}
if let Some(stripped) = selector.strip_suffix(".ini") {
selector = stripped;
}
let path =
PathBuf::from(&home).join(format!(".config/muthr/provider.d/mlxcel/{}.ini", selector));
if path.exists() { Some(path) } else { None }
}
pub fn expand_home(path: &Path) -> PathBuf {
let Some(raw) = path.to_str() else {
return path.to_path_buf();
};
let Ok(home) = std::env::var("HOME") else {
return path.to_path_buf();
};
if raw == "~" {
return PathBuf::from(home);
}
if let Some(stripped) = raw.strip_prefix("~/") {
return PathBuf::from(home).join(stripped);
}
path.to_path_buf()
}