Skip to main content

yui/
render.rs

1//! Tera template rendering for `*.tera` files.
2//!
3//! Output goes to the **same directory** as the source `.tera` file (e.g.
4//! `home/.gitconfig.tera` → `home/.gitconfig`). When `manage_gitignore` is
5//! true, the rendered files are listed in a `# >>> yui rendered ... <<<`
6//! managed section of `.gitignore` so they aren't committed.
7//!
8//! Conditional render (both honored, AND'd together when both present):
9//!   - file-header: `{# yui:when EXPR #}` as the first Tera comment in the
10//!     `.tera` file. Tera comments are stripped from output, so the header
11//!     never bleeds into the rendered file.
12//!   - config rule: `[[render.rule]] match = "<glob>", when = "<expr>"` —
13//!     the glob is matched against the path relative to the source root.
14//!
15//! Drift policy: if the rendered file already exists with content that
16//! diverges from what the template would produce now, we DO NOT overwrite
17//! it. The user has likely edited the rendered file in place and needs to
18//! reflect that change back into the `.tera` first. The divergence is
19//! reported in `RenderReport::diverged`; `--check` treats it as fatal.
20
21use camino::{Utf8Path, Utf8PathBuf};
22use globset::{Glob, GlobSet, GlobSetBuilder};
23use tera::Context as TeraContext;
24
25use crate::config::{Config, RenderRule};
26use crate::paths;
27use crate::template::{self, Engine};
28use crate::vars::YuiVars;
29use crate::{Error, Result};
30
31const GITIGNORE_BEGIN: &str = "# >>> yui rendered (auto-managed, do not edit) >>>";
32const GITIGNORE_END: &str = "# <<< yui rendered (auto-managed) <<<";
33
34#[derive(Debug, Default)]
35pub struct RenderReport {
36    /// Templates rendered for the first time (or after deletion).
37    pub written: Vec<Utf8PathBuf>,
38    /// Rendered output identical to existing file — no write needed.
39    pub unchanged: Vec<Utf8PathBuf>,
40    /// Skipped because file-header or config rule `when` evaluated to false.
41    pub skipped_when_false: Vec<Utf8PathBuf>,
42    /// Existing rendered file diverges from current template output.
43    /// User must reflect the manual edit back into `.tera` before re-rendering.
44    pub diverged: Vec<Utf8PathBuf>,
45}
46
47impl RenderReport {
48    pub fn has_drift(&self) -> bool {
49        !self.diverged.is_empty()
50    }
51}
52
53pub fn render_all(
54    source: &Utf8Path,
55    config: &Config,
56    yui: &YuiVars,
57    dry_run: bool,
58) -> Result<RenderReport> {
59    let mut engine = Engine::new();
60    let ctx = template::template_context(yui, &config.vars);
61    let rules = compile_rules(&config.render.rule)?;
62    let mut report = RenderReport::default();
63
64    // Walk every file under source. Filtering is centralized in
65    // `paths::source_walker`: ignore-files OFF (so unrelated user rules
66    // don't swallow `.tera`s) and `.yui/` skipped (backup mirrors etc.
67    // shouldn't be rendered). `.yuiignore` is registered as a custom
68    // ignore filename, so nested `.yuiignore` files are honored
69    // automatically — no extra per-entry check needed here.
70    let walker = paths::source_walker(source).build();
71    for entry in walker {
72        let entry = match entry {
73            Ok(e) => e,
74            Err(_) => continue,
75        };
76        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
77            continue;
78        }
79        let std_path = entry.path();
80        let Some(name) = std_path.file_name().and_then(|n| n.to_str()) else {
81            continue;
82        };
83        if !name.ends_with(".tera") {
84            continue;
85        }
86        let template_path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
87            Ok(p) => p,
88            Err(_) => continue,
89        };
90        process_template(
91            &template_path,
92            source,
93            &rules,
94            &mut engine,
95            &ctx,
96            dry_run,
97            &mut report,
98        )?;
99    }
100
101    if !dry_run && config.render.manage_gitignore {
102        update_gitignore(source, &collect_managed_paths(&report))?;
103    }
104    Ok(report)
105}
106
107/// Render a single template and return the body it would produce
108/// right now, or `Ok(None)` if `{# yui:when … #}` / a config rule
109/// `when` would skip it for the current host. Used by `yui diff`
110/// to compute what the rendered file *should* contain so the
111/// drift can be diffed against what's actually on disk.
112///
113/// Mirrors `process_template`'s skip / render decisions exactly,
114/// but doesn't touch the report or the on-disk rendered output.
115pub fn render_to_string(
116    template_path: &Utf8Path,
117    source: &Utf8Path,
118    config: &Config,
119    yui: &YuiVars,
120) -> Result<Option<String>> {
121    let raw = std::fs::read_to_string(template_path)
122        .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
123    let mut engine = Engine::new();
124    let ctx = template::template_context(yui, &config.vars);
125    let rules = compile_rules(&config.render.rule)?;
126
127    let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
128        if !eval_when(expr, &mut engine, &ctx)? {
129            return Ok(None);
130        }
131        body.to_string()
132    } else {
133        raw
134    };
135
136    let rel = relative_to(source, template_path);
137    let rel_for_match = rel.as_str().replace('\\', "/");
138    for rule in &rules {
139        // Nested ifs (not let-chains) so the crate's MSRV
140        // (rust-version = "1.85") stays buildable.
141        if rule.matcher.is_match(&rel_for_match) {
142            if let Some(w) = &rule.when {
143                if !eval_when(w, &mut engine, &ctx)? {
144                    return Ok(None);
145                }
146            }
147        }
148    }
149
150    Ok(Some(engine.render(&body_input, &ctx)?))
151}
152
153struct CompiledRule {
154    matcher: GlobSet,
155    when: Option<String>,
156}
157
158fn compile_rules(rules: &[RenderRule]) -> Result<Vec<CompiledRule>> {
159    let mut out = Vec::with_capacity(rules.len());
160    for r in rules {
161        let glob = Glob::new(&r.r#match)
162            .map_err(|e| Error::Config(format!("render.rule.match {:?}: {e}", r.r#match)))?;
163        let mut b = GlobSetBuilder::new();
164        b.add(glob);
165        let matcher = b
166            .build()
167            .map_err(|e| Error::Config(format!("globset build: {e}")))?;
168        out.push(CompiledRule {
169            matcher,
170            when: r.when.clone(),
171        });
172    }
173    Ok(out)
174}
175
176fn process_template(
177    template_path: &Utf8Path,
178    source: &Utf8Path,
179    rules: &[CompiledRule],
180    engine: &mut Engine,
181    ctx: &TeraContext,
182    dry_run: bool,
183    report: &mut RenderReport,
184) -> Result<()> {
185    let raw = std::fs::read_to_string(template_path)
186        .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
187    let target = template_target(template_path);
188
189    // Strip any `{# yui:when EXPR #}\n` header before handing the body to
190    // Tera, so a falsy header doesn't leave a stray newline at the top of
191    // a successful render.
192    let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
193        if !eval_when(expr, engine, ctx)? {
194            return skip_when_false(template_path, &target, dry_run, report);
195        }
196        body.to_string()
197    } else {
198        raw
199    };
200
201    let rel = relative_to(source, template_path);
202    let rel_for_match = rel.as_str().replace('\\', "/");
203    for rule in rules {
204        if rule.matcher.is_match(&rel_for_match) {
205            if let Some(w) = &rule.when {
206                if !eval_when(w, engine, ctx)? {
207                    return skip_when_false(template_path, &target, dry_run, report);
208                }
209            }
210        }
211    }
212
213    let body = engine.render(&body_input, ctx)?;
214
215    match std::fs::read_to_string(&target) {
216        Ok(existing) if existing == body => {
217            report.unchanged.push(target);
218            return Ok(());
219        }
220        Ok(_) => {
221            report.diverged.push(target);
222            return Ok(());
223        }
224        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
225        Err(e) => return Err(Error::Template(format!("read {target}: {e}"))),
226    }
227
228    if !dry_run {
229        if let Some(parent) = target.parent() {
230            std::fs::create_dir_all(parent)?;
231        }
232        std::fs::write(&target, &body)?;
233    }
234    report.written.push(target);
235    Ok(())
236}
237
238/// Common path for `when=false` skips: drops any stale rendered output so
239/// the link walk doesn't end up linking yesterday's render of a now-disabled
240/// template. Records the skip on the report.
241fn skip_when_false(
242    template_path: &Utf8Path,
243    target: &Utf8Path,
244    dry_run: bool,
245    report: &mut RenderReport,
246) -> Result<()> {
247    if !dry_run && target.exists() {
248        std::fs::remove_file(target)
249            .map_err(|e| Error::Template(format!("removing stale rendered {target}: {e}")))?;
250    }
251    report.skipped_when_false.push(template_path.to_path_buf());
252    Ok(())
253}
254
255/// If the file begins with a `{# yui:when EXPR #}` header (after optional
256/// leading whitespace) followed by an immediate newline, returns
257/// `(expr, body)` where `body` is the file content with that header line
258/// stripped. Otherwise None.
259fn split_yui_when(raw: &str) -> Option<(&str, &str)> {
260    let leading_ws = raw.len() - raw.trim_start().len();
261    let after_ws = &raw[leading_ws..];
262    let after_open = after_ws.strip_prefix("{#")?;
263    let close = after_open.find("#}")?;
264    let inside = &after_open[..close];
265    let expr = inside.trim().strip_prefix("yui:when")?.trim();
266
267    let mut body_start = leading_ws + 2 + close + 2;
268    if raw[body_start..].starts_with("\r\n") {
269        body_start += 2;
270    } else if raw[body_start..].starts_with('\n') {
271        body_start += 1;
272    }
273    Some((expr, &raw[body_start..]))
274}
275
276/// Local alias for [`template::eval_truthy`] to keep call sites short.
277fn eval_when(expr: &str, engine: &mut Engine, ctx: &TeraContext) -> Result<bool> {
278    template::eval_truthy(expr, engine, ctx)
279}
280
281fn template_target(template_path: &Utf8Path) -> Utf8PathBuf {
282    let s = template_path.as_str();
283    debug_assert!(s.ends_with(".tera"));
284    Utf8PathBuf::from(&s[..s.len() - ".tera".len()])
285}
286
287fn relative_to(base: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
288    p.strip_prefix(base)
289        .map(Utf8PathBuf::from)
290        .unwrap_or_else(|_| p.to_path_buf())
291}
292
293fn collect_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
294    let mut all: Vec<_> = report
295        .written
296        .iter()
297        .chain(report.unchanged.iter())
298        .chain(report.diverged.iter())
299        .cloned()
300        .collect();
301    all.sort();
302    all.dedup();
303    all
304}
305
306fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
307    let gi_path = source.join(".gitignore");
308    let existing = match std::fs::read_to_string(&gi_path) {
309        Ok(s) => s,
310        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
311        Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
312    };
313
314    let mut managed: Vec<String> = rendered_abs_paths
315        .iter()
316        .filter_map(|p| p.strip_prefix(source).ok())
317        .map(|p| p.as_str().replace('\\', "/"))
318        .collect();
319    managed.sort();
320    managed.dedup();
321
322    let new_section = build_managed_section(&managed);
323    let updated = replace_or_append_section(&existing, &new_section);
324
325    if updated != existing {
326        std::fs::write(&gi_path, updated)?;
327    }
328    Ok(())
329}
330
331fn build_managed_section(lines: &[String]) -> String {
332    let mut s = String::new();
333    s.push_str(GITIGNORE_BEGIN);
334    s.push('\n');
335    for l in lines {
336        s.push_str(l);
337        s.push('\n');
338    }
339    s.push_str(GITIGNORE_END);
340    s.push('\n');
341    s
342}
343
344fn replace_or_append_section(existing: &str, new_section: &str) -> String {
345    // Refactored from `if let ... && cond` (let-chains) to nested if so the
346    // crate's MSRV (rust-version = "1.85") stays buildable; let-chains were
347    // stabilized in 1.88.
348    if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
349    {
350        if start < end {
351            let end_line_end = match existing[end..].find('\n') {
352                Some(idx) => end + idx + 1,
353                None => existing.len(),
354            };
355            let mut out = String::with_capacity(existing.len() + new_section.len());
356            out.push_str(&existing[..start]);
357            out.push_str(new_section);
358            out.push_str(&existing[end_line_end..]);
359            return out;
360        }
361    }
362
363    let mut out = String::from(existing);
364    if !out.is_empty() && !out.ends_with('\n') {
365        out.push('\n');
366    }
367    if !out.is_empty() {
368        out.push('\n');
369    }
370    out.push_str(new_section);
371    out
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use tempfile::TempDir;
378
379    fn yui_vars(source: &Utf8Path) -> YuiVars {
380        YuiVars {
381            os: "linux".into(),
382            arch: "x86_64".into(),
383            host: "test".into(),
384            user: "u".into(),
385            source: source.to_string(),
386        }
387    }
388
389    fn root(tmp: &TempDir) -> Utf8PathBuf {
390        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
391    }
392
393    fn empty_config() -> Config {
394        Config::default()
395    }
396
397    fn write(p: &Utf8Path, body: &str) {
398        if let Some(parent) = p.parent() {
399            std::fs::create_dir_all(parent).unwrap();
400        }
401        std::fs::write(p, body).unwrap();
402    }
403
404    #[test]
405    fn split_yui_when_basic() {
406        assert_eq!(
407            split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
408            Some(("yui.os == 'linux'", "body"))
409        );
410        assert_eq!(
411            split_yui_when("\n  {#yui:when 1 == 1#}\nbody"),
412            Some(("1 == 1", "body"))
413        );
414        // CRLF line endings
415        assert_eq!(
416            split_yui_when("{# yui:when true #}\r\nbody"),
417            Some(("true", "body"))
418        );
419        // No newline after header — header still parsed, body is the rest
420        assert_eq!(
421            split_yui_when("{# yui:when true #}body"),
422            Some(("true", "body"))
423        );
424        assert_eq!(split_yui_when("body without header"), None);
425        assert_eq!(split_yui_when("{# regular comment #}body"), None);
426    }
427
428    #[test]
429    fn template_target_strips_tera_extension() {
430        assert_eq!(
431            template_target(Utf8Path::new("/a/b/foo.tera")),
432            Utf8PathBuf::from("/a/b/foo")
433        );
434        assert_eq!(
435            template_target(Utf8Path::new("home/.gitconfig.tera")),
436            Utf8PathBuf::from("home/.gitconfig")
437        );
438    }
439
440    #[test]
441    fn renders_simple_template_to_sibling() {
442        let tmp = TempDir::new().unwrap();
443        let r = root(&tmp);
444        write(
445            &r.join("home/.gitconfig.tera"),
446            "[user]\n  os = {{ yui.os }}\n",
447        );
448        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
449        assert_eq!(report.written.len(), 1);
450        assert_eq!(
451            std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
452            "[user]\n  os = linux\n"
453        );
454    }
455
456    #[test]
457    fn renders_user_vars() {
458        let tmp = TempDir::new().unwrap();
459        let r = root(&tmp);
460        write(&r.join("home/foo.tera"), "{{ vars.greet }}");
461        let mut cfg = empty_config();
462        cfg.vars
463            .insert("greet".into(), toml::Value::String("hello".into()));
464        let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
465        assert_eq!(
466            std::fs::read_to_string(r.join("home/foo")).unwrap(),
467            "hello"
468        );
469    }
470
471    #[test]
472    fn skips_when_file_header_false() {
473        let tmp = TempDir::new().unwrap();
474        let r = root(&tmp);
475        write(
476            &r.join("home/foo.tera"),
477            "{# yui:when yui.os == 'windows' #}\nbody",
478        );
479        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
480        assert!(report.written.is_empty());
481        assert_eq!(report.skipped_when_false.len(), 1);
482        assert!(!r.join("home/foo").exists());
483    }
484
485    #[test]
486    fn includes_when_file_header_true() {
487        let tmp = TempDir::new().unwrap();
488        let r = root(&tmp);
489        write(
490            &r.join("home/foo.tera"),
491            "{# yui:when yui.os == 'linux' #}\nbody",
492        );
493        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
494        assert_eq!(report.written.len(), 1);
495        assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
496    }
497
498    #[test]
499    fn config_rule_when_false_skips_matching_template() {
500        let tmp = TempDir::new().unwrap();
501        let r = root(&tmp);
502        write(&r.join("home/win/settings.tera"), "body");
503        let mut cfg = empty_config();
504        cfg.render.rule.push(RenderRule {
505            r#match: "home/win/**".into(),
506            when: Some("{{ yui.os == 'windows' }}".into()),
507        });
508        let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
509        assert_eq!(report.skipped_when_false.len(), 1);
510        assert!(report.written.is_empty());
511    }
512
513    #[test]
514    fn config_rule_no_match_does_not_filter() {
515        let tmp = TempDir::new().unwrap();
516        let r = root(&tmp);
517        write(&r.join("home/foo.tera"), "body");
518        let mut cfg = empty_config();
519        // glob doesn't match foo.tera
520        cfg.render.rule.push(RenderRule {
521            r#match: "home/win/**".into(),
522            when: Some("{{ yui.os == 'windows' }}".into()),
523        });
524        let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
525        assert_eq!(report.written.len(), 1);
526    }
527
528    #[test]
529    fn unchanged_when_existing_matches() {
530        let tmp = TempDir::new().unwrap();
531        let r = root(&tmp);
532        write(&r.join("home/foo.tera"), "body");
533        write(&r.join("home/foo"), "body"); // already in sync
534        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
535        assert!(report.written.is_empty());
536        assert_eq!(report.unchanged.len(), 1);
537    }
538
539    #[test]
540    fn detects_drift_when_existing_diverges() {
541        let tmp = TempDir::new().unwrap();
542        let r = root(&tmp);
543        write(&r.join("home/foo.tera"), "fresh body");
544        write(&r.join("home/foo"), "manually edited");
545        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
546        assert!(report.has_drift());
547        assert_eq!(report.diverged.len(), 1);
548        // existing content NOT overwritten
549        assert_eq!(
550            std::fs::read_to_string(r.join("home/foo")).unwrap(),
551            "manually edited"
552        );
553    }
554
555    #[test]
556    fn dry_run_does_not_write_or_touch_gitignore() {
557        let tmp = TempDir::new().unwrap();
558        let r = root(&tmp);
559        write(&r.join("home/foo.tera"), "body");
560        let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
561        assert!(!r.join("home/foo").exists());
562        assert!(!r.join(".gitignore").exists());
563    }
564
565    #[test]
566    fn updates_gitignore_managed_section() {
567        let tmp = TempDir::new().unwrap();
568        let r = root(&tmp);
569        write(&r.join("home/foo.tera"), "body");
570        write(&r.join("home/bar.tera"), "body2");
571        let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
572        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
573        assert!(gi.contains(GITIGNORE_BEGIN));
574        assert!(gi.contains(GITIGNORE_END));
575        assert!(gi.contains("home/bar"));
576        assert!(gi.contains("home/foo"));
577        // sorted: bar before foo
578        let bar_pos = gi.find("home/bar").unwrap();
579        let foo_pos = gi.find("home/foo").unwrap();
580        assert!(bar_pos < foo_pos);
581    }
582
583    #[test]
584    fn preserves_existing_gitignore_content() {
585        let tmp = TempDir::new().unwrap();
586        let r = root(&tmp);
587        write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
588        write(&r.join("home/foo.tera"), "body");
589        let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
590        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
591        assert!(gi.contains("node_modules/"));
592        assert!(gi.contains("target/"));
593        assert!(gi.contains("home/foo"));
594    }
595
596    #[test]
597    fn replaces_existing_managed_section() {
598        let tmp = TempDir::new().unwrap();
599        let r = root(&tmp);
600        // Pre-existing managed section with stale content
601        write(
602            &r.join(".gitignore"),
603            &format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
604        );
605        write(&r.join("home/foo.tera"), "body");
606        let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
607        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
608        assert!(gi.contains("node_modules/"));
609        assert!(gi.contains("home/foo"));
610        assert!(!gi.contains("stale/path"));
611        // post-section content preserved
612        assert!(gi.contains("\nfoo\n"));
613    }
614
615    #[test]
616    fn walks_into_gitignored_directories() {
617        let tmp = TempDir::new().unwrap();
618        let r = root(&tmp);
619        // Pre-existing .gitignore that would normally hide `node_modules/`.
620        write(&r.join(".gitignore"), "node_modules/\n");
621        write(&r.join("node_modules/foo.tera"), "body");
622        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
623        // Template under a gitignored dir is still discovered + rendered.
624        assert_eq!(report.written.len(), 1);
625        assert!(r.join("node_modules/foo").exists());
626    }
627
628    #[test]
629    fn removes_stale_rendered_when_file_header_becomes_false() {
630        let tmp = TempDir::new().unwrap();
631        let r = root(&tmp);
632        // Previously rendered output sitting on disk
633        write(
634            &r.join("home/foo.tera"),
635            "{# yui:when yui.os == 'windows' #}\nbody",
636        );
637        write(&r.join("home/foo"), "old rendered output");
638        let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
639        assert_eq!(report.skipped_when_false.len(), 1);
640        // Stale sibling was cleaned up so apply won't link it.
641        assert!(!r.join("home/foo").exists());
642    }
643
644    #[test]
645    fn removes_stale_rendered_when_rule_when_becomes_false() {
646        let tmp = TempDir::new().unwrap();
647        let r = root(&tmp);
648        write(&r.join("home/win/settings.tera"), "body");
649        write(&r.join("home/win/settings"), "old rendered output");
650        let mut cfg = empty_config();
651        cfg.render.rule.push(RenderRule {
652            r#match: "home/win/**".into(),
653            when: Some("{{ yui.os == 'windows' }}".into()),
654        });
655        let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
656        assert_eq!(report.skipped_when_false.len(), 1);
657        assert!(!r.join("home/win/settings").exists());
658    }
659
660    #[test]
661    fn dry_run_does_not_remove_stale_rendered() {
662        let tmp = TempDir::new().unwrap();
663        let r = root(&tmp);
664        write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
665        write(&r.join("home/foo"), "old rendered output");
666        let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
667        // Dry-run leaves the on-disk file alone.
668        assert_eq!(
669            std::fs::read_to_string(r.join("home/foo")).unwrap(),
670            "old rendered output"
671        );
672    }
673
674    #[test]
675    fn manage_gitignore_disabled_does_not_write_gitignore() {
676        let tmp = TempDir::new().unwrap();
677        let r = root(&tmp);
678        write(&r.join("home/foo.tera"), "body");
679        let mut cfg = empty_config();
680        cfg.render.manage_gitignore = false;
681        let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
682        assert!(r.join("home/foo").exists());
683        assert!(!r.join(".gitignore").exists());
684    }
685}