use std::time::SystemTime;
use camino::{Utf8Path, Utf8PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use tera::Context as TeraContext;
use crate::config::{Config, RenderRule};
use crate::paths;
use crate::template::{self, Engine};
use crate::vars::YuiVars;
use crate::{Error, Result};
const GITIGNORE_BEGIN: &str = "# >>> yui rendered (auto-managed, do not edit) >>>";
const GITIGNORE_END: &str = "# <<< yui rendered (auto-managed) <<<";
#[derive(Debug, Clone)]
pub struct DivergedEntry {
pub tera_path: Utf8PathBuf,
pub rendered_path: Utf8PathBuf,
pub fresh_body: String,
pub tera_mtime: Option<SystemTime>,
pub rendered_mtime: Option<SystemTime>,
}
#[derive(Debug, Default)]
pub struct RenderReport {
pub written: Vec<Utf8PathBuf>,
pub unchanged: Vec<Utf8PathBuf>,
pub skipped_when_false: Vec<Utf8PathBuf>,
pub diverged: Vec<DivergedEntry>,
}
impl RenderReport {
pub fn has_drift(&self) -> bool {
!self.diverged.is_empty()
}
}
pub fn render_all(
source: &Utf8Path,
config: &Config,
yui: &YuiVars,
dry_run: bool,
) -> Result<RenderReport> {
let mut engine = Engine::new();
let ctx = template::template_context(yui, &config.vars);
let rules = compile_rules(&config.render.rule)?;
let mut report = RenderReport::default();
let walker = paths::source_walker(source).build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let std_path = entry.path();
let Some(name) = std_path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.ends_with(".tera") {
continue;
}
let template_path = match Utf8PathBuf::from_path_buf(std_path.to_path_buf()) {
Ok(p) => p,
Err(_) => continue,
};
process_template(
&template_path,
source,
&rules,
&mut engine,
&ctx,
dry_run,
&mut report,
)?;
}
Ok(report)
}
pub fn report_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
collect_managed_paths(report)
}
pub fn render_to_string(
template_path: &Utf8Path,
source: &Utf8Path,
config: &Config,
yui: &YuiVars,
) -> Result<Option<String>> {
let raw = std::fs::read_to_string(template_path)
.map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
let mut engine = Engine::new();
let ctx = template::template_context(yui, &config.vars);
let rules = compile_rules(&config.render.rule)?;
let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
if !eval_when(expr, &mut engine, &ctx)? {
return Ok(None);
}
body.to_string()
} else {
raw
};
let rel = relative_to(source, template_path);
let rel_for_match = rel.as_str().replace('\\', "/");
for rule in &rules {
if rule.matcher.is_match(&rel_for_match) {
if let Some(w) = &rule.when {
if !eval_when(w, &mut engine, &ctx)? {
return Ok(None);
}
}
}
}
Ok(Some(engine.render(&body_input, &ctx)?))
}
struct CompiledRule {
matcher: GlobSet,
when: Option<String>,
}
fn compile_rules(rules: &[RenderRule]) -> Result<Vec<CompiledRule>> {
let mut out = Vec::with_capacity(rules.len());
for r in rules {
let glob = Glob::new(&r.r#match)
.map_err(|e| Error::Config(format!("render.rule.match {:?}: {e}", r.r#match)))?;
let mut b = GlobSetBuilder::new();
b.add(glob);
let matcher = b
.build()
.map_err(|e| Error::Config(format!("globset build: {e}")))?;
out.push(CompiledRule {
matcher,
when: r.when.clone(),
});
}
Ok(out)
}
fn process_template(
template_path: &Utf8Path,
source: &Utf8Path,
rules: &[CompiledRule],
engine: &mut Engine,
ctx: &TeraContext,
dry_run: bool,
report: &mut RenderReport,
) -> Result<()> {
let raw = std::fs::read_to_string(template_path)
.map_err(|e| Error::Template(format!("read {template_path}: {e}")))?;
let target = template_target(template_path);
let body_input = if let Some((expr, body)) = split_yui_when(&raw) {
if !eval_when(expr, engine, ctx)? {
return skip_when_false(template_path, &target, dry_run, report);
}
body.to_string()
} else {
raw
};
let rel = relative_to(source, template_path);
let rel_for_match = rel.as_str().replace('\\', "/");
for rule in rules {
if rule.matcher.is_match(&rel_for_match) {
if let Some(w) = &rule.when {
if !eval_when(w, engine, ctx)? {
return skip_when_false(template_path, &target, dry_run, report);
}
}
}
}
let body = engine.render(&body_input, ctx)?;
match std::fs::read_to_string(&target) {
Ok(existing) if existing == body => {
report.unchanged.push(target);
return Ok(());
}
Ok(_) => {
let tera_mtime = std::fs::metadata(template_path)
.and_then(|m| m.modified())
.ok();
let rendered_mtime = std::fs::metadata(&target).and_then(|m| m.modified()).ok();
report.diverged.push(DivergedEntry {
tera_path: template_path.to_path_buf(),
rendered_path: target,
fresh_body: body,
tera_mtime,
rendered_mtime,
});
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::Template(format!("read {target}: {e}"))),
}
if !dry_run {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &body)?;
}
report.written.push(target);
Ok(())
}
fn skip_when_false(
template_path: &Utf8Path,
target: &Utf8Path,
dry_run: bool,
report: &mut RenderReport,
) -> Result<()> {
if !dry_run && target.exists() {
std::fs::remove_file(target)
.map_err(|e| Error::Template(format!("removing stale rendered {target}: {e}")))?;
}
report.skipped_when_false.push(template_path.to_path_buf());
Ok(())
}
fn split_yui_when(raw: &str) -> Option<(&str, &str)> {
let leading_ws = raw.len() - raw.trim_start().len();
let after_ws = &raw[leading_ws..];
let after_open = after_ws.strip_prefix("{#")?;
let close = after_open.find("#}")?;
let inside = &after_open[..close];
let expr = inside.trim().strip_prefix("yui:when")?.trim();
let mut body_start = leading_ws + 2 + close + 2;
if raw[body_start..].starts_with("\r\n") {
body_start += 2;
} else if raw[body_start..].starts_with('\n') {
body_start += 1;
}
Some((expr, &raw[body_start..]))
}
fn eval_when(expr: &str, engine: &mut Engine, ctx: &TeraContext) -> Result<bool> {
template::eval_truthy(expr, engine, ctx)
}
fn template_target(template_path: &Utf8Path) -> Utf8PathBuf {
let s = template_path.as_str();
debug_assert!(s.ends_with(".tera"));
Utf8PathBuf::from(&s[..s.len() - ".tera".len()])
}
fn relative_to(base: &Utf8Path, p: &Utf8Path) -> Utf8PathBuf {
p.strip_prefix(base)
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| p.to_path_buf())
}
fn collect_managed_paths(report: &RenderReport) -> Vec<Utf8PathBuf> {
let mut all: Vec<_> = report
.written
.iter()
.chain(report.unchanged.iter())
.chain(report.diverged.iter().map(|d| &d.rendered_path))
.cloned()
.collect();
all.sort();
all.dedup();
all
}
pub fn write_managed_section(source: &Utf8Path, managed_abs_paths: &[Utf8PathBuf]) -> Result<()> {
update_gitignore(source, managed_abs_paths)
}
pub fn add_to_managed_section(source: &Utf8Path, plaintext_abs_path: &Utf8Path) -> Result<()> {
let gi_path = source.join(".gitignore");
let existing = match std::fs::read_to_string(&gi_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
};
let new_entry = plaintext_abs_path
.strip_prefix(source)
.map(|p| p.as_str().to_string())
.unwrap_or_else(|_| plaintext_abs_path.as_str().to_string())
.replace('\\', "/");
let mut managed = parse_managed_section(&existing);
managed.push(new_entry);
managed.sort();
managed.dedup();
let new_section = build_managed_section(&managed);
let updated = replace_or_append_section(&existing, &new_section);
if updated != existing {
std::fs::write(&gi_path, updated)?;
}
Ok(())
}
fn parse_managed_section(text: &str) -> Vec<String> {
let Some(start) = text.find(GITIGNORE_BEGIN) else {
return Vec::new();
};
let Some(end) = text.find(GITIGNORE_END) else {
return Vec::new();
};
if start >= end {
return Vec::new();
}
let body_start = start + GITIGNORE_BEGIN.len();
text[body_start..end]
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn update_gitignore(source: &Utf8Path, rendered_abs_paths: &[Utf8PathBuf]) -> Result<()> {
let gi_path = source.join(".gitignore");
let existing = match std::fs::read_to_string(&gi_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(Error::Template(format!("read {gi_path}: {e}"))),
};
let mut managed: Vec<String> = rendered_abs_paths
.iter()
.filter_map(|p| p.strip_prefix(source).ok())
.map(|p| p.as_str().replace('\\', "/"))
.collect();
managed.sort();
managed.dedup();
let new_section = build_managed_section(&managed);
let updated = replace_or_append_section(&existing, &new_section);
if updated != existing {
std::fs::write(&gi_path, updated)?;
}
Ok(())
}
fn build_managed_section(lines: &[String]) -> String {
let mut s = String::new();
s.push_str(GITIGNORE_BEGIN);
s.push('\n');
for l in lines {
s.push_str(l);
s.push('\n');
}
s.push_str(GITIGNORE_END);
s.push('\n');
s
}
fn replace_or_append_section(existing: &str, new_section: &str) -> String {
if let (Some(start), Some(end)) = (existing.find(GITIGNORE_BEGIN), existing.find(GITIGNORE_END))
{
if start < end {
let end_line_end = match existing[end..].find('\n') {
Some(idx) => end + idx + 1,
None => existing.len(),
};
let mut out = String::with_capacity(existing.len() + new_section.len());
out.push_str(&existing[..start]);
out.push_str(new_section);
out.push_str(&existing[end_line_end..]);
return out;
}
}
let mut out = String::from(existing);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(new_section);
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn yui_vars(source: &Utf8Path) -> YuiVars {
YuiVars {
os: "linux".into(),
arch: "x86_64".into(),
host: "test".into(),
user: "u".into(),
source: source.to_string(),
}
}
fn root(tmp: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
}
fn empty_config() -> Config {
Config::default()
}
fn write(p: &Utf8Path, body: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, body).unwrap();
}
#[test]
fn split_yui_when_basic() {
assert_eq!(
split_yui_when("{# yui:when yui.os == 'linux' #}\nbody"),
Some(("yui.os == 'linux'", "body"))
);
assert_eq!(
split_yui_when("\n {#yui:when 1 == 1#}\nbody"),
Some(("1 == 1", "body"))
);
assert_eq!(
split_yui_when("{# yui:when true #}\r\nbody"),
Some(("true", "body"))
);
assert_eq!(
split_yui_when("{# yui:when true #}body"),
Some(("true", "body"))
);
assert_eq!(split_yui_when("body without header"), None);
assert_eq!(split_yui_when("{# regular comment #}body"), None);
}
#[test]
fn template_target_strips_tera_extension() {
assert_eq!(
template_target(Utf8Path::new("/a/b/foo.tera")),
Utf8PathBuf::from("/a/b/foo")
);
assert_eq!(
template_target(Utf8Path::new("home/.gitconfig.tera")),
Utf8PathBuf::from("home/.gitconfig")
);
}
#[test]
fn renders_simple_template_to_sibling() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join("home/.gitconfig.tera"),
"[user]\n os = {{ yui.os }}\n",
);
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert_eq!(report.written.len(), 1);
assert_eq!(
std::fs::read_to_string(r.join("home/.gitconfig")).unwrap(),
"[user]\n os = linux\n"
);
}
#[test]
fn renders_user_vars() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "{{ vars.greet }}");
let mut cfg = empty_config();
cfg.vars
.insert("greet".into(), toml::Value::String("hello".into()));
let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
assert_eq!(
std::fs::read_to_string(r.join("home/foo")).unwrap(),
"hello"
);
}
#[test]
fn skips_when_file_header_false() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join("home/foo.tera"),
"{# yui:when yui.os == 'windows' #}\nbody",
);
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert!(report.written.is_empty());
assert_eq!(report.skipped_when_false.len(), 1);
assert!(!r.join("home/foo").exists());
}
#[test]
fn includes_when_file_header_true() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join("home/foo.tera"),
"{# yui:when yui.os == 'linux' #}\nbody",
);
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert_eq!(report.written.len(), 1);
assert_eq!(std::fs::read_to_string(r.join("home/foo")).unwrap(), "body");
}
#[test]
fn config_rule_when_false_skips_matching_template() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/win/settings.tera"), "body");
let mut cfg = empty_config();
cfg.render.rule.push(RenderRule {
r#match: "home/win/**".into(),
when: Some("{{ yui.os == 'windows' }}".into()),
});
let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
assert_eq!(report.skipped_when_false.len(), 1);
assert!(report.written.is_empty());
}
#[test]
fn config_rule_no_match_does_not_filter() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "body");
let mut cfg = empty_config();
cfg.render.rule.push(RenderRule {
r#match: "home/win/**".into(),
when: Some("{{ yui.os == 'windows' }}".into()),
});
let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
assert_eq!(report.written.len(), 1);
}
#[test]
fn unchanged_when_existing_matches() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "body");
write(&r.join("home/foo"), "body"); let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert!(report.written.is_empty());
assert_eq!(report.unchanged.len(), 1);
}
#[test]
fn detects_drift_when_existing_diverges() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "fresh body");
write(&r.join("home/foo"), "manually edited");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert!(report.has_drift());
assert_eq!(report.diverged.len(), 1);
let entry = &report.diverged[0];
assert_eq!(entry.tera_path, r.join("home/foo.tera"));
assert_eq!(entry.rendered_path, r.join("home/foo"));
assert_eq!(entry.fresh_body, "fresh body");
assert_eq!(
std::fs::read_to_string(r.join("home/foo")).unwrap(),
"manually edited"
);
}
#[test]
fn dry_run_does_not_write_or_touch_gitignore() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "body");
let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
assert!(!r.join("home/foo").exists());
assert!(!r.join(".gitignore").exists());
}
#[test]
fn updates_gitignore_managed_section() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "body");
write(&r.join("home/bar.tera"), "body2");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
write_managed_section(&r, &report_managed_paths(&report)).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(gi.contains(GITIGNORE_BEGIN));
assert!(gi.contains(GITIGNORE_END));
assert!(gi.contains("home/bar"));
assert!(gi.contains("home/foo"));
let bar_pos = gi.find("home/bar").unwrap();
let foo_pos = gi.find("home/foo").unwrap();
assert!(bar_pos < foo_pos);
}
#[test]
fn preserves_existing_gitignore_content() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join(".gitignore"), "node_modules/\ntarget/\n");
write(&r.join("home/foo.tera"), "body");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
write_managed_section(&r, &report_managed_paths(&report)).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(gi.contains("node_modules/"));
assert!(gi.contains("target/"));
assert!(gi.contains("home/foo"));
}
#[test]
fn replaces_existing_managed_section() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join(".gitignore"),
&format!("node_modules/\n\n{GITIGNORE_BEGIN}\nstale/path\n{GITIGNORE_END}\n\nfoo\n"),
);
write(&r.join("home/foo.tera"), "body");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
write_managed_section(&r, &report_managed_paths(&report)).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(gi.contains("node_modules/"));
assert!(gi.contains("home/foo"));
assert!(!gi.contains("stale/path"));
assert!(gi.contains("\nfoo\n"));
}
#[test]
fn walks_into_gitignored_directories() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join(".gitignore"), "node_modules/\n");
write(&r.join("node_modules/foo.tera"), "body");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert_eq!(report.written.len(), 1);
assert!(r.join("node_modules/foo").exists());
}
#[test]
fn removes_stale_rendered_when_file_header_becomes_false() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join("home/foo.tera"),
"{# yui:when yui.os == 'windows' #}\nbody",
);
write(&r.join("home/foo"), "old rendered output");
let report = render_all(&r, &empty_config(), &yui_vars(&r), false).unwrap();
assert_eq!(report.skipped_when_false.len(), 1);
assert!(!r.join("home/foo").exists());
}
#[test]
fn removes_stale_rendered_when_rule_when_becomes_false() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/win/settings.tera"), "body");
write(&r.join("home/win/settings"), "old rendered output");
let mut cfg = empty_config();
cfg.render.rule.push(RenderRule {
r#match: "home/win/**".into(),
when: Some("{{ yui.os == 'windows' }}".into()),
});
let report = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
assert_eq!(report.skipped_when_false.len(), 1);
assert!(!r.join("home/win/settings").exists());
}
#[test]
fn dry_run_does_not_remove_stale_rendered() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "{# yui:when false #}\nbody");
write(&r.join("home/foo"), "old rendered output");
let _ = render_all(&r, &empty_config(), &yui_vars(&r), true).unwrap();
assert_eq!(
std::fs::read_to_string(r.join("home/foo")).unwrap(),
"old rendered output"
);
}
#[test]
fn add_to_managed_creates_section_when_missing() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
let plain = r.join("home/.config/foo/private.env");
add_to_managed_section(&r, &plain).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(gi.contains(GITIGNORE_BEGIN));
assert!(gi.contains(GITIGNORE_END));
assert!(gi.contains("home/.config/foo/private.env"));
}
#[test]
fn add_to_managed_preserves_existing_entries() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(
&r.join(".gitignore"),
&format!(
"node_modules/\n\n{GITIGNORE_BEGIN}\nhome/.gitconfig\n{GITIGNORE_END}\n\ntarget/\n"
),
);
let plain = r.join("home/.config/foo/private.env");
add_to_managed_section(&r, &plain).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(gi.contains("home/.gitconfig"));
assert!(gi.contains("home/.config/foo/private.env"));
assert!(gi.contains("node_modules/"));
assert!(gi.contains("target/"));
}
#[test]
fn add_to_managed_is_idempotent() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
let plain = r.join("home/secret.env");
add_to_managed_section(&r, &plain).unwrap();
add_to_managed_section(&r, &plain).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
let occurrences = gi.matches("home/secret.env").count();
assert_eq!(occurrences, 1, "duplicate entries in: {gi}");
}
#[test]
fn add_to_managed_normalises_windows_separators() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
let plain = Utf8PathBuf::from(format!("{}\\home\\.config\\foo\\private.env", r.as_str()));
add_to_managed_section(&r, &plain).unwrap();
let gi = std::fs::read_to_string(r.join(".gitignore")).unwrap();
assert!(
gi.contains("home/.config/foo/private.env"),
"expected forward-slash entry in: {gi}"
);
assert!(
!gi.contains("home\\.config"),
"managed section should not carry backslashes: {gi}"
);
}
#[test]
fn manage_gitignore_disabled_does_not_write_gitignore() {
let tmp = TempDir::new().unwrap();
let r = root(&tmp);
write(&r.join("home/foo.tera"), "body");
let mut cfg = empty_config();
cfg.render.manage_gitignore = false;
let _ = render_all(&r, &cfg, &yui_vars(&r), false).unwrap();
assert!(r.join("home/foo").exists());
assert!(!r.join(".gitignore").exists());
}
}