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 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()))?;
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()))?;
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 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() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(
&template_path,
"Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
)
.unwrap();
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
);
assert!(result.is_ok());
let generated_files = result.unwrap();
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).unwrap();
assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
}
#[test]
fn test_render_nested_template() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let subdir = temp_template_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let template_path = subdir.join("nested.txt.liquid");
fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
);
assert!(result.is_ok());
let output_path = temp_output_dir.path().join("subdir/nested.txt");
assert!(output_path.exists());
let content = fs::read_to_string(&output_path).unwrap();
assert_eq!(content, "Nested: 0.1.0");
}
#[test]
fn test_conflict_detection() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
let output_path = temp_output_dir.path().join("test.txt");
fs::write(&output_path, "existing content").unwrap();
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"));
}
}
#[test]
fn test_overwrite_existing_files() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let template_path = temp_template_dir.path().join("test.txt.liquid");
fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
let output_path = temp_output_dir.path().join("test.txt");
fs::write(&output_path, "old content").unwrap();
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
true, );
assert!(result.is_ok());
let content = fs::read_to_string(&output_path).unwrap();
assert_eq!(content, "New: 0.1.0");
assert_ne!(content, "old content");
}
#[test]
fn test_nonexistent_template_directory() {
let temp_output_dir = TempDir::new().unwrap();
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"
);
}
#[test]
fn test_skip_non_liquid_files() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let liquid_path = temp_template_dir.path().join("template.txt.liquid");
fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
let non_liquid_path = temp_template_dir.path().join("regular.txt");
fs::write(&non_liquid_path, "This should be skipped").unwrap();
let config = create_test_config();
let result = render(
temp_template_dir.path(),
temp_output_dir.path(),
&config,
false,
);
assert!(result.is_ok());
let generated_files = result.unwrap();
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());
}
#[test]
fn test_template_syntax_error() {
let temp_template_dir = TempDir::new().unwrap();
let temp_output_dir = TempDir::new().unwrap();
let template_path = temp_template_dir.path().join("bad.txt.liquid");
fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
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"
);
}
#[test]
fn test_has_rust_bucket_toml_exists() {
let temp_dir = TempDir::new().unwrap();
let toml_path = temp_dir.path().join("rust-bucket.toml");
assert!(!has_rust_bucket_toml(temp_dir.path()));
fs::write(&toml_path, "test_content").unwrap();
assert!(has_rust_bucket_toml(temp_dir.path()));
}
#[test]
fn test_has_rust_bucket_toml_not_exists() {
let temp_dir = TempDir::new().unwrap();
assert!(!has_rust_bucket_toml(temp_dir.path()));
}
#[test]
fn test_check_conflicts_no_conflicts() {
let temp_dir = TempDir::new().unwrap();
let conflicts = check_conflicts(temp_dir.path());
assert!(conflicts.is_empty());
}
#[test]
fn test_check_conflicts_with_conflicts() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
fs::write(temp_dir.path().join("STYLE_GUIDE.md"), "existing content").unwrap();
let devcontainer_dir = temp_dir.path().join(".devcontainer");
fs::create_dir(&devcontainer_dir).unwrap();
fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
let conflicts = check_conflicts(temp_dir.path());
assert!(!conflicts.is_empty());
assert_eq!(conflicts.len(), 3);
let conflict_names: Vec<String> = conflicts
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(conflict_names.contains(&"AGENTS.md".to_string()));
assert!(conflict_names.contains(&"STYLE_GUIDE.md".to_string()));
assert!(conflict_names.contains(&"Dockerfile".to_string()));
}
#[test]
fn test_check_conflicts_partial_conflicts() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
fs::write(
temp_dir.path().join(".claude/agents/coordinator.md"),
"existing content",
)
.unwrap();
let conflicts = check_conflicts(temp_dir.path());
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
}
}