Skip to main content

yui/
render.rs

1//! Tera template rendering for `*.tera` files.
2//!
3//! Output goes to the **same directory** as the source `.tera` file (e.g.
4//! `home/.gitconfig.tera` → `home/.gitconfig`). When `manage_gitignore` is
5//! true, the rendered files are listed in a `# >>> yui rendered ... <<<`
6//! managed section of `.gitignore` so they aren't committed.
7//!
8//! Conditional render (both honored, AND'd together when both present):
9//!   - file-header: `{# yui:when EXPR #}` as the first Tera comment in the
10//!     `.tera` file. Tera comments are stripped from output, so the header
11//!     never bleeds into the rendered file.
12//!   - config rule: `[[render.rule]] match = "<glob>", when = "<expr>"` —
13//!     the glob is matched against the path relative to the source root.
14//!
15//! Drift policy: if the rendered file already exists with content that
16//! diverges from what the template would produce now, we DO NOT overwrite
17//! it. The user has likely edited the rendered file in place and needs to
18//! reflect that change back into the `.tera` first. The divergence is
19//! reported in `RenderReport::diverged`; `--check` treats it as fatal.
20
21use 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    /// Templates rendered for the first time (or after deletion).
37    pub written: Vec<Utf8PathBuf>,
38    /// Rendered output identical to existing file — no write needed.
39    pub unchanged: Vec<Utf8PathBuf>,
40    /// Skipped because file-header or config rule `when` evaluated to false.
41    pub skipped_when_false: Vec<Utf8PathBuf>,
42    /// Existing rendered file diverges from current template output.
43    /// User must reflect the manual edit back into `.tera` before re-rendering.
44    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    // Walk every file under source. Filtering is centralized in
65    // `paths::source_walker`: ignore-files OFF (so unrelated user rules
66    // don't swallow `.tera`s) and `.yui/` skipped (backup mirrors etc.
67    // shouldn't be rendered). `.yuiignore` is registered as a custom
68    // ignore filename, so nested `.yuiignore` files are honored
69    // automatically — no extra per-entry check needed here.
70    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
104/// Every `*.tera` output path the report knows about — written /
105/// unchanged / diverged. The apply orchestrator unions this with
106/// the secret pipeline's plaintext outputs to drive a single
107/// `write_managed_section` call (rather than render writing one
108/// list of paths and secrets immediately overwriting it with a
109/// different one). PR #57 review.
110pub fn report_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
111    collect_managed_paths(report)
112}
113
114/// Render a single template and return the body it would produce
115/// right now, or `Ok(None)` if `{# yui:when … #}` / a config rule
116/// `when` would skip it for the current host. Used by `yui diff`
117/// to compute what the rendered file *should* contain so the
118/// drift can be diffed against what's actually on disk.
119///
120/// Mirrors `process_template`'s skip / render decisions exactly,
121/// but doesn't touch the report or the on-disk rendered output.
122pub 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        // Nested ifs (not let-chains) so the crate's MSRV
147        // (rust-version = "1.85") stays buildable.
148        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    // Strip any `{# yui:when EXPR #}\n` header before handing the body to
197    // Tera, so a falsy header doesn't leave a stray newline at the top of
198    // a successful render.
199    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
245/// Common path for `when=false` skips: drops any stale rendered output so
246/// the link walk doesn't end up linking yesterday's render of a now-disabled
247/// template. Records the skip on the report.
248fn 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
262/// If the file begins with a `{# yui:when EXPR #}` header (after optional
263/// leading whitespace) followed by an immediate newline, returns
264/// `(expr, body)` where `body` is the file content with that header line
265/// stripped. Otherwise None.
266fn 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
283/// Local alias for [`template::eval_truthy`] to keep call sites short.
284fn 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
313/// Write or replace yui's managed `.gitignore` section in the
314/// repo root, listing every absolute path the apply pipeline
315/// produced as a sibling-without-suffix (rendered `.tera` outputs
316/// AND decrypted `.age` outputs share this section). The block is
317/// delimited by `# >>> yui rendered (auto-managed) >>>` /
318/// `# <<< yui rendered (auto-managed) <<<` so successive runs
319/// idempotently rewrite it without disturbing user content above
320/// or below.
321pub fn write_managed_section(source: &Utf8Path, managed_abs_paths: &[Utf8PathBuf]) -> Result<()> {
322    update_gitignore(source, managed_abs_paths)
323}
324
325/// Additively merge `plaintext_abs_path` into yui's managed
326/// `.gitignore` section, preserving every other entry already
327/// there. Used by `yui secret encrypt` to close the window where a
328/// freshly written plaintext sibling would be visible to `git add`
329/// until the next `apply` rewrites the full managed block (issue
330/// #71).
331///
332/// Unlike [`write_managed_section`], this is non-destructive: it
333/// parses the existing section, unions the single new entry, sorts
334/// and dedupes, and rewrites only the block between the markers.
335/// Content above and below the markers is untouched.
336pub 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    // Refactored from `if let ... && cond` (let-chains) to nested if so the
423    // crate's MSRV (rust-version = "1.85") stays buildable; let-chains were
424    // stabilized in 1.88.
425    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        // CRLF line endings
492        assert_eq!(
493            split_yui_when("{# yui:when true #}\r\nbody"),
494            Some(("true", "body"))
495        );
496        // No newline after header — header still parsed, body is the rest
497        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        // glob doesn't match foo.tera
597        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"); // already in sync
611        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        // existing content NOT overwritten
626        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        // PR #57: render_all no longer manages .gitignore as a
650        // side effect; callers (apply / cmd::render) drive the
651        // single deterministic write.
652        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        // sorted: bar before foo
659        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        // Pre-existing managed section with stale content
683        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        // post-section content preserved
695        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        // Pre-existing .gitignore that would normally hide `node_modules/`.
703        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        // Template under a gitignored dir is still discovered + rendered.
707        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        // Previously rendered output sitting on disk
716        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        // Stale sibling was cleaned up so apply won't link it.
724        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        // Dry-run leaves the on-disk file alone.
751        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        // No .gitignore at all yet — first encrypt should create one
762        // with the new plaintext sibling inside the managed block.
763        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        // Pre-existing managed section with a render-produced entry
776        // plus user content above + below.
777        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        // Existing render entry still there.
787        assert!(gi.contains("home/.gitconfig"));
788        // New secret entry merged in.
789        assert!(gi.contains("home/.config/foo/private.env"));
790        // User content above + below untouched.
791        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        // Synthesise a backslash-bearing absolute path the way Windows
812        // would; the managed section must always use forward slashes
813        // so the `.gitignore` works on both platforms.
814        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}