1use std::time::SystemTime;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use globset::{Glob, GlobSet, GlobSetBuilder};
27use tera::Context as TeraContext;
28
29use crate::config::{Config, RenderRule};
30use crate::paths;
31use crate::template::{self, Engine};
32use crate::vars::YuiVars;
33use crate::{Error, Result};
34
35const GITIGNORE_BEGIN: &str = "# >>> yui rendered (auto-managed, do not edit) >>>";
36const GITIGNORE_END: &str = "# <<< yui rendered (auto-managed) <<<";
37
38#[derive(Debug, Clone)]
44pub struct DivergedEntry {
45 pub tera_path: Utf8PathBuf,
47 pub rendered_path: Utf8PathBuf,
49 pub fresh_body: String,
51 pub tera_mtime: Option<SystemTime>,
53 pub rendered_mtime: Option<SystemTime>,
55}
56
57#[derive(Debug, Default)]
58pub struct RenderReport {
59 pub written: Vec<Utf8PathBuf>,
61 pub unchanged: Vec<Utf8PathBuf>,
63 pub skipped_when_false: Vec<Utf8PathBuf>,
65 pub diverged: Vec<DivergedEntry>,
69}
70
71impl RenderReport {
72 pub fn has_drift(&self) -> bool {
73 !self.diverged.is_empty()
74 }
75}
76
77pub fn render_all(
78 source: &Utf8Path,
79 config: &Config,
80 yui: &YuiVars,
81 dry_run: bool,
82) -> Result<RenderReport> {
83 let mut engine = Engine::new();
84 let ctx = template::template_context(yui, &config.vars);
85 let rules = compile_rules(&config.render.rule)?;
86 let mut report = RenderReport::default();
87
88 let walker = paths::source_walker(source).build();
95 for entry in walker {
96 let entry = match entry {
97 Ok(e) => e,
98 Err(_) => continue,
99 };
100 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
101 continue;
102 }
103 let std_path = entry.path();
104 let Some(name) = std_path.file_name().and_then(|n| n.to_str()) else {
105 continue;
106 };
107 if !name.ends_with(".tera") {
108 continue;
109 }
110 let template_path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
111 Ok(p) => p,
112 Err(_) => continue,
113 };
114 process_template(
115 &template_path,
116 source,
117 &rules,
118 &mut engine,
119 &ctx,
120 dry_run,
121 &mut report,
122 )?;
123 }
124
125 Ok(report)
126}
127
128pub fn report_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
135 collect_managed_paths(report)
136}
137
138pub fn render_to_string(
147 template_path: &Utf8Path,
148 source: &Utf8Path,
149 config: &Config,
150 yui: &YuiVars,
151) -> Result<Option<String>> {
152 let raw = std::fs::read_to_string(template_path)
153 .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
154 let mut engine = Engine::new();
155 let ctx = template::template_context(yui, &config.vars);
156 let rules = compile_rules(&config.render.rule)?;
157
158 let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
159 if !eval_when(expr, &mut engine, &ctx)? {
160 return Ok(None);
161 }
162 body.to_string()
163 } else {
164 raw
165 };
166
167 let rel = relative_to(source, template_path);
168 let rel_for_match = rel.as_str().replace('\\', "/");
169 for rule in &rules {
170 if rule.matcher.is_match(&rel_for_match) {
173 if let Some(w) = &rule.when {
174 if !eval_when(w, &mut engine, &ctx)? {
175 return Ok(None);
176 }
177 }
178 }
179 }
180
181 Ok(Some(engine.render(&body_input, &ctx)?))
182}
183
184struct CompiledRule {
185 matcher: GlobSet,
186 when: Option<String>,
187}
188
189fn compile_rules(rules: &[RenderRule]) -> Result<Vec<CompiledRule>> {
190 let mut out = Vec::with_capacity(rules.len());
191 for r in rules {
192 let glob = Glob::new(&r.r#match)
193 .map_err(|e| Error::Config(format!("render.rule.match {:?}: {e}", r.r#match)))?;
194 let mut b = GlobSetBuilder::new();
195 b.add(glob);
196 let matcher = b
197 .build()
198 .map_err(|e| Error::Config(format!("globset build: {e}")))?;
199 out.push(CompiledRule {
200 matcher,
201 when: r.when.clone(),
202 });
203 }
204 Ok(out)
205}
206
207fn process_template(
208 template_path: &Utf8Path,
209 source: &Utf8Path,
210 rules: &[CompiledRule],
211 engine: &mut Engine,
212 ctx: &TeraContext,
213 dry_run: bool,
214 report: &mut RenderReport,
215) -> Result<()> {
216 let raw = std::fs::read_to_string(template_path)
217 .map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
218 let target = template_target(template_path);
219
220 let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
224 if !eval_when(expr, engine, ctx)? {
225 return skip_when_false(template_path, &target, dry_run, report);
226 }
227 body.to_string()
228 } else {
229 raw
230 };
231
232 let rel = relative_to(source, template_path);
233 let rel_for_match = rel.as_str().replace('\\', "/");
234 for rule in rules {
235 if rule.matcher.is_match(&rel_for_match) {
236 if let Some(w) = &rule.when {
237 if !eval_when(w, engine, ctx)? {
238 return skip_when_false(template_path, &target, dry_run, report);
239 }
240 }
241 }
242 }
243
244 let body = engine.render(&body_input, ctx)?;
245
246 match std::fs::read_to_string(&target) {
247 Ok(existing) if existing == body => {
248 report.unchanged.push(target);
249 return Ok(());
250 }
251 Ok(_) => {
252 let tera_mtime = std::fs::metadata(template_path)
253 .and_then(|m| m.modified())
254 .ok();
255 let rendered_mtime = std::fs::metadata(&target).and_then(|m| m.modified()).ok();
256 report.diverged.push(DivergedEntry {
257 tera_path: template_path.to_path_buf(),
258 rendered_path: target,
259 fresh_body: body,
260 tera_mtime,
261 rendered_mtime,
262 });
263 return Ok(());
264 }
265 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
266 Err(e) => return Err(Error::Template(format!("read {target}: {e}"))),
267 }
268
269 if !dry_run {
270 if let Some(parent) = target.parent() {
271 std::fs::create_dir_all(parent)?;
272 }
273 std::fs::write(&target, &body)?;
274 }
275 report.written.push(target);
276 Ok(())
277}
278
279fn skip_when_false(
283 template_path: &Utf8Path,
284 target: &Utf8Path,
285 dry_run: bool,
286 report: &mut RenderReport,
287) -> Result<()> {
288 if !dry_run && target.exists() {
289 std::fs::remove_file(target)
290 .map_err(|e| Error::Template(format!("removing stale rendered {target}: {e}")))?;
291 }
292 report.skipped_when_false.push(template_path.to_path_buf());
293 Ok(())
294}
295
296fn split_yui_when(raw: &str) -> Option<(&str, &str)> {
301 let leading_ws = raw.len() - raw.trim_start().len();
302 let after_ws = &raw[leading_ws..];
303 let after_open = after_ws.strip_prefix("{#")?;
304 let close = after_open.find("#}")?;
305 let inside = &after_open[..close];
306 let expr = inside.trim().strip_prefix("yui:when")?.trim();
307
308 let mut body_start = leading_ws + 2 + close + 2;
309 if raw[body_start..].starts_with("\r\n") {
310 body_start += 2;
311 } else if raw[body_start..].starts_with('\n') {
312 body_start += 1;
313 }
314 Some((expr, &raw[body_start..]))
315}
316
317fn eval_when(expr: &str, engine: &mut Engine, ctx: &TeraContext) -> Result<bool> {
319 template::eval_truthy(expr, engine, ctx)
320}
321
322fn template_target(template_path: &Utf8Path) -> Utf8PathBuf {
323 let s = template_path.as_str();
324 debug_assert!(s.ends_with(".tera"));
325 Utf8PathBuf::from(&s[..s.len() - ".tera".len()])
326}
327
328fn relative_to(base: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
329 p.strip_prefix(base)
330 .map(Utf8PathBuf::from)
331 .unwrap_or_else(|_| p.to_path_buf())
332}
333
334fn collect_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
335 let mut all: Vec<_> = report
336 .written
337 .iter()
338 .chain(report.unchanged.iter())
339 .chain(report.diverged.iter().map(|d| &d.rendered_path))
340 .cloned()
341 .collect();
342 all.sort();
343 all.dedup();
344 all
345}
346
347pub fn write_managed_section(source: &Utf8Path, managed_abs_paths: &[Utf8PathBuf]) -> Result<()> {
356 update_gitignore(source, managed_abs_paths)
357}
358
359pub fn add_to_managed_section(source: &Utf8Path, plaintext_abs_path: &Utf8Path) -> Result<()> {
371 let gi_path = source.join(".gitignore");
372 let existing = match std::fs::read_to_string(&gi_path) {
373 Ok(s) => s,
374 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
375 Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
376 };
377
378 let new_entry = plaintext_abs_path
379 .strip_prefix(source)
380 .map(|p| p.as_str().to_string())
381 .unwrap_or_else(|_| plaintext_abs_path.as_str().to_string())
382 .replace('\\', "/");
383
384 let mut managed = parse_managed_section(&existing);
385 managed.push(new_entry);
386 managed.sort();
387 managed.dedup();
388
389 let new_section = build_managed_section(&managed);
390 let updated = replace_or_append_section(&existing, &new_section);
391
392 if updated != existing {
393 std::fs::write(&gi_path, updated)?;
394 }
395 Ok(())
396}
397
398fn parse_managed_section(text: &str) -> Vec<String> {
399 let Some(start) = text.find(GITIGNORE_BEGIN) else {
400 return Vec::new();
401 };
402 let Some(end) = text.find(GITIGNORE_END) else {
403 return Vec::new();
404 };
405 if start >= end {
406 return Vec::new();
407 }
408 let body_start = start + GITIGNORE_BEGIN.len();
409 text[body_start..end]
410 .lines()
411 .map(str::trim)
412 .filter(|l| !l.is_empty())
413 .map(str::to_string)
414 .collect()
415}
416
417fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
418 let gi_path = source.join(".gitignore");
419 let existing = match std::fs::read_to_string(&gi_path) {
420 Ok(s) => s,
421 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
422 Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
423 };
424
425 let mut managed: Vec<String> = rendered_abs_paths
426 .iter()
427 .filter_map(|p| p.strip_prefix(source).ok())
428 .map(|p| p.as_str().replace('\\', "/"))
429 .collect();
430 managed.sort();
431 managed.dedup();
432
433 let new_section = build_managed_section(&managed);
434 let updated = replace_or_append_section(&existing, &new_section);
435
436 if updated != existing {
437 std::fs::write(&gi_path, updated)?;
438 }
439 Ok(())
440}
441
442fn build_managed_section(lines: &[String]) -> String {
443 let mut s = String::new();
444 s.push_str(GITIGNORE_BEGIN);
445 s.push('\n');
446 for l in lines {
447 s.push_str(l);
448 s.push('\n');
449 }
450 s.push_str(GITIGNORE_END);
451 s.push('\n');
452 s
453}
454
455fn replace_or_append_section(existing: &str, new_section: &str) -> String {
456 if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
460 {
461 if start < end {
462 let end_line_end = match existing[end..].find('\n') {
463 Some(idx) => end + idx + 1,
464 None => existing.len(),
465 };
466 let mut out = String::with_capacity(existing.len() + new_section.len());
467 out.push_str(&existing[..start]);
468 out.push_str(new_section);
469 out.push_str(&existing[end_line_end..]);
470 return out;
471 }
472 }
473
474 let mut out = String::from(existing);
475 if !out.is_empty() && !out.ends_with('\n') {
476 out.push('\n');
477 }
478 if !out.is_empty() {
479 out.push('\n');
480 }
481 out.push_str(new_section);
482 out
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use tempfile::TempDir;
489
490 fn yui_vars(source: &Utf8Path) -> YuiVars {
491 YuiVars {
492 os: "linux".into(),
493 arch: "x86_64".into(),
494 host: "test".into(),
495 user: "u".into(),
496 source: source.to_string(),
497 }
498 }
499
500 fn root(tmp: &TempDir) -> Utf8PathBuf {
501 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
502 }
503
504 fn empty_config() -> Config {
505 Config::default()
506 }
507
508 fn write(p: &Utf8Path, body: &str) {
509 if let Some(parent) = p.parent() {
510 std::fs::create_dir_all(parent).unwrap();
511 }
512 std::fs::write(p, body).unwrap();
513 }
514
515 #[test]
516 fn split_yui_when_basic() {
517 assert_eq!(
518 split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
519 Some(("yui.os == 'linux'", "body"))
520 );
521 assert_eq!(
522 split_yui_when("\n {#yui:when 1 == 1#}\nbody"),
523 Some(("1 == 1", "body"))
524 );
525 assert_eq!(
527 split_yui_when("{# yui:when true #}\r\nbody"),
528 Some(("true", "body"))
529 );
530 assert_eq!(
532 split_yui_when("{# yui:when true #}body"),
533 Some(("true", "body"))
534 );
535 assert_eq!(split_yui_when("body without header"), None);
536 assert_eq!(split_yui_when("{# regular comment #}body"), None);
537 }
538
539 #[test]
540 fn template_target_strips_tera_extension() {
541 assert_eq!(
542 template_target(Utf8Path::new("/a/b/foo.tera")),
543 Utf8PathBuf::from("/a/b/foo")
544 );
545 assert_eq!(
546 template_target(Utf8Path::new("home/.gitconfig.tera")),
547 Utf8PathBuf::from("home/.gitconfig")
548 );
549 }
550
551 #[test]
552 fn renders_simple_template_to_sibling() {
553 let tmp = TempDir::new().unwrap();
554 let r = root(&tmp);
555 write(
556 &r.join("home/.gitconfig.tera"),
557 "[user]\n os = {{ yui.os }}\n",
558 );
559 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
560 assert_eq!(report.written.len(), 1);
561 assert_eq!(
562 std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
563 "[user]\n os = linux\n"
564 );
565 }
566
567 #[test]
568 fn renders_user_vars() {
569 let tmp = TempDir::new().unwrap();
570 let r = root(&tmp);
571 write(&r.join("home/foo.tera"), "{{ vars.greet }}");
572 let mut cfg = empty_config();
573 cfg.vars
574 .insert("greet".into(), toml::Value::String("hello".into()));
575 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
576 assert_eq!(
577 std::fs::read_to_string(r.join("home/foo")).unwrap(),
578 "hello"
579 );
580 }
581
582 #[test]
583 fn skips_when_file_header_false() {
584 let tmp = TempDir::new().unwrap();
585 let r = root(&tmp);
586 write(
587 &r.join("home/foo.tera"),
588 "{# yui:when yui.os == 'windows' #}\nbody",
589 );
590 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
591 assert!(report.written.is_empty());
592 assert_eq!(report.skipped_when_false.len(), 1);
593 assert!(!r.join("home/foo").exists());
594 }
595
596 #[test]
597 fn includes_when_file_header_true() {
598 let tmp = TempDir::new().unwrap();
599 let r = root(&tmp);
600 write(
601 &r.join("home/foo.tera"),
602 "{# yui:when yui.os == 'linux' #}\nbody",
603 );
604 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
605 assert_eq!(report.written.len(), 1);
606 assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
607 }
608
609 #[test]
610 fn config_rule_when_false_skips_matching_template() {
611 let tmp = TempDir::new().unwrap();
612 let r = root(&tmp);
613 write(&r.join("home/win/settings.tera"), "body");
614 let mut cfg = empty_config();
615 cfg.render.rule.push(RenderRule {
616 r#match: "home/win/**".into(),
617 when: Some("{{ yui.os == 'windows' }}".into()),
618 });
619 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
620 assert_eq!(report.skipped_when_false.len(), 1);
621 assert!(report.written.is_empty());
622 }
623
624 #[test]
625 fn config_rule_no_match_does_not_filter() {
626 let tmp = TempDir::new().unwrap();
627 let r = root(&tmp);
628 write(&r.join("home/foo.tera"), "body");
629 let mut cfg = empty_config();
630 cfg.render.rule.push(RenderRule {
632 r#match: "home/win/**".into(),
633 when: Some("{{ yui.os == 'windows' }}".into()),
634 });
635 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
636 assert_eq!(report.written.len(), 1);
637 }
638
639 #[test]
640 fn unchanged_when_existing_matches() {
641 let tmp = TempDir::new().unwrap();
642 let r = root(&tmp);
643 write(&r.join("home/foo.tera"), "body");
644 write(&r.join("home/foo"), "body"); let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
646 assert!(report.written.is_empty());
647 assert_eq!(report.unchanged.len(), 1);
648 }
649
650 #[test]
651 fn detects_drift_when_existing_diverges() {
652 let tmp = TempDir::new().unwrap();
653 let r = root(&tmp);
654 write(&r.join("home/foo.tera"), "fresh body");
655 write(&r.join("home/foo"), "manually edited");
656 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
657 assert!(report.has_drift());
658 assert_eq!(report.diverged.len(), 1);
659 let entry = &report.diverged[0];
660 assert_eq!(entry.tera_path, r.join("home/foo.tera"));
661 assert_eq!(entry.rendered_path, r.join("home/foo"));
662 assert_eq!(entry.fresh_body, "fresh body");
664 assert_eq!(
666 std::fs::read_to_string(r.join("home/foo")).unwrap(),
667 "manually edited"
668 );
669 }
670
671 #[test]
672 fn dry_run_does_not_write_or_touch_gitignore() {
673 let tmp = TempDir::new().unwrap();
674 let r = root(&tmp);
675 write(&r.join("home/foo.tera"), "body");
676 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
677 assert!(!r.join("home/foo").exists());
678 assert!(!r.join(".gitignore").exists());
679 }
680
681 #[test]
682 fn updates_gitignore_managed_section() {
683 let tmp = TempDir::new().unwrap();
684 let r = root(&tmp);
685 write(&r.join("home/foo.tera"), "body");
686 write(&r.join("home/bar.tera"), "body2");
687 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
688 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
692 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
693 assert!(gi.contains(GITIGNORE_BEGIN));
694 assert!(gi.contains(GITIGNORE_END));
695 assert!(gi.contains("home/bar"));
696 assert!(gi.contains("home/foo"));
697 let bar_pos = gi.find("home/bar").unwrap();
699 let foo_pos = gi.find("home/foo").unwrap();
700 assert!(bar_pos < foo_pos);
701 }
702
703 #[test]
704 fn preserves_existing_gitignore_content() {
705 let tmp = TempDir::new().unwrap();
706 let r = root(&tmp);
707 write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
708 write(&r.join("home/foo.tera"), "body");
709 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
710 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
711 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
712 assert!(gi.contains("node_modules/"));
713 assert!(gi.contains("target/"));
714 assert!(gi.contains("home/foo"));
715 }
716
717 #[test]
718 fn replaces_existing_managed_section() {
719 let tmp = TempDir::new().unwrap();
720 let r = root(&tmp);
721 write(
723 &r.join(".gitignore"),
724 &format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
725 );
726 write(&r.join("home/foo.tera"), "body");
727 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
728 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
729 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
730 assert!(gi.contains("node_modules/"));
731 assert!(gi.contains("home/foo"));
732 assert!(!gi.contains("stale/path"));
733 assert!(gi.contains("\nfoo\n"));
735 }
736
737 #[test]
738 fn walks_into_gitignored_directories() {
739 let tmp = TempDir::new().unwrap();
740 let r = root(&tmp);
741 write(&r.join(".gitignore"), "node_modules/\n");
743 write(&r.join("node_modules/foo.tera"), "body");
744 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
745 assert_eq!(report.written.len(), 1);
747 assert!(r.join("node_modules/foo").exists());
748 }
749
750 #[test]
751 fn removes_stale_rendered_when_file_header_becomes_false() {
752 let tmp = TempDir::new().unwrap();
753 let r = root(&tmp);
754 write(
756 &r.join("home/foo.tera"),
757 "{# yui:when yui.os == 'windows' #}\nbody",
758 );
759 write(&r.join("home/foo"), "old rendered output");
760 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
761 assert_eq!(report.skipped_when_false.len(), 1);
762 assert!(!r.join("home/foo").exists());
764 }
765
766 #[test]
767 fn removes_stale_rendered_when_rule_when_becomes_false() {
768 let tmp = TempDir::new().unwrap();
769 let r = root(&tmp);
770 write(&r.join("home/win/settings.tera"), "body");
771 write(&r.join("home/win/settings"), "old rendered output");
772 let mut cfg = empty_config();
773 cfg.render.rule.push(RenderRule {
774 r#match: "home/win/**".into(),
775 when: Some("{{ yui.os == 'windows' }}".into()),
776 });
777 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
778 assert_eq!(report.skipped_when_false.len(), 1);
779 assert!(!r.join("home/win/settings").exists());
780 }
781
782 #[test]
783 fn dry_run_does_not_remove_stale_rendered() {
784 let tmp = TempDir::new().unwrap();
785 let r = root(&tmp);
786 write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
787 write(&r.join("home/foo"), "old rendered output");
788 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
789 assert_eq!(
791 std::fs::read_to_string(r.join("home/foo")).unwrap(),
792 "old rendered output"
793 );
794 }
795
796 #[test]
797 fn add_to_managed_creates_section_when_missing() {
798 let tmp = TempDir::new().unwrap();
799 let r = root(&tmp);
800 let plain = r.join("home/.config/foo/private.env");
803 add_to_managed_section(&r, &plain).unwrap();
804 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
805 assert!(gi.contains(GITIGNORE_BEGIN));
806 assert!(gi.contains(GITIGNORE_END));
807 assert!(gi.contains("home/.config/foo/private.env"));
808 }
809
810 #[test]
811 fn add_to_managed_preserves_existing_entries() {
812 let tmp = TempDir::new().unwrap();
813 let r = root(&tmp);
814 write(
817 &r.join(".gitignore"),
818 &format!(
819 "node_modules/\n\n{GITIGNORE_BEGIN}\nhome/.gitconfig\n{GITIGNORE_END}\n\ntarget/\n"
820 ),
821 );
822 let plain = r.join("home/.config/foo/private.env");
823 add_to_managed_section(&r, &plain).unwrap();
824 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
825 assert!(gi.contains("home/.gitconfig"));
827 assert!(gi.contains("home/.config/foo/private.env"));
829 assert!(gi.contains("node_modules/"));
831 assert!(gi.contains("target/"));
832 }
833
834 #[test]
835 fn add_to_managed_is_idempotent() {
836 let tmp = TempDir::new().unwrap();
837 let r = root(&tmp);
838 let plain = r.join("home/secret.env");
839 add_to_managed_section(&r, &plain).unwrap();
840 add_to_managed_section(&r, &plain).unwrap();
841 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
842 let occurrences = gi.matches("home/secret.env").count();
843 assert_eq!(occurrences, 1, "duplicate entries in: {gi}");
844 }
845
846 #[test]
847 fn add_to_managed_normalises_windows_separators() {
848 let tmp = TempDir::new().unwrap();
849 let r = root(&tmp);
850 let plain = Utf8PathBuf::from(format!("{}\\home\\.config\\foo\\private.env", r.as_str()));
854 add_to_managed_section(&r, &plain).unwrap();
855 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
856 assert!(
857 gi.contains("home/.config/foo/private.env"),
858 "expected forward-slash entry in: {gi}"
859 );
860 assert!(
861 !gi.contains("home\\.config"),
862 "managed section should not carry backslashes: {gi}"
863 );
864 }
865
866 #[test]
867 fn manage_gitignore_disabled_does_not_write_gitignore() {
868 let tmp = TempDir::new().unwrap();
869 let r = root(&tmp);
870 write(&r.join("home/foo.tera"), "body");
871 let mut cfg = empty_config();
872 cfg.render.manage_gitignore = false;
873 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
874 assert!(r.join("home/foo").exists());
875 assert!(!r.join(".gitignore").exists());
876 }
877}