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/// Ensure .gitignore contains all required lines, appending any that are missing.
164///
165/// If no .gitignore exists, one is created with just the required lines.
166/// Existing content is preserved; only missing lines are appended.
167pub 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
211/// Replaces STYLE_GUIDE.md only when it is a stale rust-bucket-managed file (identified by the generated header); user-edited files are left untouched.
212pub 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
224/// Check if a target directory contains a rust-bucket.toml marker file
225///
226/// # Arguments
227/// * `target_dir` - Directory to check for the rust-bucket.toml file
228///
229/// # Returns
230/// `true` if rust-bucket.toml exists in the target directory, `false` otherwise
231pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
232    target_dir.join("rust-bucket.toml").exists()
233}
234
235/// Check for conflicts between managed files and existing files in a target directory
236///
237/// # Arguments
238/// * `target_dir` - Directory to check for conflicting files
239///
240/// # Returns
241/// A vector of paths to files that would conflict with managed files.
242/// Returns an empty vector if no conflicts are found.
243pub 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/// Create the CLAUDE.md symlink pointing to AGENTS.md
252///
253/// This creates a symbolic link at CLAUDE.md that points to AGENTS.md,
254/// allowing Claude Code to find the agent instructions via its standard
255/// CLAUDE.md lookup while keeping the canonical content in AGENTS.md.
256///
257/// # Arguments
258/// * `target_dir` - Directory where the symlink should be created
259///
260/// # Errors
261/// Returns `GeneratorError::IoError` if the symlink cannot be created
262#[cfg(unix)]
263pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
264    let claude_md = target_dir.join("CLAUDE.md");
265
266    // Remove existing file or symlink if present
267    if claude_md.exists() || claude_md.is_symlink() {
268        fs::remove_file(&claude_md)?;
269    }
270
271    // Create symlink: CLAUDE.md -> AGENTS.md
272    symlink("AGENTS.md", &claude_md)?;
273
274    Ok(claude_md)
275}
276
277/// Create the CLAUDE.md symlink pointing to AGENTS.md (Windows version)
278///
279/// On Windows, we create a regular file copy instead of a symlink
280/// since symlinks require elevated privileges.
281#[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    // Copy AGENTS.md to CLAUDE.md
287    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        // Create a simple template
311        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        // Create a nested directory structure
343        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        // Create a template
372        let template_path = temp_template_dir.path().join("test.txt.liquid");
373        fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
374
375        // Create a conflicting file in output directory
376        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, // overwrite disabled
385        );
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        // Create a template
405        let template_path = temp_template_dir.path().join("test.txt.liquid");
406        fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
407
408        // Create a conflicting file in output directory
409        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, // overwrite enabled
418        );
419
420        assert!(result.is_ok());
421
422        // Verify file was overwritten
423        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        // Create a .liquid template
452        let liquid_path = temp_template_dir.path().join("template.txt.liquid");
453        fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
454
455        // Create a non-.liquid file that should be skipped
456        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        // Should only generate from .liquid files
471        assert_eq!(generated_files.len(), 1);
472        assert!(generated_files[0].ends_with("template.txt"));
473
474        // The non-.liquid file should not be copied
475        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        // Create a template with invalid Liquid syntax
485        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        // Initially should not exist
509        assert!(!has_rust_bucket_toml(temp_dir.path()));
510
511        // Create the file
512        fs::write(&toml_path, "test_content").unwrap();
513
514        // Now it should exist
515        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        // Create some managed files that would conflict
536        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        // Create .devcontainer directory and file
544        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        // Should detect the conflicts
551        assert!(!conflicts.is_empty());
552        assert_eq!(conflicts.len(), 3);
553
554        // Verify the conflicting files are in the list
555        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        // Create only one managed file
570        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        // Should detect exactly one conflict
580        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}