use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::Value;
use smos_domain::MemoryKey;
#[derive(Debug, Clone)]
pub struct ProviderEntry {
pub name: String,
}
#[derive(Debug, Clone)]
pub struct PersonEntry {
pub provider: String,
pub model: String,
pub persona: String,
}
#[derive(Debug, Clone)]
pub struct PersonRoute {
pub memory_key: MemoryKey,
pub provider_name: String,
pub upstream_model: String,
pub persona_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum RouteError {
#[error("unknown person '{0}'. Configure under [persons.{0}] in smos.toml")]
UnknownPerson(String),
#[error(
"unknown provider '{0}' referenced by person '{1}'. \
Configure under [[providers]] in smos.toml"
)]
UnknownProvider(String, String),
#[error("invalid memory key '{0}': {1}")]
InvalidMemoryKey(String, String),
}
pub fn route_request(
requested_model: &str,
persons: &HashMap<String, PersonEntry>,
providers: &[ProviderEntry],
) -> Result<PersonRoute, RouteError> {
let memory_key = MemoryKey::from_raw(requested_model)
.map_err(|e| RouteError::InvalidMemoryKey(requested_model.to_string(), e.to_string()))?;
let person = persons
.get(requested_model)
.ok_or_else(|| RouteError::UnknownPerson(requested_model.to_string()))?;
if !providers.iter().any(|p| p.name == person.provider) {
return Err(RouteError::UnknownProvider(
person.provider.clone(),
requested_model.to_string(),
));
}
let persona_path = if person.persona.is_empty() {
None
} else {
Some(expand_tilde(&person.persona))
};
Ok(PersonRoute {
memory_key,
provider_name: person.provider.clone(),
upstream_model: person.model.clone(),
persona_path,
})
}
pub fn load_persona_at(path: &PathBuf) -> Option<String> {
match std::fs::read_to_string(path) {
Ok(content) => Some(content),
Err(e) => {
tracing::warn!(
persona_path = %path.display(),
error = %e,
"failed to load persona file; persona injection skipped (fail-soft)"
);
None
}
}
}
pub fn load_persona(path: &str) -> Option<String> {
let expanded = expand_tilde(path);
load_persona_at(&expanded)
}
pub fn expand_tilde(path: &str) -> PathBuf {
let stripped = path
.strip_prefix("~/")
.or_else(|| path.strip_prefix("~\\"))
.or_else(|| path.strip_prefix("~"));
if let Some(rest) = stripped
&& let Some(home) = user_home_dir()
{
return home.join(rest);
}
PathBuf::from(path)
}
pub fn user_home_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
if let Some(p) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(p));
}
let drive = std::env::var_os("HOMEDRIVE");
let path = std::env::var_os("HOMEPATH");
match (drive, path) {
(Some(d), Some(p)) => {
let mut combined = PathBuf::from(d);
combined.push(p);
Some(combined)
}
_ => None,
}
}
#[cfg(not(target_os = "windows"))]
{
std::env::var_os("HOME")
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
}
pub fn inject_persona_into_messages(messages: &mut Vec<Value>, persona: &str) {
if messages.is_empty() {
messages.push(serde_json::json!({"role": "system", "content": persona}));
return;
}
let first = &mut messages[0];
let is_system = first
.get("role")
.and_then(Value::as_str)
.map(|r| r == "system")
.unwrap_or(false);
if is_system {
let existing = first
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
first["content"] = Value::String(format!("{persona}\n\n{existing}"));
} else {
messages.insert(0, serde_json::json!({"role": "system", "content": persona}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider(name: &str) -> ProviderEntry {
ProviderEntry { name: name.into() }
}
fn person(provider: &str, model: &str, persona: &str) -> PersonEntry {
PersonEntry {
provider: provider.into(),
model: model.into(),
persona: persona.into(),
}
}
fn build_persons(entries: &[(&str, &str, &str, &str)]) -> HashMap<String, PersonEntry> {
let mut map = HashMap::new();
for (name, provider, model, persona) in entries {
map.insert(
name.to_string(),
PersonEntry {
provider: provider.to_string(),
model: model.to_string(),
persona: persona.to_string(),
},
);
}
map
}
fn build_providers(names: &[&str]) -> Vec<ProviderEntry> {
names.iter().map(|n| provider(n)).collect()
}
#[test]
fn route_request_happy_path_returns_memory_key_provider_model() {
let providers = build_providers(&["llama-local"]);
let persons = build_persons(&[("bob", "llama-local", "granite4.1:3b", "")]);
let route = route_request("bob", &persons, &providers).expect("route");
assert_eq!(route.memory_key.as_str(), "bob");
assert_eq!(route.provider_name, "llama-local");
assert_eq!(route.upstream_model, "granite4.1:3b");
assert!(route.persona_path.is_none());
}
#[test]
fn route_request_unknown_person_returns_unknown_person_error() {
let providers = build_providers(&["llama-local"]);
let persons = HashMap::new();
let err = route_request("ghost", &persons, &providers).expect_err("unknown");
assert!(matches!(err, RouteError::UnknownPerson(name) if name == "ghost"));
}
#[test]
fn route_request_unknown_provider_returns_unknown_provider_error() {
let providers = build_providers(&["llama-local"]);
let persons = build_persons(&[("bob", "typo", "granite4.1:3b", "")]);
let err = route_request("bob", &persons, &providers).expect_err("unknown");
match err {
RouteError::UnknownProvider(provider_name, person) => {
assert_eq!(provider_name, "typo");
assert_eq!(person, "bob");
}
other => panic!("expected UnknownProvider, got {other:?}"),
}
}
#[test]
fn route_request_invalid_memory_key_returns_invalid_memory_key_error() {
let providers = build_providers(&["llama-local"]);
let mut persons = HashMap::new();
persons.insert(
"a/b".to_string(),
person("llama-local", "granite4.1:3b", ""),
);
let err = route_request("a/b", &persons, &providers).expect_err("invalid key");
assert!(matches!(err, RouteError::InvalidMemoryKey(_, _)));
}
#[test]
fn route_request_returns_persona_path_when_declared() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), "You are Bob.").expect("write");
let providers = build_providers(&["llama-local"]);
let mut persons = HashMap::new();
persons.insert(
"bob".into(),
person("llama-local", "granite4.1:3b", tmp.path().to_str().unwrap()),
);
let route = route_request("bob", &persons, &providers).expect("route");
let path = route.persona_path.expect("persona path");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "You are Bob.");
}
#[test]
fn route_request_persona_path_absent_when_not_declared() {
let providers = build_providers(&["llama-local"]);
let persons = build_persons(&[("bob", "llama-local", "granite4.1:3b", "")]);
let route = route_request("bob", &persons, &providers).expect("route");
assert!(
route.persona_path.is_none(),
"empty persona string MUST yield None path"
);
}
#[test]
fn load_persona_at_returns_none_for_missing_file_without_panic() {
let path = PathBuf::from("/definitely/does/not/exist.md");
assert!(load_persona_at(&path).is_none());
}
#[test]
fn expand_tilde_passes_through_absolute_paths() {
assert_eq!(expand_tilde("/etc/passwd"), PathBuf::from("/etc/passwd"));
assert_eq!(
expand_tilde("relative/path"),
PathBuf::from("relative/path")
);
assert_eq!(expand_tilde(""), PathBuf::from(""));
}
#[test]
fn inject_persona_into_empty_messages_creates_system_message() {
let mut messages: Vec<Value> = vec![];
inject_persona_into_messages(&mut messages, "be bob");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "be bob");
}
#[test]
fn inject_persona_prepends_to_existing_system_message() {
let mut messages: Vec<Value> = vec![
serde_json::json!({"role": "system", "content": "existing"}),
serde_json::json!({"role": "user", "content": "hi"}),
];
inject_persona_into_messages(&mut messages, "persona");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "persona\n\nexisting");
}
#[test]
fn inject_persona_inserts_system_message_when_first_is_user() {
let mut messages: Vec<Value> = vec![serde_json::json!({"role": "user", "content": "hi"})];
inject_persona_into_messages(&mut messages, "persona");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "persona");
assert_eq!(messages[1]["role"], "user");
}
#[test]
fn inject_persona_into_existing_empty_system_content_uses_persona_only() {
let mut messages: Vec<Value> = vec![serde_json::json!({"role": "system", "content": ""})];
inject_persona_into_messages(&mut messages, "persona");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "persona\n\n");
}
}