1use 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 pub written: Vec<Utf8PathBuf>,
38 pub unchanged: Vec<Utf8PathBuf>,
40 pub skipped_when_false: Vec<Utf8PathBuf>,
42 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 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 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 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
197fn 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
214fn 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
235fn 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 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 assert_eq!(
375 split_yui_when("{# yui:when true #}\r\nbody"),
376 Some(("true", "body"))
377 );
378 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 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"); 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 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 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 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 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 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 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 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 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 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}