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#[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#[derive(RustEmbed)]
27#[folder = "prompts/"]
28struct Prompts;
29
30static TERA: LazyLock<Mutex<Tera>> = LazyLock::new(|| {
33 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 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 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 tera.autoescape_on(vec![]);
92
93 Mutex::new(tera)
94});
95
96fn 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
104pub fn ensure_prompts_dir() -> Result<()> {
106 let Some(user_prompts_dir) = get_user_prompts_dir() else {
107 return Ok(());
110 };
111
112 let user_llm_git_dir = user_prompts_dir
114 .parent()
115 .ok_or_else(|| CommitGenError::Other("Invalid prompts directory path".to_string()))?;
116
117 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 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 for file in Prompts::iter() {
141 let file_path = user_prompts_dir.join(file.as_ref());
142
143 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 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, }
159 } else {
160 true };
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 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
223fn load_template_file(category: &str, variant: &str) -> Result<String> {
225 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 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
257pub fn render_analysis_prompt(p: &AnalysisParams<'_>) -> Result<String> {
259 let template_content = load_template_file("analysis", p.variant)?;
261
262 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 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
291pub 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 let template_content = load_template_file("summary", variant)?;
303
304 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 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
322pub 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 let template_content = load_template_file("changelog", variant)?;
333
334 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 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
351pub 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
373pub 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}