use super::{PromptError, SystemPrompt};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tracing::{debug, warn};
use super::PromptMetadata;
#[derive(Debug, Clone, Default)]
pub struct PromptRegistry {
prompts: BTreeMap<String, Arc<SystemPrompt>>,
}
impl PromptRegistry {
pub fn new() -> Self {
Self {
prompts: BTreeMap::new(),
}
}
pub async fn discover(search_paths: Vec<PathBuf>) -> Result<Self, PromptError> {
let mut registry = Self::new();
for path in search_paths {
if !path.exists() {
debug!("prompt search path does not exist: {:?}", path);
continue;
}
match discover_in_directory(path.clone()).await {
Ok(prompts) => {
debug!("discovered {} prompts in {:?}", prompts.len(), path);
for p in prompts {
registry.register(p);
}
}
Err(e) => {
warn!("failed to discover prompts in {:?}: {}", path, e);
}
}
}
Ok(registry)
}
pub fn register(&mut self, prompt: SystemPrompt) {
self.prompts
.insert(prompt.name().to_string(), Arc::new(prompt));
}
pub fn get(&self, name: &str) -> Option<Arc<SystemPrompt>> {
self.prompts.get(name).cloned()
}
pub fn resolve(&self, request: &str) -> Option<Arc<SystemPrompt>> {
for candidate in candidate_names(request) {
if let Some(p) = self.prompts.get(&candidate) {
return Some(p.clone());
}
}
None
}
pub fn all_metadata(&self) -> Vec<PromptMetadata> {
self.prompts
.values()
.map(|p| p.metadata().clone())
.collect()
}
pub fn prompt_names(&self) -> Vec<String> {
self.prompts.keys().cloned().collect()
}
pub fn len(&self) -> usize {
self.prompts.len()
}
pub fn is_empty(&self) -> bool {
self.prompts.is_empty()
}
}
pub(crate) fn candidate_names(request: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = request.to_string();
loop {
out.push(current.clone());
if current.contains('.') {
out.push(current.replace('.', "_"));
}
match current.rsplit_once('-') {
Some((head, _)) if !head.is_empty() => current = head.to_string(),
_ => break,
}
}
out
}
async fn discover_in_directory(path: PathBuf) -> Result<Vec<SystemPrompt>, PromptError> {
if !path.is_dir() {
return Ok(Vec::new());
}
let mut prompts = Vec::new();
let mut entries = fs::read_dir(&path)
.await
.map_err(|e| PromptError::io(path.clone(), e))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| PromptError::io(path.clone(), e))?
{
let entry_path = entry.path();
if !entry_path.is_file() {
continue;
}
if entry_path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match SystemPrompt::from_path(entry_path.clone()).await {
Ok(prompt) => {
debug!("discovered prompt '{}' at {:?}", prompt.name(), entry_path);
prompts.push(prompt);
}
Err(e) => {
warn!("skipping prompt {:?}: {}", entry_path, e);
}
}
}
Ok(prompts)
}
pub fn default_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(current_dir) = std::env::current_dir() {
paths.push(current_dir.join(".sac").join("prompts"));
}
if let Some(home) = home_dir() {
paths.push(home.join(".sac").join("prompts"));
paths.push(home.join(".sombra").join("prompts"));
}
paths
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.and_then(|h| if h.is_empty() { None } else { Some(h) })
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ladder_exact_only() {
assert_eq!(candidate_names("whitehut"), vec!["whitehut".to_string()]);
}
#[test]
fn ladder_multi_strip() {
let got = candidate_names("whitehut-glm-5.1");
assert_eq!(
got,
vec![
"whitehut-glm-5.1".to_string(),
"whitehut-glm-5_1".to_string(),
"whitehut-glm".to_string(),
"whitehut".to_string(),
]
);
}
#[test]
fn ladder_dot_to_underscore_skipped_when_no_dot() {
let got = candidate_names("whitehut-glm-5_1");
assert_eq!(
got,
vec![
"whitehut-glm-5_1".to_string(),
"whitehut-glm".to_string(),
"whitehut".to_string(),
]
);
}
#[test]
fn ladder_single_segment_with_dot() {
let got = candidate_names("foo.bar");
assert_eq!(got, vec!["foo.bar".to_string(), "foo_bar".to_string()]);
}
}