use anyhow::{Context, Result};
use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
const CONFIG_DIR_NAME: &str = ".oxi";
#[derive(Debug, Clone)]
pub struct PromptTemplate {
pub name: String,
pub description: String,
pub argument_hint: Option<String>,
pub content: String,
pub variables: Vec<String>,
pub file_path: PathBuf,
}
impl PromptTemplate {
pub fn parse(name: String, content: String) -> Self {
let variables = extract_variables(&content);
Self {
name,
description: String::new(),
argument_hint: None,
content,
variables,
file_path: PathBuf::new(),
}
}
pub fn parse_with_meta(
name: String,
content: String,
description: String,
argument_hint: Option<String>,
file_path: PathBuf,
) -> Self {
let variables = extract_variables(&content);
Self {
name,
description,
argument_hint,
content,
variables,
file_path,
}
}
pub fn render(&self, vars: &HashMap<&str, &str>) -> Result<String> {
let context = RenderContext::new(vars);
context.render(&self.content)
}
pub fn render_with_args(&self, args: &[String]) -> Result<String> {
let substituted = substitute_args(&self.content, args);
let vars = HashMap::new();
let context = RenderContext::new(&vars);
context.render(&substituted)
}
}
fn extract_variables(template: &str) -> Vec<String> {
let mut vars = Vec::new();
let mut seen = std::collections::HashSet::new();
let chars: Vec<char> = template.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '{' && i + 1 < len && chars[i + 1] == '{' {
let rest_start = i + 2;
if rest_start < len && chars[rest_start] == '#' {
i = rest_start + 1;
while i < len {
if chars[i] == '}' && i + 1 < len && chars[i + 1] == '}' {
i += 2;
break;
}
i += 1;
}
continue;
}
i += 2;
let mut name = String::new();
let mut found_end = false;
while i < len {
if chars[i] == '}' && i + 1 < len && chars[i + 1] == '}' {
i += 2;
found_end = true;
break;
}
name.push(chars[i]);
i += 1;
}
if found_end {
let trimmed = name.trim().to_string();
if !trimmed.is_empty() {
if !is_helper_or_keyword(&trimmed) && seen.insert(trimmed.clone()) {
vars.push(trimmed);
}
}
}
} else {
i += 1;
}
}
vars
}
fn is_helper_or_keyword(expr: &str) -> bool {
let lower = expr.to_lowercase();
matches!(
lower.as_str(),
"date" | "time" | "cwd" | "git_branch" | "hostname" | "username"
) ||
lower.starts_with("env ") ||
lower.starts_with("env::") ||
lower == "else" ||
lower.starts_with("if ") ||
lower.starts_with("/if")
}
pub fn substitute_args(content: &str, args: &[String]) -> String {
let mut result = content.to_string();
let positional_re = regex::Regex::new(r"\$(\d+)").unwrap();
result = positional_re
.replace_all(&result, |caps: ®ex::Captures| {
let num: usize = caps[1].parse().unwrap_or(0);
if num == 0 {
String::new()
} else {
args.get(num - 1).cloned().unwrap_or_default()
}
})
.into_owned();
let slice_re = regex::Regex::new(r"\$\{@:(\d+)(?::(\d+))?\}").unwrap();
result = slice_re
.replace_all(&result, |caps: ®ex::Captures| {
let mut start: usize = caps[1].parse().unwrap_or(1);
if start == 0 {
start = 0;
} else {
start -= 1; }
if let Some(length_str) = caps.get(2) {
let length: usize = length_str.as_str().parse().unwrap_or(0);
args.iter()
.skip(start)
.take(length)
.cloned()
.collect::<Vec<_>>()
.join(" ")
} else {
args.iter().skip(start).cloned().collect::<Vec<_>>().join(" ")
}
})
.into_owned();
let all_args = args.join(" ");
result = result.replace("$ARGUMENTS", &all_args);
result = result.replace("$@", &all_args);
result
}
pub fn parse_command_args(args_string: &str) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quote: Option<char> = None;
for ch in args_string.chars() {
match in_quote {
Some(qc) => {
if ch == qc {
in_quote = None;
} else {
current.push(ch);
}
}
None => {
if ch == '"' || ch == '\'' {
in_quote = Some(ch);
} else if ch == ' ' || ch == '\t' {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
}
}
if !current.is_empty() {
args.push(current);
}
args
}
#[derive(Debug)]
struct RenderContext<'a> {
vars: &'a HashMap<&'a str, &'a str>,
}
impl<'a> RenderContext<'a> {
fn new(vars: &'a HashMap<&'a str, &'a str>) -> Self {
Self { vars }
}
fn render(&self, template: &str) -> Result<String> {
let mut result = String::with_capacity(template.len());
let chars: Vec<char> = template.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '{' && i + 1 < len && chars[i + 1] == '{' {
let expr_start = i + 2;
let mut j = expr_start;
let mut found_end = false;
while j < len {
if chars[j] == '}' && j + 1 < len && chars[j + 1] == '}' {
found_end = true;
break;
}
j += 1;
}
if found_end {
let expr: String = chars[expr_start..j].iter().collect();
let expr_trimmed = expr.trim();
if expr_trimmed.starts_with("#if ") {
let condition = expr_trimmed[4..].trim();
let cond_value = self.resolve_condition_value(condition);
let block_end = format!("{{{{/{}}}}}", "if");
let else_marker = "{{else}}";
let content_start = j + 2; let rest = &template[content_start..];
let end_pos = rest.find(&block_end);
match end_pos {
Some(ep) => {
let block_content = &rest[..ep];
let after_end = content_start + ep + block_end.len();
if cond_value {
let if_content = match block_content.find(else_marker) {
Some(else_pos) => &block_content[..else_pos],
None => block_content,
};
result.push_str(if_content);
} else {
match block_content.find(else_marker) {
Some(else_pos) => {
result
.push_str(&block_content[else_pos + else_marker.len()..]);
}
None => { }
}
}
i = template[..after_end].chars().count();
continue;
}
None => {
result.push_str("{{");
i += 2;
continue;
}
}
} else if expr_trimmed == "else" || expr_trimmed.starts_with("/if") {
i = j + 2;
continue;
} else {
let resolved = self.resolve_expression(expr_trimmed);
result.push_str(&resolved);
i = j + 2;
continue;
}
} else {
result.push_str("{{");
i += 2;
continue;
}
} else {
result.push(chars[i]);
i += 1;
}
}
Ok(result)
}
fn resolve_condition_value(&self, condition: &str) -> bool {
if let Some(val) = self.vars.get(condition) {
return !val.is_empty() && val.to_lowercase() != "false";
}
let resolved = self.resolve_expression(condition);
if resolved.starts_with("{{") && resolved.ends_with("}}") {
return false;
}
!resolved.is_empty() && resolved.to_lowercase() != "false"
}
fn resolve_expression(&self, expr: &str) -> String {
if let Some(val) = self.vars.get(expr) {
return (*val).to_string();
}
match expr {
"date" => {
let now = chrono::Local::now();
now.format("%Y-%m-%d").to_string()
}
"time" => {
let now = chrono::Local::now();
now.format("%H:%M:%S").to_string()
}
"cwd" => env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
"git_branch" => env::current_dir()
.ok()
.and_then(|d| crate::git_utils::get_current_branch(&d))
.unwrap_or_default(),
"hostname" => env::var("HOSTNAME")
.or_else(|_| env::var("HOST"))
.unwrap_or_default(),
"username" => env::var("USER")
.or_else(|_| env::var("USERNAME"))
.unwrap_or_default(),
_ => {
let rest = expr
.strip_prefix("env::")
.or_else(|| expr.strip_prefix("env "))
.map(str::trim);
if let Some(var_name) = rest {
env::var(var_name).unwrap_or_default()
} else {
format!("{{{{{}}}}}", expr)
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TemplateManager {
templates: HashMap<String, PromptTemplate>,
}
impl TemplateManager {
pub fn new() -> Self {
Self::default()
}
pub fn load_from_dir(dir: &Path) -> Result<Self> {
let mut manager = Self::new();
if !dir.exists() {
return Ok(manager);
}
let entries = std::fs::read_dir(dir)
.with_context(|| format!("Failed to read templates directory: {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read template: {}", path.display()))?;
let (description, argument_hint, body) = parse_template_metadata(&content);
let template = PromptTemplate::parse_with_meta(
name.clone(),
body,
description,
argument_hint,
path.clone(),
);
tracing::debug!(name = %name, vars = ?template.variables, "loaded template");
manager.templates.insert(name, template);
}
Ok(manager)
}
pub fn render(&self, name: &str, vars: HashMap<&str, &str>) -> Result<String> {
let template = self
.templates
.get(name)
.with_context(|| format!("Template '{}' not found", name))?;
template.render(&vars)
}
pub fn get(&self, name: &str) -> Option<&PromptTemplate> {
self.templates.get(name)
}
pub fn template_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.templates.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
pub fn len(&self) -> usize {
self.templates.len()
}
pub fn is_empty(&self) -> bool {
self.templates.is_empty()
}
pub fn insert(&mut self, template: PromptTemplate) {
self.templates.insert(template.name.clone(), template);
}
}
#[derive(Debug, Clone)]
pub struct LoadPromptTemplatesOptions {
pub cwd: PathBuf,
pub agent_dir: Option<PathBuf>,
pub prompt_paths: Vec<String>,
pub include_defaults: bool,
}
pub fn load_prompt_templates(options: &LoadPromptTemplatesOptions) -> Vec<PromptTemplate> {
let mut templates = Vec::new();
let global_prompts_dir = options
.agent_dir
.as_ref()
.map(|d| d.join("prompts"))
.unwrap_or_else(|| options.cwd.clone());
let project_prompts_dir = options.cwd.join(CONFIG_DIR_NAME).join("prompts");
if options.include_defaults {
load_templates_from_dir_into(&global_prompts_dir, &mut templates);
load_templates_from_dir_into(&project_prompts_dir, &mut templates);
}
for raw_path in &options.prompt_paths {
let resolved = resolve_prompt_path(raw_path, &options.cwd);
if !resolved.exists() {
continue;
}
if resolved.is_dir() {
load_templates_from_dir_into(&resolved, &mut templates);
} else if resolved.is_file() && resolved.extension().and_then(|e| e.to_str()) == Some("md")
{
if let Some(tmpl) = load_template_from_file(&resolved) {
templates.push(tmpl);
}
}
}
templates
}
fn load_templates_from_dir_into(dir: &Path, templates: &mut Vec<PromptTemplate>) {
if !dir.exists() {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let is_file = match std::fs::metadata(&path) {
Ok(m) => m.is_file(),
Err(_) => continue,
};
if is_file {
if let Some(tmpl) = load_template_from_file(&path) {
templates.push(tmpl);
}
}
}
}
fn load_template_from_file(file_path: &Path) -> Option<PromptTemplate> {
let raw_content = std::fs::read_to_string(file_path).ok()?;
let name = file_path.file_stem()?.to_str()?.to_string();
let (description, argument_hint, body) = parse_template_metadata(&raw_content);
Some(PromptTemplate::parse_with_meta(
name,
body,
description,
argument_hint,
file_path.to_path_buf(),
))
}
fn parse_template_metadata(raw_content: &str) -> (String, Option<String>, String) {
let mut description = String::new();
let mut argument_hint = None;
let body: String;
if let Some((fields, body_str)) = crate::frontmatter::parse_frontmatter(raw_content) {
if let Some(desc_val) = fields.get("description") {
if let Some(s) = desc_val.as_str() {
description = s.to_string();
}
}
if let Some(hint_val) = fields.get("argument-hint") {
if let Some(s) = hint_val.as_str() {
argument_hint = Some(s.to_string());
}
}
body = body_str.to_string();
} else {
body = raw_content.to_string();
}
if description.is_empty() {
for line in body.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
description = if trimmed.len() > 60 {
format!("{}...", &trimmed[..60])
} else {
trimmed.to_string()
};
break;
}
}
}
(description, argument_hint, body)
}
fn normalize_path_string(input: &str) -> PathBuf {
let trimmed = input.trim();
if trimmed == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
}
if let Some(rest) = trimmed.strip_prefix("~/") {
return dirs::home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(trimmed));
}
if let Some(rest) = trimmed.strip_prefix('~') {
return dirs::home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(trimmed));
}
PathBuf::from(trimmed)
}
fn resolve_prompt_path(p: &str, cwd: &Path) -> PathBuf {
let normalized = normalize_path_string(p);
if normalized.is_absolute() {
normalized
} else {
cwd.join(normalized)
}
}
pub fn expand_prompt_template(text: &str, templates: &[PromptTemplate]) -> String {
if !text.starts_with('/') {
return text.to_string();
}
let space_index = text.find(' ');
let template_name = match space_index {
Some(si) => &text[1..si],
None => &text[1..],
};
let args_string = match space_index {
Some(si) => &text[si + 1..],
None => "",
};
if let Some(template) = templates.iter().find(|t| t.name == template_name) {
let args = parse_command_args(args_string);
substitute_args(&template.content, &args)
} else {
text.to_string()
}
}
lazy_static::lazy_static! {
pub static ref BUILTIN_TEMPLATES: Vec<PromptTemplate> = {
let mut templates = Vec::new();
templates.push(PromptTemplate::parse_with_meta(
"system".to_string(),
include_str!("templates/system.md").to_string(),
"Default system prompt".to_string(),
None,
PathBuf::new(),
));
templates
};
}
pub fn register_builtin_templates(manager: &mut TemplateManager) {
for tmpl in BUILTIN_TEMPLATES.iter() {
manager.insert(tmpl.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_variables_simple() {
let vars = extract_variables("Hello {{name}}, welcome to {{place}}!");
assert_eq!(vars, vec!["name", "place"]);
}
#[test]
fn test_extract_variables_dedup() {
let vars = extract_variables("{{x}} + {{x}} = {{y}}");
assert_eq!(vars, vec!["x", "y"]);
}
#[test]
fn test_extract_variables_none() {
let vars = extract_variables("No variables here.");
assert!(vars.is_empty());
}
#[test]
fn test_extract_variables_whitespace() {
let vars = extract_variables("{{ name }} and {{ place }}");
assert_eq!(vars, vec!["name", "place"]);
}
#[test]
fn test_extract_variables_skip_helpers() {
let vars = extract_variables("{{date}} {{time}} {{cwd}} {{name}}");
assert_eq!(vars, vec!["name"]);
}
#[test]
fn test_extract_variables_skip_env() {
let vars = extract_variables("{{env HOME}} {{name}}");
assert_eq!(vars, vec!["name"]);
}
#[test]
fn test_render_template_basic() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
let result = context.render("Hello world!").unwrap();
assert_eq!(result, "Hello world!");
}
#[test]
fn test_render_template_with_vars() {
let mut vars = HashMap::new();
vars.insert("name", "world");
vars.insert("lang", "Rust");
let context = RenderContext::new(&vars);
let result = context
.render("Hello {{name}}, write {{lang}}!")
.unwrap();
assert_eq!(result, "Hello world, write Rust!");
}
#[test]
fn test_render_template_no_match() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
let result = context.render("No {{vars}} to replace").unwrap();
assert_eq!(result, "No {{vars}} to replace");
}
#[test]
fn test_render_helpers() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
let result = context.render("{{date}} {{time}}").unwrap();
assert!(!result.is_empty());
assert!(!result.contains("{{"));
}
#[test]
fn test_render_cwd() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
let result = context.render("{{cwd}}").unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_render_env() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
env::set_var("OXI_TEST_TEMPLATE_VAR", "hello123");
let result = context.render("{{env OXI_TEST_TEMPLATE_VAR}}").unwrap();
assert_eq!(result, "hello123");
env::remove_var("OXI_TEST_TEMPLATE_VAR");
}
#[test]
fn test_render_env_colon_syntax() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
env::set_var("OXI_TEST_COLON_VAR", "world456");
let result = context.render("{{env::OXI_TEST_COLON_VAR}}").unwrap();
assert_eq!(result, "world456");
env::remove_var("OXI_TEST_COLON_VAR");
}
#[test]
fn test_render_conditional_true() {
let mut vars = HashMap::new();
vars.insert("feature", "enabled");
let context = RenderContext::new(&vars);
let result = context
.render("{{#if feature}}Feature is on{{/if}}")
.unwrap();
assert_eq!(result, "Feature is on");
}
#[test]
fn test_render_conditional_false() {
let mut vars = HashMap::new();
vars.insert("feature", "false");
let context = RenderContext::new(&vars);
let result = context
.render("{{#if feature}}Feature is on{{/if}}")
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_render_conditional_else() {
let vars = HashMap::new();
let context = RenderContext::new(&vars);
let result = context
.render("{{#if feature}}yes{{else}}no{{/if}}")
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_render_conditional_else_true() {
let mut vars = HashMap::new();
vars.insert("feature", "yes");
let context = RenderContext::new(&vars);
let result = context
.render("{{#if feature}}yes{{else}}no{{/if}}")
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_prompt_template_parse_and_render() {
let tmpl = PromptTemplate::parse(
"greet".to_string(),
"Hello {{name}}, your role is {{role}}.".to_string(),
);
assert_eq!(tmpl.name, "greet");
assert_eq!(tmpl.variables, vec!["name", "role"]);
let mut vars = HashMap::new();
vars.insert("name", "Alice");
vars.insert("role", "admin");
let result = tmpl.render(&vars).unwrap();
assert_eq!(result, "Hello Alice, your role is admin.");
}
#[test]
fn test_prompt_template_render_with_args() {
let tmpl = PromptTemplate::parse(
"greet".to_string(),
"Hello $1, your role is $2.".to_string(),
);
let args = vec!["Alice".to_string(), "admin".to_string()];
let result = tmpl.render_with_args(&args).unwrap();
assert_eq!(result, "Hello Alice, your role is admin.");
}
#[test]
fn test_substitute_args_positional() {
let args = vec!["hello".to_string(), "world".to_string()];
let result = substitute_args("$1 $2", &args);
assert_eq!(result, "hello world");
}
#[test]
fn test_substitute_args_all() {
let args = vec!["hello".to_string(), "world".to_string()];
let result = substitute_args("$@", &args);
assert_eq!(result, "hello world");
}
#[test]
fn test_substitute_args_arguments_keyword() {
let args = vec!["hello".to_string(), "world".to_string()];
let result = substitute_args("$ARGUMENTS", &args);
assert_eq!(result, "hello world");
}
#[test]
fn test_substitute_args_slice() {
let args = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let result = substitute_args("${@:2}", &args);
assert_eq!(result, "b c d");
}
#[test]
fn test_substitute_args_slice_with_length() {
let args = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let result = substitute_args("${@:2:2}", &args);
assert_eq!(result, "b c");
}
#[test]
fn test_parse_command_args_basic() {
let args = parse_command_args("hello world");
assert_eq!(args, vec!["hello", "world"]);
}
#[test]
fn test_parse_command_args_quoted() {
let args = parse_command_args("hello \"world foo\" bar");
assert_eq!(args, vec!["hello", "world foo", "bar"]);
}
#[test]
fn test_parse_command_args_single_quoted() {
let args = parse_command_args("hello 'world bar' baz");
assert_eq!(args, vec!["hello", "world bar", "baz"]);
}
#[test]
fn test_parse_command_args_empty() {
let args = parse_command_args("");
assert!(args.is_empty());
}
#[test]
fn test_template_manager_new() {
let mgr = TemplateManager::new();
assert!(mgr.is_empty());
assert_eq!(mgr.len(), 0);
}
#[test]
fn test_template_manager_get_and_render() {
let mut mgr = TemplateManager::new();
let tmpl = PromptTemplate::parse(
"review".to_string(),
"Review this {{type}}: {{content}}".to_string(),
);
mgr.insert(tmpl);
assert_eq!(mgr.template_names(), vec!["review"]);
let mut vars = HashMap::new();
vars.insert("type", "PR");
vars.insert("content", "my changes");
let result = mgr.render("review", vars).unwrap();
assert_eq!(result, "Review this PR: my changes");
}
#[test]
fn test_template_manager_not_found() {
let mgr = TemplateManager::new();
let err = mgr.render("nonexistent", HashMap::new()).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn test_load_from_dir_missing() {
let mgr = TemplateManager::load_from_dir(Path::new("/nonexistent/templates")).unwrap();
assert!(mgr.is_empty());
}
#[test]
fn test_load_from_dir_with_files() {
let dir = tempfile::tempdir().unwrap();
let templates_dir = dir.path();
std::fs::write(templates_dir.join("greet.md"), "Hello {{name}}!").unwrap();
std::fs::write(templates_dir.join("review.md"), "Review {{lang}} code.").unwrap();
std::fs::write(templates_dir.join("notes.txt"), "Not a template").unwrap();
let mgr = TemplateManager::load_from_dir(templates_dir).unwrap();
assert_eq!(mgr.len(), 2);
let mut names = mgr.template_names();
names.sort();
assert_eq!(names, vec!["greet", "review"]);
let mut vars = HashMap::new();
vars.insert("name", "world");
assert_eq!(mgr.render("greet", vars).unwrap(), "Hello world!");
}
#[test]
fn test_load_from_file_with_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let content = "---\ndescription: A test template\nargument-hint: <name>\n---\nHello {{name}}!";
std::fs::write(dir.path().join("test.md"), content).unwrap();
let tmpl = load_template_from_file(&dir.path().join("test.md")).unwrap();
assert_eq!(tmpl.name, "test");
assert_eq!(tmpl.description, "A test template");
assert_eq!(tmpl.argument_hint, Some("<name>".to_string()));
assert_eq!(tmpl.variables, vec!["name"]);
}
#[test]
fn test_load_from_file_no_frontmatter() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.md"), "Hello world!").unwrap();
let tmpl = load_template_from_file(&dir.path().join("test.md")).unwrap();
assert_eq!(tmpl.name, "test");
assert_eq!(tmpl.description, "Hello world!");
}
#[test]
fn test_load_from_file_description_truncation() {
let dir = tempfile::tempdir().unwrap();
let long_line = "A".repeat(100);
std::fs::write(dir.path().join("test.md"), &long_line).unwrap();
let tmpl = load_template_from_file(&dir.path().join("test.md")).unwrap();
assert!(tmpl.description.len() <= 63); assert!(tmpl.description.ends_with("..."));
}
#[test]
fn test_expand_prompt_template_match() {
let templates = vec![PromptTemplate::parse(
"fix".to_string(),
"Fix the bug in $1".to_string(),
)];
let result = expand_prompt_template("/fix auth module", &templates);
assert_eq!(result, "Fix the bug in auth");
}
#[test]
fn test_expand_prompt_template_no_match() {
let templates: Vec<PromptTemplate> = vec![];
let result = expand_prompt_template("just plain text", &templates);
assert_eq!(result, "just plain text");
}
#[test]
fn test_expand_prompt_template_no_slash() {
let templates = vec![PromptTemplate::parse(
"fix".to_string(),
"Fix".to_string(),
)];
let result = expand_prompt_template("fix something", &templates);
assert_eq!(result, "fix something");
}
#[test]
fn test_expand_prompt_template_no_args() {
let templates = vec![PromptTemplate::parse(
"review".to_string(),
"Review the code".to_string(),
)];
let result = expand_prompt_template("/review", &templates);
assert_eq!(result, "Review the code");
}
#[test]
fn test_expand_prompt_template_quoted_args() {
let templates = vec![PromptTemplate::parse(
"fix".to_string(),
"Fix $1: $2".to_string(),
)];
let result = expand_prompt_template("/fix auth \"login page\"", &templates);
assert_eq!(result, "Fix auth: login page");
}
#[test]
fn test_normalize_path_string_home() {
let p = normalize_path_string("~");
assert!(p.to_string_lossy().len() > 1);
}
#[test]
fn test_normalize_path_string_home_subpath() {
let p = normalize_path_string("~/foo/bar");
assert!(p.to_string_lossy().contains("foo"));
}
#[test]
fn test_resolve_prompt_path_absolute() {
let p = resolve_prompt_path("/absolute/path", Path::new("/cwd"));
assert_eq!(p, PathBuf::from("/absolute/path"));
}
#[test]
fn test_resolve_prompt_path_relative() {
let p = resolve_prompt_path("relative/path", Path::new("/cwd"));
assert_eq!(p, PathBuf::from("/cwd/relative/path"));
}
#[test]
fn test_load_prompt_templates_from_explicit_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hello.md"), "Hello {{name}}!").unwrap();
let options = LoadPromptTemplatesOptions {
cwd: PathBuf::from("/tmp"),
agent_dir: None,
prompt_paths: vec![dir.path().to_string_lossy().to_string()],
include_defaults: false,
};
let templates = load_prompt_templates(&options);
assert_eq!(templates.len(), 1);
assert_eq!(templates[0].name, "hello");
}
}