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
325pub fn add_to_managed_section(source: &Utf8Path, plaintext_abs_path: &Utf8Path) -> Result<()> {
337 let gi_path = source.join(".gitignore");
338 let existing = match std::fs::read_to_string(&gi_path) {
339 Ok(s) => s,
340 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
341 Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
342 };
343
344 let new_entry = plaintext_abs_path
345 .strip_prefix(source)
346 .map(|p| p.as_str().to_string())
347 .unwrap_or_else(|_| plaintext_abs_path.as_str().to_string())
348 .replace('\\', "/");
349
350 let mut managed = parse_managed_section(&existing);
351 managed.push(new_entry);
352 managed.sort();
353 managed.dedup();
354
355 let new_section = build_managed_section(&managed);
356 let updated = replace_or_append_section(&existing, &new_section);
357
358 if updated != existing {
359 std::fs::write(&gi_path, updated)?;
360 }
361 Ok(())
362}
363
364fn parse_managed_section(text: &str) -> Vec<String> {
365 let Some(start) = text.find(GITIGNORE_BEGIN) else {
366 return Vec::new();
367 };
368 let Some(end) = text.find(GITIGNORE_END) else {
369 return Vec::new();
370 };
371 if start >= end {
372 return Vec::new();
373 }
374 let body_start = start + GITIGNORE_BEGIN.len();
375 text[body_start..end]
376 .lines()
377 .map(str::trim)
378 .filter(|l| !l.is_empty())
379 .map(str::to_string)
380 .collect()
381}
382
383fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
384 let gi_path = source.join(".gitignore");
385 let existing = match std::fs::read_to_string(&gi_path) {
386 Ok(s) => s,
387 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
388 Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
389 };
390
391 let mut managed: Vec<String> = rendered_abs_paths
392 .iter()
393 .filter_map(|p| p.strip_prefix(source).ok())
394 .map(|p| p.as_str().replace('\\', "/"))
395 .collect();
396 managed.sort();
397 managed.dedup();
398
399 let new_section = build_managed_section(&managed);
400 let updated = replace_or_append_section(&existing, &new_section);
401
402 if updated != existing {
403 std::fs::write(&gi_path, updated)?;
404 }
405 Ok(())
406}
407
408fn build_managed_section(lines: &[String]) -> String {
409 let mut s = String::new();
410 s.push_str(GITIGNORE_BEGIN);
411 s.push('\n');
412 for l in lines {
413 s.push_str(l);
414 s.push('\n');
415 }
416 s.push_str(GITIGNORE_END);
417 s.push('\n');
418 s
419}
420
421fn replace_or_append_section(existing: &str, new_section: &str) -> String {
422 if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
426 {
427 if start < end {
428 let end_line_end = match existing[end..].find('\n') {
429 Some(idx) => end + idx + 1,
430 None => existing.len(),
431 };
432 let mut out = String::with_capacity(existing.len() + new_section.len());
433 out.push_str(&existing[..start]);
434 out.push_str(new_section);
435 out.push_str(&existing[end_line_end..]);
436 return out;
437 }
438 }
439
440 let mut out = String::from(existing);
441 if !out.is_empty() && !out.ends_with('\n') {
442 out.push('\n');
443 }
444 if !out.is_empty() {
445 out.push('\n');
446 }
447 out.push_str(new_section);
448 out
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use tempfile::TempDir;
455
456 fn yui_vars(source: &Utf8Path) -> YuiVars {
457 YuiVars {
458 os: "linux".into(),
459 arch: "x86_64".into(),
460 host: "test".into(),
461 user: "u".into(),
462 source: source.to_string(),
463 }
464 }
465
466 fn root(tmp: &TempDir) -> Utf8PathBuf {
467 Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
468 }
469
470 fn empty_config() -> Config {
471 Config::default()
472 }
473
474 fn write(p: &Utf8Path, body: &str) {
475 if let Some(parent) = p.parent() {
476 std::fs::create_dir_all(parent).unwrap();
477 }
478 std::fs::write(p, body).unwrap();
479 }
480
481 #[test]
482 fn split_yui_when_basic() {
483 assert_eq!(
484 split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
485 Some(("yui.os == 'linux'", "body"))
486 );
487 assert_eq!(
488 split_yui_when("\n {#yui:when 1 == 1#}\nbody"),
489 Some(("1 == 1", "body"))
490 );
491 assert_eq!(
493 split_yui_when("{# yui:when true #}\r\nbody"),
494 Some(("true", "body"))
495 );
496 assert_eq!(
498 split_yui_when("{# yui:when true #}body"),
499 Some(("true", "body"))
500 );
501 assert_eq!(split_yui_when("body without header"), None);
502 assert_eq!(split_yui_when("{# regular comment #}body"), None);
503 }
504
505 #[test]
506 fn template_target_strips_tera_extension() {
507 assert_eq!(
508 template_target(Utf8Path::new("/a/b/foo.tera")),
509 Utf8PathBuf::from("/a/b/foo")
510 );
511 assert_eq!(
512 template_target(Utf8Path::new("home/.gitconfig.tera")),
513 Utf8PathBuf::from("home/.gitconfig")
514 );
515 }
516
517 #[test]
518 fn renders_simple_template_to_sibling() {
519 let tmp = TempDir::new().unwrap();
520 let r = root(&tmp);
521 write(
522 &r.join("home/.gitconfig.tera"),
523 "[user]\n os = {{ yui.os }}\n",
524 );
525 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
526 assert_eq!(report.written.len(), 1);
527 assert_eq!(
528 std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
529 "[user]\n os = linux\n"
530 );
531 }
532
533 #[test]
534 fn renders_user_vars() {
535 let tmp = TempDir::new().unwrap();
536 let r = root(&tmp);
537 write(&r.join("home/foo.tera"), "{{ vars.greet }}");
538 let mut cfg = empty_config();
539 cfg.vars
540 .insert("greet".into(), toml::Value::String("hello".into()));
541 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
542 assert_eq!(
543 std::fs::read_to_string(r.join("home/foo")).unwrap(),
544 "hello"
545 );
546 }
547
548 #[test]
549 fn skips_when_file_header_false() {
550 let tmp = TempDir::new().unwrap();
551 let r = root(&tmp);
552 write(
553 &r.join("home/foo.tera"),
554 "{# yui:when yui.os == 'windows' #}\nbody",
555 );
556 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
557 assert!(report.written.is_empty());
558 assert_eq!(report.skipped_when_false.len(), 1);
559 assert!(!r.join("home/foo").exists());
560 }
561
562 #[test]
563 fn includes_when_file_header_true() {
564 let tmp = TempDir::new().unwrap();
565 let r = root(&tmp);
566 write(
567 &r.join("home/foo.tera"),
568 "{# yui:when yui.os == 'linux' #}\nbody",
569 );
570 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
571 assert_eq!(report.written.len(), 1);
572 assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
573 }
574
575 #[test]
576 fn config_rule_when_false_skips_matching_template() {
577 let tmp = TempDir::new().unwrap();
578 let r = root(&tmp);
579 write(&r.join("home/win/settings.tera"), "body");
580 let mut cfg = empty_config();
581 cfg.render.rule.push(RenderRule {
582 r#match: "home/win/**".into(),
583 when: Some("{{ yui.os == 'windows' }}".into()),
584 });
585 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
586 assert_eq!(report.skipped_when_false.len(), 1);
587 assert!(report.written.is_empty());
588 }
589
590 #[test]
591 fn config_rule_no_match_does_not_filter() {
592 let tmp = TempDir::new().unwrap();
593 let r = root(&tmp);
594 write(&r.join("home/foo.tera"), "body");
595 let mut cfg = empty_config();
596 cfg.render.rule.push(RenderRule {
598 r#match: "home/win/**".into(),
599 when: Some("{{ yui.os == 'windows' }}".into()),
600 });
601 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
602 assert_eq!(report.written.len(), 1);
603 }
604
605 #[test]
606 fn unchanged_when_existing_matches() {
607 let tmp = TempDir::new().unwrap();
608 let r = root(&tmp);
609 write(&r.join("home/foo.tera"), "body");
610 write(&r.join("home/foo"), "body"); let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
612 assert!(report.written.is_empty());
613 assert_eq!(report.unchanged.len(), 1);
614 }
615
616 #[test]
617 fn detects_drift_when_existing_diverges() {
618 let tmp = TempDir::new().unwrap();
619 let r = root(&tmp);
620 write(&r.join("home/foo.tera"), "fresh body");
621 write(&r.join("home/foo"), "manually edited");
622 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
623 assert!(report.has_drift());
624 assert_eq!(report.diverged.len(), 1);
625 assert_eq!(
627 std::fs::read_to_string(r.join("home/foo")).unwrap(),
628 "manually edited"
629 );
630 }
631
632 #[test]
633 fn dry_run_does_not_write_or_touch_gitignore() {
634 let tmp = TempDir::new().unwrap();
635 let r = root(&tmp);
636 write(&r.join("home/foo.tera"), "body");
637 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
638 assert!(!r.join("home/foo").exists());
639 assert!(!r.join(".gitignore").exists());
640 }
641
642 #[test]
643 fn updates_gitignore_managed_section() {
644 let tmp = TempDir::new().unwrap();
645 let r = root(&tmp);
646 write(&r.join("home/foo.tera"), "body");
647 write(&r.join("home/bar.tera"), "body2");
648 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
649 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
653 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
654 assert!(gi.contains(GITIGNORE_BEGIN));
655 assert!(gi.contains(GITIGNORE_END));
656 assert!(gi.contains("home/bar"));
657 assert!(gi.contains("home/foo"));
658 let bar_pos = gi.find("home/bar").unwrap();
660 let foo_pos = gi.find("home/foo").unwrap();
661 assert!(bar_pos < foo_pos);
662 }
663
664 #[test]
665 fn preserves_existing_gitignore_content() {
666 let tmp = TempDir::new().unwrap();
667 let r = root(&tmp);
668 write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
669 write(&r.join("home/foo.tera"), "body");
670 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
671 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
672 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
673 assert!(gi.contains("node_modules/"));
674 assert!(gi.contains("target/"));
675 assert!(gi.contains("home/foo"));
676 }
677
678 #[test]
679 fn replaces_existing_managed_section() {
680 let tmp = TempDir::new().unwrap();
681 let r = root(&tmp);
682 write(
684 &r.join(".gitignore"),
685 &format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
686 );
687 write(&r.join("home/foo.tera"), "body");
688 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
689 write_managed_section(&r, &report_managed_paths(&report)).unwrap();
690 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
691 assert!(gi.contains("node_modules/"));
692 assert!(gi.contains("home/foo"));
693 assert!(!gi.contains("stale/path"));
694 assert!(gi.contains("\nfoo\n"));
696 }
697
698 #[test]
699 fn walks_into_gitignored_directories() {
700 let tmp = TempDir::new().unwrap();
701 let r = root(&tmp);
702 write(&r.join(".gitignore"), "node_modules/\n");
704 write(&r.join("node_modules/foo.tera"), "body");
705 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
706 assert_eq!(report.written.len(), 1);
708 assert!(r.join("node_modules/foo").exists());
709 }
710
711 #[test]
712 fn removes_stale_rendered_when_file_header_becomes_false() {
713 let tmp = TempDir::new().unwrap();
714 let r = root(&tmp);
715 write(
717 &r.join("home/foo.tera"),
718 "{# yui:when yui.os == 'windows' #}\nbody",
719 );
720 write(&r.join("home/foo"), "old rendered output");
721 let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
722 assert_eq!(report.skipped_when_false.len(), 1);
723 assert!(!r.join("home/foo").exists());
725 }
726
727 #[test]
728 fn removes_stale_rendered_when_rule_when_becomes_false() {
729 let tmp = TempDir::new().unwrap();
730 let r = root(&tmp);
731 write(&r.join("home/win/settings.tera"), "body");
732 write(&r.join("home/win/settings"), "old rendered output");
733 let mut cfg = empty_config();
734 cfg.render.rule.push(RenderRule {
735 r#match: "home/win/**".into(),
736 when: Some("{{ yui.os == 'windows' }}".into()),
737 });
738 let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
739 assert_eq!(report.skipped_when_false.len(), 1);
740 assert!(!r.join("home/win/settings").exists());
741 }
742
743 #[test]
744 fn dry_run_does_not_remove_stale_rendered() {
745 let tmp = TempDir::new().unwrap();
746 let r = root(&tmp);
747 write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
748 write(&r.join("home/foo"), "old rendered output");
749 let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
750 assert_eq!(
752 std::fs::read_to_string(r.join("home/foo")).unwrap(),
753 "old rendered output"
754 );
755 }
756
757 #[test]
758 fn add_to_managed_creates_section_when_missing() {
759 let tmp = TempDir::new().unwrap();
760 let r = root(&tmp);
761 let plain = r.join("home/.config/foo/private.env");
764 add_to_managed_section(&r, &plain).unwrap();
765 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
766 assert!(gi.contains(GITIGNORE_BEGIN));
767 assert!(gi.contains(GITIGNORE_END));
768 assert!(gi.contains("home/.config/foo/private.env"));
769 }
770
771 #[test]
772 fn add_to_managed_preserves_existing_entries() {
773 let tmp = TempDir::new().unwrap();
774 let r = root(&tmp);
775 write(
778 &r.join(".gitignore"),
779 &format!(
780 "node_modules/\n\n{GITIGNORE_BEGIN}\nhome/.gitconfig\n{GITIGNORE_END}\n\ntarget/\n"
781 ),
782 );
783 let plain = r.join("home/.config/foo/private.env");
784 add_to_managed_section(&r, &plain).unwrap();
785 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
786 assert!(gi.contains("home/.gitconfig"));
788 assert!(gi.contains("home/.config/foo/private.env"));
790 assert!(gi.contains("node_modules/"));
792 assert!(gi.contains("target/"));
793 }
794
795 #[test]
796 fn add_to_managed_is_idempotent() {
797 let tmp = TempDir::new().unwrap();
798 let r = root(&tmp);
799 let plain = r.join("home/secret.env");
800 add_to_managed_section(&r, &plain).unwrap();
801 add_to_managed_section(&r, &plain).unwrap();
802 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
803 let occurrences = gi.matches("home/secret.env").count();
804 assert_eq!(occurrences, 1, "duplicate entries in: {gi}");
805 }
806
807 #[test]
808 fn add_to_managed_normalises_windows_separators() {
809 let tmp = TempDir::new().unwrap();
810 let r = root(&tmp);
811 let plain = Utf8PathBuf::from(format!("{}\\home\\.config\\foo\\private.env", r.as_str()));
815 add_to_managed_section(&r, &plain).unwrap();
816 let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
817 assert!(
818 gi.contains("home/.config/foo/private.env"),
819 "expected forward-slash entry in: {gi}"
820 );
821 assert!(
822 !gi.contains("home\\.config"),
823 "managed section should not carry backslashes: {gi}"
824 );
825 }
826
827 #[test]
828 fn manage_gitignore_disabled_does_not_write_gitignore() {
829 let tmp = TempDir::new().unwrap();
830 let r = root(&tmp);
831 write(&r.join("home/foo.tera"), "body");
832 let mut cfg = empty_config();
833 cfg.render.manage_gitignore = false;
834 let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
835 assert!(r.join("home/foo").exists());
836 assert!(!r.join(".gitignore").exists());
837 }
838}