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    yuiignore: &ignore::gitignore::Gitignore,
58    dry_run: bool,
59) -> Result<RenderReport> {
60    let mut engine = Engine::new();
61    let ctx = template::template_context(yui, &config.vars);
62    let rules = compile_rules(&config.render.rule)?;
63    let mut report = RenderReport::default();
64
65    // Walk every file under source. Filtering is centralized in
66    // `paths::source_walker`: ignore-files OFF (so unrelated user rules
67    // don't swallow `.tera`s) and `.yui/` skipped (backup mirrors etc.
68    // shouldn't be rendered). `.yuiignore` patterns are checked per
69    // entry below.
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        // `.yuiignore` filter — also skip rendering the rendered counterpart
91        // (since that's what'd land in the user's tree).
92        if paths::is_ignored(yuiignore, source, &template_path, false) {
93            continue;
94        }
95        process_template(
96            &template_path,
97            source,
98            &rules,
99            &mut engine,
100            &ctx,
101            dry_run,
102            &mut report,
103        )?;
104    }
105
106    if !dry_run && config.render.manage_gitignore {
107        update_gitignore(source, &collect_managed_paths(&report))?;
108    }
109    Ok(report)
110}
111
112struct CompiledRule {
113    matcher: GlobSet,
114    when: Option<String>,
115}
116
117fn compile_rules(rules: &[RenderRule]) -> Result<Vec<CompiledRule>> {
118    let mut out = Vec::with_capacity(rules.len());
119    for r in rules {
120        let glob = Glob::new(&r.r#match)
121            .map_err(|e| Error::Config(format!("render.rule.match {:?}: {e}", r.r#match)))?;
122        let mut b = GlobSetBuilder::new();
123        b.add(glob);
124        let matcher = b
125            .build()
126            .map_err(|e| Error::Config(format!("globset build: {e}")))?;
127        out.push(CompiledRule {
128            matcher,
129            when: r.when.clone(),
130        });
131    }
132    Ok(out)
133}
134
135fn process_template(
136    template_path: &Utf8Path,
137    source: &Utf8Path,
138    rules: &[CompiledRule],
139    engine: &mut Engine,
140    ctx: &TeraContext,
141    dry_run: bool,
142    report: &mut RenderReport,
143) -> Result<()> {
144    let raw = std::fs::read_to_string(template_path)
145        .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
146    let target = template_target(template_path);
147
148    // Strip any `{# yui:when EXPR #}\n` header before handing the body to
149    // Tera, so a falsy header doesn't leave a stray newline at the top of
150    // a successful render.
151    let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
152        if !eval_when(expr, engine, ctx)? {
153            return skip_when_false(template_path, &target, dry_run, report);
154        }
155        body.to_string()
156    } else {
157        raw
158    };
159
160    let rel = relative_to(source, template_path);
161    let rel_for_match = rel.as_str().replace('\\', "/");
162    for rule in rules {
163        if rule.matcher.is_match(&rel_for_match) {
164            if let Some(w) = &rule.when {
165                if !eval_when(w, engine, ctx)? {
166                    return skip_when_false(template_path, &target, dry_run, report);
167                }
168            }
169        }
170    }
171
172    let body = engine.render(&body_input, ctx)?;
173
174    match std::fs::read_to_string(&target) {
175        Ok(existing) if existing == body => {
176            report.unchanged.push(target);
177            return Ok(());
178        }
179        Ok(_) => {
180            report.diverged.push(target);
181            return Ok(());
182        }
183        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
184        Err(e) => return Err(Error::Template(format!("read {target}: {e}"))),
185    }
186
187    if !dry_run {
188        if let Some(parent) = target.parent() {
189            std::fs::create_dir_all(parent)?;
190        }
191        std::fs::write(&target, &body)?;
192    }
193    report.written.push(target);
194    Ok(())
195}
196
197/// Common path for `when=false` skips: drops any stale rendered output so
198/// the link walk doesn't end up linking yesterday's render of a now-disabled
199/// template. Records the skip on the report.
200fn skip_when_false(
201    template_path: &Utf8Path,
202    target: &Utf8Path,
203    dry_run: bool,
204    report: &mut RenderReport,
205) -> Result<()> {
206    if !dry_run && target.exists() {
207        std::fs::remove_file(target)
208            .map_err(|e| Error::Template(format!("removing stale rendered {target}: {e}")))?;
209    }
210    report.skipped_when_false.push(template_path.to_path_buf());
211    Ok(())
212}
213
214/// If the file begins with a `{# yui:when EXPR #}` header (after optional
215/// leading whitespace) followed by an immediate newline, returns
216/// `(expr, body)` where `body` is the file content with that header line
217/// stripped. Otherwise None.
218fn split_yui_when(raw: &str) -> Option<(&str, &str)> {
219    let leading_ws = raw.len() - raw.trim_start().len();
220    let after_ws = &raw[leading_ws..];
221    let after_open = after_ws.strip_prefix("{#")?;
222    let close = after_open.find("#}")?;
223    let inside = &after_open[..close];
224    let expr = inside.trim().strip_prefix("yui:when")?.trim();
225
226    let mut body_start = leading_ws + 2 + close + 2;
227    if raw[body_start..].starts_with("\r\n") {
228        body_start += 2;
229    } else if raw[body_start..].starts_with('\n') {
230        body_start += 1;
231    }
232    Some((expr, &raw[body_start..]))
233}
234
235/// Local alias for [`template::eval_truthy`] to keep call sites short.
236fn eval_when(expr: &str, engine: &mut Engine, ctx: &TeraContext) -> Result<bool> {
237    template::eval_truthy(expr, engine, ctx)
238}
239
240fn template_target(template_path: &Utf8Path) -> Utf8PathBuf {
241    let s = template_path.as_str();
242    debug_assert!(s.ends_with(".tera"));
243    Utf8PathBuf::from(&s[..s.len() - ".tera".len()])
244}
245
246fn relative_to(base: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
247    p.strip_prefix(base)
248        .map(Utf8PathBuf::from)
249        .unwrap_or_else(|_| p.to_path_buf())
250}
251
252fn collect_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
253    let mut all: Vec<_> = report
254        .written
255        .iter()
256        .chain(report.unchanged.iter())
257        .chain(report.diverged.iter())
258        .cloned()
259        .collect();
260    all.sort();
261    all.dedup();
262    all
263}
264
265fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
266    let gi_path = source.join(".gitignore");
267    let existing = match std::fs::read_to_string(&gi_path) {
268        Ok(s) => s,
269        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
270        Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
271    };
272
273    let mut managed: Vec<String> = rendered_abs_paths
274        .iter()
275        .filter_map(|p| p.strip_prefix(source).ok())
276        .map(|p| p.as_str().replace('\\', "/"))
277        .collect();
278    managed.sort();
279    managed.dedup();
280
281    let new_section = build_managed_section(&managed);
282    let updated = replace_or_append_section(&existing, &new_section);
283
284    if updated != existing {
285        std::fs::write(&gi_path, updated)?;
286    }
287    Ok(())
288}
289
290fn build_managed_section(lines: &[String]) -> String {
291    let mut s = String::new();
292    s.push_str(GITIGNORE_BEGIN);
293    s.push('\n');
294    for l in lines {
295        s.push_str(l);
296        s.push('\n');
297    }
298    s.push_str(GITIGNORE_END);
299    s.push('\n');
300    s
301}
302
303fn replace_or_append_section(existing: &str, new_section: &str) -> String {
304    // Refactored from `if let ... && cond` (let-chains) to nested if so the
305    // crate's MSRV (rust-version = "1.85") stays buildable; let-chains were
306    // stabilized in 1.88.
307    if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
308    {
309        if start < end {
310            let end_line_end = match existing[end..].find('\n') {
311                Some(idx) => end + idx + 1,
312                None => existing.len(),
313            };
314            let mut out = String::with_capacity(existing.len() + new_section.len());
315            out.push_str(&existing[..start]);
316            out.push_str(new_section);
317            out.push_str(&existing[end_line_end..]);
318            return out;
319        }
320    }
321
322    let mut out = String::from(existing);
323    if !out.is_empty() && !out.ends_with('\n') {
324        out.push('\n');
325    }
326    if !out.is_empty() {
327        out.push('\n');
328    }
329    out.push_str(new_section);
330    out
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use tempfile::TempDir;
337
338    fn yui_vars(source: &Utf8Path) -> YuiVars {
339        YuiVars {
340            os: "linux".into(),
341            arch: "x86_64".into(),
342            host: "test".into(),
343            user: "u".into(),
344            source: source.to_string(),
345        }
346    }
347
348    fn root(tmp: &TempDir) -> Utf8PathBuf {
349        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
350    }
351
352    fn empty_config() -> Config {
353        Config::default()
354    }
355
356    fn write(p: &Utf8Path, body: &str) {
357        if let Some(parent) = p.parent() {
358            std::fs::create_dir_all(parent).unwrap();
359        }
360        std::fs::write(p, body).unwrap();
361    }
362
363    #[test]
364    fn split_yui_when_basic() {
365        assert_eq!(
366            split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
367            Some(("yui.os == 'linux'", "body"))
368        );
369        assert_eq!(
370            split_yui_when("\n  {#yui:when 1 == 1#}\nbody"),
371            Some(("1 == 1", "body"))
372        );
373        // CRLF line endings
374        assert_eq!(
375            split_yui_when("{# yui:when true #}\r\nbody"),
376            Some(("true", "body"))
377        );
378        // No newline after header — header still parsed, body is the rest
379        assert_eq!(
380            split_yui_when("{# yui:when true #}body"),
381            Some(("true", "body"))
382        );
383        assert_eq!(split_yui_when("body without header"), None);
384        assert_eq!(split_yui_when("{# regular comment #}body"), None);
385    }
386
387    #[test]
388    fn template_target_strips_tera_extension() {
389        assert_eq!(
390            template_target(Utf8Path::new("/a/b/foo.tera")),
391            Utf8PathBuf::from("/a/b/foo")
392        );
393        assert_eq!(
394            template_target(Utf8Path::new("home/.gitconfig.tera")),
395            Utf8PathBuf::from("home/.gitconfig")
396        );
397    }
398
399    #[test]
400    fn renders_simple_template_to_sibling() {
401        let tmp = TempDir::new().unwrap();
402        let r = root(&tmp);
403        write(
404            &r.join("home/.gitconfig.tera"),
405            "[user]\n  os = {{ yui.os }}\n",
406        );
407        let report = render_all(
408            &r,
409            &empty_config(),
410            &yui_vars(&r),
411            &ignore::gitignore::Gitignore::empty(),
412            false,
413        )
414        .unwrap();
415        assert_eq!(report.written.len(), 1);
416        assert_eq!(
417            std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
418            "[user]\n  os = linux\n"
419        );
420    }
421
422    #[test]
423    fn renders_user_vars() {
424        let tmp = TempDir::new().unwrap();
425        let r = root(&tmp);
426        write(&r.join("home/foo.tera"), "{{ vars.greet }}");
427        let mut cfg = empty_config();
428        cfg.vars
429            .insert("greet".into(), toml::Value::String("hello".into()));
430        let _ = render_all(
431            &r,
432            &cfg,
433            &yui_vars(&r),
434            &ignore::gitignore::Gitignore::empty(),
435            false,
436        )
437        .unwrap();
438        assert_eq!(
439            std::fs::read_to_string(r.join("home/foo")).unwrap(),
440            "hello"
441        );
442    }
443
444    #[test]
445    fn skips_when_file_header_false() {
446        let tmp = TempDir::new().unwrap();
447        let r = root(&tmp);
448        write(
449            &r.join("home/foo.tera"),
450            "{# yui:when yui.os == 'windows' #}\nbody",
451        );
452        let report = render_all(
453            &r,
454            &empty_config(),
455            &yui_vars(&r),
456            &ignore::gitignore::Gitignore::empty(),
457            false,
458        )
459        .unwrap();
460        assert!(report.written.is_empty());
461        assert_eq!(report.skipped_when_false.len(), 1);
462        assert!(!r.join("home/foo").exists());
463    }
464
465    #[test]
466    fn includes_when_file_header_true() {
467        let tmp = TempDir::new().unwrap();
468        let r = root(&tmp);
469        write(
470            &r.join("home/foo.tera"),
471            "{# yui:when yui.os == 'linux' #}\nbody",
472        );
473        let report = render_all(
474            &r,
475            &empty_config(),
476            &yui_vars(&r),
477            &ignore::gitignore::Gitignore::empty(),
478            false,
479        )
480        .unwrap();
481        assert_eq!(report.written.len(), 1);
482        assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
483    }
484
485    #[test]
486    fn config_rule_when_false_skips_matching_template() {
487        let tmp = TempDir::new().unwrap();
488        let r = root(&tmp);
489        write(&r.join("home/win/settings.tera"), "body");
490        let mut cfg = empty_config();
491        cfg.render.rule.push(RenderRule {
492            r#match: "home/win/**".into(),
493            when: Some("{{ yui.os == 'windows' }}".into()),
494        });
495        let report = render_all(
496            &r,
497            &cfg,
498            &yui_vars(&r),
499            &ignore::gitignore::Gitignore::empty(),
500            false,
501        )
502        .unwrap();
503        assert_eq!(report.skipped_when_false.len(), 1);
504        assert!(report.written.is_empty());
505    }
506
507    #[test]
508    fn config_rule_no_match_does_not_filter() {
509        let tmp = TempDir::new().unwrap();
510        let r = root(&tmp);
511        write(&r.join("home/foo.tera"), "body");
512        let mut cfg = empty_config();
513        // glob doesn't match foo.tera
514        cfg.render.rule.push(RenderRule {
515            r#match: "home/win/**".into(),
516            when: Some("{{ yui.os == 'windows' }}".into()),
517        });
518        let report = render_all(
519            &r,
520            &cfg,
521            &yui_vars(&r),
522            &ignore::gitignore::Gitignore::empty(),
523            false,
524        )
525        .unwrap();
526        assert_eq!(report.written.len(), 1);
527    }
528
529    #[test]
530    fn unchanged_when_existing_matches() {
531        let tmp = TempDir::new().unwrap();
532        let r = root(&tmp);
533        write(&r.join("home/foo.tera"), "body");
534        write(&r.join("home/foo"), "body"); // already in sync
535        let report = render_all(
536            &r,
537            &empty_config(),
538            &yui_vars(&r),
539            &ignore::gitignore::Gitignore::empty(),
540            false,
541        )
542        .unwrap();
543        assert!(report.written.is_empty());
544        assert_eq!(report.unchanged.len(), 1);
545    }
546
547    #[test]
548    fn detects_drift_when_existing_diverges() {
549        let tmp = TempDir::new().unwrap();
550        let r = root(&tmp);
551        write(&r.join("home/foo.tera"), "fresh body");
552        write(&r.join("home/foo"), "manually edited");
553        let report = render_all(
554            &r,
555            &empty_config(),
556            &yui_vars(&r),
557            &ignore::gitignore::Gitignore::empty(),
558            false,
559        )
560        .unwrap();
561        assert!(report.has_drift());
562        assert_eq!(report.diverged.len(), 1);
563        // existing content NOT overwritten
564        assert_eq!(
565            std::fs::read_to_string(r.join("home/foo")).unwrap(),
566            "manually edited"
567        );
568    }
569
570    #[test]
571    fn dry_run_does_not_write_or_touch_gitignore() {
572        let tmp = TempDir::new().unwrap();
573        let r = root(&tmp);
574        write(&r.join("home/foo.tera"), "body");
575        let _ = render_all(
576            &r,
577            &empty_config(),
578            &yui_vars(&r),
579            &ignore::gitignore::Gitignore::empty(),
580            true,
581        )
582        .unwrap();
583        assert!(!r.join("home/foo").exists());
584        assert!(!r.join(".gitignore").exists());
585    }
586
587    #[test]
588    fn updates_gitignore_managed_section() {
589        let tmp = TempDir::new().unwrap();
590        let r = root(&tmp);
591        write(&r.join("home/foo.tera"), "body");
592        write(&r.join("home/bar.tera"), "body2");
593        let _ = render_all(
594            &r,
595            &empty_config(),
596            &yui_vars(&r),
597            &ignore::gitignore::Gitignore::empty(),
598            false,
599        )
600        .unwrap();
601        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
602        assert!(gi.contains(GITIGNORE_BEGIN));
603        assert!(gi.contains(GITIGNORE_END));
604        assert!(gi.contains("home/bar"));
605        assert!(gi.contains("home/foo"));
606        // sorted: bar before foo
607        let bar_pos = gi.find("home/bar").unwrap();
608        let foo_pos = gi.find("home/foo").unwrap();
609        assert!(bar_pos < foo_pos);
610    }
611
612    #[test]
613    fn preserves_existing_gitignore_content() {
614        let tmp = TempDir::new().unwrap();
615        let r = root(&tmp);
616        write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
617        write(&r.join("home/foo.tera"), "body");
618        let _ = render_all(
619            &r,
620            &empty_config(),
621            &yui_vars(&r),
622            &ignore::gitignore::Gitignore::empty(),
623            false,
624        )
625        .unwrap();
626        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
627        assert!(gi.contains("node_modules/"));
628        assert!(gi.contains("target/"));
629        assert!(gi.contains("home/foo"));
630    }
631
632    #[test]
633    fn replaces_existing_managed_section() {
634        let tmp = TempDir::new().unwrap();
635        let r = root(&tmp);
636        // Pre-existing managed section with stale content
637        write(
638            &r.join(".gitignore"),
639            &format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
640        );
641        write(&r.join("home/foo.tera"), "body");
642        let _ = render_all(
643            &r,
644            &empty_config(),
645            &yui_vars(&r),
646            &ignore::gitignore::Gitignore::empty(),
647            false,
648        )
649        .unwrap();
650        let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
651        assert!(gi.contains("node_modules/"));
652        assert!(gi.contains("home/foo"));
653        assert!(!gi.contains("stale/path"));
654        // post-section content preserved
655        assert!(gi.contains("\nfoo\n"));
656    }
657
658    #[test]
659    fn walks_into_gitignored_directories() {
660        let tmp = TempDir::new().unwrap();
661        let r = root(&tmp);
662        // Pre-existing .gitignore that would normally hide `node_modules/`.
663        write(&r.join(".gitignore"), "node_modules/\n");
664        write(&r.join("node_modules/foo.tera"), "body");
665        let report = render_all(
666            &r,
667            &empty_config(),
668            &yui_vars(&r),
669            &ignore::gitignore::Gitignore::empty(),
670            false,
671        )
672        .unwrap();
673        // Template under a gitignored dir is still discovered + rendered.
674        assert_eq!(report.written.len(), 1);
675        assert!(r.join("node_modules/foo").exists());
676    }
677
678    #[test]
679    fn removes_stale_rendered_when_file_header_becomes_false() {
680        let tmp = TempDir::new().unwrap();
681        let r = root(&tmp);
682        // Previously rendered output sitting on disk
683        write(
684            &r.join("home/foo.tera"),
685            "{# yui:when yui.os == 'windows' #}\nbody",
686        );
687        write(&r.join("home/foo"), "old rendered output");
688        let report = render_all(
689            &r,
690            &empty_config(),
691            &yui_vars(&r),
692            &ignore::gitignore::Gitignore::empty(),
693            false,
694        )
695        .unwrap();
696        assert_eq!(report.skipped_when_false.len(), 1);
697        // Stale sibling was cleaned up so apply won't link it.
698        assert!(!r.join("home/foo").exists());
699    }
700
701    #[test]
702    fn removes_stale_rendered_when_rule_when_becomes_false() {
703        let tmp = TempDir::new().unwrap();
704        let r = root(&tmp);
705        write(&r.join("home/win/settings.tera"), "body");
706        write(&r.join("home/win/settings"), "old rendered output");
707        let mut cfg = empty_config();
708        cfg.render.rule.push(RenderRule {
709            r#match: "home/win/**".into(),
710            when: Some("{{ yui.os == 'windows' }}".into()),
711        });
712        let report = render_all(
713            &r,
714            &cfg,
715            &yui_vars(&r),
716            &ignore::gitignore::Gitignore::empty(),
717            false,
718        )
719        .unwrap();
720        assert_eq!(report.skipped_when_false.len(), 1);
721        assert!(!r.join("home/win/settings").exists());
722    }
723
724    #[test]
725    fn dry_run_does_not_remove_stale_rendered() {
726        let tmp = TempDir::new().unwrap();
727        let r = root(&tmp);
728        write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
729        write(&r.join("home/foo"), "old rendered output");
730        let _ = render_all(
731            &r,
732            &empty_config(),
733            &yui_vars(&r),
734            &ignore::gitignore::Gitignore::empty(),
735            true,
736        )
737        .unwrap();
738        // Dry-run leaves the on-disk file alone.
739        assert_eq!(
740            std::fs::read_to_string(r.join("home/foo")).unwrap(),
741            "old rendered output"
742        );
743    }
744
745    #[test]
746    fn manage_gitignore_disabled_does_not_write_gitignore() {
747        let tmp = TempDir::new().unwrap();
748        let r = root(&tmp);
749        write(&r.join("home/foo.tera"), "body");
750        let mut cfg = empty_config();
751        cfg.render.manage_gitignore = false;
752        let _ = render_all(
753            &r,
754            &cfg,
755            &yui_vars(&r),
756            &ignore::gitignore::Gitignore::empty(),
757            false,
758        )
759        .unwrap();
760        assert!(r.join("home/foo").exists());
761        assert!(!r.join(".gitignore").exists());
762    }
763}