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