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 seed_template_paths: Vec<&str> = templates::seed_files()
80 .into_iter()
81 .map(|(template, _)| template)
82 .collect();
83
84 let mut target_files = Vec::new();
86
87 for entry in WalkDir::new(template_dir)
89 .into_iter()
90 .filter_map(|e| e.ok())
91 .filter(|e| e.file_type().is_file())
92 {
93 let template_path = entry.path();
94
95 if template_path.extension().is_none_or(|ext| ext != "liquid") {
97 continue;
98 }
99
100 let relative_path = template_path
102 .strip_prefix(template_dir)
103 .map_err(|e| GeneratorError::PathError(e.to_string()))?;
104
105 if seed_template_paths
107 .iter()
108 .any(|seed| Path::new(seed) == relative_path)
109 {
110 continue;
111 }
112
113 let output_relative_path = relative_path.with_extension("");
115 let output_path = output_dir.join(&output_relative_path);
116
117 target_files.push(output_path);
118 }
119
120 if !overwrite {
122 let conflicts: Vec<PathBuf> = target_files
123 .iter()
124 .filter(|path| path.exists())
125 .cloned()
126 .collect();
127
128 if !conflicts.is_empty() {
129 return Err(GeneratorError::ConflictError(conflicts));
130 }
131 }
132
133 let mut generated_files = Vec::new();
135
136 for entry in WalkDir::new(template_dir)
137 .into_iter()
138 .filter_map(|e| e.ok())
139 .filter(|e| e.file_type().is_file())
140 {
141 let template_path = entry.path();
142
143 if template_path.extension().is_none_or(|ext| ext != "liquid") {
145 continue;
146 }
147
148 let relative_path = template_path
150 .strip_prefix(template_dir)
151 .map_err(|e| GeneratorError::PathError(e.to_string()))?;
152
153 if seed_template_paths
155 .iter()
156 .any(|seed| Path::new(seed) == relative_path)
157 {
158 continue;
159 }
160
161 let output_relative_path = relative_path.with_extension("");
163 let output_path = output_dir.join(&output_relative_path);
164
165 let template_content = fs::read_to_string(template_path)?;
167
168 let template = parser.parse(&template_content)?;
170 let rendered = template.render(&globals)?;
171
172 if let Some(parent) = output_path.parent() {
174 fs::create_dir_all(parent)?;
175 }
176
177 fs::write(&output_path, rendered)?;
179
180 generated_files.push(output_path);
181 }
182
183 Ok(generated_files)
184}
185
186pub fn ensure_gitignore(target_dir: &Path) -> Result<Vec<String>, GeneratorError> {
191 let gitignore_path = target_dir.join(".gitignore");
192 let required = templates::required_gitignore_lines();
193
194 let existing = if gitignore_path.exists() {
195 fs::read_to_string(&gitignore_path)?
196 } else {
197 String::new()
198 };
199
200 let existing_lines: Vec<&str> = existing.lines().collect();
201 let missing: Vec<&str> = required
202 .iter()
203 .filter(|line| !existing_lines.iter().any(|el| el.trim() == **line))
204 .copied()
205 .collect();
206
207 if missing.is_empty() {
208 return Ok(Vec::new());
209 }
210
211 let mut append = String::new();
212 if !existing.is_empty() && !existing.ends_with('\n') {
213 append.push('\n');
214 }
215 if !existing.is_empty() {
216 append.push_str("\n# beads_rust (managed by rust-bucket)\n");
217 }
218 for line in &missing {
219 append.push_str(line);
220 append.push('\n');
221 }
222
223 fs::write(&gitignore_path, format!("{existing}{append}"))?;
224
225 Ok(missing.iter().map(|s| s.to_string()).collect())
226}
227
228pub fn seed_files(
239 template_dir: &Path,
240 target_dir: &Path,
241 config: &Config,
242) -> Result<Vec<PathBuf>, GeneratorError> {
243 let parser = ParserBuilder::with_stdlib().build()?;
244 let globals = liquid::object!({
245 "rust_bucket_version": config.rust_bucket_version,
246 "test_timeout": config.test_timeout,
247 "project_name": config.project_name,
248 });
249
250 let mut seeded = Vec::new();
251
252 for (template_rel, dest_rel) in templates::seed_files() {
253 let dest_path = target_dir.join(dest_rel);
254 if dest_path.exists() {
255 continue;
256 }
257
258 let template_path = template_dir.join(template_rel);
259 let template_content = fs::read_to_string(&template_path)?;
260 let template = parser.parse(&template_content)?;
261 let rendered = template.render(&globals)?;
262
263 if let Some(parent) = dest_path.parent() {
264 fs::create_dir_all(parent)?;
265 }
266 fs::write(&dest_path, rendered)?;
267 seeded.push(dest_path);
268 }
269
270 Ok(seeded)
271}
272
273pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
281 target_dir.join("rust-bucket.toml").exists()
282}
283
284pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
293 templates::managed_files()
294 .iter()
295 .map(|file| target_dir.join(file))
296 .filter(|path| path.exists())
297 .collect()
298}
299
300#[cfg(unix)]
312pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
313 let claude_md = target_dir.join("CLAUDE.md");
314
315 if claude_md.exists() || claude_md.is_symlink() {
317 fs::remove_file(&claude_md)?;
318 }
319
320 symlink("AGENTS.md", &claude_md)?;
322
323 Ok(claude_md)
324}
325
326#[cfg(windows)]
331pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
332 let claude_md = target_dir.join("CLAUDE.md");
333 let agents_md = target_dir.join("AGENTS.md");
334
335 fs::copy(&agents_md, &claude_md)?;
337
338 Ok(claude_md)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use tempfile::TempDir;
345
346 fn create_test_config() -> Config {
347 Config {
348 rust_bucket_version: "0.1.0".to_string(),
349 test_timeout: 120,
350 project_name: "test-project".to_string(),
351 }
352 }
353
354 #[test]
355 fn test_render_simple_template() -> Result<(), Box<dyn std::error::Error>> {
356 let temp_template_dir = TempDir::new()?;
357 let temp_output_dir = TempDir::new()?;
358
359 let template_path = temp_template_dir.path().join("test.txt.liquid");
361 fs::write(
362 &template_path,
363 "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
364 )?;
365
366 let config = create_test_config();
367 let generated_files = render(
368 temp_template_dir.path(),
369 temp_output_dir.path(),
370 &config,
371 false,
372 )?;
373
374 assert_eq!(generated_files.len(), 1);
375
376 let output_path = temp_output_dir.path().join("test.txt");
377 assert!(output_path.exists());
378
379 let content = fs::read_to_string(&output_path)?;
380 assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
381 Ok(())
382 }
383
384 #[test]
385 fn test_render_nested_template() -> Result<(), Box<dyn std::error::Error>> {
386 let temp_template_dir = TempDir::new()?;
387 let temp_output_dir = TempDir::new()?;
388
389 let subdir = temp_template_dir.path().join("subdir");
391 fs::create_dir(&subdir)?;
392
393 let template_path = subdir.join("nested.txt.liquid");
394 fs::write(&template_path, "Nested: {{ rust_bucket_version }}")?;
395
396 let config = create_test_config();
397 render(
398 temp_template_dir.path(),
399 temp_output_dir.path(),
400 &config,
401 false,
402 )?;
403
404 let output_path = temp_output_dir.path().join("subdir/nested.txt");
405 assert!(output_path.exists());
406
407 let content = fs::read_to_string(&output_path)?;
408 assert_eq!(content, "Nested: 0.1.0");
409 Ok(())
410 }
411
412 #[test]
413 fn test_conflict_detection() -> Result<(), Box<dyn std::error::Error>> {
414 let temp_template_dir = TempDir::new()?;
415 let temp_output_dir = TempDir::new()?;
416
417 let template_path = temp_template_dir.path().join("test.txt.liquid");
419 fs::write(&template_path, "Content: {{ rust_bucket_version }}")?;
420
421 let output_path = temp_output_dir.path().join("test.txt");
423 fs::write(&output_path, "existing content")?;
424
425 let config = create_test_config();
426 let result = render(
427 temp_template_dir.path(),
428 temp_output_dir.path(),
429 &config,
430 false, );
432
433 assert!(result.is_err());
434 let err = result.unwrap_err();
435 assert!(
436 matches!(&err, GeneratorError::ConflictError(_)),
437 "Expected ConflictError"
438 );
439 if let GeneratorError::ConflictError(conflicts) = err {
440 assert_eq!(conflicts.len(), 1);
441 assert!(conflicts[0].ends_with("test.txt"));
442 }
443 Ok(())
444 }
445
446 #[test]
447 fn test_overwrite_existing_files() -> Result<(), Box<dyn std::error::Error>> {
448 let temp_template_dir = TempDir::new()?;
449 let temp_output_dir = TempDir::new()?;
450
451 let template_path = temp_template_dir.path().join("test.txt.liquid");
453 fs::write(&template_path, "New: {{ rust_bucket_version }}")?;
454
455 let output_path = temp_output_dir.path().join("test.txt");
457 fs::write(&output_path, "old content")?;
458
459 let config = create_test_config();
460 render(
461 temp_template_dir.path(),
462 temp_output_dir.path(),
463 &config,
464 true, )?;
466
467 let content = fs::read_to_string(&output_path)?;
469 assert_eq!(content, "New: 0.1.0");
470 assert_ne!(content, "old content");
471 Ok(())
472 }
473
474 #[test]
475 fn test_nonexistent_template_directory() -> Result<(), Box<dyn std::error::Error>> {
476 let temp_output_dir = TempDir::new()?;
477 let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
478
479 let config = create_test_config();
480 let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
481
482 assert!(result.is_err());
483 assert!(
484 matches!(
485 result.unwrap_err(),
486 GeneratorError::TemplateDirectoryError(_)
487 ),
488 "Expected TemplateDirectoryError"
489 );
490 Ok(())
491 }
492
493 #[test]
494 fn test_skip_non_liquid_files() -> Result<(), Box<dyn std::error::Error>> {
495 let temp_template_dir = TempDir::new()?;
496 let temp_output_dir = TempDir::new()?;
497
498 let liquid_path = temp_template_dir.path().join("template.txt.liquid");
500 fs::write(&liquid_path, "Version: {{ rust_bucket_version }}")?;
501
502 let non_liquid_path = temp_template_dir.path().join("regular.txt");
504 fs::write(&non_liquid_path, "This should be skipped")?;
505
506 let config = create_test_config();
507 let generated_files = render(
508 temp_template_dir.path(),
509 temp_output_dir.path(),
510 &config,
511 false,
512 )?;
513
514 assert_eq!(generated_files.len(), 1);
516 assert!(generated_files[0].ends_with("template.txt"));
517
518 let skipped_path = temp_output_dir.path().join("regular.txt");
520 assert!(!skipped_path.exists());
521 Ok(())
522 }
523
524 #[test]
525 fn test_template_syntax_error() -> Result<(), Box<dyn std::error::Error>> {
526 let temp_template_dir = TempDir::new()?;
527 let temp_output_dir = TempDir::new()?;
528
529 let template_path = temp_template_dir.path().join("bad.txt.liquid");
531 fs::write(&template_path, "Bad syntax: {{ unclosed_tag")?;
532
533 let config = create_test_config();
534 let result = render(
535 temp_template_dir.path(),
536 temp_output_dir.path(),
537 &config,
538 false,
539 );
540
541 assert!(result.is_err());
542 assert!(
543 matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
544 "Expected TemplateError"
545 );
546 Ok(())
547 }
548
549 #[test]
550 fn test_has_rust_bucket_toml_exists() -> Result<(), Box<dyn std::error::Error>> {
551 let temp_dir = TempDir::new()?;
552 let toml_path = temp_dir.path().join("rust-bucket.toml");
553
554 assert!(!has_rust_bucket_toml(temp_dir.path()));
556
557 fs::write(&toml_path, "test_content")?;
559
560 assert!(has_rust_bucket_toml(temp_dir.path()));
562 Ok(())
563 }
564
565 #[test]
566 fn test_has_rust_bucket_toml_not_exists() -> Result<(), Box<dyn std::error::Error>> {
567 let temp_dir = TempDir::new()?;
568 assert!(!has_rust_bucket_toml(temp_dir.path()));
569 Ok(())
570 }
571
572 #[test]
573 fn test_check_conflicts_no_conflicts() -> Result<(), Box<dyn std::error::Error>> {
574 let temp_dir = TempDir::new()?;
575 let conflicts = check_conflicts(temp_dir.path());
576 assert!(conflicts.is_empty());
577 Ok(())
578 }
579
580 #[test]
581 fn test_check_conflicts_with_conflicts() -> Result<(), Box<dyn std::error::Error>> {
582 let temp_dir = TempDir::new()?;
583
584 fs::write(temp_dir.path().join("AGENTS.md"), "existing content")?;
586 fs::write(
587 temp_dir.path().join("RUST_STYLE_GUIDE.md"),
588 "existing content",
589 )?;
590
591 let devcontainer_dir = temp_dir.path().join(".devcontainer");
593 fs::create_dir(&devcontainer_dir)?;
594 fs::write(devcontainer_dir.join("Dockerfile"), "existing content")?;
595
596 let conflicts = check_conflicts(temp_dir.path());
597
598 assert!(!conflicts.is_empty());
600 assert_eq!(conflicts.len(), 3);
601
602 let conflict_names: Vec<String> = conflicts
604 .iter()
605 .filter_map(|p| p.file_name())
606 .map(|n| n.to_string_lossy().to_string())
607 .collect();
608
609 assert!(conflict_names.contains(&"AGENTS.md".to_string()));
610 assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
611 assert!(conflict_names.contains(&"Dockerfile".to_string()));
612 Ok(())
613 }
614
615 #[test]
616 fn test_check_conflicts_partial_conflicts() -> Result<(), Box<dyn std::error::Error>> {
617 let temp_dir = TempDir::new()?;
618
619 fs::create_dir_all(temp_dir.path().join(".claude/agents"))?;
621 fs::write(
622 temp_dir.path().join(".claude/agents/coordinator.md"),
623 "existing content",
624 )?;
625
626 let conflicts = check_conflicts(temp_dir.path());
627
628 assert_eq!(conflicts.len(), 1);
630 assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
631 Ok(())
632 }
633
634 #[test]
635 fn test_ensure_gitignore_creates_file_when_missing() -> Result<(), Box<dyn std::error::Error>> {
636 let temp_dir = TempDir::new()?;
637 let added = ensure_gitignore(temp_dir.path())?;
638 assert_eq!(added.len(), 4);
639 let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
640 assert!(content.contains(".beads/.br_history/"));
641 assert!(content.contains(".beads/beads.db-wal"));
642 Ok(())
643 }
644
645 #[test]
646 fn test_ensure_gitignore_appends_missing_lines() -> Result<(), Box<dyn std::error::Error>> {
647 let temp_dir = TempDir::new()?;
648 fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
649 let added = ensure_gitignore(temp_dir.path())?;
650 assert_eq!(added.len(), 4);
651 let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
652 assert!(content.starts_with("target/\n"));
653 assert!(content.contains("# beads_rust (managed by rust-bucket)"));
654 assert!(content.contains(".beads/beads.db"));
655 Ok(())
656 }
657
658 #[test]
659 fn test_ensure_gitignore_skips_existing_lines() -> Result<(), Box<dyn std::error::Error>> {
660 let temp_dir = TempDir::new()?;
661 fs::write(
662 temp_dir.path().join(".gitignore"),
663 "target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n.beads/last-touched\n",
664 )?;
665 let added = ensure_gitignore(temp_dir.path())?;
666 assert!(added.is_empty());
667 Ok(())
668 }
669
670 #[test]
671 fn test_seed_files_writes_when_absent() -> Result<(), Box<dyn std::error::Error>> {
672 let temp_template_dir = TempDir::new()?;
673 let temp_target_dir = TempDir::new()?;
674
675 let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
676 fs::write(&template_path, "enabled_ratchets = []\n")?;
677 let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
678 fs::write(&style_template, "# Style Guide\n")?;
679
680 let config = create_test_config();
681 let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
682
683 let dest = temp_target_dir.path().join("ratchets.toml");
684 assert!(dest.exists());
685 assert!(seeded.contains(&dest));
686 assert_eq!(fs::read_to_string(&dest)?, "enabled_ratchets = []\n");
687 Ok(())
688 }
689
690 #[test]
691 fn test_seed_files_leaves_existing_unchanged() -> Result<(), Box<dyn std::error::Error>> {
692 let temp_template_dir = TempDir::new()?;
693 let temp_target_dir = TempDir::new()?;
694
695 let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
696 fs::write(&template_path, "enabled_ratchets = []\n")?;
697 let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
698 fs::write(&style_template, "# Style Guide\n")?;
699
700 let dest = temp_target_dir.path().join("ratchets.toml");
701 let custom = "enabled_ratchets = [\"no-unwrap\"]\n# customized\n";
702 fs::write(&dest, custom)?;
703 let style_dest = temp_target_dir.path().join("STYLE_GUIDE.md");
704 fs::write(&style_dest, "# existing style\n")?;
705
706 let config = create_test_config();
707 let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
708
709 assert!(seeded.is_empty());
710 assert_eq!(fs::read_to_string(&dest)?, custom);
711 Ok(())
712 }
713
714 #[test]
715 fn test_seed_files_writes_style_guide_when_absent() -> Result<(), Box<dyn std::error::Error>> {
716 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
717 let temp_target_dir = TempDir::new()?;
718
719 let config = create_test_config();
720 let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
721
722 let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
723 assert!(dest.exists());
724 assert!(seeded.contains(&dest));
725
726 let content = fs::read_to_string(&dest)?;
727 assert!(content.starts_with("# Style Guide\n"));
728 assert!(content.contains("RUST_STYLE_GUIDE.md"));
729 assert!(!content.contains("Generated by rust-bucket"));
730 Ok(())
731 }
732
733 #[test]
734 fn test_seed_files_leaves_existing_style_guide_unchanged()
735 -> Result<(), Box<dyn std::error::Error>> {
736 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
737 let temp_target_dir = TempDir::new()?;
738
739 let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
740 let custom = "<!-- Generated by rust-bucket v0.7.0. DO NOT EDIT BY HAND. -->\n# Custom\n";
741 fs::write(&dest, custom)?;
742
743 let config = create_test_config();
744 let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
745
746 assert!(!seeded.contains(&dest));
747 assert_eq!(fs::read_to_string(&dest)?, custom);
748 Ok(())
749 }
750
751 #[test]
752 fn test_render_skips_seed_templates() -> Result<(), Box<dyn std::error::Error>> {
753 let temp_template_dir = TempDir::new()?;
754 let temp_output_dir = TempDir::new()?;
755
756 let seed_template = temp_template_dir.path().join("ratchets.toml.liquid");
757 fs::write(&seed_template, "enabled_ratchets = []\n")?;
758
759 let managed_template = temp_template_dir.path().join("AGENTS.md.liquid");
760 fs::write(&managed_template, "Version: {{ rust_bucket_version }}")?;
761
762 let config = create_test_config();
763 let generated = render(
764 temp_template_dir.path(),
765 temp_output_dir.path(),
766 &config,
767 false,
768 )?;
769
770 assert!(
771 !temp_output_dir.path().join("ratchets.toml").exists(),
772 "render must not emit seed templates"
773 );
774 assert!(generated.iter().any(|p| p.ends_with("AGENTS.md")));
775 assert!(!generated.iter().any(|p| p.ends_with("ratchets.toml")));
776 Ok(())
777 }
778
779 #[test]
780 fn test_ensure_gitignore_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
781 let temp_dir = TempDir::new()?;
782 fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
783 ensure_gitignore(temp_dir.path())?;
784 let first = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
785 let added = ensure_gitignore(temp_dir.path())?;
786 assert!(added.is_empty());
787 let second = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
788 assert_eq!(first, second);
789 Ok(())
790 }
791}