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    // Seed templates are written only via seed_files(); the managed render path
78    // must never emit them.
79    let seed_template_paths: Vec<&str> = templates::seed_files()
80        .into_iter()
81        .map(|(template, _)| template)
82        .collect();
83
84    // Track all files that will be generated
85    let mut target_files = Vec::new();
86
87    // First pass: collect all target files and check for conflicts
88    for entry in WalkDir::new(template_dir)
89        .into_iter()
90        .filter_map(|e| e.ok())
91        .filter(|e| e.file_type().is_file())
92    {
93        let template_path = entry.path();
94
95        // Skip files that aren't .liquid templates
96        if template_path.extension().is_none_or(|ext| ext != "liquid") {
97            continue;
98        }
99
100        // Calculate relative path from template_dir
101        let relative_path = template_path
102            .strip_prefix(template_dir)
103            .map_err(|e| GeneratorError::PathError(e.to_string()))?;
104
105        // Skip seed templates; they are handled by seed_files().
106        if seed_template_paths
107            .iter()
108            .any(|seed| Path::new(seed) == relative_path)
109        {
110            continue;
111        }
112
113        // Remove .liquid extension for output file
114        let output_relative_path = relative_path.with_extension("");
115        let output_path = output_dir.join(&output_relative_path);
116
117        target_files.push(output_path);
118    }
119
120    // Check for conflicts if overwrite is disabled
121    if !overwrite {
122        let conflicts: Vec<PathBuf> = target_files
123            .iter()
124            .filter(|path| path.exists())
125            .cloned()
126            .collect();
127
128        if !conflicts.is_empty() {
129            return Err(GeneratorError::ConflictError(conflicts));
130        }
131    }
132
133    // Second pass: render and write all templates
134    let mut generated_files = Vec::new();
135
136    for entry in WalkDir::new(template_dir)
137        .into_iter()
138        .filter_map(|e| e.ok())
139        .filter(|e| e.file_type().is_file())
140    {
141        let template_path = entry.path();
142
143        // Skip files that aren't .liquid templates
144        if template_path.extension().is_none_or(|ext| ext != "liquid") {
145            continue;
146        }
147
148        // Calculate relative path from template_dir
149        let relative_path = template_path
150            .strip_prefix(template_dir)
151            .map_err(|e| GeneratorError::PathError(e.to_string()))?;
152
153        // Skip seed templates; they are handled by seed_files().
154        if seed_template_paths
155            .iter()
156            .any(|seed| Path::new(seed) == relative_path)
157        {
158            continue;
159        }
160
161        // Remove .liquid extension for output file
162        let output_relative_path = relative_path.with_extension("");
163        let output_path = output_dir.join(&output_relative_path);
164
165        // Read template content
166        let template_content = fs::read_to_string(template_path)?;
167
168        // Parse and render template
169        let template = parser.parse(&template_content)?;
170        let rendered = template.render(&globals)?;
171
172        // Create parent directory if it doesn't exist
173        if let Some(parent) = output_path.parent() {
174            fs::create_dir_all(parent)?;
175        }
176
177        // Write rendered content to output file
178        fs::write(&output_path, rendered)?;
179
180        generated_files.push(output_path);
181    }
182
183    Ok(generated_files)
184}
185
186/// Ensure .gitignore contains all required lines, appending any that are missing.
187///
188/// If no .gitignore exists, one is created with just the required lines.
189/// Existing content is preserved; only missing lines are appended.
190pub fn ensure_gitignore(target_dir: &Path) -> Result<Vec<String>, GeneratorError> {
191    let gitignore_path = target_dir.join(".gitignore");
192    let required = templates::required_gitignore_lines();
193
194    let existing = if gitignore_path.exists() {
195        fs::read_to_string(&gitignore_path)?
196    } else {
197        String::new()
198    };
199
200    let existing_lines: Vec<&str> = existing.lines().collect();
201    let missing: Vec<&str> = required
202        .iter()
203        .filter(|line| !existing_lines.iter().any(|el| el.trim() == **line))
204        .copied()
205        .collect();
206
207    if missing.is_empty() {
208        return Ok(Vec::new());
209    }
210
211    let mut append = String::new();
212    if !existing.is_empty() && !existing.ends_with('\n') {
213        append.push('\n');
214    }
215    if !existing.is_empty() {
216        append.push_str("\n# beads_rust (managed by rust-bucket)\n");
217    }
218    for line in &missing {
219        append.push_str(line);
220        append.push('\n');
221    }
222
223    fs::write(&gitignore_path, format!("{existing}{append}"))?;
224
225    Ok(missing.iter().map(|s| s.to_string()).collect())
226}
227
228/// Render and write each registered seed template into the target, but only if
229/// the destination file is absent. Existing files are left byte-for-byte
230/// untouched, so seeding is safe to repeat on every apply.
231///
232/// # Returns
233/// The destination paths that were newly seeded.
234///
235/// # Errors
236/// Returns `GeneratorError` if a seed template cannot be read, parsed, rendered,
237/// or written.
238pub fn seed_files(
239    template_dir: &Path,
240    target_dir: &Path,
241    config: &Config,
242) -> Result<Vec<PathBuf>, GeneratorError> {
243    let parser = ParserBuilder::with_stdlib().build()?;
244    let globals = liquid::object!({
245        "rust_bucket_version": config.rust_bucket_version,
246        "test_timeout": config.test_timeout,
247        "project_name": config.project_name,
248    });
249
250    let mut seeded = Vec::new();
251
252    for (template_rel, dest_rel) in templates::seed_files() {
253        let dest_path = target_dir.join(dest_rel);
254        if dest_path.exists() {
255            continue;
256        }
257
258        let template_path = template_dir.join(template_rel);
259        let template_content = fs::read_to_string(&template_path)?;
260        let template = parser.parse(&template_content)?;
261        let rendered = template.render(&globals)?;
262
263        if let Some(parent) = dest_path.parent() {
264            fs::create_dir_all(parent)?;
265        }
266        fs::write(&dest_path, rendered)?;
267        seeded.push(dest_path);
268    }
269
270    Ok(seeded)
271}
272
273/// Check if a target directory contains a rust-bucket.toml marker file
274///
275/// # Arguments
276/// * `target_dir` - Directory to check for the rust-bucket.toml file
277///
278/// # Returns
279/// `true` if rust-bucket.toml exists in the target directory, `false` otherwise
280pub fn has_rust_bucket_toml(target_dir: &Path) -> bool {
281    target_dir.join("rust-bucket.toml").exists()
282}
283
284/// Check for conflicts between managed files and existing files in a target directory
285///
286/// # Arguments
287/// * `target_dir` - Directory to check for conflicting files
288///
289/// # Returns
290/// A vector of paths to files that would conflict with managed files.
291/// Returns an empty vector if no conflicts are found.
292pub fn check_conflicts(target_dir: &Path) -> Vec<PathBuf> {
293    templates::managed_files()
294        .iter()
295        .map(|file| target_dir.join(file))
296        .filter(|path| path.exists())
297        .collect()
298}
299
300/// Create the CLAUDE.md symlink pointing to AGENTS.md
301///
302/// This creates a symbolic link at CLAUDE.md that points to AGENTS.md,
303/// allowing Claude Code to find the agent instructions via its standard
304/// CLAUDE.md lookup while keeping the canonical content in AGENTS.md.
305///
306/// # Arguments
307/// * `target_dir` - Directory where the symlink should be created
308///
309/// # Errors
310/// Returns `GeneratorError::IoError` if the symlink cannot be created
311#[cfg(unix)]
312pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
313    let claude_md = target_dir.join("CLAUDE.md");
314
315    // Remove existing file or symlink if present
316    if claude_md.exists() || claude_md.is_symlink() {
317        fs::remove_file(&claude_md)?;
318    }
319
320    // Create symlink: CLAUDE.md -> AGENTS.md
321    symlink("AGENTS.md", &claude_md)?;
322
323    Ok(claude_md)
324}
325
326/// Create the CLAUDE.md symlink pointing to AGENTS.md (Windows version)
327///
328/// On Windows, we create a regular file copy instead of a symlink
329/// since symlinks require elevated privileges.
330#[cfg(windows)]
331pub fn create_claude_symlink(target_dir: &Path) -> Result<PathBuf, GeneratorError> {
332    let claude_md = target_dir.join("CLAUDE.md");
333    let agents_md = target_dir.join("AGENTS.md");
334
335    // Copy AGENTS.md to CLAUDE.md
336    fs::copy(&agents_md, &claude_md)?;
337
338    Ok(claude_md)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use tempfile::TempDir;
345
346    fn create_test_config() -> Config {
347        Config {
348            rust_bucket_version: "0.1.0".to_string(),
349            test_timeout: 120,
350            project_name: "test-project".to_string(),
351        }
352    }
353
354    #[test]
355    fn test_render_simple_template() -> Result<(), Box<dyn std::error::Error>> {
356        let temp_template_dir = TempDir::new()?;
357        let temp_output_dir = TempDir::new()?;
358
359        // Create a simple template
360        let template_path = temp_template_dir.path().join("test.txt.liquid");
361        fs::write(
362            &template_path,
363            "Version: {{ rust_bucket_version }}\nTimeout: {{ test_timeout }}s",
364        )?;
365
366        let config = create_test_config();
367        let generated_files = render(
368            temp_template_dir.path(),
369            temp_output_dir.path(),
370            &config,
371            false,
372        )?;
373
374        assert_eq!(generated_files.len(), 1);
375
376        let output_path = temp_output_dir.path().join("test.txt");
377        assert!(output_path.exists());
378
379        let content = fs::read_to_string(&output_path)?;
380        assert_eq!(content, "Version: 0.1.0\nTimeout: 120s");
381        Ok(())
382    }
383
384    #[test]
385    fn test_render_nested_template() -> Result<(), Box<dyn std::error::Error>> {
386        let temp_template_dir = TempDir::new()?;
387        let temp_output_dir = TempDir::new()?;
388
389        // Create a nested directory structure
390        let subdir = temp_template_dir.path().join("subdir");
391        fs::create_dir(&subdir)?;
392
393        let template_path = subdir.join("nested.txt.liquid");
394        fs::write(&template_path, "Nested: {{ rust_bucket_version }}")?;
395
396        let config = create_test_config();
397        render(
398            temp_template_dir.path(),
399            temp_output_dir.path(),
400            &config,
401            false,
402        )?;
403
404        let output_path = temp_output_dir.path().join("subdir/nested.txt");
405        assert!(output_path.exists());
406
407        let content = fs::read_to_string(&output_path)?;
408        assert_eq!(content, "Nested: 0.1.0");
409        Ok(())
410    }
411
412    #[test]
413    fn test_conflict_detection() -> Result<(), Box<dyn std::error::Error>> {
414        let temp_template_dir = TempDir::new()?;
415        let temp_output_dir = TempDir::new()?;
416
417        // Create a template
418        let template_path = temp_template_dir.path().join("test.txt.liquid");
419        fs::write(&template_path, "Content: {{ rust_bucket_version }}")?;
420
421        // Create a conflicting file in output directory
422        let output_path = temp_output_dir.path().join("test.txt");
423        fs::write(&output_path, "existing content")?;
424
425        let config = create_test_config();
426        let result = render(
427            temp_template_dir.path(),
428            temp_output_dir.path(),
429            &config,
430            false, // overwrite disabled
431        );
432
433        assert!(result.is_err());
434        let err = result.unwrap_err();
435        assert!(
436            matches!(&err, GeneratorError::ConflictError(_)),
437            "Expected ConflictError"
438        );
439        if let GeneratorError::ConflictError(conflicts) = err {
440            assert_eq!(conflicts.len(), 1);
441            assert!(conflicts[0].ends_with("test.txt"));
442        }
443        Ok(())
444    }
445
446    #[test]
447    fn test_overwrite_existing_files() -> Result<(), Box<dyn std::error::Error>> {
448        let temp_template_dir = TempDir::new()?;
449        let temp_output_dir = TempDir::new()?;
450
451        // Create a template
452        let template_path = temp_template_dir.path().join("test.txt.liquid");
453        fs::write(&template_path, "New: {{ rust_bucket_version }}")?;
454
455        // Create a conflicting file in output directory
456        let output_path = temp_output_dir.path().join("test.txt");
457        fs::write(&output_path, "old content")?;
458
459        let config = create_test_config();
460        render(
461            temp_template_dir.path(),
462            temp_output_dir.path(),
463            &config,
464            true, // overwrite enabled
465        )?;
466
467        // Verify file was overwritten
468        let content = fs::read_to_string(&output_path)?;
469        assert_eq!(content, "New: 0.1.0");
470        assert_ne!(content, "old content");
471        Ok(())
472    }
473
474    #[test]
475    fn test_nonexistent_template_directory() -> Result<(), Box<dyn std::error::Error>> {
476        let temp_output_dir = TempDir::new()?;
477        let nonexistent_dir = PathBuf::from("/nonexistent/template/dir");
478
479        let config = create_test_config();
480        let result = render(&nonexistent_dir, temp_output_dir.path(), &config, false);
481
482        assert!(result.is_err());
483        assert!(
484            matches!(
485                result.unwrap_err(),
486                GeneratorError::TemplateDirectoryError(_)
487            ),
488            "Expected TemplateDirectoryError"
489        );
490        Ok(())
491    }
492
493    #[test]
494    fn test_skip_non_liquid_files() -> Result<(), Box<dyn std::error::Error>> {
495        let temp_template_dir = TempDir::new()?;
496        let temp_output_dir = TempDir::new()?;
497
498        // Create a .liquid template
499        let liquid_path = temp_template_dir.path().join("template.txt.liquid");
500        fs::write(&liquid_path, "Version: {{ rust_bucket_version }}")?;
501
502        // Create a non-.liquid file that should be skipped
503        let non_liquid_path = temp_template_dir.path().join("regular.txt");
504        fs::write(&non_liquid_path, "This should be skipped")?;
505
506        let config = create_test_config();
507        let generated_files = render(
508            temp_template_dir.path(),
509            temp_output_dir.path(),
510            &config,
511            false,
512        )?;
513
514        // Should only generate from .liquid files
515        assert_eq!(generated_files.len(), 1);
516        assert!(generated_files[0].ends_with("template.txt"));
517
518        // The non-.liquid file should not be copied
519        let skipped_path = temp_output_dir.path().join("regular.txt");
520        assert!(!skipped_path.exists());
521        Ok(())
522    }
523
524    #[test]
525    fn test_template_syntax_error() -> Result<(), Box<dyn std::error::Error>> {
526        let temp_template_dir = TempDir::new()?;
527        let temp_output_dir = TempDir::new()?;
528
529        // Create a template with invalid Liquid syntax
530        let template_path = temp_template_dir.path().join("bad.txt.liquid");
531        fs::write(&template_path, "Bad syntax: {{ unclosed_tag")?;
532
533        let config = create_test_config();
534        let result = render(
535            temp_template_dir.path(),
536            temp_output_dir.path(),
537            &config,
538            false,
539        );
540
541        assert!(result.is_err());
542        assert!(
543            matches!(result.unwrap_err(), GeneratorError::TemplateError(_)),
544            "Expected TemplateError"
545        );
546        Ok(())
547    }
548
549    #[test]
550    fn test_has_rust_bucket_toml_exists() -> Result<(), Box<dyn std::error::Error>> {
551        let temp_dir = TempDir::new()?;
552        let toml_path = temp_dir.path().join("rust-bucket.toml");
553
554        // Initially should not exist
555        assert!(!has_rust_bucket_toml(temp_dir.path()));
556
557        // Create the file
558        fs::write(&toml_path, "test_content")?;
559
560        // Now it should exist
561        assert!(has_rust_bucket_toml(temp_dir.path()));
562        Ok(())
563    }
564
565    #[test]
566    fn test_has_rust_bucket_toml_not_exists() -> Result<(), Box<dyn std::error::Error>> {
567        let temp_dir = TempDir::new()?;
568        assert!(!has_rust_bucket_toml(temp_dir.path()));
569        Ok(())
570    }
571
572    #[test]
573    fn test_check_conflicts_no_conflicts() -> Result<(), Box<dyn std::error::Error>> {
574        let temp_dir = TempDir::new()?;
575        let conflicts = check_conflicts(temp_dir.path());
576        assert!(conflicts.is_empty());
577        Ok(())
578    }
579
580    #[test]
581    fn test_check_conflicts_with_conflicts() -> Result<(), Box<dyn std::error::Error>> {
582        let temp_dir = TempDir::new()?;
583
584        // Create some managed files that would conflict
585        fs::write(temp_dir.path().join("AGENTS.md"), "existing content")?;
586        fs::write(
587            temp_dir.path().join("RUST_STYLE_GUIDE.md"),
588            "existing content",
589        )?;
590
591        // Create .devcontainer directory and file
592        let devcontainer_dir = temp_dir.path().join(".devcontainer");
593        fs::create_dir(&devcontainer_dir)?;
594        fs::write(devcontainer_dir.join("Dockerfile"), "existing content")?;
595
596        let conflicts = check_conflicts(temp_dir.path());
597
598        // Should detect the conflicts
599        assert!(!conflicts.is_empty());
600        assert_eq!(conflicts.len(), 3);
601
602        // Verify the conflicting files are in the list
603        let conflict_names: Vec<String> = conflicts
604            .iter()
605            .filter_map(|p| p.file_name())
606            .map(|n| n.to_string_lossy().to_string())
607            .collect();
608
609        assert!(conflict_names.contains(&"AGENTS.md".to_string()));
610        assert!(conflict_names.contains(&"RUST_STYLE_GUIDE.md".to_string()));
611        assert!(conflict_names.contains(&"Dockerfile".to_string()));
612        Ok(())
613    }
614
615    #[test]
616    fn test_check_conflicts_partial_conflicts() -> Result<(), Box<dyn std::error::Error>> {
617        let temp_dir = TempDir::new()?;
618
619        // Create only one managed file
620        fs::create_dir_all(temp_dir.path().join(".claude/agents"))?;
621        fs::write(
622            temp_dir.path().join(".claude/agents/coordinator.md"),
623            "existing content",
624        )?;
625
626        let conflicts = check_conflicts(temp_dir.path());
627
628        // Should detect exactly one conflict
629        assert_eq!(conflicts.len(), 1);
630        assert!(conflicts[0].ends_with(".claude/agents/coordinator.md"));
631        Ok(())
632    }
633
634    #[test]
635    fn test_ensure_gitignore_creates_file_when_missing() -> Result<(), Box<dyn std::error::Error>> {
636        let temp_dir = TempDir::new()?;
637        let added = ensure_gitignore(temp_dir.path())?;
638        assert_eq!(added.len(), 4);
639        let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
640        assert!(content.contains(".beads/.br_history/"));
641        assert!(content.contains(".beads/beads.db-wal"));
642        Ok(())
643    }
644
645    #[test]
646    fn test_ensure_gitignore_appends_missing_lines() -> Result<(), Box<dyn std::error::Error>> {
647        let temp_dir = TempDir::new()?;
648        fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
649        let added = ensure_gitignore(temp_dir.path())?;
650        assert_eq!(added.len(), 4);
651        let content = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
652        assert!(content.starts_with("target/\n"));
653        assert!(content.contains("# beads_rust (managed by rust-bucket)"));
654        assert!(content.contains(".beads/beads.db"));
655        Ok(())
656    }
657
658    #[test]
659    fn test_ensure_gitignore_skips_existing_lines() -> Result<(), Box<dyn std::error::Error>> {
660        let temp_dir = TempDir::new()?;
661        fs::write(
662            temp_dir.path().join(".gitignore"),
663            "target/\n.beads/.br_history/\n.beads/beads.db\n.beads/beads.db-wal\n.beads/last-touched\n",
664        )?;
665        let added = ensure_gitignore(temp_dir.path())?;
666        assert!(added.is_empty());
667        Ok(())
668    }
669
670    #[test]
671    fn test_seed_files_writes_when_absent() -> Result<(), Box<dyn std::error::Error>> {
672        let temp_template_dir = TempDir::new()?;
673        let temp_target_dir = TempDir::new()?;
674
675        let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
676        fs::write(&template_path, "enabled_ratchets = []\n")?;
677        let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
678        fs::write(&style_template, "# Style Guide\n")?;
679
680        let config = create_test_config();
681        let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
682
683        let dest = temp_target_dir.path().join("ratchets.toml");
684        assert!(dest.exists());
685        assert!(seeded.contains(&dest));
686        assert_eq!(fs::read_to_string(&dest)?, "enabled_ratchets = []\n");
687        Ok(())
688    }
689
690    #[test]
691    fn test_seed_files_leaves_existing_unchanged() -> Result<(), Box<dyn std::error::Error>> {
692        let temp_template_dir = TempDir::new()?;
693        let temp_target_dir = TempDir::new()?;
694
695        let template_path = temp_template_dir.path().join("ratchets.toml.liquid");
696        fs::write(&template_path, "enabled_ratchets = []\n")?;
697        let style_template = temp_template_dir.path().join("STYLE_GUIDE.md.liquid");
698        fs::write(&style_template, "# Style Guide\n")?;
699
700        let dest = temp_target_dir.path().join("ratchets.toml");
701        let custom = "enabled_ratchets = [\"no-unwrap\"]\n# customized\n";
702        fs::write(&dest, custom)?;
703        let style_dest = temp_target_dir.path().join("STYLE_GUIDE.md");
704        fs::write(&style_dest, "# existing style\n")?;
705
706        let config = create_test_config();
707        let seeded = seed_files(temp_template_dir.path(), temp_target_dir.path(), &config)?;
708
709        assert!(seeded.is_empty());
710        assert_eq!(fs::read_to_string(&dest)?, custom);
711        Ok(())
712    }
713
714    #[test]
715    fn test_seed_files_writes_style_guide_when_absent() -> Result<(), Box<dyn std::error::Error>> {
716        let (_temp_dir, temp_path) = templates::extract_to_temp()?;
717        let temp_target_dir = TempDir::new()?;
718
719        let config = create_test_config();
720        let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
721
722        let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
723        assert!(dest.exists());
724        assert!(seeded.contains(&dest));
725
726        let content = fs::read_to_string(&dest)?;
727        assert!(content.starts_with("# Style Guide\n"));
728        assert!(content.contains("RUST_STYLE_GUIDE.md"));
729        assert!(!content.contains("Generated by rust-bucket"));
730        Ok(())
731    }
732
733    #[test]
734    fn test_seed_files_leaves_existing_style_guide_unchanged()
735    -> Result<(), Box<dyn std::error::Error>> {
736        let (_temp_dir, temp_path) = templates::extract_to_temp()?;
737        let temp_target_dir = TempDir::new()?;
738
739        let dest = temp_target_dir.path().join("STYLE_GUIDE.md");
740        let custom = "<!-- Generated by rust-bucket v0.7.0. DO NOT EDIT BY HAND. -->\n# Custom\n";
741        fs::write(&dest, custom)?;
742
743        let config = create_test_config();
744        let seeded = seed_files(&temp_path, temp_target_dir.path(), &config)?;
745
746        assert!(!seeded.contains(&dest));
747        assert_eq!(fs::read_to_string(&dest)?, custom);
748        Ok(())
749    }
750
751    #[test]
752    fn test_render_skips_seed_templates() -> Result<(), Box<dyn std::error::Error>> {
753        let temp_template_dir = TempDir::new()?;
754        let temp_output_dir = TempDir::new()?;
755
756        let seed_template = temp_template_dir.path().join("ratchets.toml.liquid");
757        fs::write(&seed_template, "enabled_ratchets = []\n")?;
758
759        let managed_template = temp_template_dir.path().join("AGENTS.md.liquid");
760        fs::write(&managed_template, "Version: {{ rust_bucket_version }}")?;
761
762        let config = create_test_config();
763        let generated = render(
764            temp_template_dir.path(),
765            temp_output_dir.path(),
766            &config,
767            false,
768        )?;
769
770        assert!(
771            !temp_output_dir.path().join("ratchets.toml").exists(),
772            "render must not emit seed templates"
773        );
774        assert!(generated.iter().any(|p| p.ends_with("AGENTS.md")));
775        assert!(!generated.iter().any(|p| p.ends_with("ratchets.toml")));
776        Ok(())
777    }
778
779    #[test]
780    fn test_ensure_gitignore_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
781        let temp_dir = TempDir::new()?;
782        fs::write(temp_dir.path().join(".gitignore"), "target/\n")?;
783        ensure_gitignore(temp_dir.path())?;
784        let first = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
785        let added = ensure_gitignore(temp_dir.path())?;
786        assert!(added.is_empty());
787        let second = fs::read_to_string(temp_dir.path().join(".gitignore"))?;
788        assert_eq!(first, second);
789        Ok(())
790    }
791}