use std::collections::BTreeSet;
use std::fmt;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use bon::Builder;
use conflaguration::{Settings, Validate, ValidationMessage};
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use proxima_core::ProximaError;
use proxima_recording::replay::CassetteMeta;
use proxima_test::Mode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(String);
impl fmt::Display for ParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModePolicy {
Auto,
Record,
Replay,
}
impl ModePolicy {
#[must_use]
pub fn resolve(self, discovered: Mode) -> Mode {
match self {
Self::Auto => discovered,
Self::Record => Mode::Record,
Self::Replay => Mode::Replay,
}
}
}
impl FromStr for ModePolicy {
type Err = ParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"auto" => Ok(Self::Auto),
"record" => Ok(Self::Record),
"replay" => Ok(Self::Replay),
other => Err(ParseError(format!(
"unknown cassette mode `{other}` (expected auto|record|replay)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RerecordPolicy {
Truncate,
Backup,
Fail,
}
impl RerecordPolicy {
#[must_use]
pub fn decision(self) -> RerecordDecision {
match self {
Self::Truncate => RerecordDecision::Truncate,
Self::Backup => RerecordDecision::Backup,
Self::Fail => RerecordDecision::Fail,
}
}
}
impl FromStr for RerecordPolicy {
type Err = ParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"truncate" => Ok(Self::Truncate),
"backup" => Ok(Self::Backup),
"fail" => Ok(Self::Fail),
other => Err(ParseError(format!(
"unknown rerecord policy `{other}` (expected truncate|backup|fail)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DuplicatePolicy {
RejectDivergent,
LastWins,
FirstWins,
}
impl FromStr for DuplicatePolicy {
type Err = ParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"reject-divergent" => Ok(Self::RejectDivergent),
"last-wins" => Ok(Self::LastWins),
"first-wins" => Ok(Self::FirstWins),
other => Err(ParseError(format!(
"unknown duplicate policy `{other}` (expected reject-divergent|last-wins|first-wins)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StalenessPolicy {
Fail,
Warn,
}
impl StalenessPolicy {
#[must_use]
pub fn decision(self) -> StaleDecision {
match self {
Self::Fail => StaleDecision::Fail,
Self::Warn => StaleDecision::Proceed,
}
}
}
impl FromStr for StalenessPolicy {
type Err = ParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
"fail" => Ok(Self::Fail),
"warn" => Ok(Self::Warn),
other => Err(ParseError(format!(
"unknown staleness policy `{other}` (expected fail|warn)"
))),
}
}
}
fn parse_mode(raw: &str) -> Result<ModePolicy, ParseError> {
raw.parse()
}
fn parse_rerecord(raw: &str) -> Result<RerecordPolicy, ParseError> {
raw.parse()
}
fn parse_duplicates(raw: &str) -> Result<DuplicatePolicy, ParseError> {
raw.parse()
}
fn parse_staleness(raw: &str) -> Result<StalenessPolicy, ParseError> {
raw.parse()
}
fn default_mode() -> ModePolicy {
ModePolicy::Auto
}
fn default_rerecord() -> RerecordPolicy {
RerecordPolicy::Truncate
}
fn default_duplicates() -> DuplicatePolicy {
DuplicatePolicy::RejectDivergent
}
fn default_staleness() -> StalenessPolicy {
StalenessPolicy::Fail
}
#[derive(Debug, Clone, PartialEq, Eq, Builder, Deserialize, Serialize, Settings)]
#[settings(prefix = "PROXIMA_CASSETTE")]
#[builder(derive(Clone, Debug))]
pub struct CassetteConfig {
#[setting(default_str = "auto", resolve_with = "parse_mode", envs = ["PROXIMA_CASSETTE_MODE", "PROXIMA_CASSETTE"], override)]
#[serde(default = "default_mode")]
#[builder(default = default_mode())]
pub mode: ModePolicy,
#[setting(default_str = "truncate", resolve_with = "parse_rerecord")]
#[serde(default = "default_rerecord")]
#[builder(default = default_rerecord())]
pub rerecord: RerecordPolicy,
#[setting(default_str = "reject-divergent", resolve_with = "parse_duplicates")]
#[serde(default = "default_duplicates")]
#[builder(default = default_duplicates())]
pub duplicates: DuplicatePolicy,
#[setting(default = false)]
#[serde(default)]
#[builder(default = false)]
pub match_body: bool,
#[setting(default = 0)]
#[serde(default)]
#[builder(default = 0)]
pub max_age_ms: u64,
#[setting(default_str = "fail", resolve_with = "parse_staleness")]
#[serde(default = "default_staleness")]
#[builder(default = default_staleness())]
pub staleness: StalenessPolicy,
}
impl Default for CassetteConfig {
fn default() -> Self {
Self {
mode: default_mode(),
rerecord: default_rerecord(),
duplicates: default_duplicates(),
match_body: false,
max_age_ms: 0,
staleness: default_staleness(),
}
}
}
impl Validate for CassetteConfig {
fn validate(&self) -> conflaguration::Result<()> {
Ok(())
}
}
impl CassetteConfig {
#[must_use]
pub fn max_age(&self) -> Option<Duration> {
(self.max_age_ms > 0).then(|| Duration::from_millis(self.max_age_ms))
}
#[must_use]
pub fn layered() -> CassetteLayerBuilder {
CassetteLayerBuilder {
inner: Self::default(),
touched: BTreeSet::new(),
}
}
pub fn resolve_for_dir(cassette_dir: &Path) -> Result<Self, ProximaError> {
let file = cassette_dir.join("config.toml");
let mut builder = conflaguration::builder().value(Self::default());
if file.is_file() {
builder = builder.file(&file);
}
builder.env().build().map_err(|error| {
ProximaError::Config(format!(
"cassette config ({}): {error}",
cassette_dir.display()
))
})
}
}
#[derive(Debug, Clone)]
pub struct CassetteLayerBuilder {
inner: CassetteConfig,
touched: BTreeSet<String>,
}
impl CassetteLayerBuilder {
pub fn from_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, conflaguration::Error> {
let incoming: Value = conflaguration::from_file(path.as_ref())?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Override,
)?;
Ok(self)
}
pub fn underlay_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self, conflaguration::Error> {
let incoming: Value = conflaguration::from_file(path.as_ref())?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Underlay,
)?;
Ok(self)
}
pub fn from_env(mut self) -> Result<Self, conflaguration::Error> {
let incoming = cassette_env_partial()?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Override,
)?;
Ok(self)
}
pub fn underlay_env(mut self) -> Result<Self, conflaguration::Error> {
let incoming = cassette_env_partial()?;
apply_layer(
&mut self.inner,
&mut self.touched,
incoming,
MergeMode::Underlay,
)?;
Ok(self)
}
#[must_use]
pub fn with_mode(mut self, mode: ModePolicy) -> Self {
self.inner.mode = mode;
self.touched.insert("mode".to_string());
self
}
#[must_use]
pub fn with_rerecord(mut self, rerecord: RerecordPolicy) -> Self {
self.inner.rerecord = rerecord;
self.touched.insert("rerecord".to_string());
self
}
#[must_use]
pub fn with_duplicates(mut self, duplicates: DuplicatePolicy) -> Self {
self.inner.duplicates = duplicates;
self.touched.insert("duplicates".to_string());
self
}
#[must_use]
pub fn with_match_body(mut self, match_body: bool) -> Self {
self.inner.match_body = match_body;
self.touched.insert("match_body".to_string());
self
}
#[must_use]
pub fn with_max_age(mut self, max_age: Duration) -> Self {
self.inner.max_age_ms = u64::try_from(max_age.as_millis()).unwrap_or(u64::MAX);
self.touched.insert("max_age_ms".to_string());
self
}
#[must_use]
pub fn with_staleness(mut self, staleness: StalenessPolicy) -> Self {
self.inner.staleness = staleness;
self.touched.insert("staleness".to_string());
self
}
#[must_use]
pub fn build(self) -> CassetteConfig {
self.inner
}
}
fn cassette_env_partial() -> Result<Value, conflaguration::Error> {
let resolved = CassetteConfig::from_env()?;
let mut partial = Map::new();
insert_if_env_set(
&mut partial,
"mode",
&["PROXIMA_CASSETTE_MODE", "PROXIMA_CASSETTE"],
&resolved.mode,
)?;
insert_if_env_set(
&mut partial,
"rerecord",
&["PROXIMA_CASSETTE_RERECORD"],
&resolved.rerecord,
)?;
insert_if_env_set(
&mut partial,
"duplicates",
&["PROXIMA_CASSETTE_DUPLICATES"],
&resolved.duplicates,
)?;
insert_if_env_set(
&mut partial,
"match_body",
&["PROXIMA_CASSETTE_MATCH_BODY"],
&resolved.match_body,
)?;
insert_if_env_set(
&mut partial,
"max_age_ms",
&["PROXIMA_CASSETTE_MAX_AGE_MS"],
&resolved.max_age_ms,
)?;
insert_if_env_set(
&mut partial,
"staleness",
&["PROXIMA_CASSETTE_STALENESS"],
&resolved.staleness,
)?;
Ok(Value::Object(partial))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RerecordDecision {
Truncate,
Backup,
Fail,
UseExisting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleDecision {
Proceed,
Fail,
}
pub type RerecordHook = Arc<dyn Fn(&Path) -> RerecordDecision + Send + Sync>;
pub type StaleHook = Arc<dyn Fn(&Path, Option<&CassetteMeta>) -> StaleDecision + Send + Sync>;
#[derive(Clone, Default)]
pub struct CassetteHooks {
pub on_rerecord: Option<RerecordHook>,
pub on_stale: Option<StaleHook>,
}
impl CassetteHooks {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_on_rerecord(
mut self,
hook: impl Fn(&Path) -> RerecordDecision + Send + Sync + 'static,
) -> Self {
self.on_rerecord = Some(Arc::new(hook));
self
}
#[must_use]
pub fn with_on_stale(
mut self,
hook: impl Fn(&Path, Option<&CassetteMeta>) -> StaleDecision + Send + Sync + 'static,
) -> Self {
self.on_stale = Some(Arc::new(hook));
self
}
}
impl fmt::Debug for CassetteHooks {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CassetteHooks")
.field("on_rerecord", &self.on_rerecord.is_some())
.field("on_stale", &self.on_stale.is_some())
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeMode {
Override,
Underlay,
}
fn apply_layer<T>(
inner: &mut T,
touched: &mut BTreeSet<String>,
incoming: Value,
mode: MergeMode,
) -> Result<(), conflaguration::Error>
where
T: Serialize + DeserializeOwned,
{
let Value::Object(incoming_map) = incoming else {
return Ok(());
};
let mut base = to_value(inner)?;
let Value::Object(base_map) = &mut base else {
return Ok(());
};
for (key, value) in incoming_map {
apply_leaf(base_map, &key, value, mode, &key, touched);
}
*inner = from_value(base)?;
Ok(())
}
fn apply_leaf(
map: &mut Map<String, Value>,
key: &str,
value: Value,
mode: MergeMode,
touched_path: &str,
touched: &mut BTreeSet<String>,
) {
let should_apply = match mode {
MergeMode::Override => true,
MergeMode::Underlay => !touched.contains(touched_path),
};
if should_apply {
map.insert(key.to_string(), value);
touched.insert(touched_path.to_string());
}
}
fn insert_if_env_set<T: Serialize>(
partial: &mut Map<String, Value>,
field: &str,
env_names: &[&str],
value: &T,
) -> Result<(), conflaguration::Error> {
if env_names.iter().any(|name| std::env::var(name).is_ok()) {
partial.insert(field.to_string(), to_value(value)?);
}
Ok(())
}
fn to_value<T: Serialize>(value: &T) -> Result<Value, conflaguration::Error> {
serde_json::to_value(value).map_err(|error| conflaguration::Error::Validation {
errors: vec![ValidationMessage::new(
"layered",
format!("serialize failed: {error}"),
)],
})
}
fn from_value<T: DeserializeOwned>(value: Value) -> Result<T, conflaguration::Error> {
serde_json::from_value(value).map_err(|error| conflaguration::Error::Validation {
errors: vec![ValidationMessage::new(
"layered",
format!("deserialize failed: {error}"),
)],
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn defaults_preserve_pre_hardening_behavior() {
let config = CassetteConfig::default();
assert_eq!(config.mode, ModePolicy::Auto);
assert_eq!(config.rerecord, RerecordPolicy::Truncate);
assert_eq!(config.duplicates, DuplicatePolicy::RejectDivergent);
assert!(!config.match_body);
assert_eq!(config.max_age(), None);
}
#[test]
fn mode_policy_resolves_against_discovered_mode() {
assert_eq!(ModePolicy::Auto.resolve(Mode::Record), Mode::Record);
assert_eq!(ModePolicy::Auto.resolve(Mode::Replay), Mode::Replay);
assert_eq!(ModePolicy::Record.resolve(Mode::Replay), Mode::Record);
assert_eq!(ModePolicy::Replay.resolve(Mode::Record), Mode::Replay);
}
#[test]
fn enum_parsing_accepts_known_and_rejects_unknown() {
assert_eq!("auto".parse::<ModePolicy>().unwrap(), ModePolicy::Auto);
assert_eq!(
"backup".parse::<RerecordPolicy>().unwrap(),
RerecordPolicy::Backup
);
assert_eq!(
"reject-divergent".parse::<DuplicatePolicy>().unwrap(),
DuplicatePolicy::RejectDivergent
);
assert_eq!(
"warn".parse::<StalenessPolicy>().unwrap(),
StalenessPolicy::Warn
);
assert!("sometimes".parse::<ModePolicy>().is_err());
assert!("archive".parse::<RerecordPolicy>().is_err());
assert!("merge".parse::<DuplicatePolicy>().is_err());
assert!("ignore".parse::<StalenessPolicy>().is_err());
}
#[test]
fn toml_round_trip_preserves_state() {
let original = CassetteConfig::builder()
.rerecord(RerecordPolicy::Backup)
.match_body(true)
.max_age_ms(86_400_000)
.staleness(StalenessPolicy::Warn)
.build();
let toml_text = toml::to_string(&original).expect("serialize");
let restored: CassetteConfig = toml::from_str(&toml_text).expect("deserialize");
assert_eq!(restored, original);
}
#[test]
fn env_overrides_defaults() {
temp_env::with_vars(
[
("PROXIMA_CASSETTE_RERECORD", Some("fail")),
("PROXIMA_CASSETTE_MATCH_BODY", Some("true")),
],
|| {
let config = CassetteConfig::from_env().expect("env config");
assert_eq!(config.rerecord, RerecordPolicy::Fail);
assert!(config.match_body);
},
);
}
#[test]
fn legacy_mode_env_var_still_selects_mode() {
temp_env::with_vars([("PROXIMA_CASSETTE", Some("replay"))], || {
let config = CassetteConfig::from_env().expect("env config");
assert_eq!(config.mode, ModePolicy::Replay);
});
}
#[test]
fn malformed_env_value_is_a_loud_error() {
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("archive"))], || {
assert!(CassetteConfig::from_env().is_err());
});
}
#[test]
fn resolve_for_dir_layers_file_then_env() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("config.toml"),
"rerecord = \"backup\"\nmax_age_ms = 1000\n",
)
.expect("write config");
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let config = CassetteConfig::resolve_for_dir(dir.path()).expect("resolve");
assert_eq!(config.rerecord, RerecordPolicy::Fail);
assert_eq!(config.max_age_ms, 1000);
assert_eq!(config.duplicates, DuplicatePolicy::RejectDivergent);
});
}
#[test]
fn resolve_for_dir_without_file_uses_defaults() {
temp_env::with_vars_unset(
[
"PROXIMA_CASSETTE_MODE",
"PROXIMA_CASSETTE",
"PROXIMA_CASSETTE_RERECORD",
"PROXIMA_CASSETTE_DUPLICATES",
"PROXIMA_CASSETTE_MATCH_BODY",
"PROXIMA_CASSETTE_MAX_AGE_MS",
"PROXIMA_CASSETTE_STALENESS",
],
|| {
let dir = tempfile::tempdir().expect("tempdir");
let config = CassetteConfig::resolve_for_dir(dir.path()).expect("resolve");
assert_eq!(config, CassetteConfig::default());
},
);
}
#[test]
fn layered_call_order_gives_precedence() {
let config = CassetteConfig::layered()
.with_rerecord(RerecordPolicy::Backup)
.with_max_age(Duration::from_secs(60))
.build();
assert_eq!(config.rerecord, RerecordPolicy::Backup);
assert_eq!(config.max_age(), Some(Duration::from_secs(60)));
}
fn write_toml(dir: &tempfile::TempDir, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.path().join(name);
std::fs::write(&path, contents).expect("write toml");
path
}
#[test]
fn seam_3_from_path_then_from_env_preserves_files_untouched_field() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(
&dir,
"config.toml",
"rerecord = \"backup\"\nmax_age_ms = 1000\n",
);
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let config = CassetteConfig::layered()
.from_path(&path)
.expect("from_path")
.from_env()
.expect("from_env")
.build();
assert_eq!(
config.rerecord,
RerecordPolicy::Fail,
"env wins the field it sets"
);
assert_eq!(
config.max_age_ms, 1000,
"the file's max_age_ms must survive — env never touched it"
);
});
}
#[test]
fn order_independence_override_flavor_both_directions() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(&dir, "config.toml", "max_age_ms = 1000\n");
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let file_then_env = CassetteConfig::layered()
.from_path(&path)
.expect("from_path")
.from_env()
.expect("from_env")
.build();
assert_eq!(file_then_env.max_age_ms, 1000, "file's field survives");
assert_eq!(
file_then_env.rerecord,
RerecordPolicy::Fail,
"env's field applies"
);
let env_then_file = CassetteConfig::layered()
.from_env()
.expect("from_env")
.from_path(&path)
.expect("from_path")
.build();
assert_eq!(
env_then_file.rerecord,
RerecordPolicy::Fail,
"env's field survives"
);
assert_eq!(env_then_file.max_age_ms, 1000, "file's field applies");
});
}
#[test]
fn full_stack_defaults_file_env_with_override_each_field() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(
&dir,
"config.toml",
"duplicates = \"last-wins\"\nmax_age_ms = 5000\n",
);
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let config = CassetteConfig::layered()
.from_path(&path)
.expect("from_path")
.from_env()
.expect("from_env")
.with_match_body(true)
.build();
assert_eq!(config.rerecord, RerecordPolicy::Fail, "env layer");
assert_eq!(config.duplicates, DuplicatePolicy::LastWins, "file layer");
assert_eq!(config.max_age_ms, 5000, "file layer");
assert!(config.match_body, "with_* layer");
assert_eq!(
config.staleness,
StalenessPolicy::Fail,
"untouched by any layer — falls through to the default"
);
});
}
#[test]
fn underlay_never_clobbers_already_set_field() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(&dir, "config.toml", "rerecord = \"backup\"\n");
let config = CassetteConfig::layered()
.with_rerecord(RerecordPolicy::Fail)
.underlay_path(&path)
.expect("underlay_path")
.build();
assert_eq!(
config.rerecord,
RerecordPolicy::Fail,
"the explicit with_* value is preserved; the file's value is dropped"
);
}
#[test]
fn underlay_fills_unset_field_from_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(&dir, "config.toml", "rerecord = \"backup\"\n");
let config = CassetteConfig::layered()
.underlay_path(&path)
.expect("underlay_path")
.build();
assert_eq!(config.rerecord, RerecordPolicy::Backup);
}
#[test]
fn underlay_env_fills_only_unset_fields() {
temp_env::with_vars(
[
("PROXIMA_CASSETTE_RERECORD", Some("fail")),
("PROXIMA_CASSETTE_MATCH_BODY", Some("true")),
],
|| {
let config = CassetteConfig::layered()
.with_rerecord(RerecordPolicy::Backup)
.underlay_env()
.expect("underlay_env")
.build();
assert_eq!(
config.rerecord,
RerecordPolicy::Backup,
"already set by with_*; env's value is dropped"
);
assert!(config.match_body, "unset before underlay_env; env fills it");
},
);
}
#[test]
fn order_independence_underlay_flavor_first_setter_wins_either_direction() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(&dir, "config.toml", "rerecord = \"backup\"\n");
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let file_first = CassetteConfig::layered()
.underlay_path(&path)
.expect("underlay_path")
.underlay_env()
.expect("underlay_env")
.build();
assert_eq!(
file_first.rerecord,
RerecordPolicy::Backup,
"file applied first, wins"
);
let env_first = CassetteConfig::layered()
.underlay_env()
.expect("underlay_env")
.underlay_path(&path)
.expect("underlay_path")
.build();
assert_eq!(
env_first.rerecord,
RerecordPolicy::Fail,
"env applied first, wins"
);
});
}
#[test]
fn combined_underlay_file_then_override_env_then_with() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_toml(
&dir,
"config.toml",
"rerecord = \"backup\"\nduplicates = \"last-wins\"\nmax_age_ms = 1000\n",
);
temp_env::with_vars([("PROXIMA_CASSETTE_RERECORD", Some("fail"))], || {
let config = CassetteConfig::layered()
.underlay_path(&path)
.expect("underlay_path")
.from_env()
.expect("from_env")
.with_max_age(Duration::from_millis(9000))
.build();
assert_eq!(
config.rerecord,
RerecordPolicy::Fail,
"override(env) wins over underlay(file) for the field both set"
);
assert_eq!(
config.duplicates,
DuplicatePolicy::LastWins,
"underlay(file) fills the field env never touched"
);
assert_eq!(
config.max_age_ms, 9000,
"the later explicit with_* overrides underlay(file)'s value"
);
assert_eq!(
config.staleness,
StalenessPolicy::Fail,
"no layer set it — falls through to the default"
);
});
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
struct MergeCoreInner {
#[serde(default)]
a: u32,
#[serde(default)]
b: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
struct MergeCoreOuter {
#[serde(default)]
list: Vec<u32>,
#[serde(default)]
nested: MergeCoreInner,
}
#[test]
fn nested_struct_partial_merge_preserves_sibling_subfield() {
let mut inner = MergeCoreOuter::default();
let mut touched = BTreeSet::new();
apply_layer(
&mut inner,
&mut touched,
serde_json::json!({ "nested": { "a": 7 } }),
MergeMode::Override,
)
.expect("first layer");
assert_eq!(inner.nested, MergeCoreInner { a: 7, b: 0 });
}
#[test]
fn collection_field_replaces_wholesale_not_union() {
let mut inner = MergeCoreOuter::default();
let mut touched = BTreeSet::new();
apply_layer(
&mut inner,
&mut touched,
serde_json::json!({ "list": [1, 2, 3] }),
MergeMode::Override,
)
.expect("first layer");
apply_layer(
&mut inner,
&mut touched,
serde_json::json!({ "list": [9] }),
MergeMode::Override,
)
.expect("second layer");
assert_eq!(
inner.list,
vec![9],
"replaced wholesale, not unioned to [1,2,3,9]"
);
}
#[test]
fn collection_field_underlay_never_element_merges() {
let mut inner = MergeCoreOuter::default();
let mut touched = BTreeSet::new();
apply_layer(
&mut inner,
&mut touched,
serde_json::json!({ "list": [1] }),
MergeMode::Override,
)
.expect("explicit layer");
apply_layer(
&mut inner,
&mut touched,
serde_json::json!({ "list": [9, 9, 9] }),
MergeMode::Underlay,
)
.expect("underlay layer");
assert_eq!(
inner.list,
vec![1],
"already-set collection is untouched, not merged"
);
}
#[test]
fn hooks_debug_shows_presence_not_contents() {
let hooks = CassetteHooks::new().with_on_rerecord(|_path| RerecordDecision::Backup);
let rendered = format!("{hooks:?}");
assert!(rendered.contains("on_rerecord: true"));
assert!(rendered.contains("on_stale: false"));
}
}