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/// Seed STYLE_GUIDE.md if missing, or replace it if it's a stale managed file.
212///
213/// Prior to 0.6.5, STYLE_GUIDE.md was managed by rust-bucket. It was renamed
214/// to RUST_STYLE_GUIDE.md and STYLE_GUIDE.md became user-editable. On upgrade,
215/// detect the old managed header and replace with the seed content so the user
216/// gets a clean starting point.
217pub fn seed_style_guide(target_dir: &Path) -> Result<bool, GeneratorError> {
218    let path = target_dir.join("STYLE_GUIDE.md");
219    if path.exists() {
220        let content = fs::read_to_string(&path)?;
221        if !content.contains("<!-- Generated by rust-bucket") {
222            return Ok(false);
223        }
224    }
225    fs::write(&path, STYLE_GUIDE_SEED)?;
226    Ok(true)
227}
228
229/// Check if a target directory contains a rust-bucket.toml marker file
230///
231/// # Arguments
232/// * `target_dir` - Directory to check for the rust-bucket.toml file
233///
234/// # Returns
235/// `true` if rust-bucket.toml exists in the target directory, `false` otherwise
236pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
237    target_dir.join("rust-bucket.toml").exists()
238}
239
240/// Check for conflicts between managed files and existing files in a target directory
241///
242/// # Arguments
243/// * `target_dir` - Directory to check for conflicting files
244///
245/// # Returns
246/// A vector of paths to files that would conflict with managed files.
247/// Returns an empty vector if no conflicts are found.
248pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
249    templates::managed_files()
250        .iter()
251        .map(|file| target_dir.join(file))
252        .filter(|path| path.exists())
253        .collect()
254}
255
256/// Create the CLAUDE.md symlink pointing to AGENTS.md
257///
258/// This creates a symbolic link at CLAUDE.md that points to AGENTS.md,
259/// allowing Claude Code to find the agent instructions via its standard
260/// CLAUDE.md lookup while keeping the canonical content in AGENTS.md.
261///
262/// # Arguments
263/// * `target_dir` - Directory where the symlink should be created
264///
265/// # Errors
266/// Returns `GeneratorError::IoError` if the symlink cannot be created
267#[cfg(unix)]
268pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
269    let claude_md = target_dir.join("CLAUDE.md");
270
271    // Remove existing file or symlink if present
272    if claude_md.exists() || claude_md.is_symlink() {
273        fs::remove_file(&claude_md)?;
274    }
275
276    // Create symlink: CLAUDE.md -> AGENTS.md
277    symlink("AGENTS.md", &claude_md)?;
278
279    Ok(claude_md)
280}
281
282/// Create the CLAUDE.md symlink pointing to AGENTS.md (Windows version)
283///
284/// On Windows, we create a regular file copy instead of a symlink
285/// since symlinks require elevated privileges.
286#[cfg(windows)]
287pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
288    let claude_md = target_dir.join("CLAUDE.md");
289    let agents_md = target_dir.join("AGENTS.md");
290
291    // Copy AGENTS.md to CLAUDE.md
292    fs::copy(&agents_md, &claude_md)?;
293
294    Ok(claude_md)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use tempfile::TempDir;
301
302    fn create_test_config() -> Config {
303        Config {
304            rust_bucket_version: "0.1.0".to_string(),
305            test_timeout: 120,
306            project_name: "test-project".to_string(),
307        }
308    }
309
310    #[test]
311    fn test_render_simple_template() {
312        let temp_template_dir = TempDir::new().unwrap();
313        let temp_output_dir = TempDir::new().unwrap();
314
315        // Create a simple template
316        let template_path = temp_template_dir.path().join("test.txt.liquid");
317        fs::write(
318            &template_path,
319            "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
320        )
321        .unwrap();
322
323        let config = create_test_config();
324        let result = render(
325            temp_template_dir.path(),
326            temp_output_dir.path(),
327            &config,
328            false,
329        );
330
331        assert!(result.is_ok());
332        let generated_files = result.unwrap();
333        assert_eq!(generated_files.len(), 1);
334
335        let output_path = temp_output_dir.path().join("test.txt");
336        assert!(output_path.exists());
337
338        let content = fs::read_to_string(&output_path).unwrap();
339        assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
340    }
341
342    #[test]
343    fn test_render_nested_template() {
344        let temp_template_dir = TempDir::new().unwrap();
345        let temp_output_dir = TempDir::new().unwrap();
346
347        // Create a nested directory structure
348        let subdir = temp_template_dir.path().join("subdir");
349        fs::create_dir(&subdir).unwrap();
350
351        let template_path = subdir.join("nested.txt.liquid");
352        fs::write(&template_path, "Nested: {{ rust_bucket_version }}").unwrap();
353
354        let config = create_test_config();
355        let result = render(
356            temp_template_dir.path(),
357            temp_output_dir.path(),
358            &config,
359            false,
360        );
361
362        assert!(result.is_ok());
363
364        let output_path = temp_output_dir.path().join("subdir/nested.txt");
365        assert!(output_path.exists());
366
367        let content = fs::read_to_string(&output_path).unwrap();
368        assert_eq!(content, "Nested: 0.1.0");
369    }
370
371    #[test]
372    fn test_conflict_detection() {
373        let temp_template_dir = TempDir::new().unwrap();
374        let temp_output_dir = TempDir::new().unwrap();
375
376        // Create a template
377        let template_path = temp_template_dir.path().join("test.txt.liquid");
378        fs::write(&template_path, "Content: {{ rust_bucket_version }}").unwrap();
379
380        // Create a conflicting file in output directory
381        let output_path = temp_output_dir.path().join("test.txt");
382        fs::write(&output_path, "existing content").unwrap();
383
384        let config = create_test_config();
385        let result = render(
386            temp_template_dir.path(),
387            temp_output_dir.path(),
388            &config,
389            false, // overwrite disabled
390        );
391
392        assert!(result.is_err());
393        let err = result.unwrap_err();
394        assert!(
395            matches!(&err, GeneratorError::ConflictError(_)),
396            "Expected ConflictError"
397        );
398        if let GeneratorError::ConflictError(conflicts) = err {
399            assert_eq!(conflicts.len(), 1);
400            assert!(conflicts[0].ends_with("test.txt"));
401        }
402    }
403
404    #[test]
405    fn test_overwrite_existing_files() {
406        let temp_template_dir = TempDir::new().unwrap();
407        let temp_output_dir = TempDir::new().unwrap();
408
409        // Create a template
410        let template_path = temp_template_dir.path().join("test.txt.liquid");
411        fs::write(&template_path, "New: {{ rust_bucket_version }}").unwrap();
412
413        // Create a conflicting file in output directory
414        let output_path = temp_output_dir.path().join("test.txt");
415        fs::write(&output_path, "old content").unwrap();
416
417        let config = create_test_config();
418        let result = render(
419            temp_template_dir.path(),
420            temp_output_dir.path(),
421            &config,
422            true, // overwrite enabled
423        );
424
425        assert!(result.is_ok());
426
427        // Verify file was overwritten
428        let content = fs::read_to_string(&output_path).unwrap();
429        assert_eq!(content, "New: 0.1.0");
430        assert_ne!(content, "old content");
431    }
432
433    #[test]
434    fn test_nonexistent_template_directory() {
435        let temp_output_dir = TempDir::new().unwrap();
436        let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
437
438        let config = create_test_config();
439        let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
440
441        assert!(result.is_err());
442        assert!(
443            matches!(
444                result.unwrap_err(),
445                GeneratorError::TemplateDirectoryError(_)
446            ),
447            "Expected TemplateDirectoryError"
448        );
449    }
450
451    #[test]
452    fn test_skip_non_liquid_files() {
453        let temp_template_dir = TempDir::new().unwrap();
454        let temp_output_dir = TempDir::new().unwrap();
455
456        // Create a .liquid template
457        let liquid_path = temp_template_dir.path().join("template.txt.liquid");
458        fs::write(&liquid_path, "Version: {{ rust_bucket_version }}").unwrap();
459
460        // Create a non-.liquid file that should be skipped
461        let non_liquid_path = temp_template_dir.path().join("regular.txt");
462        fs::write(&non_liquid_path, "This should be skipped").unwrap();
463
464        let config = create_test_config();
465        let result = render(
466            temp_template_dir.path(),
467            temp_output_dir.path(),
468            &config,
469            false,
470        );
471
472        assert!(result.is_ok());
473        let generated_files = result.unwrap();
474
475        // Should only generate from .liquid files
476        assert_eq!(generated_files.len(), 1);
477        assert!(generated_files[0].ends_with("template.txt"));
478
479        // The non-.liquid file should not be copied
480        let skipped_path = temp_output_dir.path().join("regular.txt");
481        assert!(!skipped_path.exists());
482    }
483
484    #[test]
485    fn test_template_syntax_error() {
486        let temp_template_dir = TempDir::new().unwrap();
487        let temp_output_dir = TempDir::new().unwrap();
488
489        // Create a template with invalid Liquid syntax
490        let template_path = temp_template_dir.path().join("bad.txt.liquid");
491        fs::write(&template_path, "Bad syntax: {{ unclosed_tag").unwrap();
492
493        let config = create_test_config();
494        let result = render(
495            temp_template_dir.path(),
496            temp_output_dir.path(),
497            &config,
498            false,
499        );
500
501        assert!(result.is_err());
502        assert!(
503            matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
504            "Expected TemplateError"
505        );
506    }
507
508    #[test]
509    fn test_has_rust_bucket_toml_exists() {
510        let temp_dir = TempDir::new().unwrap();
511        let toml_path = temp_dir.path().join("rust-bucket.toml");
512
513        // Initially should not exist
514        assert!(!has_rust_bucket_toml(temp_dir.path()));
515
516        // Create the file
517        fs::write(&toml_path, "test_content").unwrap();
518
519        // Now it should exist
520        assert!(has_rust_bucket_toml(temp_dir.path()));
521    }
522
523    #[test]
524    fn test_has_rust_bucket_toml_not_exists() {
525        let temp_dir = TempDir::new().unwrap();
526        assert!(!has_rust_bucket_toml(temp_dir.path()));
527    }
528
529    #[test]
530    fn test_check_conflicts_no_conflicts() {
531        let temp_dir = TempDir::new().unwrap();
532        let conflicts = check_conflicts(temp_dir.path());
533        assert!(conflicts.is_empty());
534    }
535
536    #[test]
537    fn test_check_conflicts_with_conflicts() {
538        let temp_dir = TempDir::new().unwrap();
539
540        // Create some managed files that would conflict
541        fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
542        fs::write(
543            temp_dir.path().join("RUST_STYLE_GUIDE.md"),
544            "existing content",
545        )
546        .unwrap();
547
548        // Create .devcontainer directory and file
549        let devcontainer_dir = temp_dir.path().join(".devcontainer");
550        fs::create_dir(&devcontainer_dir).unwrap();
551        fs::write(devcontainer_dir.join("Dockerfile"), "existing content").unwrap();
552
553        let conflicts = check_conflicts(temp_dir.path());
554
555        // Should detect the conflicts
556        assert!(!conflicts.is_empty());
557        assert_eq!(conflicts.len(), 3);
558
559        // Verify the conflicting files are in the list
560        let conflict_names: Vec<String> = conflicts
561            .iter()
562            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
563            .collect();
564
565        assert!(conflict_names.contains(&"AGENTS.md".to_string()));
566        assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
567        assert!(conflict_names.contains(&"Dockerfile".to_string()));
568    }
569
570    #[test]
571    fn test_check_conflicts_partial_conflicts() {
572        let temp_dir = TempDir::new().unwrap();
573
574        // Create only one managed file
575        fs::create_dir_all(temp_dir.path().join(".claude/agents")).unwrap();
576        fs::write(
577            temp_dir.path().join(".claude/agents/coordinator.md"),
578            "existing content",
579        )
580        .unwrap();
581
582        let conflicts = check_conflicts(temp_dir.path());
583
584        // Should detect exactly one conflict
585        assert_eq!(conflicts.len(), 1);
586        assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
587    }
588
589    #[test]
590    fn test_ensure_gitignore_creates_file_when_missing() {
591        let temp_dir = TempDir::new().unwrap();
592        let added = ensure_gitignore(temp_dir.path()).unwrap();
593        assert_eq!(added.len(), 3);
594        let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
595        assert!(content.contains(".beads/.br_history/"));
596        assert!(content.contains(".beads/beads.db-wal"));
597    }
598
599    #[test]
600    fn test_ensure_gitignore_appends_missing_lines() {
601        let temp_dir = TempDir::new().unwrap();
602        fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
603        let added = ensure_gitignore(temp_dir.path()).unwrap();
604        assert_eq!(added.len(), 3);
605        let content = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
606        assert!(content.starts_with("target/\n"));
607        assert!(content.contains("# beads_rust (managed by rust-bucket)"));
608        assert!(content.contains(".beads/beads.db"));
609    }
610
611    #[test]
612    fn test_ensure_gitignore_skips_existing_lines() {
613        let temp_dir = TempDir::new().unwrap();
614        fs::write(
615            temp_dir.path().join(".gitignore"),
616            "target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n",
617        )
618        .unwrap();
619        let added = ensure_gitignore(temp_dir.path()).unwrap();
620        assert!(added.is_empty());
621    }
622
623    #[test]
624    fn test_ensure_gitignore_is_idempotent() {
625        let temp_dir = TempDir::new().unwrap();
626        fs::write(temp_dir.path().join(".gitignore"), "target/\n").unwrap();
627        ensure_gitignore(temp_dir.path()).unwrap();
628        let first = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
629        let added = ensure_gitignore(temp_dir.path()).unwrap();
630        assert!(added.is_empty());
631        let second = fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
632        assert_eq!(first, second);
633    }
634}