use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preset {
#[serde(skip)]
pub name: String,
pub description: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_processor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresetSource {
BuiltIn,
User,
}
impl std::fmt::Display for PresetSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PresetSource::BuiltIn => write!(f, "built-in"),
PresetSource::User => write!(f, "user"),
}
}
}
impl Preset {
pub fn presets_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("whis")
.join("presets")
}
pub fn builtins() -> Vec<Preset> {
vec![
Preset {
name: "ai-prompt".to_string(),
description: "Clean voice transcript for AI assistant prompts".to_string(),
prompt: "Clean up this voice transcript for use as an AI prompt. \
Remove filler words (um, uh, like, you know) and false starts. \
Fix grammar and punctuation. \
If the speaker corrected themselves, keep only the correction. \
Preserve the speaker's wording. Only restructure if the original is genuinely unclear. \
Output only the cleaned text."
.to_string(),
post_processor: None,
model: None,
},
Preset {
name: "email".to_string(),
description: "Format transcript as an email".to_string(),
prompt: "Clean up this voice transcript into an email. \
Fix grammar and punctuation. Remove filler words. \
Keep it concise. Match the sender's original tone (casual or formal). \
Do NOT add placeholder names or unnecessary formalities. \
Output only the cleaned text."
.to_string(),
post_processor: None,
model: None,
},
Preset {
name: "default".to_string(),
description: "Basic cleanup - fixes grammar and removes filler words".to_string(),
prompt: "Lightly clean up this voice transcript for personal notes. \
Fix major grammar issues and remove excessive filler words. \
Preserve the speaker's natural voice and thought structure. \
IMPORTANT: Start directly with the cleaned content. NEVER add any introduction, preamble, or meta-commentary like 'Here are the notes'. \
Output ONLY the cleaned transcript, nothing else."
.to_string(),
post_processor: None,
model: None,
},
]
}
fn load_user_preset(name: &str) -> Option<Preset> {
let path = Self::presets_dir().join(format!("{}.json", name));
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Preset>(&content) {
Ok(mut preset) => {
preset.name = name.to_string();
Some(preset)
}
Err(e) => {
eprintln!(
"Warning: Failed to parse preset '{}': {}",
path.display(),
e
);
None
}
},
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => {
eprintln!("Warning: Failed to read preset '{}': {}", path.display(), e);
None
}
}
}
pub fn load(name: &str) -> Result<(Preset, PresetSource), String> {
if let Some(preset) = Self::load_user_preset(name) {
return Ok((preset, PresetSource::User));
}
if let Some(preset) = Self::builtins().into_iter().find(|p| p.name == name) {
return Ok((preset, PresetSource::BuiltIn));
}
Err(format!(
"Unknown preset '{}'\nAvailable: {}",
name,
Self::all_names().join(", ")
))
}
pub fn list_all() -> Vec<(Preset, PresetSource)> {
let mut presets: HashMap<String, (Preset, PresetSource)> = HashMap::new();
for preset in Self::builtins() {
presets.insert(preset.name.clone(), (preset, PresetSource::BuiltIn));
}
if let Ok(entries) = fs::read_dir(Self::presets_dir()) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let Some(filename_stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Preset>(&content) {
Ok(mut preset) => {
preset.name = filename_stem.to_string();
presets.insert(preset.name.clone(), (preset, PresetSource::User));
}
Err(e) => {
eprintln!(
"Warning: Failed to parse preset '{}': {}",
path.display(),
e
);
}
},
Err(e) => {
eprintln!("Warning: Failed to read preset '{}': {}", path.display(), e);
}
}
}
}
}
let mut result: Vec<_> = presets.into_values().collect();
result.sort_by(|a, b| a.0.name.cmp(&b.0.name));
result
}
pub fn all_names() -> Vec<String> {
Self::list_all().into_iter().map(|(p, _)| p.name).collect()
}
pub fn template(name: &str) -> Preset {
Preset {
name: name.to_string(),
description: "Describe what this preset does".to_string(),
prompt: "Your system prompt here".to_string(),
post_processor: None,
model: None,
}
}
pub fn is_builtin(name: &str) -> bool {
Self::builtins().iter().any(|p| p.name == name)
}
pub fn validate_name(name: &str, allow_builtin_conflict: bool) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("Preset name cannot be empty".to_string());
}
if name.len() > 50 {
return Err("Preset name must be 50 characters or less".to_string());
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(
"Preset name can only contain letters, numbers, hyphens, and underscores"
.to_string(),
);
}
if !allow_builtin_conflict && Self::is_builtin(name) {
return Err(format!(
"Cannot use '{}' - it's a built-in preset name",
name
));
}
Ok(())
}
pub fn save(&self) -> Result<(), String> {
let dir = Self::presets_dir();
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create presets directory: {}", e))?;
let path = dir.join(format!("{}.json", self.name));
let content = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize: {}", e))?;
fs::write(&path, content).map_err(|e| format!("Failed to write preset file: {}", e))?;
Ok(())
}
pub fn delete(name: &str) -> Result<(), String> {
if Self::is_builtin(name) {
return Err(format!("Cannot delete built-in preset '{}'", name));
}
let path = Self::presets_dir().join(format!("{}.json", name));
if !path.exists() {
return Err(format!("Preset '{}' not found", name));
}
fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {}", e))?;
Ok(())
}
}