use crate::config::Config;
use crate::templates;
use liquid::ParserBuilder;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use thiserror::Error;
use walkdir::WalkDir;
#[derive(Debug, Error)]
pub enum GeneratorError {
#[error("Template error: {0}")]
TemplateError(#[from] liquid::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("File conflicts detected (use overwrite=true to replace): {}", .0.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", "))]
ConflictError(Vec<PathBuf>),
#[error("Template directory not found or not a directory: {0}")]
TemplateDirectoryError(String),
#[error("Failed to determine relative path for template: {0}")]
PathError(String),
}
pub fn render(
template_dir: &Path,
output_dir: &Path,
config: &Config,
overwrite: bool,
) -> Result<Vec<PathBuf>, GeneratorError> {
if !template_dir.is_dir() {
return Err(GeneratorError::TemplateDirectoryError(
template_dir.display().to_string(),
));
}
let parser = ParserBuilder::with_stdlib().build()?;
let globals = liquid::object!({
"rust_bucket_version": config.rust_bucket_version,
"test_timeout": config.test_timeout,
"project_name": config.project_name,
});
let seed_template_paths: Vec<&str> = templates::seed_files()
.into_iter()
.map(|(template, _)| template)
.collect();
let mut target_files = Vec::new();
for entry in WalkDir::new(template_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let template_path = entry.path();
if template_path.extension().is_none_or(|ext| ext != "liquid") {
continue;
}
let relative_path = template_path
.strip_prefix(template_dir)
.map_err(|e| GeneratorError::PathError(e.to_string()))?;
if seed_template_paths
.iter()
.any(|seed| Path::new(seed) == relative_path)
{
continue;
}
let output_relative_path = relative_path.with_extension("");
let output_path = output_dir.join(&output_relative_path);
target_files.push(output_path);
}
if !overwrite {
let conflicts: Vec<PathBuf> = target_files
.iter()
.filter(|path| path.exists())
.cloned()
.collect();
if !conflicts.is_empty() {
return Err(GeneratorError::ConflictError(conflicts));
}
}
let mut generated_files = Vec::new();
for entry in WalkDir::new(template_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let template_path = entry.path();
if template_path.extension().is_none_or(|ext| ext != "liquid") {
continue;
}
let relative_path = template_path
.strip_prefix(template_dir)
.map_err(|e| GeneratorError::PathError(e.to_string()))?;
if seed_template_paths
.iter()
.any(|seed| Path::new(seed) == relative_path)
{
continue;
}
let output_relative_path = relative_path.with_extension("");
let output_path = output_dir.join(&output_relative_path);
let template_content = fs::read_to_string(template_path)?;
let template = parser.parse(&template_content)?;
let rendered = template.render(&globals)?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&output_path, rendered)?;
generated_files.push(output_path);
}
Ok(generated_files)
}
pub fn ensure_gitignore(target_dir: &Path) -> Result<Vec<String>, GeneratorError> {
let gitignore_path = target_dir.join(".gitignore");
let required = templates::required_gitignore_lines();
let existing = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
let existing_lines: Vec<&str> = existing.lines().collect();
let missing: Vec<&str> = required
.iter()
.filter(|line| !existing_lines.iter().any(|el| el.trim() == **line))
.copied()
.collect();
if missing.is_empty() {
return Ok(Vec::new());
}
let mut append = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
append.push('\n');
}
if !existing.is_empty() {
append.push_str("\n# beads_rust (managed by rust-bucket)\n");
}
for line in &missing {
append.push_str(line);
append.push('\n');
}
fs::write(&gitignore_path, format!("{existing}{append}"))?;
Ok(missing.iter().map(|s| s.to_string()).collect())
}
pub fn seed_files(
template_dir: &Path,
target_dir: &Path,
config: &Config,
) -> Result<Vec<PathBuf>, GeneratorError> {
let parser = ParserBuilder::with_stdlib().build()?;
let globals = liquid::object!({
"rust_bucket_version": config.rust_bucket_version,
"test_timeout": config.test_timeout,
"project_name": config.project_name,
});
let mut seeded = Vec::new();
for (template_rel, dest_rel) in templates::seed_files() {
let dest_path = target_dir.join(dest_rel);
if dest_path.exists() {
continue;
}
let template_path = template_dir.join(template_rel);
let template_content = fs::read_to_string(&template_path)?;
let template = parser.parse(&template_content)?;
let rendered = template.render(&globals)?;
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dest_path, rendered)?;
seeded.push(dest_path);
}
Ok(seeded)
}
pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
target_dir.join("rust-bucket.toml").exists()
}
pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
templates::managed_files()
.iter()
.map(|file| target_dir.join(file))
.filter(|path| path.exists())
.collect()
}
#[cfg(unix)]
pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
let claude_md = target_dir.join("CLAUDE.md");
if claude_md.exists() || claude_md.is_symlink() {
fs::remove_file(&claude_md)?;
}
symlink("AGENTS.md", &claude_md)?;
Ok(claude_md)
}
#[cfg(windows)]
pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
let claude_md = target_dir.join("CLAUDE.md");
let agents_md = target_dir.join("AGENTS.md");
fs::copy(&agents_md, &claude_md)?;
Ok(claude_md)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_config() -> Config {
Config {
rust_bucket_version: "0.1.0".to_string(),
test_timeout: 120,
project_name: "test-project".to_string(),
}
}
#[test]
fn test_render_simple_template() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(
&template_path,
"Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
)?;
let config = create_test_config();
let generated_files = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
)?;
assert_eq!(generated_files.len(), 1);
let output_path = temp_output_dir.path().join("test.txt");
assert!(output_path.exists());
let content = fs::read_to_string(&output_path)?;
assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
Ok(())
}
#[test]
fn test_render_nested_template() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let subdir = temp_template_dir.path().join("subdir");
fs::create_dir(&subdir)?;
let template_path = subdir.join("nested.txt.liquid");
fs::write(&template_path, "Nested: {{ rust_bucket_version }}")?;
let config = create_test_config();
render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
)?;
let output_path = temp_output_dir.path().join("subdir/nested.txt");
assert!(output_path.exists());
let content = fs::read_to_string(&output_path)?;
assert_eq!(content, "Nested: 0.1.0");
Ok(())
}
#[test]
fn test_conflict_detection() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(&template_path, "Content: {{ rust_bucket_version }}")?;
let output_path = temp_output_dir.path().join("test.txt");
fs::write(&output_path, "existing content")?;
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false, );
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(&err, GeneratorError::ConflictError(_)),
"Expected ConflictError"
);
if let GeneratorError::ConflictError(conflicts) = err {
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].ends_with("test.txt"));
}
Ok(())
}
#[test]
fn test_overwrite_existing_files() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(&template_path, "New: {{ rust_bucket_version }}")?;
let output_path = temp_output_dir.path().join("test.txt");
fs::write(&output_path, "old content")?;
let config = create_test_config();
render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
true, )?;
let content = fs::read_to_string(&output_path)?;
assert_eq!(content, "New: 0.1.0");
assert_ne!(content, "old content");
Ok(())
}
#[test]
fn test_nonexistent_template_directory() -> Result<(), Box<dyn std::error::Error>> {
let temp_output_dir = TempDir::new()?;
let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
let config = create_test_config();
let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
assert!(result.is_err());
assert!(
matches!(
result.unwrap_err(),
GeneratorError::TemplateDirectoryError(_)
),
"Expected TemplateDirectoryError"
);
Ok(())
}
#[test]
fn test_skip_non_liquid_files() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let liquid_path = temp_template_dir.path().join("template.txt.liquid");
fs::write(&liquid_path, "Version: {{ rust_bucket_version }}")?;
let non_liquid_path = temp_template_dir.path().join("regular.txt");
fs::write(&non_liquid_path, "This should be skipped")?;
let config = create_test_config();
let generated_files = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
)?;
assert_eq!(generated_files.len(), 1);
assert!(generated_files[0].ends_with("template.txt"));
let skipped_path = temp_output_dir.path().join("regular.txt");
assert!(!skipped_path.exists());
Ok(())
}
#[test]
fn test_template_syntax_error() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("bad.txt.liquid");
fs::write(&template_path, "Bad syntax: {{ unclosed_tag")?;
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
"Expected TemplateError"
);
Ok(())
}
#[test]
fn test_has_rust_bucket_toml_exists() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let toml_path = temp_dir.path().join("rust-bucket.toml");
assert!(!has_rust_bucket_toml(temp_dir.path()));
fs::write(&toml_path, "test_content")?;
assert!(has_rust_bucket_toml(temp_dir.path()));
Ok(())
}
#[test]
fn test_has_rust_bucket_toml_not_exists() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
assert!(!has_rust_bucket_toml(temp_dir.path()));
Ok(())
}
#[test]
fn test_check_conflicts_no_conflicts() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let conflicts = check_conflicts(temp_dir.path());
assert!(conflicts.is_empty());
Ok(())
}
#[test]
fn test_check_conflicts_with_conflicts() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
fs::write(temp_dir.path().join("AGENTS.md"), "existing content")?;
fs::write(
temp_dir.path().join("RUST_STYLE_GUIDE.md"),
"existing content",
)?;
let devcontainer_dir = temp_dir.path().join(".devcontainer");
fs::create_dir(&devcontainer_dir)?;
fs::write(devcontainer_dir.join("Dockerfile"), "existing content")?;
let conflicts = check_conflicts(temp_dir.path());
assert!(!conflicts.is_empty());
assert_eq!(conflicts.len(), 3);
let conflict_names: Vec<String> = conflicts
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.collect();
assert!(conflict_names.contains(&"AGENTS.md".to_string()));
assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
assert!(conflict_names.contains(&"Dockerfile".to_string()));
Ok(())
}
#[test]
fn test_check_conflicts_partial_conflicts() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
fs::create_dir_all(temp_dir.path().join(".claude/agents"))?;
fs::write(
temp_dir.path().join(".claude/agents/coordinator.md"),
"existing content",
)?;
let conflicts = check_conflicts(temp_dir.path());
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
Ok(())
}
#[test]
fn test_ensure_gitignore_creates_file_when_missing() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let added = ensure_gitignore(temp_dir.path())?;
assert_eq!(added.len(), 4);
let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
assert!(content.contains(".beads/.br_history/"));
assert!(content.contains(".beads/beads.db-wal"));
Ok(())
}
#[test]
fn test_ensure_gitignore_appends_missing_lines() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
let added = ensure_gitignore(temp_dir.path())?;
assert_eq!(added.len(), 4);
let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
assert!(content.starts_with("target/\n"));
assert!(content.contains("# beads_rust (managed by rust-bucket)"));
assert!(content.contains(".beads/beads.db"));
Ok(())
}
#[test]
fn test_ensure_gitignore_skips_existing_lines() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
fs::write(
temp_dir.path().join(".gitignore"),
"target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n.beads/last-touched\n",
)?;
let added = ensure_gitignore(temp_dir.path())?;
assert!(added.is_empty());
Ok(())
}
#[test]
fn test_seed_files_writes_when_absent() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_target_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
fs::write(&template_path, "enabled_ratchets = []\n")?;
let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
fs::write(&style_template, "# Style Guide\n")?;
let config = create_test_config();
let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
let dest = temp_target_dir.path().join("ratchets.toml");
assert!(dest.exists());
assert!(seeded.contains(&dest));
assert_eq!(fs::read_to_string(&dest)?, "enabled_ratchets = []\n");
Ok(())
}
#[test]
fn test_seed_files_leaves_existing_unchanged() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_target_dir = TempDir::new()?;
let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
fs::write(&template_path, "enabled_ratchets = []\n")?;
let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
fs::write(&style_template, "# Style Guide\n")?;
let dest = temp_target_dir.path().join("ratchets.toml");
let custom = "enabled_ratchets = [\"no-unwrap\"]\n# customized\n";
fs::write(&dest, custom)?;
let style_dest = temp_target_dir.path().join("STYLE_GUIDE.md");
fs::write(&style_dest, "# existing style\n")?;
let config = create_test_config();
let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
assert!(seeded.is_empty());
assert_eq!(fs::read_to_string(&dest)?, custom);
Ok(())
}
#[test]
fn test_seed_files_writes_style_guide_when_absent() -> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, temp_path) = templates::extract_to_temp()?;
let temp_target_dir = TempDir::new()?;
let config = create_test_config();
let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
assert!(dest.exists());
assert!(seeded.contains(&dest));
let content = fs::read_to_string(&dest)?;
assert!(content.starts_with("# Style Guide\n"));
assert!(content.contains("RUST_STYLE_GUIDE.md"));
assert!(!content.contains("Generated by rust-bucket"));
Ok(())
}
#[test]
fn test_seed_files_leaves_existing_style_guide_unchanged()
-> Result<(), Box<dyn std::error::Error>> {
let (_temp_dir, temp_path) = templates::extract_to_temp()?;
let temp_target_dir = TempDir::new()?;
let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
let custom = "<!-- Generated by rust-bucket v0.7.0. DO NOT EDIT BY HAND. -->\n# Custom\n";
fs::write(&dest, custom)?;
let config = create_test_config();
let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
assert!(!seeded.contains(&dest));
assert_eq!(fs::read_to_string(&dest)?, custom);
Ok(())
}
#[test]
fn test_render_skips_seed_templates() -> Result<(), Box<dyn std::error::Error>> {
let temp_template_dir = TempDir::new()?;
let temp_output_dir = TempDir::new()?;
let seed_template = temp_template_dir.path().join("ratchets.toml.liquid");
fs::write(&seed_template, "enabled_ratchets = []\n")?;
let managed_template = temp_template_dir.path().join("AGENTS.md.liquid");
fs::write(&managed_template, "Version: {{ rust_bucket_version }}")?;
let config = create_test_config();
let generated = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
)?;
assert!(
!temp_output_dir.path().join("ratchets.toml").exists(),
"render must not emit seed templates"
);
assert!(generated.iter().any(|p| p.ends_with("AGENTS.md")));
assert!(!generated.iter().any(|p| p.ends_with("ratchets.toml")));
Ok(())
}
#[test]
fn test_ensure_gitignore_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
ensure_gitignore(temp_dir.path())?;
let first = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
let added = ensure_gitignore(temp_dir.path())?;
assert!(added.is_empty());
let second = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
assert_eq!(first, second);
Ok(())
}
}