llm_git/
templates.rs

1use std::{
2   path::{Path, PathBuf},
3   sync::LazyLock,
4};
5
6use parking_lot::Mutex;
7use rust_embed::RustEmbed;
8use tera::{Context, Tera};
9
10use crate::error::{CommitGenError, Result};
11
12/// Embedded prompts folder (compiled into binary)
13#[derive(RustEmbed)]
14#[folder = "prompts/"]
15struct Prompts;
16
17/// Global Tera instance for template rendering (wrapped in Mutex for mutable
18/// access)
19static TERA: LazyLock<Mutex<Tera>> = LazyLock::new(|| {
20   // Ensure prompts are initialized
21   if let Err(e) = ensure_prompts_dir() {
22      eprintln!("Warning: Failed to initialize prompts directory: {e}");
23   }
24
25   let mut tera = Tera::default();
26
27   // Load templates from user prompts directory first so they take precedence.
28   if let Some(prompts_dir) = get_user_prompts_dir() {
29      if let Err(e) =
30         register_directory_templates(&mut tera, &prompts_dir.join("analysis"), "analysis")
31      {
32         eprintln!("Warning: {e}");
33      }
34      if let Err(e) =
35         register_directory_templates(&mut tera, &prompts_dir.join("summary"), "summary")
36      {
37         eprintln!("Warning: {e}");
38      }
39   }
40
41   // Register embedded templates that aren't overridden by user-provided files.
42   for file in Prompts::iter() {
43      if tera.get_template_names().any(|name| name == file.as_ref()) {
44         continue;
45      }
46
47      if let Some(embedded_file) = Prompts::get(file.as_ref()) {
48         match std::str::from_utf8(embedded_file.data.as_ref()) {
49            Ok(content) => {
50               if let Err(e) = tera.add_raw_template(file.as_ref(), content) {
51                  eprintln!(
52                     "Warning: Failed to register embedded template {}: {}",
53                     file.as_ref(),
54                     e
55                  );
56               }
57            },
58            Err(e) => {
59               eprintln!("Warning: Embedded template {} is not valid UTF-8: {}", file.as_ref(), e);
60            },
61         }
62      }
63   }
64
65   // Disable auto-escaping for markdown files
66   tera.autoescape_on(vec![]);
67
68   Mutex::new(tera)
69});
70
71/// Determine user prompts directory (~/.llm-git/prompts/) if a home dir exists.
72fn get_user_prompts_dir() -> Option<PathBuf> {
73   std::env::var("HOME")
74      .or_else(|_| std::env::var("USERPROFILE"))
75      .ok()
76      .map(|home| PathBuf::from(home).join(".llm-git").join("prompts"))
77}
78
79/// Initialize prompts directory by unpacking embedded prompts if needed
80pub fn ensure_prompts_dir() -> Result<()> {
81   let Some(user_prompts_dir) = get_user_prompts_dir() else {
82      // No HOME/USERPROFILE, so we can't materialize templates on disk.
83      // We'll fall back to the embedded prompts in-memory.
84      return Ok(());
85   };
86
87   // Safety: prompts dir always has a parent (…/.llm-git/prompts)
88   let user_llm_git_dir = user_prompts_dir
89      .parent()
90      .ok_or_else(|| CommitGenError::Other("Invalid prompts directory path".to_string()))?;
91
92   // Create ~/.llm-git directory if it doesn't exist
93   if !user_llm_git_dir.exists() {
94      std::fs::create_dir_all(user_llm_git_dir).map_err(|e| {
95         CommitGenError::Other(format!(
96            "Failed to create directory {}: {}",
97            user_llm_git_dir.display(),
98            e
99         ))
100      })?;
101   }
102
103   // Create prompts subdirectory if it doesn't exist
104   if !user_prompts_dir.exists() {
105      std::fs::create_dir_all(&user_prompts_dir).map_err(|e| {
106         CommitGenError::Other(format!(
107            "Failed to create directory {}: {}",
108            user_prompts_dir.display(),
109            e
110         ))
111      })?;
112   }
113
114   // Unpack embedded prompts, updating if content differs
115   for file in Prompts::iter() {
116      let file_path = user_prompts_dir.join(file.as_ref());
117
118      // Create parent directories if needed
119      if let Some(parent) = file_path.parent() {
120         std::fs::create_dir_all(parent).map_err(|e| {
121            CommitGenError::Other(format!("Failed to create directory {}: {}", parent.display(), e))
122         })?;
123      }
124
125      if let Some(embedded_file) = Prompts::get(file.as_ref()) {
126         let embedded_content = embedded_file.data;
127
128         // Check if we need to write: file doesn't exist OR content differs
129         let should_write = if file_path.exists() {
130            match std::fs::read(&file_path) {
131               Ok(existing_content) => existing_content != embedded_content.as_ref(),
132               Err(_) => true, // Can't read, assume we should write
133            }
134         } else {
135            true // File doesn't exist
136         };
137
138         if should_write {
139            std::fs::write(&file_path, embedded_content.as_ref()).map_err(|e| {
140               CommitGenError::Other(format!("Failed to write file {}: {}", file_path.display(), e))
141            })?;
142         }
143      }
144   }
145
146   Ok(())
147}
148
149fn register_directory_templates(tera: &mut Tera, directory: &Path, category: &str) -> Result<()> {
150   if !directory.exists() {
151      return Ok(());
152   }
153
154   for entry in std::fs::read_dir(directory).map_err(|e| {
155      CommitGenError::Other(format!(
156         "Failed to read {} templates directory {}: {}",
157         category,
158         directory.display(),
159         e
160      ))
161   })? {
162      let entry = match entry {
163         Ok(entry) => entry,
164         Err(e) => {
165            eprintln!(
166               "Warning: Failed to iterate template entry in {}: {}",
167               directory.display(),
168               e
169            );
170            continue;
171         },
172      };
173
174      let path = entry.path();
175      if path.extension().and_then(|s| s.to_str()) != Some("md") {
176         continue;
177      }
178
179      let template_name = format!(
180         "{}/{}",
181         category,
182         path
183            .file_name()
184            .and_then(|s| s.to_str())
185            .unwrap_or_default()
186      );
187
188      // Add template (overwrites if exists, allowing user files to override embedded
189      // defaults)
190      if let Err(e) = tera.add_template_file(&path, Some(&template_name)) {
191         eprintln!("Warning: Failed to load template file {}: {}", path.display(), e);
192      }
193   }
194
195   Ok(())
196}
197
198/// Load template content from file (for dynamic user templates)
199fn load_template_file(category: &str, variant: &str) -> Result<String> {
200   // Prefer user-provided template if available.
201   if let Some(prompts_dir) = get_user_prompts_dir() {
202      let template_path = prompts_dir.join(category).join(format!("{variant}.md"));
203      if template_path.exists() {
204         return std::fs::read_to_string(&template_path).map_err(|e| {
205            CommitGenError::Other(format!(
206               "Failed to read template file {}: {}",
207               template_path.display(),
208               e
209            ))
210         });
211      }
212   }
213
214   // Fallback to embedded template bundled with the binary.
215   let embedded_key = format!("{category}/{variant}.md");
216   if let Some(bytes) = Prompts::get(&embedded_key) {
217      return std::str::from_utf8(bytes.data.as_ref())
218         .map(|s| s.to_string())
219         .map_err(|e| {
220            CommitGenError::Other(format!(
221               "Embedded template {embedded_key} is not valid UTF-8: {e}"
222            ))
223         });
224   }
225
226   Err(CommitGenError::Other(format!(
227      "Template variant '{variant}' in category '{category}' not found as user override or \
228       embedded default"
229   )))
230}
231
232/// Render analysis prompt template
233pub fn render_analysis_prompt(
234   variant: &str,
235   stat: &str,
236   diff: &str,
237   scope_candidates: &str,
238) -> Result<String> {
239   // Try to load template dynamically (supports user-added templates)
240   let template_content = load_template_file("analysis", variant)?;
241
242   // Create context with all the data
243   let mut context = Context::new();
244   context.insert("stat", stat);
245   context.insert("diff", diff);
246   context.insert("scope_candidates", scope_candidates);
247
248   // Render using render_str for dynamic templates
249   let mut tera = TERA.lock();
250
251   tera.render_str(&template_content, &context).map_err(|e| {
252      CommitGenError::Other(format!("Failed to render analysis prompt template '{variant}': {e}"))
253   })
254}
255
256/// Render summary prompt template
257pub fn render_summary_prompt(
258   variant: &str,
259   commit_type: &str,
260   scope: &str,
261   chars: &str,
262   details: &str,
263   stat: &str,
264   user_context: Option<&str>,
265) -> Result<String> {
266   // Try to load template dynamically (supports user-added templates)
267   let template_content = load_template_file("summary", variant)?;
268
269   // Create context with all the data
270   let mut context = Context::new();
271   context.insert("commit_type", commit_type);
272   context.insert("scope", scope);
273   context.insert("chars", chars);
274   context.insert("details", details);
275   context.insert("stat", stat);
276   if let Some(ctx) = user_context {
277      context.insert("user_context", ctx);
278   }
279
280   // Render using render_str for dynamic templates
281   let mut tera = TERA.lock();
282   tera.render_str(&template_content, &context).map_err(|e| {
283      CommitGenError::Other(format!("Failed to render summary prompt template '{variant}': {e}"))
284   })
285}