1use crate::config::Config;
4use crate::templates;
5use liquid::ParserBuilder;
6use std::fs;
7#[cfg(unix)]
8use std::os::unix::fs::symlink;
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11use walkdir::WalkDir;
12
13#[derive(Debug, Error)]
15pub enum GeneratorError {
16 #[error("Template error: {0}")]
18 TemplateError(#[from] liquid::Error),
19
20 #[error("IO error: {0}")]
22 IoError(#[from] std::io::Error),
23
24 #[error("File conflicts detected (use overwrite=true to replace): {}", .0.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", "))]
26 ConflictError(Vec<PathBuf>),
27
28 #[error("Template directory not found or not a directory: {0}")]
30 TemplateDirectoryError(String),
31
32 #[error("Failed to determine relative path for template: {0}")]
34 PathError(String),
35}
36
37pub fn render(
55 template_dir: &Path,
56 output_dir: &Path,
57 config: &Config,
58 overwrite: bool,
59) -> Result<Vec<PathBuf>, GeneratorError> {
60 if !template_dir.is_dir() {
62 return Err(GeneratorError::TemplateDirectoryError(
63 template_dir.display().to_string(),
64 ));
65 }
66
67 let parser = ParserBuilder::with_stdlib().build()?;
69
70 let globals = liquid::object!({
72 "rust_bucket_version": config.rust_bucket_version,
73 "test_timeout": config.test_timeout,
74 "project_name": config.project_name,
75 });
76
77 let mut target_files = Vec::new();
79
80 for entry in WalkDir::new(template_dir)
82 .into_iter()
83 .filter_map(|e| e.ok())
84 .filter(|e| e.file_type().is_file())
85 {
86 let template_path = entry.path();
87
88 if template_path.extension().is_none_or(|ext| ext != "liquid") {
90 continue;
91 }
92
93 let relative_path = template_path
95 .strip_prefix(template_dir)
96 .map_err(|e| GeneratorError::PathError(e.to_string()))?;
97
98 let output_relative_path = relative_path.with_extension("");
100 let output_path = output_dir.join(&output_relative_path);
101
102 target_files.push(output_path);
103 }
104
105 if !overwrite {
107 let conflicts: Vec<PathBuf> = target_files
108 .iter()
109 .filter(|path| path.exists())
110 .cloned()
111 .collect();
112
113 if !conflicts.is_empty() {
114 return Err(GeneratorError::ConflictError(conflicts));
115 }
116 }
117
118 let mut generated_files = Vec::new();
120
121 for entry in WalkDir::new(template_dir)
122 .into_iter()
123 .filter_map(|e| e.ok())
124 .filter(|e| e.file_type().is_file())
125 {
126 let template_path = entry.path();
127
128 if template_path.extension().is_none_or(|ext| ext != "liquid") {
130 continue;
131 }
132
133 let relative_path = template_path
135 .strip_prefix(template_dir)
136 .map_err(|e| GeneratorError::PathError(e.to_string()))?;
137
138 let output_relative_path = relative_path.with_extension("");
140 let output_path = output_dir.join(&output_relative_path);
141
142 let template_content = fs::read_to_string(template_path)?;
144
145 let template = parser.parse(&template_content)?;
147 let rendered = template.render(&globals)?;
148
149 if let Some(parent) = output_path.parent() {
151 fs::create_dir_all(parent)?;
152 }
153
154 fs::write(&output_path, rendered)?;
156
157 generated_files.push(output_path);
158 }
159
160 Ok(generated_files)
161}
162
163pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
171 target_dir.join("rust-bucket.toml").exists()
172}
173
174pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
183 templates::managed_files()
184 .iter()
185 .map(|file| target_dir.join(file))
186 .filter(|path| path.exists())
187 .collect()
188}
189
190#[cfg(unix)]
202pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
203 let claude_md = target_dir.join("CLAUDE.md");
204
205 if claude_md.exists() || claude_md.is_symlink() {
207 fs::remove_file(&claude_md)?;
208 }
209
210 symlink("AGENTS.md", &claude_md)?;
212
213 Ok(claude_md)
214}
215
216#[cfg(windows)]
221pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
222 let claude_md = target_dir.join("CLAUDE.md");
223 let agents_md = target_dir.join("AGENTS.md");
224
225 fs::copy(&agents_md, &claude_md)?;
227
228 Ok(claude_md)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use tempfile::TempDir;
235
236 fn create_test_config() -> Config {
237 Config {
238 rust_bucket_version: "0.1.0".to_string(),
239 test_timeout: 120,
240 project_name: "test-project".to_string(),
241 }
242 }
243
244 #[test]
245 fn test_render_simple_template() {
246 let temp_template_dir = TempDir::new().unwrap();
247 let temp_output_dir = TempDir::new().unwrap();
248
249 let template_path = temp_template_dir.path().join("test.txt.liquid");
251 fs::write(
252 &template_path,
253 "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
254 )
255 .unwrap();
256
257 let config = create_test_config();
258 let result = render(
259 temp_template_dir.path(),
260 temp_output_dir.path(),
261 &config,
262 false,
263 );
264
265 assert!(result.is_ok());
266 let generated_files = result.unwrap();
267 assert_eq!(generated_files.len(), 1);
268
269 let output_path = temp_output_dir.path().join("test.txt");
270 assert!(output_path.exists());
271
272 let content = fs::read_to_string(&output_path).unwrap();
273 assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
274 }
275
276 #[test]
277 fn test_render_nested_template() {
278 let temp_template_dir = TempDir::new().unwrap();
279 let temp_output_dir = TempDir::new().unwrap();
280
281 let subdir = temp_template_dir.path().join("subdir");
283 fs::create_dir(&subdir).unwrap();
284
285 let template_path = subdir.join("nested.txt.liquid");
286 fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
287
288 let config = create_test_config();
289 let result = render(
290 temp_template_dir.path(),
291 temp_output_dir.path(),
292 &config,
293 false,
294 );
295
296 assert!(result.is_ok());
297
298 let output_path = temp_output_dir.path().join("subdir/nested.txt");
299 assert!(output_path.exists());
300
301 let content = fs::read_to_string(&output_path).unwrap();
302 assert_eq!(content, "Nested: 0.1.0");
303 }
304
305 #[test]
306 fn test_conflict_detection() {
307 let temp_template_dir = TempDir::new().unwrap();
308 let temp_output_dir = TempDir::new().unwrap();
309
310 let template_path = temp_template_dir.path().join("test.txt.liquid");
312 fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
313
314 let output_path = temp_output_dir.path().join("test.txt");
316 fs::write(&output_path, "existing content").unwrap();
317
318 let config = create_test_config();
319 let result = render(
320 temp_template_dir.path(),
321 temp_output_dir.path(),
322 &config,
323 false, );
325
326 assert!(result.is_err());
327 let err = result.unwrap_err();
328 assert!(
329 matches!(&err, GeneratorError::ConflictError(_)),
330 "Expected ConflictError"
331 );
332 if let GeneratorError::ConflictError(conflicts) = err {
333 assert_eq!(conflicts.len(), 1);
334 assert!(conflicts[0].ends_with("test.txt"));
335 }
336 }
337
338 #[test]
339 fn test_overwrite_existing_files() {
340 let temp_template_dir = TempDir::new().unwrap();
341 let temp_output_dir = TempDir::new().unwrap();
342
343 let template_path = temp_template_dir.path().join("test.txt.liquid");
345 fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
346
347 let output_path = temp_output_dir.path().join("test.txt");
349 fs::write(&output_path, "old content").unwrap();
350
351 let config = create_test_config();
352 let result = render(
353 temp_template_dir.path(),
354 temp_output_dir.path(),
355 &config,
356 true, );
358
359 assert!(result.is_ok());
360
361 let content = fs::read_to_string(&output_path).unwrap();
363 assert_eq!(content, "New: 0.1.0");
364 assert_ne!(content, "old content");
365 }
366
367 #[test]
368 fn test_nonexistent_template_directory() {
369 let temp_output_dir = TempDir::new().unwrap();
370 let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
371
372 let config = create_test_config();
373 let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
374
375 assert!(result.is_err());
376 assert!(
377 matches!(
378 result.unwrap_err(),
379 GeneratorError::TemplateDirectoryError(_)
380 ),
381 "Expected TemplateDirectoryError"
382 );
383 }
384
385 #[test]
386 fn test_skip_non_liquid_files() {
387 let temp_template_dir = TempDir::new().unwrap();
388 let temp_output_dir = TempDir::new().unwrap();
389
390 let liquid_path = temp_template_dir.path().join("template.txt.liquid");
392 fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
393
394 let non_liquid_path = temp_template_dir.path().join("regular.txt");
396 fs::write(&non_liquid_path, "This should be skipped").unwrap();
397
398 let config = create_test_config();
399 let result = render(
400 temp_template_dir.path(),
401 temp_output_dir.path(),
402 &config,
403 false,
404 );
405
406 assert!(result.is_ok());
407 let generated_files = result.unwrap();
408
409 assert_eq!(generated_files.len(), 1);
411 assert!(generated_files[0].ends_with("template.txt"));
412
413 let skipped_path = temp_output_dir.path().join("regular.txt");
415 assert!(!skipped_path.exists());
416 }
417
418 #[test]
419 fn test_template_syntax_error() {
420 let temp_template_dir = TempDir::new().unwrap();
421 let temp_output_dir = TempDir::new().unwrap();
422
423 let template_path = temp_template_dir.path().join("bad.txt.liquid");
425 fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
426
427 let config = create_test_config();
428 let result = render(
429 temp_template_dir.path(),
430 temp_output_dir.path(),
431 &config,
432 false,
433 );
434
435 assert!(result.is_err());
436 assert!(
437 matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
438 "Expected TemplateError"
439 );
440 }
441
442 #[test]
443 fn test_has_rust_bucket_toml_exists() {
444 let temp_dir = TempDir::new().unwrap();
445 let toml_path = temp_dir.path().join("rust-bucket.toml");
446
447 assert!(!has_rust_bucket_toml(temp_dir.path()));
449
450 fs::write(&toml_path, "test_content").unwrap();
452
453 assert!(has_rust_bucket_toml(temp_dir.path()));
455 }
456
457 #[test]
458 fn test_has_rust_bucket_toml_not_exists() {
459 let temp_dir = TempDir::new().unwrap();
460 assert!(!has_rust_bucket_toml(temp_dir.path()));
461 }
462
463 #[test]
464 fn test_check_conflicts_no_conflicts() {
465 let temp_dir = TempDir::new().unwrap();
466 let conflicts = check_conflicts(temp_dir.path());
467 assert!(conflicts.is_empty());
468 }
469
470 #[test]
471 fn test_check_conflicts_with_conflicts() {
472 let temp_dir = TempDir::new().unwrap();
473
474 fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
476 fs::write(temp_dir.path().join("STYLE_GUIDE.md"), "existing content").unwrap();
477
478 let devcontainer_dir = temp_dir.path().join(".devcontainer");
480 fs::create_dir(&devcontainer_dir).unwrap();
481 fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
482
483 let conflicts = check_conflicts(temp_dir.path());
484
485 assert!(!conflicts.is_empty());
487 assert_eq!(conflicts.len(), 3);
488
489 let conflict_names: Vec<String> = conflicts
491 .iter()
492 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
493 .collect();
494
495 assert!(conflict_names.contains(&"AGENTS.md".to_string()));
496 assert!(conflict_names.contains(&"STYLE_GUIDE.md".to_string()));
497 assert!(conflict_names.contains(&"Dockerfile".to_string()));
498 }
499
500 #[test]
501 fn test_check_conflicts_partial_conflicts() {
502 let temp_dir = TempDir::new().unwrap();
503
504 fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
506 fs::write(
507 temp_dir.path().join(".claude/agents/coordinator.md"),
508 "existing content",
509 )
510 .unwrap();
511
512 let conflicts = check_conflicts(temp_dir.path());
513
514 assert_eq!(conflicts.len(), 1);
516 assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
517 }
518}