1use 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 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 = 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 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
194fn 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
211fn 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
232fn 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 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 assert_eq!(
384 split_yui_when("{# yui:when true #}\r\nbody"),
385 Some(("true", "body"))
386 );
387 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 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"); 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 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 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 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 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 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 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 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 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 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}