use anyhow::{Context, Result};
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use crate::storage::Storage;
use crate::template::TemplateProcessor;
pub struct Composer {
storage: Storage,
template_processor: TemplateProcessor,
}
impl Composer {
pub fn new(storage: Storage) -> Self {
let mut template_processor = TemplateProcessor::new();
if let Some(config_dir) = dirs::config_dir() {
let config_path = config_dir
.join("prompthive")
.join("template_variables.conf");
if let Err(e) = template_processor.load_config(&config_path) {
eprintln!("Warning: Failed to load template variables config: {}", e);
}
}
Self {
storage,
template_processor,
}
}
pub fn compose(&self, prompt_names: &[String]) -> Result<String> {
if prompt_names.is_empty() {
return Err(anyhow::anyhow!("No prompts specified for composition"));
}
let mut result = String::new();
use is_terminal::IsTerminal;
if !io::stdin().is_terminal() {
use std::io::BufRead;
let stdin = io::stdin();
let mut handle = stdin.lock();
match handle.fill_buf() {
Ok(buf) if !buf.is_empty() => {
drop(handle); io::stdin().read_to_string(&mut result)?;
}
_ => {
}
}
}
for prompt_name in prompt_names.iter() {
let prompt_body = if prompt_name.starts_with('~')
|| prompt_name.starts_with('/')
|| prompt_name.contains('.')
{
let expanded_path = shellexpand::tilde(prompt_name);
let path = PathBuf::from(expanded_path.as_ref());
fs::read_to_string(&path)
.with_context(|| format!("Failed to read file '{}'", prompt_name))?
} else {
let resolved_name = self
.storage
.resolve_prompt(prompt_name)
.with_context(|| format!("Failed to resolve prompt '{}'", prompt_name))?;
let (_, body) = self
.storage
.read_prompt(&resolved_name)
.with_context(|| format!("Failed to read prompt '{}'", resolved_name))?;
body
};
let input = &result;
let processed_prompt = self.process_prompt(&prompt_body, input)?;
result = processed_prompt;
}
Ok(result)
}
pub fn compose_pipe(&self, prompt_names: &[String], input: &str) -> Result<String> {
let mut current_input = input.to_string();
for prompt_name in prompt_names {
let prompt_body = if prompt_name.starts_with('~')
|| prompt_name.starts_with('/')
|| prompt_name.contains('.')
{
let expanded_path = shellexpand::tilde(prompt_name);
let path = PathBuf::from(expanded_path.as_ref());
fs::read_to_string(&path)
.with_context(|| format!("Failed to read file '{}'", prompt_name))?
} else {
let resolved_name = self
.storage
.resolve_prompt(prompt_name)
.with_context(|| format!("Failed to resolve prompt '{}'", prompt_name))?;
let (_, body) = self
.storage
.read_prompt(&resolved_name)
.with_context(|| format!("Failed to read prompt '{}'", resolved_name))?;
body
};
current_input = self.process_prompt(&prompt_body, ¤t_input)?;
}
Ok(current_input)
}
fn process_prompt(&self, prompt: &str, input: &str) -> Result<String> {
self.template_processor.process(prompt, input)
}
pub fn execute_composition(
&self,
prompt_names: &[String],
input: Option<String>,
edit: bool,
) -> Result<()> {
let mut result = if let Some(initial_input) = input {
self.compose_pipe(prompt_names, &initial_input)?
} else {
self.compose(prompt_names)?
};
if edit {
result = crate::edit::edit_content(&result)?;
}
use is_terminal::IsTerminal;
if io::stdout().is_terminal() {
let mut clipboard = crate::Clipboard::new();
clipboard.copy_or_pipe(&result, true)?;
} else {
io::stdout().write_all(result.as_bytes())?;
}
Ok(())
}
pub fn compose_and_return(
&self,
prompt_names: &[String],
input: Option<String>,
edit: bool,
) -> Result<String> {
let mut result = if let Some(initial_input) = input {
self.compose_pipe(prompt_names, &initial_input)?
} else {
self.compose(prompt_names)?
};
if edit {
result = crate::edit::edit_content(&result)?;
}
Ok(result)
}
pub fn template_processor(&mut self) -> &mut TemplateProcessor {
&mut self.template_processor
}
pub fn set_template_variable(&mut self, name: &str, value: &str) {
self.template_processor.set_custom_variable(name, value);
}
pub fn remove_template_variable(&mut self, name: &str) {
self.template_processor.remove_custom_variable(name);
}
pub fn list_template_variables(&self) -> Vec<(String, String)> {
self.template_processor.list_available_variables()
}
pub fn save_template_config(&self) -> Result<()> {
if let Some(config_dir) = dirs::config_dir() {
let config_dir = config_dir.join("prompthive");
std::fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join("template_variables.conf");
self.template_processor.save_config(&config_path)?;
}
Ok(())
}
}
pub fn parse_prompt_list(input: &str) -> Vec<String> {
input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::PromptMetadata;
use tempfile::TempDir;
fn create_test_storage() -> (Storage, TempDir) {
let temp_dir = TempDir::new().unwrap();
let storage = Storage::new_with_base(temp_dir.path().to_path_buf()).unwrap();
storage.init().unwrap();
let metadata1 = PromptMetadata {
id: "format".to_string(),
description: "Format text".to_string(),
tags: None,
created_at: None,
updated_at: None,
version: None,
git_hash: None,
parent_version: None,
};
let body1 = "Format this text nicely:\n{input}";
storage.write_prompt("format", &metadata1, body1).unwrap();
let metadata2 = PromptMetadata {
id: "summarize".to_string(),
description: "Summarize content".to_string(),
tags: None,
created_at: None,
updated_at: None,
version: None,
git_hash: None,
parent_version: None,
};
let body2 = "Summarize the following:\n{input}";
storage
.write_prompt("summarize", &metadata2, body2)
.unwrap();
(storage, temp_dir)
}
#[test]
fn test_compose_single_prompt() {
let (storage, _temp) = create_test_storage();
let composer = Composer::new(storage);
let result = composer
.compose_pipe(&["format".to_string()], "hello world")
.unwrap();
assert!(result.contains("Format this text nicely:"));
assert!(result.contains("hello world"));
}
#[test]
fn test_compose_multiple_prompts() {
let (storage, _temp) = create_test_storage();
let composer = Composer::new(storage);
let prompts = vec!["format".to_string(), "summarize".to_string()];
let result = composer.compose_pipe(&prompts, "raw data").unwrap();
assert!(result.contains("Summarize the following:"));
assert!(result.contains("Format this text nicely:"));
}
#[test]
fn test_parse_prompt_list() {
assert_eq!(parse_prompt_list("a,b,c"), vec!["a", "b", "c"]);
assert_eq!(parse_prompt_list("a, b , c "), vec!["a", "b", "c"]);
assert_eq!(parse_prompt_list("single"), vec!["single"]);
assert_eq!(parse_prompt_list(""), Vec::<String>::new());
}
#[test]
fn test_placeholder_replacement() {
let (storage, _temp) = create_test_storage();
let composer = Composer::new(storage);
let result = composer.process_prompt("Hello {input}!", "world").unwrap();
assert_eq!(result, "Hello world!");
let result = composer.process_prompt("Process: {INPUT}", "data").unwrap();
assert_eq!(result, "Process: data");
}
}