use async_trait::async_trait;
use std::path::PathBuf;
use crate::ports::{Persona, PersonaProvider};
use crate::SdkError;
pub struct FilePersonaProvider {
root: PathBuf,
}
impl std::fmt::Debug for FilePersonaProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FilePersonaProvider").field("root", &self.root).finish()
}
}
impl FilePersonaProvider {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
#[async_trait]
impl PersonaProvider for FilePersonaProvider {
async fn list(&self) -> Result<Vec<Persona>, SdkError> {
if !self.root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let entries = std::fs::read_dir(&self.root).map_err(scan_err)?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
if let Ok(p) = parse_persona(name, &path) {
out.push(p);
}
}
}
Ok(out)
}
async fn get(&self, name: &str) -> Result<Option<Persona>, SdkError> {
let path = self.root.join(format!("{name}.md"));
if !path.exists() {
return Ok(None);
}
let p = parse_persona(name, &path)?;
Ok(Some(p))
}
}
fn split_frontmatter(text: &str) -> (Option<String>, String) {
if let Some(body) = text.strip_prefix("---\n") {
if let Some(idx) = body.find("\n---") {
let fm = body[..idx].to_string();
let after = &body[idx + 4..];
return (Some(fm), after.trim_start_matches('\n').to_string());
}
}
(None, text.to_string())
}
fn parse_persona(name: &str, path: &std::path::Path) -> Result<Persona, SdkError> {
let text = std::fs::read_to_string(path).map_err(read_err)?;
let (front, body) = split_frontmatter(&text);
let mut preferred_model = None;
let mut allowed_tools = None;
if let Some(fm) = front {
for line in fm.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("preferred_model:") {
preferred_model = Some(rest.trim().trim_matches('"').to_string());
} else if let Some(rest) = line.strip_prefix("allowed_tools:") {
let items: Vec<String> = rest
.trim()
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
if !items.is_empty() {
allowed_tools = Some(items);
}
}
}
}
Ok(Persona {
name: name.to_string(),
system_prompt: body,
preferred_model,
allowed_tools,
})
}
fn read_err(e: std::io::Error) -> SdkError {
SdkError::Internal(anyhow::anyhow!(e))
}
fn scan_err(e: std::io::Error) -> SdkError {
SdkError::Internal(anyhow::anyhow!(e))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn discovers_persona_with_frontmatter() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("reviewer.md"),
"---\npreferred_model: anthropic/claude-sonnet-4-20250514\n---\nYou are a reviewer.",
)
.unwrap();
let p = FilePersonaProvider::new(tmp.path());
let list = p.list().await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "reviewer");
assert_eq!(
list[0].preferred_model.as_deref(),
Some("anthropic/claude-sonnet-4-20250514")
);
assert!(list[0].system_prompt.contains("You are a reviewer"));
}
#[tokio::test]
async fn load_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let p = FilePersonaProvider::new(tmp.path());
assert!(p.get("absent").await.unwrap().is_none());
}
#[tokio::test]
async fn persona_without_frontmatter_uses_full_body() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("plain.md"), "Just a system prompt.").unwrap();
let p = FilePersonaProvider::new(tmp.path());
let got = p.get("plain").await.unwrap().unwrap();
assert!(got.system_prompt.contains("Just a system prompt"));
assert!(got.preferred_model.is_none());
}
}