use crate::error::{Error, Result};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderManifest {
pub template_sha256: String,
pub vars_sha256: String,
pub output_sha256: String,
}
pub fn render_with_manifest(
template_path: &Path,
vars: &Value,
) -> Result<(String, RenderManifest)> {
let template_source = std::fs::read_to_string(template_path)
.map_err(|error| Error::unknown(format!("{}: {error}", template_path.display())))?;
let output = crate::env::build()
.template_from_str(&template_source)?
.render(vars)?;
let vars_bytes = serde_json::to_vec(vars)
.map_err(|error| Error::unknown(format!("vars not JSON-serializable: {error}")))?;
let manifest = RenderManifest {
template_sha256: sha256_hex(template_source.as_bytes()),
vars_sha256: sha256_hex(&vars_bytes),
output_sha256: sha256_hex(output.as_bytes()),
};
Ok((output, manifest))
}
fn sha256_hex(bytes: &[u8]) -> String {
use core::fmt::Write;
Sha256::digest(bytes)
.iter()
.fold(String::new(), |mut acc, byte| {
write!(&mut acc, "{byte:02x}").expect("writing to a String cannot fail");
acc
})
}
#[cfg(test)]
mod tests {
use super::render_with_manifest;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SCRATCH_TEMPLATE_COUNTER: AtomicU64 = AtomicU64::new(0);
fn scratch_template_path(label: &str) -> std::path::PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let nonce = SCRATCH_TEMPLATE_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"shepherd-render-manifest-{label}-{stamp:x}-{nonce}.md.j2"
))
}
#[test]
fn scratch_template_paths_are_distinct() {
assert_ne!(
scratch_template_path("same-label"),
scratch_template_path("same-label"),
"each fixture reservation must receive its own path"
);
}
#[test]
fn digests_reproduce() {
let path = scratch_template_path("reproduce");
std::fs::write(&path, "{{ greeting }}, {{ name }}!\n").expect("write fixture template");
let vars = serde_json::json!({ "name": "World", "greeting": "Hello" });
let (first_text, first_manifest) =
render_with_manifest(&path, &vars).expect("first render must succeed");
let (second_text, second_manifest) =
render_with_manifest(&path, &vars).expect("second render must succeed");
let _ = std::fs::remove_file(&path);
assert_eq!(first_text, "Hello, World!\n");
assert_eq!(first_text, second_text, "output bytes must reproduce");
assert_eq!(
first_manifest, second_manifest,
"the full digest triad must reproduce byte-for-byte across renders"
);
assert_eq!(
first_manifest.template_sha256, second_manifest.template_sha256,
"template_sha256 must reproduce"
);
assert_eq!(
first_manifest.vars_sha256, second_manifest.vars_sha256,
"vars_sha256 must reproduce"
);
assert_eq!(
first_manifest.output_sha256, second_manifest.output_sha256,
"output_sha256 must reproduce"
);
}
#[test]
fn vars_digest_is_order_independent() {
let path = scratch_template_path("order-independent");
std::fs::write(&path, "{{ a }}-{{ b }}\n").expect("write fixture template");
let forward: serde_json::Value =
serde_json::from_str(r#"{"a": 1, "b": 2}"#).expect("parse forward-order vars");
let reversed: serde_json::Value =
serde_json::from_str(r#"{"b": 2, "a": 1}"#).expect("parse reversed-order vars");
let (_, forward_manifest) =
render_with_manifest(&path, &forward).expect("forward-order render must succeed");
let (_, reversed_manifest) =
render_with_manifest(&path, &reversed).expect("reversed-order render must succeed");
let _ = std::fs::remove_file(&path);
assert_eq!(
forward_manifest.vars_sha256, reversed_manifest.vars_sha256,
"vars_sha256 must not depend on JSON key insertion order"
);
}
#[test]
fn undefined_variable_is_hard_error() {
let path = scratch_template_path("undefined-var");
std::fs::write(&path, "{{ missing }}").expect("write fixture template");
let result = render_with_manifest(&path, &serde_json::json!({}));
let _ = std::fs::remove_file(&path);
match result {
Err(crate::error::Error::Template(error)) => {
assert_eq!(error.kind(), minijinja::ErrorKind::UndefinedError);
}
other => panic!("expected Err(Error::Template(UndefinedError)), got {other:?}"),
}
}
}