use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::{DodotError, Result};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Baseline {
pub version: u32,
#[serde(default)]
pub source_path: PathBuf,
pub rendered_hash: String,
pub rendered_content: String,
pub source_hash: String,
#[serde(default)]
pub context_hash: String,
#[serde(default)]
pub tracked_render: String,
pub timestamp: u64,
}
impl Baseline {
pub fn build(
source_path: &Path,
rendered_content: &[u8],
source_bytes: &[u8],
tracked_render: Option<&str>,
context_hash: Option<&[u8; 32]>,
) -> Self {
Self {
version: SCHEMA_VERSION,
source_path: source_path.to_path_buf(),
rendered_hash: hex_sha256(rendered_content),
rendered_content: String::from_utf8_lossy(rendered_content).into_owned(),
source_hash: hex_sha256(source_bytes),
context_hash: context_hash.map(hex_encode_32).unwrap_or_default(),
tracked_render: tracked_render.unwrap_or("").to_string(),
timestamp: now_secs_unix(),
}
}
pub fn write(
&self,
fs: &dyn Fs,
paths: &dyn Pather,
pack: &str,
handler: &str,
filename: &str,
) -> Result<PathBuf> {
let path = paths.preprocessor_baseline_path(pack, handler, filename);
if let Some(parent) = path.parent() {
fs.mkdir_all(parent)?;
}
let body = serde_json::to_string_pretty(self).map_err(|e| {
DodotError::Other(format!(
"failed to serialise baseline for {pack}/{handler}/{filename}: {e}"
))
})?;
fs.write_file(&path, body.as_bytes())?;
Ok(path)
}
pub fn load(
fs: &dyn Fs,
paths: &dyn Pather,
pack: &str,
handler: &str,
filename: &str,
) -> Result<Option<Self>> {
let path = paths.preprocessor_baseline_path(pack, handler, filename);
if !fs.exists(&path) {
return Ok(None);
}
let raw = fs.read_to_string(&path)?;
let baseline: Self = serde_json::from_str(&raw).map_err(|e| {
DodotError::Other(format!(
"failed to parse baseline at {}: {e}\n \
Try `dodot up --force` to re-baseline.",
path.display()
))
})?;
if baseline.version != SCHEMA_VERSION {
return Err(DodotError::Other(format!(
"baseline at {} has unsupported schema version {} (expected {}). \
Clear the file and run `dodot up` to rebuild.",
path.display(),
baseline.version,
SCHEMA_VERSION
)));
}
Ok(Some(baseline))
}
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_encode_32(&hasher.finalize().into())
}
fn hex_encode_32(bytes: &[u8; 32]) -> String {
let mut out = String::with_capacity(64);
for b in bytes {
out.push(hex_nibble(b >> 4));
out.push(hex_nibble(b & 0x0f));
}
out
}
fn hex_nibble(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + n - 10) as char,
_ => unreachable!(),
}
}
fn now_secs_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn cache_filename_for(virtual_relative: &Path) -> String {
virtual_relative
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| virtual_relative.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
#[test]
fn build_then_write_then_load_round_trips() {
let env = TempEnvironment::builder().build();
let baseline = Baseline::build(
Path::new("/tmp/config.toml.tmpl"),
b"name = Alice\n",
b"name = {{ name }}\n",
Some("name = \u{1e}Alice\u{1f}\n"),
Some(&[0x42; 32]),
);
let path = baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
assert!(env.fs.exists(&path));
let loaded = Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.expect("baseline must exist after write");
assert_eq!(loaded, baseline);
}
#[test]
fn load_returns_none_for_missing_file() {
let env = TempEnvironment::builder().build();
let result = Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"nope.toml",
)
.unwrap();
assert!(result.is_none());
}
#[test]
fn load_rejects_unsupported_schema_version() {
let env = TempEnvironment::builder().build();
let path = env
.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
env.fs.mkdir_all(path.parent().unwrap()).unwrap();
env.fs
.write_file(
&path,
br#"{"version": 999, "rendered_hash": "x", "rendered_content": "x", "source_hash": "x", "timestamp": 0}"#,
)
.unwrap();
let err = Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap_err();
assert!(
format!("{err}").contains("unsupported schema version"),
"got: {err}"
);
}
#[test]
fn load_rejects_corrupted_json() {
let env = TempEnvironment::builder().build();
let path = env
.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
env.fs.mkdir_all(path.parent().unwrap()).unwrap();
env.fs.write_file(&path, b"{not json").unwrap();
let err = Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("failed to parse"), "got: {msg}");
assert!(
msg.contains("--force"),
"expected recovery hint, got: {msg}"
);
}
#[test]
fn build_records_hashes_and_optional_fields() {
let p = Path::new("/dummy/source");
let b = Baseline::build(p, b"hello", b"hello", None, None);
assert_eq!(b.version, SCHEMA_VERSION);
assert_eq!(b.source_path, p);
assert_eq!(b.rendered_hash.len(), 64); assert_eq!(b.source_hash, b.rendered_hash); assert!(b.context_hash.is_empty());
assert!(b.tracked_render.is_empty());
let b2 = Baseline::build(p, b"x", b"y", Some("tracked"), Some(&[0xff; 32]));
assert_eq!(b2.context_hash.len(), 64);
assert!(b2.context_hash.chars().all(|c| c == 'f'));
assert_eq!(b2.tracked_render, "tracked");
}
#[test]
fn rendered_content_preserves_lossy_utf8() {
let b = Baseline::build(
Path::new("/dummy"),
&[0x66, 0x6f, 0xff, 0x6f],
b"src",
None,
None,
);
assert_eq!(b.rendered_content, "fo\u{fffd}o");
}
#[test]
fn write_creates_nested_directories() {
let env = TempEnvironment::builder().build();
let baseline = Baseline::build(Path::new("/dummy"), b"x", b"y", None, None);
let path = baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"deep",
"preprocessed",
"x",
)
.unwrap();
assert!(env.fs.exists(&path));
assert!(env.fs.is_dir(path.parent().unwrap()));
}
#[test]
fn write_overwrites_existing_baseline() {
let env = TempEnvironment::builder().build();
let first = Baseline::build(Path::new("/dummy"), b"first", b"src", None, None);
first
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"f",
)
.unwrap();
let second = Baseline::build(Path::new("/dummy"), b"second", b"src", None, None);
second
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"f",
)
.unwrap();
let loaded = Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"f",
)
.unwrap()
.unwrap();
assert_eq!(loaded.rendered_content, "second");
}
#[test]
fn cache_filename_for_drops_parent_directories() {
assert_eq!(cache_filename_for(Path::new("config.toml")), "config.toml");
assert_eq!(
cache_filename_for(Path::new("subdir/config.toml")),
"config.toml"
);
assert_eq!(cache_filename_for(Path::new("a/b/c/leaf.txt")), "leaf.txt");
}
#[test]
fn hex_encoding_is_lowercase_and_padded() {
assert_eq!(hex_encode_32(&[0; 32]).len(), 64);
assert!(hex_encode_32(&[0; 32]).chars().all(|c| c == '0'));
assert_eq!(hex_encode_32(&[0xab; 32]).len(), 64);
assert!(hex_encode_32(&[0xab; 32])
.chars()
.all(|c| c == 'a' || c == 'b'));
}
}