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> {
213 let path = target_dir.join("STYLE_GUIDE.md");
214 if path.exists() {
215 let content = fs::read_to_string(&path)?;
216 if !content.contains("<!-- Generated by rust-bucket") {
217 return Ok(false);
218 }
219 }
220 fs::write(&path, STYLE_GUIDE_SEED)?;
221 Ok(true)
222}
223
224pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
232 target_dir.join("rust-bucket.toml").exists()
233}
234
235pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
244 templates::managed_files()
245 .iter()
246 .map(|file| target_dir.join(file))
247 .filter(|path| path.exists())
248 .collect()
249}
250
251#[cfg(unix)]
263pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
264 let claude_md = target_dir.join("CLAUDE.md");
265
266 if claude_md.exists() || claude_md.is_symlink() {
268 fs::remove_file(&claude_md)?;
269 }
270
271 symlink("AGENTS.md", &claude_md)?;
273
274 Ok(claude_md)
275}
276
277#[cfg(windows)]
282pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
283 let claude_md = target_dir.join("CLAUDE.md");
284 let agents_md = target_dir.join("AGENTS.md");
285
286 fs::copy(&agents_md, &claude_md)?;
288
289 Ok(claude_md)
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use tempfile::TempDir;
296
297 fn create_test_config() -> Config {
298 Config {
299 rust_bucket_version: "0.1.0".to_string(),
300 test_timeout: 120,
301 project_name: "test-project".to_string(),
302 }
303 }
304
305 #[test]
306 fn test_render_simple_template() {
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(
313 &template_path,
314 "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
315 )
316 .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,
324 );
325
326 assert!(result.is_ok());
327 let generated_files = result.unwrap();
328 assert_eq!(generated_files.len(), 1);
329
330 let output_path = temp_output_dir.path().join("test.txt");
331 assert!(output_path.exists());
332
333 let content = fs::read_to_string(&output_path).unwrap();
334 assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
335 }
336
337 #[test]
338 fn test_render_nested_template() {
339 let temp_template_dir = TempDir::new().unwrap();
340 let temp_output_dir = TempDir::new().unwrap();
341
342 let subdir = temp_template_dir.path().join("subdir");
344 fs::create_dir(&subdir).unwrap();
345
346 let template_path = subdir.join("nested.txt.liquid");
347 fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
348
349 let config = create_test_config();
350 let result = render(
351 temp_template_dir.path(),
352 temp_output_dir.path(),
353 &config,
354 false,
355 );
356
357 assert!(result.is_ok());
358
359 let output_path = temp_output_dir.path().join("subdir/nested.txt");
360 assert!(output_path.exists());
361
362 let content = fs::read_to_string(&output_path).unwrap();
363 assert_eq!(content, "Nested: 0.1.0");
364 }
365
366 #[test]
367 fn test_conflict_detection() {
368 let temp_template_dir = TempDir::new().unwrap();
369 let temp_output_dir = TempDir::new().unwrap();
370
371 let template_path = temp_template_dir.path().join("test.txt.liquid");
373 fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
374
375 let output_path = temp_output_dir.path().join("test.txt");
377 fs::write(&output_path, "existing content").unwrap();
378
379 let config = create_test_config();
380 let result = render(
381 temp_template_dir.path(),
382 temp_output_dir.path(),
383 &config,
384 false, );
386
387 assert!(result.is_err());
388 let err = result.unwrap_err();
389 assert!(
390 matches!(&err, GeneratorError::ConflictError(_)),
391 "Expected ConflictError"
392 );
393 if let GeneratorError::ConflictError(conflicts) = err {
394 assert_eq!(conflicts.len(), 1);
395 assert!(conflicts[0].ends_with("test.txt"));
396 }
397 }
398
399 #[test]
400 fn test_overwrite_existing_files() {
401 let temp_template_dir = TempDir::new().unwrap();
402 let temp_output_dir = TempDir::new().unwrap();
403
404 let template_path = temp_template_dir.path().join("test.txt.liquid");
406 fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
407
408 let output_path = temp_output_dir.path().join("test.txt");
410 fs::write(&output_path, "old content").unwrap();
411
412 let config = create_test_config();
413 let result = render(
414 temp_template_dir.path(),
415 temp_output_dir.path(),
416 &config,
417 true, );
419
420 assert!(result.is_ok());
421
422 let content = fs::read_to_string(&output_path).unwrap();
424 assert_eq!(content, "New: 0.1.0");
425 assert_ne!(content, "old content");
426 }
427
428 #[test]
429 fn test_nonexistent_template_directory() {
430 let temp_output_dir = TempDir::new().unwrap();
431 let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
432
433 let config = create_test_config();
434 let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
435
436 assert!(result.is_err());
437 assert!(
438 matches!(
439 result.unwrap_err(),
440 GeneratorError::TemplateDirectoryError(_)
441 ),
442 "Expected TemplateDirectoryError"
443 );
444 }
445
446 #[test]
447 fn test_skip_non_liquid_files() {
448 let temp_template_dir = TempDir::new().unwrap();
449 let temp_output_dir = TempDir::new().unwrap();
450
451 let liquid_path = temp_template_dir.path().join("template.txt.liquid");
453 fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
454
455 let non_liquid_path = temp_template_dir.path().join("regular.txt");
457 fs::write(&non_liquid_path, "This should be skipped").unwrap();
458
459 let config = create_test_config();
460 let result = render(
461 temp_template_dir.path(),
462 temp_output_dir.path(),
463 &config,
464 false,
465 );
466
467 assert!(result.is_ok());
468 let generated_files = result.unwrap();
469
470 assert_eq!(generated_files.len(), 1);
472 assert!(generated_files[0].ends_with("template.txt"));
473
474 let skipped_path = temp_output_dir.path().join("regular.txt");
476 assert!(!skipped_path.exists());
477 }
478
479 #[test]
480 fn test_template_syntax_error() {
481 let temp_template_dir = TempDir::new().unwrap();
482 let temp_output_dir = TempDir::new().unwrap();
483
484 let template_path = temp_template_dir.path().join("bad.txt.liquid");
486 fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
487
488 let config = create_test_config();
489 let result = render(
490 temp_template_dir.path(),
491 temp_output_dir.path(),
492 &config,
493 false,
494 );
495
496 assert!(result.is_err());
497 assert!(
498 matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
499 "Expected TemplateError"
500 );
501 }
502
503 #[test]
504 fn test_has_rust_bucket_toml_exists() {
505 let temp_dir = TempDir::new().unwrap();
506 let toml_path = temp_dir.path().join("rust-bucket.toml");
507
508 assert!(!has_rust_bucket_toml(temp_dir.path()));
510
511 fs::write(&toml_path, "test_content").unwrap();
513
514 assert!(has_rust_bucket_toml(temp_dir.path()));
516 }
517
518 #[test]
519 fn test_has_rust_bucket_toml_not_exists() {
520 let temp_dir = TempDir::new().unwrap();
521 assert!(!has_rust_bucket_toml(temp_dir.path()));
522 }
523
524 #[test]
525 fn test_check_conflicts_no_conflicts() {
526 let temp_dir = TempDir::new().unwrap();
527 let conflicts = check_conflicts(temp_dir.path());
528 assert!(conflicts.is_empty());
529 }
530
531 #[test]
532 fn test_check_conflicts_with_conflicts() {
533 let temp_dir = TempDir::new().unwrap();
534
535 fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
537 fs::write(
538 temp_dir.path().join("RUST_STYLE_GUIDE.md"),
539 "existing content",
540 )
541 .unwrap();
542
543 let devcontainer_dir = temp_dir.path().join(".devcontainer");
545 fs::create_dir(&devcontainer_dir).unwrap();
546 fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
547
548 let conflicts = check_conflicts(temp_dir.path());
549
550 assert!(!conflicts.is_empty());
552 assert_eq!(conflicts.len(), 3);
553
554 let conflict_names: Vec<String> = conflicts
556 .iter()
557 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
558 .collect();
559
560 assert!(conflict_names.contains(&"AGENTS.md".to_string()));
561 assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
562 assert!(conflict_names.contains(&"Dockerfile".to_string()));
563 }
564
565 #[test]
566 fn test_check_conflicts_partial_conflicts() {
567 let temp_dir = TempDir::new().unwrap();
568
569 fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
571 fs::write(
572 temp_dir.path().join(".claude/agents/coordinator.md"),
573 "existing content",
574 )
575 .unwrap();
576
577 let conflicts = check_conflicts(temp_dir.path());
578
579 assert_eq!(conflicts.len(), 1);
581 assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
582 }
583
584 #[test]
585 fn test_ensure_gitignore_creates_file_when_missing() {
586 let temp_dir = TempDir::new().unwrap();
587 let added = ensure_gitignore(temp_dir.path()).unwrap();
588 assert_eq!(added.len(), 3);
589 let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
590 assert!(content.contains(".beads/.br_history/"));
591 assert!(content.contains(".beads/beads.db-wal"));
592 }
593
594 #[test]
595 fn test_ensure_gitignore_appends_missing_lines() {
596 let temp_dir = TempDir::new().unwrap();
597 fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
598 let added = ensure_gitignore(temp_dir.path()).unwrap();
599 assert_eq!(added.len(), 3);
600 let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
601 assert!(content.starts_with("target/\n"));
602 assert!(content.contains("# beads_rust (managed by rust-bucket)"));
603 assert!(content.contains(".beads/beads.db"));
604 }
605
606 #[test]
607 fn test_ensure_gitignore_skips_existing_lines() {
608 let temp_dir = TempDir::new().unwrap();
609 fs::write(
610 temp_dir.path().join(".gitignore"),
611 "target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n",
612 )
613 .unwrap();
614 let added = ensure_gitignore(temp_dir.path()).unwrap();
615 assert!(added.is_empty());
616 }
617
618 #[test]
619 fn test_ensure_gitignore_is_idempotent() {
620 let temp_dir = TempDir::new().unwrap();
621 fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
622 ensure_gitignore(temp_dir.path()).unwrap();
623 let first = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
624 let added = ensure_gitignore(temp_dir.path()).unwrap();
625 assert!(added.is_empty());
626 let second = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
627 assert_eq!(first, second);
628 }
629}