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/// Parameters for rendering the analysis prompt template.
13#[derive(Default)]
14pub struct AnalysisParams<'a> {
15   pub variant:           &'a str,
16   pub stat:              &'a str,
17   pub diff:              &'a str,
18   pub scope_candidates:  &'a str,
19   pub recent_commits:    Option<&'a str>,
20   pub common_scopes:     Option<&'a str>,
21   pub types_description: Option<&'a str>,
22   pub project_context:   Option<&'a str>,
23}
24
25/// Embedded prompts folder (compiled into binary)
26#[derive(RustEmbed)]
27#[folder = "prompts/"]
28struct Prompts;
29
30/// Global Tera instance for template rendering (wrapped in Mutex for mutable
31/// access)
32static TERA: LazyLock<Mutex<Tera>> = LazyLock::new(|| {
33   // Ensure prompts are initialized
34   if let Err(e) = ensure_prompts_dir() {
35      eprintln!("Warning: Failed to initialize prompts directory: {e}");
36   }
37
38   let mut tera = Tera::default();
39
40   // Load templates from user prompts directory first so they take precedence.
41   if let Some(prompts_dir) = get_user_prompts_dir() {
42      if let Err(e) =
43         register_directory_templates(&mut tera, &prompts_dir.join("analysis"), "analysis")
44      {
45         eprintln!("Warning: {e}");
46      }
47      if let Err(e) =
48         register_directory_templates(&mut tera, &prompts_dir.join("summary"), "summary")
49      {
50         eprintln!("Warning: {e}");
51      }
52      if let Err(e) =
53         register_directory_templates(&mut tera, &prompts_dir.join("changelog"), "changelog")
54      {
55         eprintln!("Warning: {e}");
56      }
57      if let Err(e) = register_directory_templates(&mut tera, &prompts_dir.join("map"), "map") {
58         eprintln!("Warning: {e}");
59      }
60      if let Err(e) = register_directory_templates(&mut tera, &prompts_dir.join("reduce"), "reduce")
61      {
62         eprintln!("Warning: {e}");
63      }
64   }
65
66   // Register embedded templates that aren't overridden by user-provided files.
67   for file in Prompts::iter() {
68      if tera.get_template_names().any(|name| name == file.as_ref()) {
69         continue;
70      }
71
72      if let Some(embedded_file) = Prompts::get(file.as_ref()) {
73         match std::str::from_utf8(embedded_file.data.as_ref()) {
74            Ok(content) => {
75               if let Err(e) = tera.add_raw_template(file.as_ref(), content) {
76                  eprintln!(
77                     "Warning: Failed to register embedded template {}: {}",
78                     file.as_ref(),
79                     e
80                  );
81               }
82            },
83            Err(e) => {
84               eprintln!("Warning: Embedded template {} is not valid UTF-8: {}", file.as_ref(), e);
85            },
86         }
87      }
88   }
89
90   // Disable auto-escaping for markdown files
91   tera.autoescape_on(vec![]);
92
93   Mutex::new(tera)
94});
95
96/// Determine user prompts directory (~/.llm-git/prompts/) if a home dir exists.
97fn get_user_prompts_dir() -> Option<PathBuf> {
98   std::env::var("HOME")
99      .or_else(|_| std::env::var("USERPROFILE"))
100      .ok()
101      .map(|home| PathBuf::from(home).join(".llm-git").join("prompts"))
102}
103
104/// Initialize prompts directory by unpacking embedded prompts if needed
105pub fn ensure_prompts_dir() -> Result<()> {
106   let Some(user_prompts_dir) = get_user_prompts_dir() else {
107      // No HOME/USERPROFILE, so we can't materialize templates on disk.
108      // We'll fall back to the embedded prompts in-memory.
109      return Ok(());
110   };
111
112   // Safety: prompts dir always has a parent (…/.llm-git/prompts)
113   let user_llm_git_dir = user_prompts_dir
114      .parent()
115      .ok_or_else(|| CommitGenError::Other("Invalid prompts directory path".to_string()))?;
116
117   // Create ~/.llm-git directory if it doesn't exist
118   if !user_llm_git_dir.exists() {
119      std::fs::create_dir_all(user_llm_git_dir).map_err(|e| {
120         CommitGenError::Other(format!(
121            "Failed to create directory {}: {}",
122            user_llm_git_dir.display(),
123            e
124         ))
125      })?;
126   }
127
128   // Create prompts subdirectory if it doesn't exist
129   if !user_prompts_dir.exists() {
130      std::fs::create_dir_all(&user_prompts_dir).map_err(|e| {
131         CommitGenError::Other(format!(
132            "Failed to create directory {}: {}",
133            user_prompts_dir.display(),
134            e
135         ))
136      })?;
137   }
138
139   // Unpack embedded prompts, updating if content differs
140   for file in Prompts::iter() {
141      let file_path = user_prompts_dir.join(file.as_ref());
142
143      // Create parent directories if needed
144      if let Some(parent) = file_path.parent() {
145         std::fs::create_dir_all(parent).map_err(|e| {
146            CommitGenError::Other(format!("Failed to create directory {}: {}", parent.display(), e))
147         })?;
148      }
149
150      if let Some(embedded_file) = Prompts::get(file.as_ref()) {
151         let embedded_content = embedded_file.data;
152
153         // Check if we need to write: file doesn't exist OR content differs
154         let should_write = if file_path.exists() {
155            match std::fs::read(&file_path) {
156               Ok(existing_content) => existing_content != embedded_content.as_ref(),
157               Err(_) => true, // Can't read, assume we should write
158            }
159         } else {
160            true // File doesn't exist
161         };
162
163         if should_write {
164            std::fs::write(&file_path, embedded_content.as_ref()).map_err(|e| {
165               CommitGenError::Other(format!("Failed to write file {}: {}", file_path.display(), e))
166            })?;
167         }
168      }
169   }
170
171   Ok(())
172}
173
174fn register_directory_templates(tera: &mut Tera, directory: &Path, category: &str) -> Result<()> {
175   if !directory.exists() {
176      return Ok(());
177   }
178
179   for entry in std::fs::read_dir(directory).map_err(|e| {
180      CommitGenError::Other(format!(
181         "Failed to read {} templates directory {}: {}",
182         category,
183         directory.display(),
184         e
185      ))
186   })? {
187      let entry = match entry {
188         Ok(entry) => entry,
189         Err(e) => {
190            eprintln!(
191               "Warning: Failed to iterate template entry in {}: {}",
192               directory.display(),
193               e
194            );
195            continue;
196         },
197      };
198
199      let path = entry.path();
200      if path.extension().and_then(|s| s.to_str()) != Some("md") {
201         continue;
202      }
203
204      let template_name = format!(
205         "{}/{}",
206         category,
207         path
208            .file_name()
209            .and_then(|s| s.to_str())
210            .unwrap_or_default()
211      );
212
213      // Add template (overwrites if exists, allowing user files to override embedded
214      // defaults)
215      if let Err(e) = tera.add_template_file(&path, Some(&template_name)) {
216         eprintln!("Warning: Failed to load template file {}: {}", path.display(), e);
217      }
218   }
219
220   Ok(())
221}
222
223/// Load template content from file (for dynamic user templates)
224fn load_template_file(category: &str, variant: &str) -> Result<String> {
225   // Prefer user-provided template if available.
226   if let Some(prompts_dir) = get_user_prompts_dir() {
227      let template_path = prompts_dir.join(category).join(format!("{variant}.md"));
228      if template_path.exists() {
229         return std::fs::read_to_string(&template_path).map_err(|e| {
230            CommitGenError::Other(format!(
231               "Failed to read template file {}: {}",
232               template_path.display(),
233               e
234            ))
235         });
236      }
237   }
238
239   // Fallback to embedded template bundled with the binary.
240   let embedded_key = format!("{category}/{variant}.md");
241   if let Some(bytes) = Prompts::get(&embedded_key) {
242      return std::str::from_utf8(bytes.data.as_ref())
243         .map(|s| s.to_string())
244         .map_err(|e| {
245            CommitGenError::Other(format!(
246               "Embedded template {embedded_key} is not valid UTF-8: {e}"
247            ))
248         });
249   }
250
251   Err(CommitGenError::Other(format!(
252      "Template variant '{variant}' in category '{category}' not found as user override or \
253       embedded default"
254   )))
255}
256
257/// Render analysis prompt template
258pub fn render_analysis_prompt(p: &AnalysisParams<'_>) -> Result<String> {
259   // Try to load template dynamically (supports user-added templates)
260   let template_content = load_template_file("analysis", p.variant)?;
261
262   // Create context with all the data
263   let mut context = Context::new();
264   context.insert("stat", p.stat);
265   context.insert("diff", p.diff);
266   context.insert("scope_candidates", p.scope_candidates);
267   if let Some(commits) = p.recent_commits {
268      context.insert("recent_commits", commits);
269   }
270   if let Some(scopes) = p.common_scopes {
271      context.insert("common_scopes", scopes);
272   }
273   if let Some(types) = p.types_description {
274      context.insert("types_description", types);
275   }
276   if let Some(ctx) = p.project_context {
277      context.insert("project_context", ctx);
278   }
279
280   // Render using render_str for dynamic templates
281   let mut tera = TERA.lock();
282
283   tera.render_str(&template_content, &context).map_err(|e| {
284      CommitGenError::Other(format!(
285         "Failed to render analysis prompt template '{}': {e}",
286         p.variant
287      ))
288   })
289}
290
291/// Render summary prompt template
292pub fn render_summary_prompt(
293   variant: &str,
294   commit_type: &str,
295   scope: &str,
296   chars: &str,
297   details: &str,
298   stat: &str,
299   user_context: Option<&str>,
300) -> Result<String> {
301   // Try to load template dynamically (supports user-added templates)
302   let template_content = load_template_file("summary", variant)?;
303
304   // Create context with all the data
305   let mut context = Context::new();
306   context.insert("commit_type", commit_type);
307   context.insert("scope", scope);
308   context.insert("chars", chars);
309   context.insert("details", details);
310   context.insert("stat", stat);
311   if let Some(ctx) = user_context {
312      context.insert("user_context", ctx);
313   }
314
315   // Render using render_str for dynamic templates
316   let mut tera = TERA.lock();
317   tera.render_str(&template_content, &context).map_err(|e| {
318      CommitGenError::Other(format!("Failed to render summary prompt template '{variant}': {e}"))
319   })
320}
321
322/// Render changelog prompt template
323pub fn render_changelog_prompt(
324   variant: &str,
325   changelog_path: &str,
326   is_package_changelog: bool,
327   stat: &str,
328   diff: &str,
329   existing_entries: Option<&str>,
330) -> Result<String> {
331   // Try to load template dynamically (supports user-added templates)
332   let template_content = load_template_file("changelog", variant)?;
333
334   // Create context with all the data
335   let mut context = Context::new();
336   context.insert("changelog_path", changelog_path);
337   context.insert("is_package_changelog", &is_package_changelog);
338   context.insert("stat", stat);
339   context.insert("diff", diff);
340   if let Some(entries) = existing_entries {
341      context.insert("existing_entries", entries);
342   }
343
344   // Render using render_str for dynamic templates
345   let mut tera = TERA.lock();
346   tera.render_str(&template_content, &context).map_err(|e| {
347      CommitGenError::Other(format!("Failed to render changelog prompt template '{variant}': {e}"))
348   })
349}
350
351/// Render map prompt template (per-file observation extraction)
352pub fn render_map_prompt(
353   variant: &str,
354   filename: &str,
355   diff: &str,
356   context_header: &str,
357) -> Result<String> {
358   let template_content = load_template_file("map", variant)?;
359
360   let mut context = Context::new();
361   context.insert("filename", filename);
362   context.insert("diff", diff);
363   if !context_header.is_empty() {
364      context.insert("context_header", context_header);
365   }
366
367   let mut tera = TERA.lock();
368   tera.render_str(&template_content, &context).map_err(|e| {
369      CommitGenError::Other(format!("Failed to render map prompt template '{variant}': {e}"))
370   })
371}
372
373/// Render reduce prompt template (synthesis from observations)
374pub fn render_reduce_prompt(
375   variant: &str,
376   observations: &str,
377   stat: &str,
378   scope_candidates: &str,
379   types_description: Option<&str>,
380) -> Result<String> {
381   let template_content = load_template_file("reduce", variant)?;
382
383   let mut context = Context::new();
384   context.insert("observations", observations);
385   context.insert("stat", stat);
386   context.insert("scope_candidates", scope_candidates);
387   if let Some(types_desc) = types_description {
388      context.insert("types_description", types_desc);
389   }
390
391   let mut tera = TERA.lock();
392   tera.render_str(&template_content, &context).map_err(|e| {
393      CommitGenError::Other(format!("Failed to render reduce prompt template '{variant}': {e}"))
394   })
395}