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();
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 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
190fn 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
207fn 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
228fn 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 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 assert_eq!(
368 split_yui_when("{# yui:when true #}\r\nbody"),
369 Some(("true", "body"))
370 );
371 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 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"); 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 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 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 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 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 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 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 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 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 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}