Skip to main content

rust_bucket/
generator.rs

1// Template generation and file creation
2
3use 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/// Errors that can occur during template generation
14#[derive(Debug, Error)]
15pub enum GeneratorError {
16    /// Error parsing or rendering a Liquid template
17    #[error("Template error: {0}")]
18    TemplateError(#[from] liquid::Error),
19
20    /// IO error when reading or writing files
21    #[error("IO error: {0}")]
22    IoError(#[from] std::io::Error),
23
24    /// File conflicts detected when overwrite is disabled
25    #[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    /// Template directory does not exist or is not a directory
29    #[error("Template directory not found or not a directory: {0}")]
30    TemplateDirectoryError(String),
31
32    /// Failed to determine relative path for template
33    #[error("Failed to determine relative path for template: {0}")]
34    PathError(String),
35}
36
37/// Render templates from a template directory to an output directory
38///
39/// # Arguments
40/// * `template_dir` - Directory containing .liquid template files
41/// * `output_dir` - Directory where rendered files will be written
42/// * `config` - Configuration containing template variables (rust_bucket_version, test_timeout)
43/// * `overwrite` - If false, fail if any target file exists. If true, replace existing files.
44///
45/// # Returns
46/// A list of all generated file paths on success
47///
48/// # Errors
49/// Returns `GeneratorError` if:
50/// - Template directory doesn't exist or isn't readable
51/// - Template parsing or rendering fails
52/// - IO errors occur during file operations
53/// - File conflicts are detected when overwrite=false
54pub fn render(
55    template_dir: &Path,
56    output_dir: &Path,
57    config: &Config,
58    overwrite: bool,
59) -> Result<Vec<PathBuf>, GeneratorError> {
60    // Validate template directory exists
61    if !template_dir.is_dir() {
62        return Err(GeneratorError::TemplateDirectoryError(
63            template_dir.display().to_string(),
64        ));
65    }
66
67    // Create Liquid parser
68    let parser = ParserBuilder::with_stdlib().build()?;
69
70    // Prepare template variables from config
71    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    // Track all files that will be generated
78    let mut target_files = Vec::new();
79
80    // First pass: collect all target files and check for conflicts
81    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        // Skip files that aren't .liquid templates
89        if template_path.extension().is_none_or(|ext| ext != "liquid") {
90            continue;
91        }
92
93        // Calculate relative path from template_dir
94        let relative_path = template_path
95            .strip_prefix(template_dir)
96            .map_err(|e| GeneratorError::PathError(e.to_string()))?;
97
98        // Remove .liquid extension for output file
99        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    // Check for conflicts if overwrite is disabled
106    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    // Second pass: render and write all templates
119    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        // Skip files that aren't .liquid templates
129        if template_path.extension().is_none_or(|ext| ext != "liquid") {
130            continue;
131        }
132
133        // Calculate relative path from template_dir
134        let relative_path = template_path
135            .strip_prefix(template_dir)
136            .map_err(|e| GeneratorError::PathError(e.to_string()))?;
137
138        // Remove .liquid extension for output file
139        let output_relative_path = relative_path.with_extension("");
140        let output_path = output_dir.join(&output_relative_path);
141
142        // Read template content
143        let template_content = fs::read_to_string(template_path)?;
144
145        // Parse and render template
146        let template = parser.parse(&template_content)?;
147        let rendered = template.render(&globals)?;
148
149        // Create parent directory if it doesn't exist
150        if let Some(parent) = output_path.parent() {
151            fs::create_dir_all(parent)?;
152        }
153
154        // Write rendered content to output file
155        fs::write(&output_path, rendered)?;
156
157        generated_files.push(output_path);
158    }
159
160    Ok(generated_files)
161}
162
163/// Check if a target directory contains a rust-bucket.toml marker file
164///
165/// # Arguments
166/// * `target_dir` - Directory to check for the rust-bucket.toml file
167///
168/// # Returns
169/// `true` if rust-bucket.toml exists in the target directory, `false` otherwise
170pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
171    target_dir.join("rust-bucket.toml").exists()
172}
173
174/// Check for conflicts between managed files and existing files in a target directory
175///
176/// # Arguments
177/// * `target_dir` - Directory to check for conflicting files
178///
179/// # Returns
180/// A vector of paths to files that would conflict with managed files.
181/// Returns an empty vector if no conflicts are found.
182pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
183    templates::managed_files()
184        .iter()
185        .map(|file| target_dir.join(file))
186        .filter(|path| path.exists())
187        .collect()
188}
189
190/// Create the CLAUDE.md symlink pointing to AGENTS.md
191///
192/// This creates a symbolic link at CLAUDE.md that points to AGENTS.md,
193/// allowing Claude Code to find the agent instructions via its standard
194/// CLAUDE.md lookup while keeping the canonical content in AGENTS.md.
195///
196/// # Arguments
197/// * `target_dir` - Directory where the symlink should be created
198///
199/// # Errors
200/// Returns `GeneratorError::IoError` if the symlink cannot be created
201#[cfg(unix)]
202pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
203    let claude_md = target_dir.join("CLAUDE.md");
204
205    // Remove existing file or symlink if present
206    if claude_md.exists() || claude_md.is_symlink() {
207        fs::remove_file(&claude_md)?;
208    }
209
210    // Create symlink: CLAUDE.md -> AGENTS.md
211    symlink("AGENTS.md", &claude_md)?;
212
213    Ok(claude_md)
214}
215
216/// Create the CLAUDE.md symlink pointing to AGENTS.md (Windows version)
217///
218/// On Windows, we create a regular file copy instead of a symlink
219/// since symlinks require elevated privileges.
220#[cfg(windows)]
221pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
222    let claude_md = target_dir.join("CLAUDE.md");
223    let agents_md = target_dir.join("AGENTS.md");
224
225    // Copy AGENTS.md to CLAUDE.md
226    fs::copy(&agents_md, &claude_md)?;
227
228    Ok(claude_md)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use tempfile::TempDir;
235
236    fn create_test_config() -> Config {
237        Config {
238            rust_bucket_version: "0.1.0".to_string(),
239            test_timeout: 120,
240            project_name: "test-project".to_string(),
241        }
242    }
243
244    #[test]
245    fn test_render_simple_template() {
246        let temp_template_dir = TempDir::new().unwrap();
247        let temp_output_dir = TempDir::new().unwrap();
248
249        // Create a simple template
250        let template_path = temp_template_dir.path().join("test.txt.liquid");
251        fs::write(
252            &template_path,
253            "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
254        )
255        .unwrap();
256
257        let config = create_test_config();
258        let result = render(
259            temp_template_dir.path(),
260            temp_output_dir.path(),
261            &config,
262            false,
263        );
264
265        assert!(result.is_ok());
266        let generated_files = result.unwrap();
267        assert_eq!(generated_files.len(), 1);
268
269        let output_path = temp_output_dir.path().join("test.txt");
270        assert!(output_path.exists());
271
272        let content = fs::read_to_string(&output_path).unwrap();
273        assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
274    }
275
276    #[test]
277    fn test_render_nested_template() {
278        let temp_template_dir = TempDir::new().unwrap();
279        let temp_output_dir = TempDir::new().unwrap();
280
281        // Create a nested directory structure
282        let subdir = temp_template_dir.path().join("subdir");
283        fs::create_dir(&subdir).unwrap();
284
285        let template_path = subdir.join("nested.txt.liquid");
286        fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
287
288        let config = create_test_config();
289        let result = render(
290            temp_template_dir.path(),
291            temp_output_dir.path(),
292            &config,
293            false,
294        );
295
296        assert!(result.is_ok());
297
298        let output_path = temp_output_dir.path().join("subdir/nested.txt");
299        assert!(output_path.exists());
300
301        let content = fs::read_to_string(&output_path).unwrap();
302        assert_eq!(content, "Nested: 0.1.0");
303    }
304
305    #[test]
306    fn test_conflict_detection() {
307        let temp_template_dir = TempDir::new().unwrap();
308        let temp_output_dir = TempDir::new().unwrap();
309
310        // Create a template
311        let template_path = temp_template_dir.path().join("test.txt.liquid");
312        fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
313
314        // Create a conflicting file in output directory
315        let output_path = temp_output_dir.path().join("test.txt");
316        fs::write(&output_path, "existing content").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, // overwrite disabled
324        );
325
326        assert!(result.is_err());
327        let err = result.unwrap_err();
328        assert!(
329            matches!(&err, GeneratorError::ConflictError(_)),
330            "Expected ConflictError"
331        );
332        if let GeneratorError::ConflictError(conflicts) = err {
333            assert_eq!(conflicts.len(), 1);
334            assert!(conflicts[0].ends_with("test.txt"));
335        }
336    }
337
338    #[test]
339    fn test_overwrite_existing_files() {
340        let temp_template_dir = TempDir::new().unwrap();
341        let temp_output_dir = TempDir::new().unwrap();
342
343        // Create a template
344        let template_path = temp_template_dir.path().join("test.txt.liquid");
345        fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
346
347        // Create a conflicting file in output directory
348        let output_path = temp_output_dir.path().join("test.txt");
349        fs::write(&output_path, "old content").unwrap();
350
351        let config = create_test_config();
352        let result = render(
353            temp_template_dir.path(),
354            temp_output_dir.path(),
355            &config,
356            true, // overwrite enabled
357        );
358
359        assert!(result.is_ok());
360
361        // Verify file was overwritten
362        let content = fs::read_to_string(&output_path).unwrap();
363        assert_eq!(content, "New: 0.1.0");
364        assert_ne!(content, "old content");
365    }
366
367    #[test]
368    fn test_nonexistent_template_directory() {
369        let temp_output_dir = TempDir::new().unwrap();
370        let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
371
372        let config = create_test_config();
373        let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
374
375        assert!(result.is_err());
376        assert!(
377            matches!(
378                result.unwrap_err(),
379                GeneratorError::TemplateDirectoryError(_)
380            ),
381            "Expected TemplateDirectoryError"
382        );
383    }
384
385    #[test]
386    fn test_skip_non_liquid_files() {
387        let temp_template_dir = TempDir::new().unwrap();
388        let temp_output_dir = TempDir::new().unwrap();
389
390        // Create a .liquid template
391        let liquid_path = temp_template_dir.path().join("template.txt.liquid");
392        fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
393
394        // Create a non-.liquid file that should be skipped
395        let non_liquid_path = temp_template_dir.path().join("regular.txt");
396        fs::write(&non_liquid_path, "This should be skipped").unwrap();
397
398        let config = create_test_config();
399        let result = render(
400            temp_template_dir.path(),
401            temp_output_dir.path(),
402            &config,
403            false,
404        );
405
406        assert!(result.is_ok());
407        let generated_files = result.unwrap();
408
409        // Should only generate from .liquid files
410        assert_eq!(generated_files.len(), 1);
411        assert!(generated_files[0].ends_with("template.txt"));
412
413        // The non-.liquid file should not be copied
414        let skipped_path = temp_output_dir.path().join("regular.txt");
415        assert!(!skipped_path.exists());
416    }
417
418    #[test]
419    fn test_template_syntax_error() {
420        let temp_template_dir = TempDir::new().unwrap();
421        let temp_output_dir = TempDir::new().unwrap();
422
423        // Create a template with invalid Liquid syntax
424        let template_path = temp_template_dir.path().join("bad.txt.liquid");
425        fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
426
427        let config = create_test_config();
428        let result = render(
429            temp_template_dir.path(),
430            temp_output_dir.path(),
431            &config,
432            false,
433        );
434
435        assert!(result.is_err());
436        assert!(
437            matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
438            "Expected TemplateError"
439        );
440    }
441
442    #[test]
443    fn test_has_rust_bucket_toml_exists() {
444        let temp_dir = TempDir::new().unwrap();
445        let toml_path = temp_dir.path().join("rust-bucket.toml");
446
447        // Initially should not exist
448        assert!(!has_rust_bucket_toml(temp_dir.path()));
449
450        // Create the file
451        fs::write(&toml_path, "test_content").unwrap();
452
453        // Now it should exist
454        assert!(has_rust_bucket_toml(temp_dir.path()));
455    }
456
457    #[test]
458    fn test_has_rust_bucket_toml_not_exists() {
459        let temp_dir = TempDir::new().unwrap();
460        assert!(!has_rust_bucket_toml(temp_dir.path()));
461    }
462
463    #[test]
464    fn test_check_conflicts_no_conflicts() {
465        let temp_dir = TempDir::new().unwrap();
466        let conflicts = check_conflicts(temp_dir.path());
467        assert!(conflicts.is_empty());
468    }
469
470    #[test]
471    fn test_check_conflicts_with_conflicts() {
472        let temp_dir = TempDir::new().unwrap();
473
474        // Create some managed files that would conflict
475        fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
476        fs::write(temp_dir.path().join("STYLE_GUIDE.md"), "existing content").unwrap();
477
478        // Create .devcontainer directory and file
479        let devcontainer_dir = temp_dir.path().join(".devcontainer");
480        fs::create_dir(&devcontainer_dir).unwrap();
481        fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
482
483        let conflicts = check_conflicts(temp_dir.path());
484
485        // Should detect the conflicts
486        assert!(!conflicts.is_empty());
487        assert_eq!(conflicts.len(), 3);
488
489        // Verify the conflicting files are in the list
490        let conflict_names: Vec<String> = conflicts
491            .iter()
492            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
493            .collect();
494
495        assert!(conflict_names.contains(&"AGENTS.md".to_string()));
496        assert!(conflict_names.contains(&"STYLE_GUIDE.md".to_string()));
497        assert!(conflict_names.contains(&"Dockerfile".to_string()));
498    }
499
500    #[test]
501    fn test_check_conflicts_partial_conflicts() {
502        let temp_dir = TempDir::new().unwrap();
503
504        // Create only one managed file
505        fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
506        fs::write(
507            temp_dir.path().join(".claude/agents/coordinator.md"),
508            "existing content",
509        )
510        .unwrap();
511
512        let conflicts = check_conflicts(temp_dir.path());
513
514        // Should detect exactly one conflict
515        assert_eq!(conflicts.len(), 1);
516        assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
517    }
518}