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