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