use crate::config::{ConfigError, ConfigStore};
use crate::profile::AgentProfileId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const STORED_PROMPT_SCHEMA_VERSION: i64 = 2;
pub const LEGACY_STORED_PROMPT_SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum IdentityState {
#[default]
Ready,
NeedsRename,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StoredPrompt {
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub normalized_name: String,
pub instructions: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<AgentProfileId>,
}
impl StoredPrompt {
pub fn validate(&self) -> Result<(), String> {
if self.instructions.trim().is_empty() {
return Err("stored prompt instructions must not be empty".to_string());
}
if self.display_name.trim().is_empty() {
return Err("stored prompt display name must not be empty".to_string());
}
let normalized = normalize_prompt_name(&self.display_name);
if normalized.is_empty() {
return Err(
"stored prompt display name must produce a non-empty normalized handle".to_string(),
);
}
if self.normalized_name != normalized {
return Err(format!(
"stored prompt normalized_name '{}' does not match derived '{}'",
self.normalized_name, normalized
));
}
for skill in &self.skills {
validate_skill_identifier(skill)?;
}
let mut seen = std::collections::HashSet::new();
for skill in &self.skills {
let lower = skill.to_lowercase();
if !seen.insert(lower) {
return Err(format!(
"duplicate skill identifier '{}' (case-insensitive)",
skill
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StoredPromptEntry {
pub id: String,
pub prompt: StoredPrompt,
pub identity_state: IdentityState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptLoadIssue {
UnsupportedSchemaVersion { version: i64 },
InvalidPayload,
MissingRecord,
UnavailableProfile { profile_id: String },
UnavailableSkill { skill: String },
NeedsRename,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptLoadDiagnostic {
pub prompt_id: String,
pub issue: PromptLoadIssue,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PromptLoadReport {
pub loaded: Vec<StoredPromptEntry>,
pub diagnostics: Vec<PromptLoadDiagnostic>,
}
#[derive(Debug, Default)]
pub struct StoredPromptRegistry {
prompts: HashMap<String, StoredPrompt>,
}
impl StoredPromptRegistry {
pub fn new() -> Self {
Self {
prompts: HashMap::new(),
}
}
pub fn register(&mut self, id: String, prompt: StoredPrompt) -> Result<(), String> {
let trimmed = id.trim();
if trimmed.is_empty() {
return Err("prompt ID must not be empty".to_string());
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return Err("prompt ID must not contain control characters".to_string());
}
prompt.validate()?;
self.prompts.insert(trimmed.to_string(), prompt);
Ok(())
}
pub fn unregister(&mut self, id: &str) -> bool {
self.prompts.remove(id.trim()).is_some()
}
pub fn get(&self, id: &str) -> Option<&StoredPrompt> {
self.prompts.get(id.trim())
}
pub fn list(&self) -> Vec<StoredPromptEntry> {
let mut ids: Vec<&String> = self.prompts.keys().collect();
ids.sort();
ids.into_iter()
.map(|id| StoredPromptEntry {
id: id.clone(),
prompt: self.prompts[id].clone(),
identity_state: IdentityState::Ready,
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.prompts.is_empty()
}
pub fn len(&self) -> usize {
self.prompts.len()
}
}
pub const LEGACY_RESERVED_PREFIX: &str = "legacy-";
pub fn is_reserved_handle(normalized: &str) -> bool {
normalized.starts_with(LEGACY_RESERVED_PREFIX)
}
pub fn normalize_prompt_name(name: &str) -> String {
let trimmed = name.trim();
let mut result = String::with_capacity(trimmed.len());
let mut prev_was_hyphen = false;
for c in trimmed.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
prev_was_hyphen = false;
} else if (c == '-' || c == '_' || c.is_whitespace())
&& !prev_was_hyphen
&& !result.is_empty()
{
result.push('-');
prev_was_hyphen = true;
}
}
if result.ends_with('-') {
result.pop();
}
result
}
pub fn validate_skill_identifier(skill: &str) -> Result<(), String> {
let trimmed = skill.trim();
if trimmed.is_empty() {
return Err("skill identifier must not be empty or whitespace".to_string());
}
if trimmed != skill {
return Err(format!(
"skill identifier '{}' must not have leading or trailing whitespace",
skill
));
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return Err(format!(
"skill identifier '{}' must not contain control characters",
trimmed
));
}
if trimmed.contains(char::is_whitespace) {
return Err(format!(
"skill identifier '{}' must not contain whitespace",
trimmed
));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
{
return Err(format!(
"skill identifier '{}' contains invalid characters; use only letters, digits, hyphens, underscores, dots, or slashes",
trimmed
));
}
Ok(())
}
pub fn kebab_to_title_case(id: &str) -> String {
let normalized = id.replace('_', "-");
normalized
.split('-')
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub async fn load_prompts(store: &ConfigStore) -> Result<PromptLoadReport, ConfigError> {
let mut report = PromptLoadReport::default();
let mut ids = store.list_prompt_ids().await?;
ids.sort();
for id in ids {
let trimmed_id = id.trim();
if trimmed_id.is_empty() || trimmed_id.as_bytes().iter().any(|b| b.is_ascii_control()) {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
let record = match store.get_prompt(&id).await? {
Some(record) => record,
None => {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: id,
issue: PromptLoadIssue::MissingRecord,
});
continue;
}
};
if record.schema_version == LEGACY_STORED_PROMPT_SCHEMA_VERSION {
let prompt: StoredPrompt = match serde_json::from_value(record.payload.clone()) {
Ok(p) => p,
Err(_) => {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
};
if prompt.instructions.trim().is_empty() {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
let display_name = kebab_to_title_case(&record.id);
let normalized_name = normalize_prompt_name(&display_name);
let identity_state = if record.identity_state == "needs_rename" {
IdentityState::NeedsRename
} else {
IdentityState::Ready
};
let prompt = StoredPrompt {
display_name,
normalized_name,
instructions: prompt.instructions,
skills: prompt.skills,
profile: prompt.profile,
};
if prompt.validate().is_err() {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
report.loaded.push(StoredPromptEntry {
id: record.id,
prompt,
identity_state,
});
continue;
}
if record.schema_version != STORED_PROMPT_SCHEMA_VERSION {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::UnsupportedSchemaVersion {
version: record.schema_version,
},
});
continue;
}
let prompt: StoredPrompt = match serde_json::from_value(record.payload.clone()) {
Ok(prompt) => prompt,
Err(_) => {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
};
if prompt.validate().is_err() {
report.diagnostics.push(PromptLoadDiagnostic {
prompt_id: record.id,
issue: PromptLoadIssue::InvalidPayload,
});
continue;
}
let identity_state = if record.identity_state == "needs_rename" {
IdentityState::NeedsRename
} else {
IdentityState::Ready
};
report.loaded.push(StoredPromptEntry {
id: record.id,
prompt,
identity_state,
});
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_prompt() -> StoredPrompt {
StoredPrompt {
display_name: "Check Email".to_string(),
normalized_name: "check-email".to_string(),
instructions: "do thing".to_string(),
skills: Vec::new(),
profile: None,
}
}
#[test]
fn stored_prompt_rejects_empty_instructions() {
let prompt = StoredPrompt {
display_name: "Test".to_string(),
normalized_name: "test".to_string(),
instructions: " ".to_string(),
skills: Vec::new(),
profile: None,
};
assert!(prompt.validate().is_err());
}
#[test]
fn stored_prompt_rejects_empty_display_name() {
let prompt = StoredPrompt {
display_name: " ".to_string(),
normalized_name: "".to_string(),
instructions: "do thing".to_string(),
skills: Vec::new(),
profile: None,
};
assert!(prompt.validate().is_err());
}
#[test]
fn stored_prompt_rejects_mismatched_normalized() {
let prompt = StoredPrompt {
display_name: "Check Email".to_string(),
normalized_name: "wrong-handle".to_string(),
instructions: "do thing".to_string(),
skills: Vec::new(),
profile: None,
};
assert!(prompt.validate().is_err());
}
#[test]
fn registry_replaces_existing() {
let mut reg = StoredPromptRegistry::new();
let prompt = valid_prompt();
reg.register("task".to_string(), prompt.clone()).unwrap();
reg.register("task".to_string(), prompt).unwrap();
assert_eq!(reg.len(), 1);
}
#[test]
fn registry_list_is_sorted() {
let mut reg = StoredPromptRegistry::new();
let prompt = valid_prompt();
reg.register("b".to_string(), prompt.clone()).unwrap();
reg.register("a".to_string(), prompt).unwrap();
let list = reg.list();
assert_eq!(list[0].id, "a");
assert_eq!(list[1].id, "b");
}
#[tokio::test]
async fn legacy_load_reports_invalid_prompt_without_suppressing_valid_siblings() {
use crate::config::PromptInput;
let store = ConfigStore::open_in_memory().await.unwrap();
store
.set_prompt(&PromptInput {
id: "valid-legacy".to_string(),
schema_version: LEGACY_STORED_PROMPT_SCHEMA_VERSION,
payload: serde_json::json!({"instructions": "Do work"}),
display_name: "valid-legacy".to_string(),
normalized_name: "valid-legacy".to_string(),
})
.await
.unwrap();
store
.set_prompt(&PromptInput {
id: "invalid-legacy".to_string(),
schema_version: LEGACY_STORED_PROMPT_SCHEMA_VERSION,
payload: serde_json::json!({
"instructions": "Do work",
"skills": ["invalid skill"]
}),
display_name: "invalid-legacy".to_string(),
normalized_name: "invalid-legacy".to_string(),
})
.await
.unwrap();
let report = load_prompts(&store).await.unwrap();
assert_eq!(report.loaded.len(), 1);
assert_eq!(report.loaded[0].id, "valid-legacy");
assert!(report.diagnostics.iter().any(|diagnostic| {
diagnostic.prompt_id == "invalid-legacy"
&& diagnostic.issue == PromptLoadIssue::InvalidPayload
}));
}
#[test]
fn normalize_basic() {
assert_eq!(normalize_prompt_name("Check Email"), "check-email");
}
#[test]
fn normalize_underscore_as_separator() {
assert_eq!(normalize_prompt_name("Check_Email"), "check-email");
}
#[test]
fn normalize_equivalent_separators() {
assert_eq!(normalize_prompt_name("Check Email"), "check-email");
assert_eq!(normalize_prompt_name("Check_Email"), "check-email");
assert_eq!(normalize_prompt_name("CHECK-EMAIL"), "check-email");
}
#[test]
fn normalize_collapses_multiple_separators() {
assert_eq!(normalize_prompt_name("My___Task"), "my-task");
assert_eq!(normalize_prompt_name("My Task"), "my-task");
}
#[test]
fn normalize_strips_leading_trailing() {
assert_eq!(normalize_prompt_name(" __Check__ "), "check");
}
#[test]
fn normalize_removes_special_chars() {
assert_eq!(normalize_prompt_name("Paul's Brief!"), "pauls-brief");
}
#[test]
fn normalize_empty_result() {
assert_eq!(normalize_prompt_name("'!!!'"), "");
}
#[test]
fn normalize_ascii_only() {
assert_eq!(normalize_prompt_name("Café Report"), "caf-report");
}
#[test]
fn title_case_basic() {
assert_eq!(kebab_to_title_case("check-email"), "Check Email");
}
#[test]
fn title_case_underscore() {
assert_eq!(kebab_to_title_case("daily_report"), "Daily Report");
}
#[test]
fn title_case_single_word() {
assert_eq!(kebab_to_title_case("report"), "Report");
}
}