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