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 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 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
107pub fn render_to_string(
116 template_path: &Utf8Path,
117 source: &Utf8Path,
118 config: &Config,
119 yui: &YuiVars,
120) -> Result<Option<String>> {
121 let raw = std::fs::read_to_string(template_path)
122 .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
123 let mut engine = Engine::new();
124 let ctx = template::template_context(yui, &config.vars);
125 let rules = compile_rules(&config.render.rule)?;
126
127 let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
128 if !eval_when(expr, &mut engine, &ctx)? {
129 return Ok(None);
130 }
131 body.to_string()
132 } else {
133 raw
134 };
135
136 let rel = relative_to(source, template_path);
137 let rel_for_match = rel.as_str().replace('\\', "/");
138 for rule in &rules {
139 if rule.matcher.is_match(&rel_for_match) {
142 if let Some(w) = &rule.when {
143 if !eval_when(w, &mut engine, &ctx)? {
144 return Ok(None);
145 }
146 }
147 }
148 }
149
150 Ok(Some(engine.render(&body_input, &ctx)?))
151}
152
153struct CompiledRule {
154 matcher: GlobSet,
155 when: Option<String>,
156}
157
158fn compile_rules(rules: &[RenderRule]) -> Result<Vec<CompiledRule>> {
159 let mut out = Vec::with_capacity(rules.len());
160 for r in rules {
161 let glob = Glob::new(&r.r#match)
162 .map_err(|e| Error::Config(format!("render.rule.match {:?}: {e}", r.r#match)))?;
163 let mut b = GlobSetBuilder::new();
164 b.add(glob);
165 let matcher = b
166 .build()
167 .map_err(|e| Error::Config(format!("globset build: {e}")))?;
168 out.push(CompiledRule {
169 matcher,
170 when: r.when.clone(),
171 });
172 }
173 Ok(out)
174}
175
176fn process_template(
177 template_path: &Utf8Path,
178 source: &Utf8Path,
179 rules: &[CompiledRule],
180 engine: &mut Engine,
181 ctx: &TeraContext,
182 dry_run: bool,
183 report: &mut RenderReport,
184) -> Result<()> {
185 let raw = std::fs::read_to_string(template_path)
186 .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
187 let target = template_target(template_path);
188
189 let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
193 if !eval_when(expr, engine, ctx)? {
194 return skip_when_false(template_path, &target, dry_run, report);
195 }
196 body.to_string()
197 } else {
198 raw
199 };
200
201 let rel = relative_to(source, template_path);
202 let rel_for_match = rel.as_str().replace('\\', "/");
203 for rule in rules {
204 if rule.matcher.is_match(&rel_for_match) {
205 if let Some(w) = &rule.when {
206 if !eval_when(w, engine, ctx)? {
207 return skip_when_false(template_path, &target, dry_run, report);
208 }
209 }
210 }
211 }
212
213 let body = engine.render(&body_input, ctx)?;
214
215 match std::fs::read_to_string(&target) {
216 Ok(existing) if existing == body => {
217 report.unchanged.push(target);
218 return Ok(());
219 }
220 Ok(_) => {
221 report.diverged.push(target);
222 return Ok(());
223 }
224 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
225 Err(e) => return Err(Error::Template(format!("read {target}: {e}"))),
226 }
227
228 if !dry_run {
229 if let Some(parent) = target.parent() {
230 std::fs::create_dir_all(parent)?;
231 }
232 std::fs::write(&target, &body)?;
233 }
234 report.written.push(target);
235 Ok(())
236}
237
238fn skip_when_false(
242 template_path: &Utf8Path,
243 target: &Utf8Path,
244 dry_run: bool,
245 report: &mut RenderReport,
246) -> Result<()> {
247 if !dry_run && target.exists() {
248 std::fs::remove_file(target)
249 .map_err(|e| Error::Template(format!("removing stale rendered {target}: {e}")))?;
250 }
251 report.skipped_when_false.push(template_path.to_path_buf());
252 Ok(())
253}
254
255fn split_yui_when(raw: &str) -> Option<(&str, &str)> {
260 let leading_ws = raw.len() - raw.trim_start().len();
261 let after_ws = &raw[leading_ws..];
262 let after_open = after_ws.strip_prefix("{#")?;
263 let close = after_open.find("#}")?;
264 let inside = &after_open[..close];
265 let expr = inside.trim().strip_prefix("yui:when")?.trim();
266
267 let mut body_start = leading_ws + 2 + close + 2;
268 if raw[body_start..].starts_with("\r\n") {
269 body_start += 2;
270 } else if raw[body_start..].starts_with('\n') {
271 body_start += 1;
272 }
273 Some((expr, &raw[body_start..]))
274}
275
276fn eval_when(expr: &str, engine: &mut Engine, ctx: &TeraContext) -> Result<bool> {
278 template::eval_truthy(expr, engine, ctx)
279}
280
281fn template_target(template_path: &Utf8Path) -> Utf8PathBuf {
282 let s = template_path.as_str();
283 debug_assert!(s.ends_with(".tera"));
284 Utf8PathBuf::from(&s[..s.len() - ".tera".len()])
285}
286
287fn relative_to(base: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
288 p.strip_prefix(base)
289 .map(Utf8PathBuf::from)
290 .unwrap_or_else(|_| p.to_path_buf())
291}
292
293fn collect_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
294 let mut all: Vec<_> = report
295 .written
296 .iter()
297 .chain(report.unchanged.iter())
298 .chain(report.diverged.iter())
299 .cloned()
300 .collect();
301 all.sort();
302 all.dedup();
303 all
304}
305
306fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
307 let gi_path = source.join(".gitignore");
308 let existing = match std::fs::read_to_string(&gi_path) {
309 Ok(s) => s,
310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
311 Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
312 };
313
314 let mut managed: Vec<String> = rendered_abs_paths
315 .iter()
316 .filter_map(|p| p.strip_prefix(source).ok())
317 .map(|p| p.as_str().replace('\\', "/"))
318 .collect();
319 managed.sort();
320 managed.dedup();
321
322 let new_section = build_managed_section(&managed);
323 let updated = replace_or_append_section(&existing, &new_section);
324
325 if updated != existing {
326 std::fs::write(&gi_path, updated)?;
327 }
328 Ok(())
329}
330
331fn build_managed_section(lines: &[String]) -> String {
332 let mut s = String::new();
333 s.push_str(GITIGNORE_BEGIN);
334 s.push('\n');
335 for l in lines {
336 s.push_str(l);
337 s.push('\n');
338 }
339 s.push_str(GITIGNORE_END);
340 s.push('\n');
341 s
342}
343
344fn replace_or_append_section(existing: &str, new_section: &str) -> String {
345 if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
349 {
350 if start < end {
351 let end_line_end = match existing[end..].find('\n') {
352 Some(idx) => end + idx + 1,
353 None => existing.len(),
354 };
355 let mut out = String::with_capacity(existing.len() + new_section.len());
356 out.push_str(&existing[..start]);
357 out.push_str(new_section);
358 out.push_str(&existing[end_line_end..]);
359 return out;
360 }
361 }
362
363 let mut out = String::from(existing);
364 if !out.is_empty() && !out.ends_with('\n') {
365 out.push('\n');
366 }
367 if !out.is_empty() {
368 out.push('\n');
369 }
370 out.push_str(new_section);
371 out
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use tempfile::TempDir;
378
379 fn yui_vars(source: &Utf8Path) -> YuiVars {
380 YuiVars {
381 os: "linux".into(),
382 arch: "x86_64".into(),
383 host: "test".into(),
384 user: "u".into(),
385 source: source.to_string(),
386 }
387 }
388
389 fn root(tmp: &TempDir) -> Utf8PathBuf {
390 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
391 }
392
393 fn empty_config() -> Config {
394 Config::default()
395 }
396
397 fn write(p: &Utf8Path, body: &str) {
398 if let Some(parent) = p.parent() {
399 std::fs::create_dir_all(parent).unwrap();
400 }
401 std::fs::write(p, body).unwrap();
402 }
403
404 #[test]
405 fn split_yui_when_basic() {
406 assert_eq!(
407 split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
408 Some(("yui.os == 'linux'", "body"))
409 );
410 assert_eq!(
411 split_yui_when("\n {#yui:when 1 == 1#}\nbody"),
412 Some(("1 == 1", "body"))
413 );
414 assert_eq!(
416 split_yui_when("{# yui:when true #}\r\nbody"),
417 Some(("true", "body"))
418 );
419 assert_eq!(
421 split_yui_when("{# yui:when true #}body"),
422 Some(("true", "body"))
423 );
424 assert_eq!(split_yui_when("body without header"), None);
425 assert_eq!(split_yui_when("{# regular comment #}body"), None);
426 }
427
428 #[test]
429 fn template_target_strips_tera_extension() {
430 assert_eq!(
431 template_target(Utf8Path::new("/a/b/foo.tera")),
432 Utf8PathBuf::from("/a/b/foo")
433 );
434 assert_eq!(
435 template_target(Utf8Path::new("home/.gitconfig.tera")),
436 Utf8PathBuf::from("home/.gitconfig")
437 );
438 }
439
440 #[test]
441 fn renders_simple_template_to_sibling() {
442 let tmp = TempDir::new().unwrap();
443 let r = root(&tmp);
444 write(
445 &r.join("home/.gitconfig.tera"),
446 "[user]\n os = {{ yui.os }}\n",
447 );
448 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
449 assert_eq!(report.written.len(), 1);
450 assert_eq!(
451 std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
452 "[user]\n os = linux\n"
453 );
454 }
455
456 #[test]
457 fn renders_user_vars() {
458 let tmp = TempDir::new().unwrap();
459 let r = root(&tmp);
460 write(&r.join("home/foo.tera"), "{{ vars.greet }}");
461 let mut cfg = empty_config();
462 cfg.vars
463 .insert("greet".into(), toml::Value::String("hello".into()));
464 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
465 assert_eq!(
466 std::fs::read_to_string(r.join("home/foo")).unwrap(),
467 "hello"
468 );
469 }
470
471 #[test]
472 fn skips_when_file_header_false() {
473 let tmp = TempDir::new().unwrap();
474 let r = root(&tmp);
475 write(
476 &r.join("home/foo.tera"),
477 "{# yui:when yui.os == 'windows' #}\nbody",
478 );
479 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
480 assert!(report.written.is_empty());
481 assert_eq!(report.skipped_when_false.len(), 1);
482 assert!(!r.join("home/foo").exists());
483 }
484
485 #[test]
486 fn includes_when_file_header_true() {
487 let tmp = TempDir::new().unwrap();
488 let r = root(&tmp);
489 write(
490 &r.join("home/foo.tera"),
491 "{# yui:when yui.os == 'linux' #}\nbody",
492 );
493 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
494 assert_eq!(report.written.len(), 1);
495 assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
496 }
497
498 #[test]
499 fn config_rule_when_false_skips_matching_template() {
500 let tmp = TempDir::new().unwrap();
501 let r = root(&tmp);
502 write(&r.join("home/win/settings.tera"), "body");
503 let mut cfg = empty_config();
504 cfg.render.rule.push(RenderRule {
505 r#match: "home/win/**".into(),
506 when: Some("{{ yui.os == 'windows' }}".into()),
507 });
508 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
509 assert_eq!(report.skipped_when_false.len(), 1);
510 assert!(report.written.is_empty());
511 }
512
513 #[test]
514 fn config_rule_no_match_does_not_filter() {
515 let tmp = TempDir::new().unwrap();
516 let r = root(&tmp);
517 write(&r.join("home/foo.tera"), "body");
518 let mut cfg = empty_config();
519 cfg.render.rule.push(RenderRule {
521 r#match: "home/win/**".into(),
522 when: Some("{{ yui.os == 'windows' }}".into()),
523 });
524 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
525 assert_eq!(report.written.len(), 1);
526 }
527
528 #[test]
529 fn unchanged_when_existing_matches() {
530 let tmp = TempDir::new().unwrap();
531 let r = root(&tmp);
532 write(&r.join("home/foo.tera"), "body");
533 write(&r.join("home/foo"), "body"); let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
535 assert!(report.written.is_empty());
536 assert_eq!(report.unchanged.len(), 1);
537 }
538
539 #[test]
540 fn detects_drift_when_existing_diverges() {
541 let tmp = TempDir::new().unwrap();
542 let r = root(&tmp);
543 write(&r.join("home/foo.tera"), "fresh body");
544 write(&r.join("home/foo"), "manually edited");
545 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
546 assert!(report.has_drift());
547 assert_eq!(report.diverged.len(), 1);
548 assert_eq!(
550 std::fs::read_to_string(r.join("home/foo")).unwrap(),
551 "manually edited"
552 );
553 }
554
555 #[test]
556 fn dry_run_does_not_write_or_touch_gitignore() {
557 let tmp = TempDir::new().unwrap();
558 let r = root(&tmp);
559 write(&r.join("home/foo.tera"), "body");
560 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
561 assert!(!r.join("home/foo").exists());
562 assert!(!r.join(".gitignore").exists());
563 }
564
565 #[test]
566 fn updates_gitignore_managed_section() {
567 let tmp = TempDir::new().unwrap();
568 let r = root(&tmp);
569 write(&r.join("home/foo.tera"), "body");
570 write(&r.join("home/bar.tera"), "body2");
571 let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
572 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
573 assert!(gi.contains(GITIGNORE_BEGIN));
574 assert!(gi.contains(GITIGNORE_END));
575 assert!(gi.contains("home/bar"));
576 assert!(gi.contains("home/foo"));
577 let bar_pos = gi.find("home/bar").unwrap();
579 let foo_pos = gi.find("home/foo").unwrap();
580 assert!(bar_pos < foo_pos);
581 }
582
583 #[test]
584 fn preserves_existing_gitignore_content() {
585 let tmp = TempDir::new().unwrap();
586 let r = root(&tmp);
587 write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
588 write(&r.join("home/foo.tera"), "body");
589 let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
590 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
591 assert!(gi.contains("node_modules/"));
592 assert!(gi.contains("target/"));
593 assert!(gi.contains("home/foo"));
594 }
595
596 #[test]
597 fn replaces_existing_managed_section() {
598 let tmp = TempDir::new().unwrap();
599 let r = root(&tmp);
600 write(
602 &r.join(".gitignore"),
603 &format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
604 );
605 write(&r.join("home/foo.tera"), "body");
606 let _ = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
607 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
608 assert!(gi.contains("node_modules/"));
609 assert!(gi.contains("home/foo"));
610 assert!(!gi.contains("stale/path"));
611 assert!(gi.contains("\nfoo\n"));
613 }
614
615 #[test]
616 fn walks_into_gitignored_directories() {
617 let tmp = TempDir::new().unwrap();
618 let r = root(&tmp);
619 write(&r.join(".gitignore"), "node_modules/\n");
621 write(&r.join("node_modules/foo.tera"), "body");
622 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
623 assert_eq!(report.written.len(), 1);
625 assert!(r.join("node_modules/foo").exists());
626 }
627
628 #[test]
629 fn removes_stale_rendered_when_file_header_becomes_false() {
630 let tmp = TempDir::new().unwrap();
631 let r = root(&tmp);
632 write(
634 &r.join("home/foo.tera"),
635 "{# yui:when yui.os == 'windows' #}\nbody",
636 );
637 write(&r.join("home/foo"), "old rendered output");
638 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
639 assert_eq!(report.skipped_when_false.len(), 1);
640 assert!(!r.join("home/foo").exists());
642 }
643
644 #[test]
645 fn removes_stale_rendered_when_rule_when_becomes_false() {
646 let tmp = TempDir::new().unwrap();
647 let r = root(&tmp);
648 write(&r.join("home/win/settings.tera"), "body");
649 write(&r.join("home/win/settings"), "old rendered output");
650 let mut cfg = empty_config();
651 cfg.render.rule.push(RenderRule {
652 r#match: "home/win/**".into(),
653 when: Some("{{ yui.os == 'windows' }}".into()),
654 });
655 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
656 assert_eq!(report.skipped_when_false.len(), 1);
657 assert!(!r.join("home/win/settings").exists());
658 }
659
660 #[test]
661 fn dry_run_does_not_remove_stale_rendered() {
662 let tmp = TempDir::new().unwrap();
663 let r = root(&tmp);
664 write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
665 write(&r.join("home/foo"), "old rendered output");
666 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
667 assert_eq!(
669 std::fs::read_to_string(r.join("home/foo")).unwrap(),
670 "old rendered output"
671 );
672 }
673
674 #[test]
675 fn manage_gitignore_disabled_does_not_write_gitignore() {
676 let tmp = TempDir::new().unwrap();
677 let r = root(&tmp);
678 write(&r.join("home/foo.tera"), "body");
679 let mut cfg = empty_config();
680 cfg.render.manage_gitignore = false;
681 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
682 assert!(r.join("home/foo").exists());
683 assert!(!r.join(".gitignore").exists());
684 }
685}