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 ensure_gitignore(target_dir: &Path) -> Result<Vec<String>, GeneratorError> {
168 let gitignore_path = target_dir.join(".gitignore");
169 let required = templates::required_gitignore_lines();
170
171 let existing = if gitignore_path.exists() {
172 fs::read_to_string(&gitignore_path)?
173 } else {
174 String::new()
175 };
176
177 let existing_lines: Vec<&str> = existing.lines().collect();
178 let missing: Vec<&str> = required
179 .iter()
180 .filter(|line| !existing_lines.iter().any(|el| el.trim() == **line))
181 .copied()
182 .collect();
183
184 if missing.is_empty() {
185 return Ok(Vec::new());
186 }
187
188 let mut append = String::new();
189 if !existing.is_empty() && !existing.ends_with('\n') {
190 append.push('\n');
191 }
192 if !existing.is_empty() {
193 append.push_str("\n# beads_rust (managed by rust-bucket)\n");
194 }
195 for line in &missing {
196 append.push_str(line);
197 append.push('\n');
198 }
199
200 fs::write(&gitignore_path, format!("{existing}{append}"))?;
201
202 Ok(missing.iter().map(|s| s.to_string()).collect())
203}
204
205const STYLE_GUIDE_SEED: &str = "\
206# Style Guide\n\
207\n\
208Project-specific coding standards go here.\n\
209See also `RUST_STYLE_GUIDE.md` for Rust-specific rules managed by rust-bucket.\n";
210
211pub fn seed_style_guide(target_dir: &Path) -> Result<bool, GeneratorError> {
218 let path = target_dir.join("STYLE_GUIDE.md");
219 if path.exists() {
220 let content = fs::read_to_string(&path)?;
221 if !content.contains("<!-- Generated by rust-bucket") {
222 return Ok(false);
223 }
224 }
225 fs::write(&path, STYLE_GUIDE_SEED)?;
226 Ok(true)
227}
228
229pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
237 target_dir.join("rust-bucket.toml").exists()
238}
239
240pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
249 templates::managed_files()
250 .iter()
251 .map(|file| target_dir.join(file))
252 .filter(|path| path.exists())
253 .collect()
254}
255
256#[cfg(unix)]
268pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
269 let claude_md = target_dir.join("CLAUDE.md");
270
271 if claude_md.exists() || claude_md.is_symlink() {
273 fs::remove_file(&claude_md)?;
274 }
275
276 symlink("AGENTS.md", &claude_md)?;
278
279 Ok(claude_md)
280}
281
282#[cfg(windows)]
287pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
288 let claude_md = target_dir.join("CLAUDE.md");
289 let agents_md = target_dir.join("AGENTS.md");
290
291 fs::copy(&agents_md, &claude_md)?;
293
294 Ok(claude_md)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use tempfile::TempDir;
301
302 fn create_test_config() -> Config {
303 Config {
304 rust_bucket_version: "0.1.0".to_string(),
305 test_timeout: 120,
306 project_name: "test-project".to_string(),
307 }
308 }
309
310 #[test]
311 fn test_render_simple_template() {
312 let temp_template_dir = TempDir::new().unwrap();
313 let temp_output_dir = TempDir::new().unwrap();
314
315 let template_path = temp_template_dir.path().join("test.txt.liquid");
317 fs::write(
318 &template_path,
319 "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
320 )
321 .unwrap();
322
323 let config = create_test_config();
324 let result = render(
325 temp_template_dir.path(),
326 temp_output_dir.path(),
327 &config,
328 false,
329 );
330
331 assert!(result.is_ok());
332 let generated_files = result.unwrap();
333 assert_eq!(generated_files.len(), 1);
334
335 let output_path = temp_output_dir.path().join("test.txt");
336 assert!(output_path.exists());
337
338 let content = fs::read_to_string(&output_path).unwrap();
339 assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
340 }
341
342 #[test]
343 fn test_render_nested_template() {
344 let temp_template_dir = TempDir::new().unwrap();
345 let temp_output_dir = TempDir::new().unwrap();
346
347 let subdir = temp_template_dir.path().join("subdir");
349 fs::create_dir(&subdir).unwrap();
350
351 let template_path = subdir.join("nested.txt.liquid");
352 fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
353
354 let config = create_test_config();
355 let result = render(
356 temp_template_dir.path(),
357 temp_output_dir.path(),
358 &config,
359 false,
360 );
361
362 assert!(result.is_ok());
363
364 let output_path = temp_output_dir.path().join("subdir/nested.txt");
365 assert!(output_path.exists());
366
367 let content = fs::read_to_string(&output_path).unwrap();
368 assert_eq!(content, "Nested: 0.1.0");
369 }
370
371 #[test]
372 fn test_conflict_detection() {
373 let temp_template_dir = TempDir::new().unwrap();
374 let temp_output_dir = TempDir::new().unwrap();
375
376 let template_path = temp_template_dir.path().join("test.txt.liquid");
378 fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
379
380 let output_path = temp_output_dir.path().join("test.txt");
382 fs::write(&output_path, "existing content").unwrap();
383
384 let config = create_test_config();
385 let result = render(
386 temp_template_dir.path(),
387 temp_output_dir.path(),
388 &config,
389 false, );
391
392 assert!(result.is_err());
393 let err = result.unwrap_err();
394 assert!(
395 matches!(&err, GeneratorError::ConflictError(_)),
396 "Expected ConflictError"
397 );
398 if let GeneratorError::ConflictError(conflicts) = err {
399 assert_eq!(conflicts.len(), 1);
400 assert!(conflicts[0].ends_with("test.txt"));
401 }
402 }
403
404 #[test]
405 fn test_overwrite_existing_files() {
406 let temp_template_dir = TempDir::new().unwrap();
407 let temp_output_dir = TempDir::new().unwrap();
408
409 let template_path = temp_template_dir.path().join("test.txt.liquid");
411 fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
412
413 let output_path = temp_output_dir.path().join("test.txt");
415 fs::write(&output_path, "old content").unwrap();
416
417 let config = create_test_config();
418 let result = render(
419 temp_template_dir.path(),
420 temp_output_dir.path(),
421 &config,
422 true, );
424
425 assert!(result.is_ok());
426
427 let content = fs::read_to_string(&output_path).unwrap();
429 assert_eq!(content, "New: 0.1.0");
430 assert_ne!(content, "old content");
431 }
432
433 #[test]
434 fn test_nonexistent_template_directory() {
435 let temp_output_dir = TempDir::new().unwrap();
436 let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
437
438 let config = create_test_config();
439 let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
440
441 assert!(result.is_err());
442 assert!(
443 matches!(
444 result.unwrap_err(),
445 GeneratorError::TemplateDirectoryError(_)
446 ),
447 "Expected TemplateDirectoryError"
448 );
449 }
450
451 #[test]
452 fn test_skip_non_liquid_files() {
453 let temp_template_dir = TempDir::new().unwrap();
454 let temp_output_dir = TempDir::new().unwrap();
455
456 let liquid_path = temp_template_dir.path().join("template.txt.liquid");
458 fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
459
460 let non_liquid_path = temp_template_dir.path().join("regular.txt");
462 fs::write(&non_liquid_path, "This should be skipped").unwrap();
463
464 let config = create_test_config();
465 let result = render(
466 temp_template_dir.path(),
467 temp_output_dir.path(),
468 &config,
469 false,
470 );
471
472 assert!(result.is_ok());
473 let generated_files = result.unwrap();
474
475 assert_eq!(generated_files.len(), 1);
477 assert!(generated_files[0].ends_with("template.txt"));
478
479 let skipped_path = temp_output_dir.path().join("regular.txt");
481 assert!(!skipped_path.exists());
482 }
483
484 #[test]
485 fn test_template_syntax_error() {
486 let temp_template_dir = TempDir::new().unwrap();
487 let temp_output_dir = TempDir::new().unwrap();
488
489 let template_path = temp_template_dir.path().join("bad.txt.liquid");
491 fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
492
493 let config = create_test_config();
494 let result = render(
495 temp_template_dir.path(),
496 temp_output_dir.path(),
497 &config,
498 false,
499 );
500
501 assert!(result.is_err());
502 assert!(
503 matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
504 "Expected TemplateError"
505 );
506 }
507
508 #[test]
509 fn test_has_rust_bucket_toml_exists() {
510 let temp_dir = TempDir::new().unwrap();
511 let toml_path = temp_dir.path().join("rust-bucket.toml");
512
513 assert!(!has_rust_bucket_toml(temp_dir.path()));
515
516 fs::write(&toml_path, "test_content").unwrap();
518
519 assert!(has_rust_bucket_toml(temp_dir.path()));
521 }
522
523 #[test]
524 fn test_has_rust_bucket_toml_not_exists() {
525 let temp_dir = TempDir::new().unwrap();
526 assert!(!has_rust_bucket_toml(temp_dir.path()));
527 }
528
529 #[test]
530 fn test_check_conflicts_no_conflicts() {
531 let temp_dir = TempDir::new().unwrap();
532 let conflicts = check_conflicts(temp_dir.path());
533 assert!(conflicts.is_empty());
534 }
535
536 #[test]
537 fn test_check_conflicts_with_conflicts() {
538 let temp_dir = TempDir::new().unwrap();
539
540 fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
542 fs::write(
543 temp_dir.path().join("RUST_STYLE_GUIDE.md"),
544 "existing content",
545 )
546 .unwrap();
547
548 let devcontainer_dir = temp_dir.path().join(".devcontainer");
550 fs::create_dir(&devcontainer_dir).unwrap();
551 fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
552
553 let conflicts = check_conflicts(temp_dir.path());
554
555 assert!(!conflicts.is_empty());
557 assert_eq!(conflicts.len(), 3);
558
559 let conflict_names: Vec<String> = conflicts
561 .iter()
562 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
563 .collect();
564
565 assert!(conflict_names.contains(&"AGENTS.md".to_string()));
566 assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
567 assert!(conflict_names.contains(&"Dockerfile".to_string()));
568 }
569
570 #[test]
571 fn test_check_conflicts_partial_conflicts() {
572 let temp_dir = TempDir::new().unwrap();
573
574 fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
576 fs::write(
577 temp_dir.path().join(".claude/agents/coordinator.md"),
578 "existing content",
579 )
580 .unwrap();
581
582 let conflicts = check_conflicts(temp_dir.path());
583
584 assert_eq!(conflicts.len(), 1);
586 assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
587 }
588
589 #[test]
590 fn test_ensure_gitignore_creates_file_when_missing() {
591 let temp_dir = TempDir::new().unwrap();
592 let added = ensure_gitignore(temp_dir.path()).unwrap();
593 assert_eq!(added.len(), 3);
594 let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
595 assert!(content.contains(".beads/.br_history/"));
596 assert!(content.contains(".beads/beads.db-wal"));
597 }
598
599 #[test]
600 fn test_ensure_gitignore_appends_missing_lines() {
601 let temp_dir = TempDir::new().unwrap();
602 fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
603 let added = ensure_gitignore(temp_dir.path()).unwrap();
604 assert_eq!(added.len(), 3);
605 let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
606 assert!(content.starts_with("target/\n"));
607 assert!(content.contains("# beads_rust (managed by rust-bucket)"));
608 assert!(content.contains(".beads/beads.db"));
609 }
610
611 #[test]
612 fn test_ensure_gitignore_skips_existing_lines() {
613 let temp_dir = TempDir::new().unwrap();
614 fs::write(
615 temp_dir.path().join(".gitignore"),
616 "target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n",
617 )
618 .unwrap();
619 let added = ensure_gitignore(temp_dir.path()).unwrap();
620 assert!(added.is_empty());
621 }
622
623 #[test]
624 fn test_ensure_gitignore_is_idempotent() {
625 let temp_dir = TempDir::new().unwrap();
626 fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
627 ensure_gitignore(temp_dir.path()).unwrap();
628 let first = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
629 let added = ensure_gitignore(temp_dir.path()).unwrap();
630 assert!(added.is_empty());
631 let second = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
632 assert_eq!(first, second);
633 }
634}