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 as a side effect of rendering. Each divergence is recorded as a
18//! [`DivergedEntry`] (with both paths, the fresh body, and best-effort
19//! mtimes) so callers can resolve it however they want — `apply` walks
20//! the list and prompts the user per entry, `render --check` bails as
21//! the CI gate.
22
23use 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/// A render-drift case: the on-disk rendered file's content differs
39/// from what the template would produce now. Carries the template
40/// path, the rendered path, the fresh output (so callers can apply
41/// it without re-rendering), and best-effort mtimes that the apply
42/// prompt uses to pick a sensible default choice.
43#[derive(Debug, Clone)]
44pub struct DivergedEntry {
45    /// `*.tera` source path.
46    pub tera_path: Utf8PathBuf,
47    /// Rendered sibling-without-suffix path (the on-disk file that diverged).
48    pub rendered_path: Utf8PathBuf,
49    /// What the template would produce right now (with current vars).
50    pub fresh_body: String,
51    /// `mtime` of the `.tera`, if obtainable.
52    pub tera_mtime: Option<SystemTime>,
53    /// `mtime` of the on-disk rendered file, if obtainable.
54    pub rendered_mtime: Option<SystemTime>,
55}
56
57#[derive(Debug, Default)]
58pub struct RenderReport {
59    /// Templates rendered for the first time (or after deletion).
60    pub written: Vec<Utf8PathBuf>,
61    /// Rendered output identical to existing file — no write needed.
62    pub unchanged: Vec<Utf8PathBuf>,
63    /// Skipped because file-header or config rule `when` evaluated to false.
64    pub skipped_when_false: Vec<Utf8PathBuf>,
65    /// Existing rendered file diverges from current template output.
66    /// `apply` resolves these interactively (`[o]verwrite` / `[s]kip` / …);
67    /// `render --check` bails on them as the CI gate.
68    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    // Walk every file under source. Filtering is centralized in
89    // `paths::source_walker`: ignore-files OFF (so unrelated user rules
90    // don't swallow `.tera`s) and `.yui/` skipped (backup mirrors etc.
91    // shouldn't be rendered). `.yuiignore` is registered as a custom
92    // ignore filename, so nested `.yuiignore` files are honored
93    // automatically — no extra per-entry check needed here.
94    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
128/// Every `*.tera` output path the report knows about — written /
129/// unchanged / diverged. The apply orchestrator unions this with
130/// the secret pipeline's plaintext outputs to drive a single
131/// `write_managed_section` call (rather than render writing one
132/// list of paths and secrets immediately overwriting it with a
133/// different one). PR #57 review.
134pub fn report_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
135    collect_managed_paths(report)
136}
137
138/// Render a single template and return the body it would produce
139/// right now, or `Ok(None)` if `{# yui:when … #}` / a config rule
140/// `when` would skip it for the current host. Used by `yui diff`
141/// to compute what the rendered file *should* contain so the
142/// drift can be diffed against what's actually on disk.
143///
144/// Mirrors `process_template`'s skip / render decisions exactly,
145/// but doesn't touch the report or the on-disk rendered output.
146pub 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        // Nested ifs (not let-chains) so the crate's MSRV
171        // (rust-version = "1.85") stays buildable.
172        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    // Strip any `{# yui:when EXPR #}\n` header before handing the body to
221    // Tera, so a falsy header doesn't leave a stray newline at the top of
222    // a successful render.
223    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
279/// Common path for `when=false` skips: drops any stale rendered output so
280/// the link walk doesn't end up linking yesterday's render of a now-disabled
281/// template. Records the skip on the report.
282fn 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
296/// If the file begins with a `{# yui:when EXPR #}` header (after optional
297/// leading whitespace) followed by an immediate newline, returns
298/// `(expr, body)` where `body` is the file content with that header line
299/// stripped. Otherwise None.
300fn 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
317/// Local alias for [`template::eval_truthy`] to keep call sites short.
318fn 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
347/// Write or replace yui's managed `.gitignore` section in the
348/// repo root, listing every absolute path the apply pipeline
349/// produced as a sibling-without-suffix (rendered `.tera` outputs
350/// AND decrypted `.age` outputs share this section). The block is
351/// delimited by `# >>> yui rendered (auto-managed) >>>` /
352/// `# <<< yui rendered (auto-managed) <<<` so successive runs
353/// idempotently rewrite it without disturbing user content above
354/// or below.
355pub fn write_managed_section(source: &Utf8Path, managed_abs_paths: &[Utf8PathBuf]) -> Result<()> {
356    update_gitignore(source, managed_abs_paths)
357}
358
359/// Additively merge `plaintext_abs_path` into yui's managed
360/// `.gitignore` section, preserving every other entry already
361/// there. Used by `yui secret encrypt` to close the window where a
362/// freshly written plaintext sibling would be visible to `git add`
363/// until the next `apply` rewrites the full managed block (issue
364/// #71).
365///
366/// Unlike [`write_managed_section`], this is non-destructive: it
367/// parses the existing section, unions the single new entry, sorts
368/// and dedupes, and rewrites only the block between the markers.
369/// Content above and below the markers is untouched.
370pub 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    // Refactored from `if let ... && cond` (let-chains) to nested if so the
457    // crate's MSRV (rust-version = "1.85") stays buildable; let-chains were
458    // stabilized in 1.88.
459    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        // CRLF line endings
526        assert_eq!(
527            split_yui_when("{# yui:when true #}\r\nbody"),
528            Some(("true", "body"))
529        );
530        // No newline after header — header still parsed, body is the rest
531        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        // glob doesn't match foo.tera
631        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"); // already in sync
645        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        // Fresh body is what the template would produce, NOT what's on disk.
663        assert_eq!(entry.fresh_body, "fresh body");
664        // Existing content NOT overwritten by render.
665        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        // PR #57: render_all no longer manages .gitignore as a
689        // side effect; callers (apply / cmd::render) drive the
690        // single deterministic write.
691        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        // sorted: bar before foo
698        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        // Pre-existing managed section with stale content
722        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        // post-section content preserved
734        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        // Pre-existing .gitignore that would normally hide `node_modules/`.
742        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        // Template under a gitignored dir is still discovered + rendered.
746        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        // Previously rendered output sitting on disk
755        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        // Stale sibling was cleaned up so apply won't link it.
763        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        // Dry-run leaves the on-disk file alone.
790        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        // No .gitignore at all yet — first encrypt should create one
801        // with the new plaintext sibling inside the managed block.
802        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        // Pre-existing managed section with a render-produced entry
815        // plus user content above + below.
816        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        // Existing render entry still there.
826        assert!(gi.contains("home/.gitconfig"));
827        // New secret entry merged in.
828        assert!(gi.contains("home/.config/foo/private.env"));
829        // User content above + below untouched.
830        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        // Synthesise a backslash-bearing absolute path the way Windows
851        // would; the managed section must always use forward slashes
852        // so the `.gitignore` works on both platforms.
853        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}