use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, info};
use crate::datastore::DataStore;
use crate::fs::Fs;
use crate::packs::Pack;
use crate::paths::Pather;
use crate::preprocessing::baseline::{cache_filename_for, hex_sha256, Baseline};
use crate::preprocessing::divergence::DivergenceState;
use crate::preprocessing::PreprocessorRegistry;
use crate::rules::PackEntry;
use crate::{DodotError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreprocessMode {
Active,
Passive,
}
fn validate_safe_relative_path(path: &Path, preprocessor: &str, source_file: &Path) -> Result<()> {
let mut has_normal = false;
for component in path.components() {
match component {
Component::Normal(_) => has_normal = true,
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(DodotError::PreprocessorError {
preprocessor: preprocessor.into(),
source_file: source_file.to_path_buf(),
message: format!(
"unsafe path in preprocessor output: {} (absolute or contains `..`)",
path.display()
),
});
}
}
}
if !has_normal {
return Err(DodotError::PreprocessorError {
preprocessor: preprocessor.into(),
source_file: source_file.to_path_buf(),
message: format!(
"preprocessor produced an empty output path (\"{}\"). This usually means a file like \
`.tmpl` or `.identity` has no stem after stripping the preprocessor extension — \
rename the source file so that it has a non-empty name after stripping.",
path.display()
),
});
}
Ok(())
}
fn normalize_relative(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
if let Component::Normal(n) = component {
out.push(n);
}
}
out
}
#[derive(Debug)]
pub struct PreprocessResult {
pub regular_entries: Vec<PackEntry>,
pub virtual_entries: Vec<PackEntry>,
pub source_map: HashMap<PathBuf, PathBuf>,
pub rendered_bytes: HashMap<PathBuf, Arc<[u8]>>,
pub skipped: Vec<SkippedRender>,
}
#[derive(Debug, Clone)]
pub struct SkippedRender {
pub pack: String,
pub virtual_relative: PathBuf,
pub deployed_path: PathBuf,
pub state: DivergenceState,
}
impl PreprocessResult {
pub fn passthrough(entries: Vec<PackEntry>) -> Self {
Self {
regular_entries: entries,
virtual_entries: Vec::new(),
source_map: HashMap::new(),
rendered_bytes: HashMap::new(),
skipped: Vec::new(),
}
}
pub fn merged_entries(&self) -> Vec<PackEntry> {
let mut all = Vec::with_capacity(self.regular_entries.len() + self.virtual_entries.len());
all.extend(self.regular_entries.iter().cloned());
all.extend(self.virtual_entries.iter().cloned());
all.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
all
}
}
const PREPROCESSED_HANDLER: &str = "preprocessed";
enum DivergenceCheck {
Proceed,
Skip {
state: DivergenceState,
deployed_path: PathBuf,
},
}
fn check_divergence(
fs: &dyn Fs,
paths: &dyn Pather,
pack_name: &str,
virtual_relative: &Path,
source_path: &Path,
) -> Result<DivergenceCheck> {
let cache_filename = cache_filename_for(virtual_relative);
let baseline =
match Baseline::load(fs, paths, pack_name, PREPROCESSED_HANDLER, &cache_filename)? {
Some(b) => b,
None => return Ok(DivergenceCheck::Proceed),
};
let deployed_path = paths
.handler_data_dir(pack_name, PREPROCESSED_HANDLER)
.join(virtual_relative);
if !fs.exists(&deployed_path) {
return Ok(DivergenceCheck::Proceed);
}
let deployed_bytes = fs.read_file(&deployed_path)?;
if hex_sha256(&deployed_bytes) == baseline.rendered_hash {
return Ok(DivergenceCheck::Proceed);
}
let source_changed = match fs.read_file(source_path) {
Ok(bytes) => hex_sha256(&bytes) != baseline.source_hash,
Err(_) => false,
};
let state = if source_changed {
DivergenceState::BothChanged
} else {
DivergenceState::OutputChanged
};
Ok(DivergenceCheck::Skip {
state,
deployed_path,
})
}
#[allow(clippy::too_many_arguments)] pub fn preprocess_pack(
entries: Vec<PackEntry>,
registry: &PreprocessorRegistry,
pack: &Pack,
fs: &dyn Fs,
datastore: &dyn DataStore,
paths: &dyn Pather,
mode: PreprocessMode,
force: bool,
) -> Result<PreprocessResult> {
let mut regular_entries = Vec::new();
let mut preprocessor_entries = Vec::new();
for entry in entries {
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if !entry.is_dir && registry.is_preprocessor_file(&filename) {
preprocessor_entries.push(entry);
} else {
regular_entries.push(entry);
}
}
debug!(
pack = %pack.name,
preprocessor = preprocessor_entries.len(),
regular = regular_entries.len(),
"partitioned entries"
);
if preprocessor_entries.is_empty() {
return Ok(PreprocessResult {
regular_entries,
virtual_entries: Vec::new(),
source_map: HashMap::new(),
rendered_bytes: HashMap::new(),
skipped: Vec::new(),
});
}
if mode == PreprocessMode::Passive {
return preprocess_pack_passive(
preprocessor_entries,
regular_entries,
registry,
pack,
fs,
paths,
);
}
let mut virtual_entries = Vec::new();
let mut source_map = HashMap::new();
let mut rendered_bytes: HashMap<PathBuf, Arc<[u8]>> = HashMap::new();
let mut skipped: Vec<SkippedRender> = Vec::new();
let mut claimed_paths: std::collections::HashSet<PathBuf> = regular_entries
.iter()
.map(|e| e.relative_path.clone())
.collect();
for entry in &preprocessor_entries {
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let preprocessor = registry
.find_for_file(&filename)
.expect("already checked in partition");
info!(
pack = %pack.name,
preprocessor = preprocessor.name(),
file = %filename,
"expanding"
);
if preprocessor.supports_reverse_merge() {
let source_bytes = fs.read_file(&entry.absolute_path)?;
let source_str = String::from_utf8_lossy(&source_bytes);
crate::preprocessing::conflict::ensure_no_unresolved_markers(
&source_str,
&entry.absolute_path,
)?;
}
let expanded_files = preprocessor.expand(&entry.absolute_path, fs)?;
for expanded in expanded_files {
validate_safe_relative_path(
&expanded.relative_path,
preprocessor.name(),
&entry.absolute_path,
)?;
let virtual_relative = if let Some(parent) = entry.relative_path.parent() {
if parent == Path::new("") {
expanded.relative_path.clone()
} else {
parent.join(&expanded.relative_path)
}
} else {
expanded.relative_path.clone()
};
validate_safe_relative_path(
&virtual_relative,
preprocessor.name(),
&entry.absolute_path,
)?;
let virtual_relative = normalize_relative(&virtual_relative);
if claimed_paths.contains(&virtual_relative) {
return Err(DodotError::PreprocessorCollision {
pack: pack.name.clone(),
source_file: filename.clone(),
expanded_name: virtual_relative.to_string_lossy().into_owned(),
});
}
let mut skip_path: Option<PathBuf> = None;
if !force && !expanded.is_dir && expanded.tracked_render.is_some() {
match check_divergence(
fs,
paths,
&pack.name,
&virtual_relative,
&entry.absolute_path,
)? {
DivergenceCheck::Proceed => {}
DivergenceCheck::Skip {
state,
deployed_path,
} => {
info!(
pack = %pack.name,
file = %virtual_relative.display(),
?state,
"preserving divergent deployed file (skipping write)"
);
skipped.push(SkippedRender {
pack: pack.name.clone(),
virtual_relative: virtual_relative.clone(),
deployed_path: deployed_path.clone(),
state,
});
skip_path = Some(deployed_path);
}
}
}
let was_skipped = skip_path.is_some();
let datastore_path = if let Some(p) = skip_path {
p
} else if expanded.is_dir {
datastore.write_rendered_dir(
&pack.name,
PREPROCESSED_HANDLER,
&virtual_relative.to_string_lossy(),
)?
} else {
datastore.write_rendered_file(
&pack.name,
PREPROCESSED_HANDLER,
&virtual_relative.to_string_lossy(),
&expanded.content,
)?
};
debug!(
pack = %pack.name,
virtual_path = %virtual_relative.display(),
datastore_path = %datastore_path.display(),
is_dir = expanded.is_dir,
skipped = was_skipped,
"wrote expanded entry"
);
if let (false, Some(tracked), false) = (
expanded.is_dir,
expanded.tracked_render.as_deref(),
was_skipped,
) {
let cache_filename = cache_filename_for(&virtual_relative);
let source_bytes = fs.read_file(&entry.absolute_path)?;
let baseline = Baseline::build(
&entry.absolute_path,
&expanded.content,
&source_bytes,
Some(tracked),
expanded.context_hash.as_ref(),
);
if let Err(err) =
baseline.write(fs, paths, &pack.name, PREPROCESSED_HANDLER, &cache_filename)
{
debug!(
pack = %pack.name,
file = %cache_filename,
error = %err,
"baseline write failed (non-fatal)"
);
} else {
debug!(
pack = %pack.name,
file = %cache_filename,
"baseline written"
);
}
}
claimed_paths.insert(virtual_relative.clone());
source_map.insert(datastore_path.clone(), entry.absolute_path.clone());
if !expanded.is_dir {
let bytes: Arc<[u8]> = if was_skipped {
fs.read_file(&datastore_path)
.map(Arc::from)
.unwrap_or_else(|_| Arc::from(expanded.content.clone()))
} else {
Arc::from(expanded.content.clone())
};
rendered_bytes.insert(datastore_path.clone(), bytes);
}
virtual_entries.push(PackEntry {
relative_path: virtual_relative,
absolute_path: datastore_path,
is_dir: expanded.is_dir,
});
}
}
info!(
pack = %pack.name,
virtual_count = virtual_entries.len(),
"preprocessing complete"
);
Ok(PreprocessResult {
regular_entries,
virtual_entries,
source_map,
rendered_bytes,
skipped,
})
}
fn preprocess_pack_passive(
preprocessor_entries: Vec<PackEntry>,
regular_entries: Vec<PackEntry>,
registry: &PreprocessorRegistry,
pack: &Pack,
fs: &dyn Fs,
paths: &dyn Pather,
) -> Result<PreprocessResult> {
let mut virtual_entries = Vec::new();
let mut source_map = HashMap::new();
let mut rendered_bytes: HashMap<PathBuf, Arc<[u8]>> = HashMap::new();
let mut skipped: Vec<SkippedRender> = Vec::new();
for entry in preprocessor_entries {
let filename = entry
.relative_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let preprocessor = registry
.find_for_file(&filename)
.expect("already checked in partition");
let stripped = preprocessor.stripped_name(&filename);
let virtual_relative = match entry.relative_path.parent() {
Some(parent) if parent != Path::new("") => parent.join(&stripped),
_ => PathBuf::from(&stripped),
};
let virtual_relative = normalize_relative(&virtual_relative);
let datastore_path = paths
.handler_data_dir(&pack.name, PREPROCESSED_HANDLER)
.join(&virtual_relative);
let cache_filename = cache_filename_for(&virtual_relative);
let baseline =
match Baseline::load(fs, paths, &pack.name, PREPROCESSED_HANDLER, &cache_filename)? {
Some(b) => Some(b),
None => {
debug!(
pack = %pack.name,
file = %virtual_relative.display(),
"passive: no baseline yet — surfacing placeholder (run `dodot up` first)"
);
None
}
};
if baseline.is_some() {
if let Ok(DivergenceCheck::Skip {
state,
deployed_path,
}) = check_divergence(
fs,
paths,
&pack.name,
&virtual_relative,
&entry.absolute_path,
) {
skipped.push(SkippedRender {
pack: pack.name.clone(),
virtual_relative: virtual_relative.clone(),
deployed_path,
state,
});
}
}
if let Some(b) = baseline {
let bytes: Arc<[u8]> = Arc::from(b.rendered_content.into_bytes());
rendered_bytes.insert(datastore_path.clone(), bytes);
}
source_map.insert(datastore_path.clone(), entry.absolute_path.clone());
virtual_entries.push(PackEntry {
relative_path: virtual_relative,
absolute_path: datastore_path,
is_dir: false,
});
}
info!(
pack = %pack.name,
virtual_count = virtual_entries.len(),
skipped_count = skipped.len(),
"passive preprocessing complete"
);
Ok(PreprocessResult {
regular_entries,
virtual_entries,
source_map,
rendered_bytes,
skipped,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::FilesystemDataStore;
use crate::handlers::HandlerConfig;
use crate::preprocessing::identity::IdentityPreprocessor;
use crate::testing::TempEnvironment;
use std::sync::Arc;
fn make_pack(name: &str, path: PathBuf) -> Pack {
Pack::new(name.into(), path, HandlerConfig::default())
}
fn make_registry() -> PreprocessorRegistry {
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(IdentityPreprocessor::new()));
registry
}
fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
let runner = Arc::new(crate::datastore::ShellCommandRunner::new(false));
FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner)
}
#[test]
fn passthrough_when_no_preprocessor_files() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("vim", env.dotfiles_root.join("vim"));
let entries = vec![
PackEntry {
relative_path: "vimrc".into(),
absolute_path: env.dotfiles_root.join("vim/vimrc"),
is_dir: false,
},
PackEntry {
relative_path: "gvimrc".into(),
absolute_path: env.dotfiles_root.join("vim/gvimrc"),
is_dir: false,
},
];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.regular_entries.len(), 2);
assert!(result.virtual_entries.is_empty());
assert!(result.source_map.is_empty());
}
#[test]
fn identity_preprocessor_creates_virtual_entry() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "host = localhost")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert!(result.regular_entries.is_empty());
assert_eq!(result.virtual_entries.len(), 1);
let virtual_entry = &result.virtual_entries[0];
assert_eq!(virtual_entry.relative_path, PathBuf::from("config.toml"));
assert!(!virtual_entry.is_dir);
let content = env.fs.read_to_string(&virtual_entry.absolute_path).unwrap();
assert_eq!(content, "host = localhost");
assert_eq!(
result.source_map[&virtual_entry.absolute_path],
env.dotfiles_root.join("app/config.toml.identity")
);
}
#[test]
fn mixed_pack_partitions_correctly() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "host = localhost")
.file("readme.txt", "hello")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
},
PackEntry {
relative_path: "readme.txt".into(),
absolute_path: env.dotfiles_root.join("app/readme.txt"),
is_dir: false,
},
];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.regular_entries.len(), 1);
assert_eq!(
result.regular_entries[0].relative_path,
PathBuf::from("readme.txt")
);
assert_eq!(result.virtual_entries.len(), 1);
assert_eq!(
result.virtual_entries[0].relative_path,
PathBuf::from("config.toml")
);
}
#[test]
fn collision_detection_rejects_conflict() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "preprocessed")
.file("config.toml", "regular")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
},
PackEntry {
relative_path: "config.toml".into(),
absolute_path: env.dotfiles_root.join("app/config.toml"),
is_dir: false,
},
];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorCollision { .. }),
"expected PreprocessorCollision, got: {err}"
);
}
#[test]
fn merged_entries_combines_and_sorts() {
let result = PreprocessResult {
regular_entries: vec![PackEntry {
relative_path: "zebra".into(),
absolute_path: "/z".into(),
is_dir: false,
}],
virtual_entries: vec![PackEntry {
relative_path: "alpha".into(),
absolute_path: "/a".into(),
is_dir: false,
}],
source_map: HashMap::new(),
rendered_bytes: HashMap::new(),
skipped: Vec::new(),
};
let merged = result.merged_entries();
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].relative_path, PathBuf::from("alpha"));
assert_eq!(merged[1].relative_path, PathBuf::from("zebra"));
}
#[test]
fn empty_registry_passes_all_through() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "content")
.done()
.build();
let registry = PreprocessorRegistry::new(); let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.regular_entries.len(), 1);
assert!(result.virtual_entries.is_empty());
}
#[test]
fn directories_are_never_preprocessed() {
let env = TempEnvironment::builder()
.pack("app")
.file("bin.identity/tool", "#!/bin/sh")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bin.identity".into(),
absolute_path: env.dotfiles_root.join("app/bin.identity"),
is_dir: true, }];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.regular_entries.len(), 1);
assert!(result.virtual_entries.is_empty());
}
#[test]
fn subdirectory_preprocessor_file_preserves_parent() {
let env = TempEnvironment::builder()
.pack("app")
.file("subdir/config.toml.identity", "nested content")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "subdir/config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/subdir/config.toml.identity"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.virtual_entries.len(), 1);
assert_eq!(
result.virtual_entries[0].relative_path,
PathBuf::from("subdir/config.toml")
);
}
#[test]
fn multiple_preprocessor_files_in_one_pack() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "config content")
.file("settings.json.identity", "settings content")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
},
PackEntry {
relative_path: "settings.json.identity".into(),
absolute_path: env.dotfiles_root.join("app/settings.json.identity"),
is_dir: false,
},
];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert!(result.regular_entries.is_empty());
assert_eq!(result.virtual_entries.len(), 2);
let names: Vec<String> = result
.virtual_entries
.iter()
.map(|e| e.relative_path.to_string_lossy().to_string())
.collect();
assert!(names.contains(&"config.toml".to_string()));
assert!(names.contains(&"settings.json".to_string()));
assert_eq!(result.source_map.len(), 2);
}
#[test]
fn pack_with_only_preprocessor_files() {
let env = TempEnvironment::builder()
.pack("app")
.file("only.conf.identity", "the only file")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "only.conf.identity".into(),
absolute_path: env.dotfiles_root.join("app/only.conf.identity"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert!(result.regular_entries.is_empty());
assert_eq!(result.virtual_entries.len(), 1);
assert_eq!(result.merged_entries().len(), 1);
}
#[test]
fn source_map_is_complete() {
let env = TempEnvironment::builder()
.pack("app")
.file("a.conf.identity", "aaa")
.file("b.conf.identity", "bbb")
.file("regular.txt", "ccc")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "a.conf.identity".into(),
absolute_path: env.dotfiles_root.join("app/a.conf.identity"),
is_dir: false,
},
PackEntry {
relative_path: "b.conf.identity".into(),
absolute_path: env.dotfiles_root.join("app/b.conf.identity"),
is_dir: false,
},
PackEntry {
relative_path: "regular.txt".into(),
absolute_path: env.dotfiles_root.join("app/regular.txt"),
is_dir: false,
},
];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
for ve in &result.virtual_entries {
assert!(
result.source_map.contains_key(&ve.absolute_path),
"virtual entry {} has no source_map entry",
ve.absolute_path.display()
);
}
for re in &result.regular_entries {
assert!(
!result.source_map.contains_key(&re.absolute_path),
"regular entry {} should not be in source_map",
re.absolute_path.display()
);
}
}
#[test]
fn preprocessing_is_idempotent() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "content")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let make_entries = || {
vec![PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
}]
};
let result1 = preprocess_pack(
make_entries(),
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
let result2 = preprocess_pack(
make_entries(),
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result1.virtual_entries.len(), result2.virtual_entries.len());
assert_eq!(
result1.virtual_entries[0].relative_path,
result2.virtual_entries[0].relative_path
);
let content1 = env
.fs
.read_to_string(&result1.virtual_entries[0].absolute_path)
.unwrap();
let content2 = env
.fs
.read_to_string(&result2.virtual_entries[0].absolute_path)
.unwrap();
assert_eq!(content1, content2);
}
#[test]
fn expansion_error_propagates() {
let env = TempEnvironment::builder()
.pack("app")
.file("placeholder", "")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "missing.conf.identity".into(),
absolute_path: env.dotfiles_root.join("app/missing.conf.identity"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::Fs { .. }),
"expected Fs error for missing file, got: {err}"
);
}
#[test]
fn inter_preprocessor_collision_detected() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "a")
.file("config.toml.other", "b")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(IdentityPreprocessor::new()));
registry.register(Box::new(IdentityPreprocessor::with_extension("other")));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
},
PackEntry {
relative_path: "config.toml.other".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.other"),
is_dir: false,
},
];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorCollision { .. }),
"expected PreprocessorCollision for inter-preprocessor clash, got: {err}"
);
}
#[test]
fn datastore_preserves_directory_structure() {
let env = TempEnvironment::builder()
.pack("app")
.file("sub/config.toml.identity", "nested")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "sub/config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/sub/config.toml.identity"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.virtual_entries.len(), 1);
let datastore_path = &result.virtual_entries[0].absolute_path;
let ds_str = datastore_path.to_string_lossy();
assert!(
ds_str.contains("sub/config.toml"),
"datastore path should preserve directory structure, got: {ds_str}"
);
assert!(
!ds_str.contains("__"),
"datastore path should not contain flattening separator, got: {ds_str}"
);
assert!(env.fs.exists(datastore_path));
let content = env.fs.read_to_string(datastore_path).unwrap();
assert_eq!(content, "nested");
}
#[test]
fn datastore_distinguishes_sibling_from_flattened_name() {
let env = TempEnvironment::builder()
.pack("app")
.file("a/b.txt.identity", "nested")
.file("a__b.txt.identity", "flat")
.done()
.build();
let registry = make_registry();
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![
PackEntry {
relative_path: "a/b.txt.identity".into(),
absolute_path: env.dotfiles_root.join("app/a/b.txt.identity"),
is_dir: false,
},
PackEntry {
relative_path: "a__b.txt.identity".into(),
absolute_path: env.dotfiles_root.join("app/a__b.txt.identity"),
is_dir: false,
},
];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.virtual_entries.len(), 2);
let nested = result
.virtual_entries
.iter()
.find(|e| e.relative_path == std::path::Path::new("a/b.txt"))
.expect("nested entry");
let flat = result
.virtual_entries
.iter()
.find(|e| e.relative_path == std::path::Path::new("a__b.txt"))
.expect("flat entry");
assert_ne!(nested.absolute_path, flat.absolute_path);
assert_eq!(
env.fs.read_to_string(&nested.absolute_path).unwrap(),
"nested"
);
assert_eq!(env.fs.read_to_string(&flat.absolute_path).unwrap(), "flat");
}
struct ScriptedPreprocessor {
name: &'static str,
extension: &'static str,
outputs: Vec<crate::preprocessing::ExpandedFile>,
supports_reverse_merge: bool,
}
impl Default for ScriptedPreprocessor {
fn default() -> Self {
Self {
name: "scripted",
extension: ".scripted",
outputs: Vec::new(),
supports_reverse_merge: false,
}
}
}
impl crate::preprocessing::Preprocessor for ScriptedPreprocessor {
fn name(&self) -> &str {
self.name
}
fn transform_type(&self) -> crate::preprocessing::TransformType {
crate::preprocessing::TransformType::Opaque
}
fn matches_extension(&self, filename: &str) -> bool {
filename.ends_with(self.extension)
}
fn stripped_name(&self, filename: &str) -> String {
filename
.strip_suffix(self.extension)
.unwrap_or(filename)
.to_string()
}
fn expand(
&self,
_source: &Path,
_fs: &dyn Fs,
) -> Result<Vec<crate::preprocessing::ExpandedFile>> {
Ok(self.outputs.clone())
}
fn supports_reverse_merge(&self) -> bool {
self.supports_reverse_merge
}
}
#[test]
fn rejects_absolute_path_from_preprocessor() {
let env = TempEnvironment::builder()
.pack("app")
.file("bad.evil", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "evil",
extension: ".evil",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("/etc/passwd"),
content: b"pwn".to_vec(),
is_dir: false,
..Default::default()
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bad.evil".into(),
absolute_path: env.dotfiles_root.join("app/bad.evil"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorError { ref message, .. } if message.contains("unsafe path")),
"expected unsafe-path error, got: {err}"
);
assert!(!std::path::Path::new("/etc/passwd.dodot-would-have-written-here").exists());
}
#[test]
fn rejects_parent_dir_escape_from_preprocessor() {
let env = TempEnvironment::builder()
.pack("app")
.file("bad.evil", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "evil",
extension: ".evil",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("../../escape.txt"),
content: b"pwn".to_vec(),
is_dir: false,
..Default::default()
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bad.evil".into(),
absolute_path: env.dotfiles_root.join("app/bad.evil"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorError { ref message, .. } if message.contains("unsafe path")),
"expected unsafe-path error, got: {err}"
);
}
#[test]
fn directory_entry_is_mkdird_not_written_as_file() {
let env = TempEnvironment::builder()
.pack("app")
.file("bundle.zz", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "scripted",
extension: ".zz",
outputs: vec![
crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("sub"),
content: Vec::new(),
is_dir: true,
..Default::default()
},
crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("sub/nested.txt"),
content: b"hello".to_vec(),
is_dir: false,
..Default::default()
},
],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bundle.zz".into(),
absolute_path: env.dotfiles_root.join("app/bundle.zz"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.virtual_entries.len(), 2);
let dir_entry = result
.virtual_entries
.iter()
.find(|e| e.is_dir)
.expect("directory entry");
assert!(
env.fs.is_dir(&dir_entry.absolute_path),
"directory entry should be a real directory: {}",
dir_entry.absolute_path.display()
);
let file_entry = result
.virtual_entries
.iter()
.find(|e| !e.is_dir)
.expect("file entry");
assert_eq!(
env.fs.read_to_string(&file_entry.absolute_path).unwrap(),
"hello"
);
}
#[test]
fn rejects_empty_path_from_preprocessor() {
let env = TempEnvironment::builder()
.pack("app")
.file("bad.zz", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "scripted",
extension: ".zz",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from(""),
content: b"nope".to_vec(),
is_dir: false,
..Default::default()
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bad.zz".into(),
absolute_path: env.dotfiles_root.join("app/bad.zz"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorError { ref message, .. } if message.contains("empty output path")),
"expected empty-path error, got: {err}"
);
}
#[test]
fn rejects_curdir_only_path_from_preprocessor() {
let env = TempEnvironment::builder()
.pack("app")
.file("bad.zz", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "scripted",
extension: ".zz",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("."),
content: b"nope".to_vec(),
is_dir: false,
..Default::default()
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bad.zz".into(),
absolute_path: env.dotfiles_root.join("app/bad.zz"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorError { ref message, .. } if message.contains("empty output path")),
"expected empty-path error, got: {err}"
);
}
#[test]
fn curdir_prefixed_paths_collide_with_plain_paths() {
let env = TempEnvironment::builder()
.pack("app")
.file("bundle.zz", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "scripted",
extension: ".zz",
outputs: vec![
crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("foo"),
content: b"first".to_vec(),
is_dir: false,
..Default::default()
},
crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("./foo"),
content: b"second".to_vec(),
is_dir: false,
..Default::default()
},
],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bundle.zz".into(),
absolute_path: env.dotfiles_root.join("app/bundle.zz"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::PreprocessorCollision { .. }),
"expected PreprocessorCollision for ./foo vs foo, got: {err}"
);
}
#[test]
fn virtual_entry_relative_path_is_normalized() {
let env = TempEnvironment::builder()
.pack("app")
.file("bundle.zz", "x")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "scripted",
extension: ".zz",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("./nested/file.txt"),
content: b"hi".to_vec(),
is_dir: false,
..Default::default()
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "bundle.zz".into(),
absolute_path: env.dotfiles_root.join("app/bundle.zz"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap();
assert_eq!(result.virtual_entries.len(), 1);
assert_eq!(
result.virtual_entries[0].relative_path,
PathBuf::from("nested/file.txt"),
"CurDir components must be stripped from virtual entry"
);
}
#[test]
fn baseline_is_written_when_paths_provided_and_tracked_render_present() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tracked", "name = original")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "tracked-scripted",
extension: ".tracked",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"name = rendered".to_vec(),
is_dir: false,
tracked_render: Some("name = \u{1e}rendered\u{1f}".into()),
context_hash: Some([0xab; 32]),
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}];
preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap();
let baseline = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.expect("baseline must be written for a tracked-render expansion");
assert_eq!(baseline.rendered_content, "name = rendered");
assert_eq!(baseline.tracked_render, "name = \u{1e}rendered\u{1f}");
assert_eq!(baseline.source_hash.len(), 64);
assert!(
baseline.context_hash.chars().all(|c| c == 'a' || c == 'b'),
"context hash should be 0xab repeated, got: {}",
baseline.context_hash
);
assert_eq!(baseline.context_hash.len(), 64);
}
#[test]
fn baseline_is_skipped_in_passive_mode() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tracked", "src")
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "tracked-scripted",
extension: ".tracked",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"x".to_vec(),
is_dir: false,
tracked_render: Some("x".into()),
context_hash: Some([0; 32]),
}],
..Default::default()
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}];
preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Passive,
false,
)
.unwrap();
let path = env
.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
assert!(
!env.fs.exists(&path),
"no baseline should exist after a Passive run, but found: {}",
path.display()
);
}
#[test]
fn baseline_is_skipped_for_preprocessors_without_tracked_render() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.identity", "data")
.done()
.build();
let registry = make_registry(); let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.identity".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.identity"),
is_dir: false,
}];
preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap();
let path = env
.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
assert!(
!env.fs.exists(&path),
"identity preprocessor (no tracked render) should not write a baseline"
);
}
#[test]
fn baseline_overwrites_on_repeated_up() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tracked", "first")
.done()
.build();
let outputs_first = vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"FIRST".to_vec(),
is_dir: false,
tracked_render: Some("FIRST".into()),
context_hash: Some([1; 32]),
}];
let outputs_second = vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"SECOND".to_vec(),
is_dir: false,
tracked_render: Some("SECOND".into()),
context_hash: Some([2; 32]),
}];
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let make_entries = || {
vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}]
};
let mut registry1 = PreprocessorRegistry::new();
registry1.register(Box::new(ScriptedPreprocessor {
name: "ts",
extension: ".tracked",
outputs: outputs_first,
..Default::default()
}));
preprocess_pack(
make_entries(),
®istry1,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap();
let mut registry2 = PreprocessorRegistry::new();
registry2.register(Box::new(ScriptedPreprocessor {
name: "ts",
extension: ".tracked",
outputs: outputs_second,
..Default::default()
}));
preprocess_pack(
make_entries(),
®istry2,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap();
let baseline = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.unwrap();
assert_eq!(baseline.rendered_content, "SECOND");
}
#[test]
fn end_to_end_baseline_for_real_template_preprocessor() {
use std::collections::HashMap;
let env = TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "hello {{ name }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let template_pp = crate::preprocessing::template::TemplatePreprocessor::new(
vec!["tmpl".into()],
vars,
env.paths.as_ref(),
)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(template_pp));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "greet.tmpl".into(),
absolute_path: env.dotfiles_root.join("app/greet.tmpl"),
is_dir: false,
}];
preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap();
let baseline = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"greet",
)
.unwrap()
.expect("template baseline must be written");
assert_eq!(baseline.rendered_content, "hello Alice");
assert!(
baseline.tracked_render.contains(burgertocow::VAR_START),
"tracked render must contain marker bytes, got: {:?}",
baseline.tracked_render
);
assert_eq!(baseline.context_hash.len(), 64);
assert_eq!(baseline.rendered_hash.len(), 64);
}
#[test]
fn conflict_marker_in_template_source_blocks_expansion() {
use std::collections::HashMap;
let template_with_conflict = format!(
"name = Alice\n{}\nhost = \"{{{{ env.DB_HOST }}}}\"\n{}\nhost = \"prod\"\n{}\nport = 5432\n",
crate::preprocessing::conflict::MARKER_START,
crate::preprocessing::conflict::MARKER_MID,
crate::preprocessing::conflict::MARKER_END,
);
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", &template_with_conflict)
.done()
.build();
let template_pp = crate::preprocessing::template::TemplatePreprocessor::new(
vec!["tmpl".into()],
HashMap::new(),
env.paths.as_ref(),
)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(template_pp));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tmpl".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tmpl"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap_err();
match err {
DodotError::UnresolvedConflictMarker {
source_file,
line_numbers,
} => {
assert!(source_file.ends_with("config.toml.tmpl"));
assert_eq!(line_numbers.len(), 3, "got: {line_numbers:?}");
}
other => panic!("expected UnresolvedConflictMarker, got: {other}"),
}
let datastore_path = env
.paths
.data_dir()
.join("packs")
.join("app")
.join("preprocessed")
.join("config.toml");
assert!(
!env.fs.exists(&datastore_path),
"no rendered output should land in the datastore when the gate fires"
);
let baseline_path =
env.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
assert!(
!env.fs.exists(&baseline_path),
"no baseline should be written when the gate fires"
);
}
#[test]
fn conflict_marker_gate_skipped_for_preprocessors_without_reverse_merge() {
let env = TempEnvironment::builder()
.pack("app")
.file(
"data.scripted",
&format!(
"header\n{}\nbody\n",
crate::preprocessing::conflict::MARKER_START
),
)
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "bytes-only",
extension: ".scripted",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("data"),
content: b"emitted".to_vec(),
is_dir: false,
..Default::default()
}],
supports_reverse_merge: false,
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "data.scripted".into(),
absolute_path: env.dotfiles_root.join("app/data.scripted"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.expect("non-tracking preprocessor must not be gated by markers in its source");
assert_eq!(result.virtual_entries.len(), 1);
}
#[test]
fn conflict_marker_gate_runs_on_tracking_scripted_preprocessor() {
let env = TempEnvironment::builder()
.pack("app")
.file(
"config.toml.tracked",
&format!(
"ok\n{}\nbody\n{}\n",
crate::preprocessing::conflict::MARKER_START,
crate::preprocessing::conflict::MARKER_END
),
)
.done()
.build();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "tracking-bytes",
extension: ".tracked",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"x".to_vec(),
is_dir: false,
tracked_render: Some("x".into()),
context_hash: Some([0; 32]),
}],
supports_reverse_merge: true,
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::UnresolvedConflictMarker { .. }),
"expected UnresolvedConflictMarker, got: {err}"
);
}
#[test]
fn gate_handles_non_utf8_source_via_lossy_decode() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tracked", "placeholder")
.done()
.build();
let bytes: Vec<u8> = vec![
b'h', b'e', b'l', b'l', b'o', b'\n', 0xff, 0xfe, b'\n', b'w', b'o', b'r', b'l', b'd',
b'\n',
];
env.fs
.write_file(&env.dotfiles_root.join("app/config.toml.tracked"), &bytes)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "tracking-bytes",
extension: ".tracked",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"x".to_vec(),
is_dir: false,
tracked_render: Some("x".into()),
context_hash: Some([0; 32]),
}],
supports_reverse_merge: true,
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.expect("non-UTF-8 source without markers must not crash the gate");
assert_eq!(result.virtual_entries.len(), 1);
}
#[test]
fn gate_detects_markers_in_non_utf8_source() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tracked", "placeholder")
.done()
.build();
let mut bytes: Vec<u8> = Vec::new();
bytes.extend_from_slice(b"prefix\n");
bytes.push(0xff);
bytes.push(0xfe);
bytes.push(b'\n');
bytes.extend_from_slice(crate::preprocessing::conflict::MARKER_START.as_bytes());
bytes.push(b'\n');
bytes.extend_from_slice(b"body\n");
env.fs
.write_file(&env.dotfiles_root.join("app/config.toml.tracked"), &bytes)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(ScriptedPreprocessor {
name: "tracking-bytes",
extension: ".tracked",
outputs: vec![crate::preprocessing::ExpandedFile {
relative_path: PathBuf::from("config.toml"),
content: b"x".to_vec(),
is_dir: false,
tracked_render: Some("x".into()),
context_hash: Some([0; 32]),
}],
supports_reverse_merge: true,
}));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tracked".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tracked"),
is_dir: false,
}];
let err = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(
matches!(err, DodotError::UnresolvedConflictMarker { .. }),
"expected UnresolvedConflictMarker even on non-UTF-8 source, got: {err}"
);
}
#[test]
fn template_renders_normally_after_markers_are_resolved() {
use std::collections::HashMap;
let env = TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "hello {{ name }}")
.done()
.build();
let mut vars = HashMap::new();
vars.insert("name".into(), "Alice".into());
let template_pp = crate::preprocessing::template::TemplatePreprocessor::new(
vec!["tmpl".into()],
vars,
env.paths.as_ref(),
)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(template_pp));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "greet.tmpl".into(),
absolute_path: env.dotfiles_root.join("app/greet.tmpl"),
is_dir: false,
}];
let result = preprocess_pack(
entries.clone(),
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.expect("clean source should expand successfully");
assert_eq!(result.virtual_entries.len(), 1);
let dirty = format!(
"hello\n{}\n{{{{ name }}}}\n{}\n",
crate::preprocessing::conflict::MARKER_START,
crate::preprocessing::conflict::MARKER_END,
);
env.fs
.write_file(&env.dotfiles_root.join("app/greet.tmpl"), dirty.as_bytes())
.unwrap();
let err = preprocess_pack(
entries.clone(),
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.unwrap_err();
assert!(matches!(err, DodotError::UnresolvedConflictMarker { .. }));
env.fs
.write_file(
&env.dotfiles_root.join("app/greet.tmpl"),
b"hello {{ name }}",
)
.unwrap();
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
false,
)
.expect("resolved source should expand again");
assert_eq!(result.virtual_entries.len(), 1);
}
fn run_template_preprocess(
env: &TempEnvironment,
pack_name: &str,
force: bool,
) -> PreprocessResult {
use std::collections::HashMap;
let template_pp = crate::preprocessing::template::TemplatePreprocessor::new(
vec!["tmpl".into()],
HashMap::new(),
env.paths.as_ref(),
)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(template_pp));
let datastore = make_datastore(env);
let pack = make_pack(pack_name, env.dotfiles_root.join(pack_name));
let entries = vec![PackEntry {
relative_path: "config.toml.tmpl".into(),
absolute_path: env.dotfiles_root.join(pack_name).join("config.toml.tmpl"),
is_dir: false,
}];
preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
PreprocessMode::Active,
force,
)
.unwrap()
}
#[test]
fn divergence_guard_skips_when_deployed_was_edited() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
assert!(first.skipped.is_empty(), "first deploy must not skip");
let deployed_path = &first.virtual_entries[0].absolute_path.clone();
env.fs
.write_file(deployed_path, b"name = USER EDITED")
.unwrap();
let second = run_template_preprocess(&env, "app", false);
assert_eq!(second.skipped.len(), 1, "deployed-edit must skip");
let skip = &second.skipped[0];
assert_eq!(skip.state, DivergenceState::OutputChanged);
assert_eq!(skip.pack, "app");
assert_eq!(skip.virtual_relative, std::path::Path::new("config.toml"));
let on_disk = env.fs.read_to_string(deployed_path).unwrap();
assert_eq!(on_disk, "name = USER EDITED");
assert_eq!(second.virtual_entries.len(), 1);
assert_eq!(&second.virtual_entries[0].absolute_path, deployed_path);
}
#[test]
fn divergence_guard_skips_when_both_changed() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
let deployed_path = first.virtual_entries[0].absolute_path.clone();
env.fs
.write_file(
&env.dotfiles_root.join("app/config.toml.tmpl"),
b"name = SOURCE EDITED",
)
.unwrap();
env.fs
.write_file(&deployed_path, b"name = USER EDITED")
.unwrap();
let second = run_template_preprocess(&env, "app", false);
assert_eq!(second.skipped.len(), 1);
assert_eq!(second.skipped[0].state, DivergenceState::BothChanged);
let on_disk = env.fs.read_to_string(&deployed_path).unwrap();
assert_eq!(on_disk, "name = USER EDITED");
}
#[test]
fn divergence_guard_proceeds_when_source_changed_only() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
let deployed_path = first.virtual_entries[0].absolute_path.clone();
env.fs
.write_file(
&env.dotfiles_root.join("app/config.toml.tmpl"),
b"name = NEW VALUE",
)
.unwrap();
let second = run_template_preprocess(&env, "app", false);
assert!(
second.skipped.is_empty(),
"source-only change must not trigger the guard"
);
let on_disk = env.fs.read_to_string(&deployed_path).unwrap();
assert_eq!(on_disk, "name = NEW VALUE");
}
#[test]
fn divergence_guard_no_op_when_nothing_changed() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let _ = run_template_preprocess(&env, "app", false);
let second = run_template_preprocess(&env, "app", false);
assert!(second.skipped.is_empty());
}
#[test]
fn divergence_guard_overridden_by_force() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
let deployed_path = first.virtual_entries[0].absolute_path.clone();
env.fs
.write_file(&deployed_path, b"name = USER EDITED")
.unwrap();
let second = run_template_preprocess(&env, "app", true);
assert!(
second.skipped.is_empty(),
"force=true must bypass the guard"
);
let on_disk = env.fs.read_to_string(&deployed_path).unwrap();
assert_eq!(
on_disk, "name = original",
"force must rewrite to the rendered content"
);
}
#[test]
fn divergence_guard_baseline_stays_pinned_to_last_successful_render() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
let deployed_path = first.virtual_entries[0].absolute_path.clone();
let baseline_before = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.unwrap();
env.fs
.write_file(&deployed_path, b"name = USER EDITED")
.unwrap();
let _ = run_template_preprocess(&env, "app", false);
let baseline_after = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.unwrap();
assert_eq!(
baseline_before.rendered_hash, baseline_after.rendered_hash,
"baseline must not be rewritten when the guard skips"
);
assert_eq!(
baseline_before.rendered_content, baseline_after.rendered_content,
"baseline content must not change after a skipped write"
);
}
#[test]
fn divergence_guard_reproceeds_when_user_undoes_their_edit() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let first = run_template_preprocess(&env, "app", false);
let deployed_path = first.virtual_entries[0].absolute_path.clone();
env.fs
.write_file(&deployed_path, b"name = USER EDITED")
.unwrap();
let blocked = run_template_preprocess(&env, "app", false);
assert_eq!(blocked.skipped.len(), 1);
env.fs
.write_file(&deployed_path, b"name = original")
.unwrap();
let cleared = run_template_preprocess(&env, "app", false);
assert!(
cleared.skipped.is_empty(),
"guard must clear once divergence is gone"
);
}
#[test]
fn divergence_guard_active_for_read_only_callers() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = original")
.done()
.build();
let _ = run_template_preprocess(&env, "app", false);
let baseline_before = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.unwrap();
let deployed_path = env
.paths
.handler_data_dir("app", "preprocessed")
.join("config.toml");
env.fs
.write_file(&deployed_path, b"name = USER EDITED")
.unwrap();
use std::collections::HashMap;
let template_pp = crate::preprocessing::template::TemplatePreprocessor::new(
vec!["tmpl".into()],
HashMap::new(),
env.paths.as_ref(),
)
.unwrap();
let mut registry = PreprocessorRegistry::new();
registry.register(Box::new(template_pp));
let datastore = make_datastore(&env);
let pack = make_pack("app", env.dotfiles_root.join("app"));
let entries = vec![PackEntry {
relative_path: "config.toml.tmpl".into(),
absolute_path: env.dotfiles_root.join("app/config.toml.tmpl"),
is_dir: false,
}];
let result = preprocess_pack(
entries,
®istry,
&pack,
env.fs.as_ref(),
&datastore,
env.paths.as_ref(),
crate::preprocessing::PreprocessMode::Passive,
false,
)
.unwrap();
assert_eq!(
result.skipped.len(),
1,
"guard must fire for read-only callers too"
);
assert_eq!(
env.fs.read_to_string(&deployed_path).unwrap(),
"name = USER EDITED",
"user's deployed-file edit must be preserved"
);
let baseline_after = crate::preprocessing::baseline::Baseline::load(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap()
.unwrap();
assert_eq!(baseline_before, baseline_after);
}
}