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