use serde::{Deserialize, Serialize};
use std::time::SystemTime;
use super::buffers::{
self, DEFAULT_INDEX_BUFFER, DEFAULT_MAX_PREDICATES, DEFAULT_MAX_QUERY_LENGTH,
DEFAULT_MAX_REPOSITORIES, DEFAULT_MAX_SOURCE_FILE_SIZE, DEFAULT_MMAP_THRESHOLD,
DEFAULT_PARSE_BUFFER, DEFAULT_READ_BUFFER, DEFAULT_WATCH_EVENT_QUEUE, DEFAULT_WRITE_BUFFER,
};
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSource {
Cli,
Env,
ProjectConfig,
Default,
}
impl std::fmt::Display for ConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigSource::Cli => write!(f, "cli"),
ConfigSource::Env => write!(f, "env"),
ConfigSource::ProjectConfig => write!(f, "project_config"),
ConfigSource::Default => write!(f, "default"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigScope {
Global,
Project,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfigRisk {
Dos,
Perf,
Security,
Reliability,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfigEntry {
pub name: String,
pub effective_value: String,
pub default_value: String,
#[serde(default)]
pub min_value: Option<String>,
#[serde(default)]
pub max_value: Option<String>,
pub source: ConfigSource,
pub scope: ConfigScope,
#[serde(default)]
pub risk: Option<ConfigRisk>,
#[serde(default)]
pub description: Option<String>,
}
impl ConfigEntry {
#[must_use]
pub fn new(
name: impl Into<String>,
effective_value: impl Into<String>,
default_value: impl Into<String>,
source: ConfigSource,
scope: ConfigScope,
) -> Self {
Self {
name: name.into(),
effective_value: effective_value.into(),
default_value: default_value.into(),
min_value: None,
max_value: None,
source,
scope,
risk: None,
description: None,
}
}
#[must_use]
pub fn with_range(mut self, min: impl Into<String>, max: impl Into<String>) -> Self {
self.min_value = Some(min.into());
self.max_value = Some(max.into());
self
}
#[must_use]
pub fn with_risk(mut self, risk: ConfigRisk) -> Self {
self.risk = Some(risk);
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfigSnapshot {
pub schema_version: u32,
#[serde(with = "system_time_serde")]
pub collected_at: SystemTime,
pub entries: Vec<ConfigEntry>,
}
impl ConfigSnapshot {
#[must_use]
pub fn new() -> Self {
Self {
schema_version: CONFIG_SCHEMA_VERSION,
collected_at: SystemTime::now(),
entries: Vec::new(),
}
}
pub fn add_entry(&mut self, entry: ConfigEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&ConfigEntry> {
self.entries.iter().find(|e| e.name == name)
}
#[must_use]
pub fn compute_hash(&self) -> String {
use crate::hash::hash_bytes;
use std::fmt::Write;
let mut canonical = String::new();
let _ = writeln!(canonical, "schema_version:{}", self.schema_version);
let mut sorted_entries: Vec<_> = self.entries.iter().collect();
sorted_entries.sort_by_key(|e| &e.name);
for entry in sorted_entries {
let _ = writeln!(canonical, "entry:{}", entry.name);
let _ = writeln!(canonical, " effective_value:{}", entry.effective_value);
let _ = writeln!(canonical, " default_value:{}", entry.default_value);
let _ = writeln!(
canonical,
" min_value:{}",
entry.min_value.as_deref().unwrap_or("")
);
let _ = writeln!(
canonical,
" max_value:{}",
entry.max_value.as_deref().unwrap_or("")
);
let _ = writeln!(canonical, " source:{}", entry.source);
let _ = writeln!(canonical, " scope:{:?}", entry.scope);
if let Some(risk) = entry.risk {
let _ = writeln!(canonical, " risk:{risk:?}");
} else {
let _ = writeln!(canonical, " risk:");
}
let _ = writeln!(
canonical,
" description:{}",
entry.description.as_deref().unwrap_or("")
);
}
hash_bytes(canonical.as_bytes()).to_hex()
}
}
impl Default for ConfigSnapshot {
fn default() -> Self {
Self::new()
}
}
mod system_time_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let duration = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
let millis = u64::try_from(duration.as_millis()).map_err(serde::ser::Error::custom)?;
millis.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
where
D: Deserializer<'de>,
{
let millis = u64::deserialize(deserializer)?;
Ok(UNIX_EPOCH + Duration::from_millis(millis))
}
}
pub struct ConfigSnapshotBuilder {
snapshot: ConfigSnapshot,
}
impl ConfigSnapshotBuilder {
#[must_use]
pub fn new() -> Self {
Self {
snapshot: ConfigSnapshot::new(),
}
}
#[must_use]
pub fn build(mut self) -> ConfigSnapshot {
self.add_dos_limits();
self.add_git_limits();
self.add_buffer_sizes();
self.add_memory_settings();
self.add_cache_settings();
self.snapshot
}
fn add_dos_limits(&mut self) {
let effective = buffers::max_source_file_size();
let source = Self::detect_source(
"SQRY_MAX_SOURCE_FILE_SIZE",
&effective,
&DEFAULT_MAX_SOURCE_FILE_SIZE,
);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_MAX_SOURCE_FILE_SIZE",
effective.to_string(),
DEFAULT_MAX_SOURCE_FILE_SIZE.to_string(),
source,
ConfigScope::Global,
)
.with_range("1048576", "524288000") .with_risk(ConfigRisk::Dos)
.with_description("Maximum source file size for indexing"),
);
let effective = buffers::max_repositories();
let source = Self::detect_source(
"SQRY_MAX_REPOSITORIES",
&effective,
&DEFAULT_MAX_REPOSITORIES,
);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_MAX_REPOSITORIES",
effective.to_string(),
DEFAULT_MAX_REPOSITORIES.to_string(),
source,
ConfigScope::Global,
)
.with_range("10", "10000")
.with_risk(ConfigRisk::Dos)
.with_description("Maximum repositories per workspace"),
);
let effective = buffers::watch_event_queue_capacity();
let source = Self::detect_source(
"SQRY_WATCH_EVENT_QUEUE",
&effective,
&DEFAULT_WATCH_EVENT_QUEUE,
);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_WATCH_EVENT_QUEUE",
effective.to_string(),
DEFAULT_WATCH_EVENT_QUEUE.to_string(),
source,
ConfigScope::Global,
)
.with_range("100", "100000")
.with_risk(ConfigRisk::Dos)
.with_description("Maximum queued filesystem events"),
);
let effective = buffers::max_query_length();
let source = Self::detect_source(
"SQRY_MAX_QUERY_LENGTH",
&effective,
&DEFAULT_MAX_QUERY_LENGTH,
);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_MAX_QUERY_LENGTH",
effective.to_string(),
DEFAULT_MAX_QUERY_LENGTH.to_string(),
source,
ConfigScope::Global,
)
.with_range("1024", "102400") .with_risk(ConfigRisk::Dos)
.with_description("Maximum query string length in bytes"),
);
let effective = buffers::max_predicates();
let source =
Self::detect_source("SQRY_MAX_PREDICATES", &effective, &DEFAULT_MAX_PREDICATES);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_MAX_PREDICATES",
effective.to_string(),
DEFAULT_MAX_PREDICATES.to_string(),
source,
ConfigScope::Global,
)
.with_range("10", "1000")
.with_risk(ConfigRisk::Dos)
.with_description("Maximum predicates per query"),
);
}
fn add_buffer_sizes(&mut self) {
let effective = buffers::read_buffer_size();
let source = Self::detect_source("SQRY_READ_BUFFER", &effective, &DEFAULT_READ_BUFFER);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_READ_BUFFER",
effective.to_string(),
DEFAULT_READ_BUFFER.to_string(),
source,
ConfigScope::Global,
)
.with_range("1024", "1048576") .with_risk(ConfigRisk::Perf)
.with_description("Read buffer size for file I/O"),
);
let effective = buffers::write_buffer_size();
let source = Self::detect_source("SQRY_WRITE_BUFFER", &effective, &DEFAULT_WRITE_BUFFER);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_WRITE_BUFFER",
effective.to_string(),
DEFAULT_WRITE_BUFFER.to_string(),
source,
ConfigScope::Global,
)
.with_range("1024", "1048576")
.with_risk(ConfigRisk::Perf)
.with_description("Write buffer size for file I/O"),
);
let effective = buffers::parse_buffer_size();
let source = Self::detect_source("SQRY_PARSE_BUFFER", &effective, &DEFAULT_PARSE_BUFFER);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_PARSE_BUFFER",
effective.to_string(),
DEFAULT_PARSE_BUFFER.to_string(),
source,
ConfigScope::Global,
)
.with_range("4096", "10485760") .with_risk(ConfigRisk::Perf)
.with_description("Parse buffer size for tree-sitter"),
);
let effective = buffers::index_buffer_size();
let source = Self::detect_source("SQRY_INDEX_BUFFER", &effective, &DEFAULT_INDEX_BUFFER);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_INDEX_BUFFER",
effective.to_string(),
DEFAULT_INDEX_BUFFER.to_string(),
source,
ConfigScope::Global,
)
.with_range("65536", "104857600") .with_risk(ConfigRisk::Perf)
.with_description("Index buffer size for serialization"),
);
}
fn add_memory_settings(&mut self) {
let effective = buffers::mmap_threshold();
let source =
Self::detect_source_u64("SQRY_MMAP_THRESHOLD", effective, DEFAULT_MMAP_THRESHOLD);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_MMAP_THRESHOLD",
effective.to_string(),
DEFAULT_MMAP_THRESHOLD.to_string(),
source,
ConfigScope::Global,
)
.with_range("1048576", "1073741824") .with_risk(ConfigRisk::Perf)
.with_description("File size threshold for memory-mapped I/O"),
);
}
fn add_cache_settings(&mut self) {
let default_entries: usize = 10_000;
let effective = std::env::var("SQRY_CACHE_BUDGET_ENTRIES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_entries);
let source = Self::detect_source("SQRY_CACHE_BUDGET_ENTRIES", &effective, &default_entries);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_CACHE_BUDGET_ENTRIES",
effective.to_string(),
default_entries.to_string(),
source,
ConfigScope::Global,
)
.with_risk(ConfigRisk::Perf)
.with_description("Maximum cache entries"),
);
let default_bytes: u64 = 100 * 1024 * 1024; let effective = std::env::var("SQRY_CACHE_BUDGET_BYTES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_bytes);
let source = Self::detect_source_u64("SQRY_CACHE_BUDGET_BYTES", effective, default_bytes);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_CACHE_BUDGET_BYTES",
effective.to_string(),
default_bytes.to_string(),
source,
ConfigScope::Global,
)
.with_risk(ConfigRisk::Perf)
.with_description("Maximum cache size in bytes"),
);
let default_cache_max: u64 = 50 * 1024 * 1024; let effective = std::env::var("SQRY_CACHE_MAX_BYTES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_cache_max);
let source = Self::detect_source_u64("SQRY_CACHE_MAX_BYTES", effective, default_cache_max);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_CACHE_MAX_BYTES",
effective.to_string(),
default_cache_max.to_string(),
source,
ConfigScope::Global,
)
.with_range("1048576", "10737418240") .with_risk(ConfigRisk::Dos)
.with_description("Cache size cap (CacheConfig max_bytes limit)"),
);
let default_regex_cache: usize = 100;
let effective = std::env::var("SQRY_REGEX_CACHE_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&s| (1..=10_000).contains(&s))
.unwrap_or(default_regex_cache);
let source = Self::detect_source("SQRY_REGEX_CACHE_SIZE", &effective, &default_regex_cache);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_REGEX_CACHE_SIZE",
effective.to_string(),
default_regex_cache.to_string(),
source,
ConfigScope::Global,
)
.with_range("1", "10000")
.with_risk(ConfigRisk::Perf)
.with_description("LRU cache size for compiled regexes"),
);
let default_lexer_pool: usize = 4;
let effective = std::env::var("SQRY_LEXER_POOL_MAX")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default_lexer_pool);
let source = Self::detect_source("SQRY_LEXER_POOL_MAX", &effective, &default_lexer_pool);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_LEXER_POOL_MAX",
effective.to_string(),
default_lexer_pool.to_string(),
source,
ConfigScope::Global,
)
.with_risk(ConfigRisk::Perf)
.with_description("Maximum lexer pool size"),
);
}
fn add_git_limits(&mut self) {
let default_git_output: usize = 10 * 1024 * 1024; let min_git_output: usize = 1024 * 1024; let max_git_output: usize = 100 * 1024 * 1024;
let effective = std::env::var("SQRY_GIT_MAX_OUTPUT_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.map_or(default_git_output, |size: usize| {
size.clamp(min_git_output, max_git_output)
});
let source =
Self::detect_source("SQRY_GIT_MAX_OUTPUT_SIZE", &effective, &default_git_output);
self.snapshot.add_entry(
ConfigEntry::new(
"SQRY_GIT_MAX_OUTPUT_SIZE",
effective.to_string(),
default_git_output.to_string(),
source,
ConfigScope::Global,
)
.with_range(min_git_output.to_string(), max_git_output.to_string())
.with_risk(ConfigRisk::Dos)
.with_description("Maximum git command output size to prevent memory exhaustion"),
);
}
fn detect_source<T: PartialEq>(env_var: &str, effective: &T, default: &T) -> ConfigSource {
if std::env::var(env_var).is_ok() {
ConfigSource::Env
} else if effective != default {
ConfigSource::ProjectConfig
} else {
ConfigSource::Default
}
}
fn detect_source_u64(env_var: &str, effective: u64, default: u64) -> ConfigSource {
if std::env::var(env_var).is_ok() {
ConfigSource::Env
} else if effective != default {
ConfigSource::ProjectConfig
} else {
ConfigSource::Default
}
}
}
impl Default for ConfigSnapshotBuilder {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn collect_snapshot() -> ConfigSnapshot {
ConfigSnapshotBuilder::new().build()
}
pub const CONFIG_INVENTORY: &[&str] = &[
"SQRY_MAX_SOURCE_FILE_SIZE",
"SQRY_MAX_REPOSITORIES",
"SQRY_WATCH_EVENT_QUEUE",
"SQRY_MAX_QUERY_LENGTH",
"SQRY_MAX_PREDICATES",
"SQRY_GIT_MAX_OUTPUT_SIZE", "SQRY_READ_BUFFER",
"SQRY_WRITE_BUFFER",
"SQRY_PARSE_BUFFER",
"SQRY_INDEX_BUFFER",
"SQRY_MMAP_THRESHOLD",
"SQRY_CACHE_BUDGET_ENTRIES",
"SQRY_CACHE_BUDGET_BYTES",
"SQRY_CACHE_MAX_BYTES", "SQRY_REGEX_CACHE_SIZE", "SQRY_LEXER_POOL_MAX", ];
pub fn validate_completeness(snapshot: &ConfigSnapshot) -> Result<(), Vec<&'static str>> {
let present: std::collections::HashSet<_> =
snapshot.entries.iter().map(|e| e.name.as_str()).collect();
let missing: Vec<_> = CONFIG_INVENTORY
.iter()
.filter(|name| !present.contains(*name))
.copied()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(missing)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigProvenance {
pub schema_version: u32,
pub config_hash: String,
#[serde(with = "system_time_serde")]
pub generated_at: SystemTime,
pub entries: Vec<ConfigEntry>,
}
impl ConfigProvenance {
#[must_use]
pub fn from_snapshot(snapshot: &ConfigSnapshot) -> Self {
Self {
schema_version: snapshot.schema_version,
config_hash: snapshot.compute_hash(),
generated_at: SystemTime::now(),
entries: snapshot.entries.clone(),
}
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
let file = std::fs::File::create(path)?;
serde_json::to_writer_pretty(file, self)?;
Ok(())
}
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let file = std::fs::File::open(path)?;
let provenance: Self = serde_json::from_reader(file)?;
Ok(provenance)
}
#[must_use]
pub fn verify(&self, snapshot: &ConfigSnapshot) -> bool {
self.config_hash == snapshot.compute_hash()
&& self.schema_version == snapshot.schema_version
}
}
pub const CONFIG_PROVENANCE_FILENAME: &str = "config-provenance.json";
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_config_entry_creation() {
let entry = ConfigEntry::new(
"TEST_VAR",
"100",
"50",
ConfigSource::Env,
ConfigScope::Global,
)
.with_range("10", "1000")
.with_risk(ConfigRisk::Dos)
.with_description("Test variable");
assert_eq!(entry.name, "TEST_VAR");
assert_eq!(entry.effective_value, "100");
assert_eq!(entry.default_value, "50");
assert_eq!(entry.min_value, Some("10".to_string()));
assert_eq!(entry.max_value, Some("1000".to_string()));
assert_eq!(entry.source, ConfigSource::Env);
assert_eq!(entry.scope, ConfigScope::Global);
assert_eq!(entry.risk, Some(ConfigRisk::Dos));
assert_eq!(entry.description, Some("Test variable".to_string()));
}
#[test]
fn test_config_snapshot_new() {
let snapshot = ConfigSnapshot::new();
assert_eq!(snapshot.schema_version, CONFIG_SCHEMA_VERSION);
assert!(snapshot.is_empty());
}
#[test]
fn test_config_snapshot_add_entry() {
let mut snapshot = ConfigSnapshot::new();
snapshot.add_entry(ConfigEntry::new(
"TEST",
"value",
"default",
ConfigSource::Default,
ConfigScope::Global,
));
assert_eq!(snapshot.len(), 1);
assert!(!snapshot.is_empty());
assert!(snapshot.get("TEST").is_some());
assert!(snapshot.get("NONEXISTENT").is_none());
}
#[test]
fn test_config_snapshot_hash() {
let mut snapshot1 = ConfigSnapshot::new();
snapshot1.add_entry(ConfigEntry::new(
"TEST",
"value",
"default",
ConfigSource::Default,
ConfigScope::Global,
));
let mut snapshot2 = ConfigSnapshot::new();
snapshot2.add_entry(ConfigEntry::new(
"TEST",
"value",
"default",
ConfigSource::Default,
ConfigScope::Global,
));
assert_eq!(snapshot1.compute_hash(), snapshot2.compute_hash());
snapshot2.add_entry(ConfigEntry::new(
"TEST2",
"value2",
"default2",
ConfigSource::Env,
ConfigScope::Global,
));
assert_ne!(snapshot1.compute_hash(), snapshot2.compute_hash());
}
#[test]
#[serial]
fn test_collect_snapshot_defaults() {
for var in CONFIG_INVENTORY {
unsafe { std::env::remove_var(var) };
}
let snapshot = collect_snapshot();
assert_eq!(snapshot.len(), CONFIG_INVENTORY.len());
assert!(validate_completeness(&snapshot).is_ok());
let entry = snapshot.get("SQRY_MAX_SOURCE_FILE_SIZE").unwrap();
assert_eq!(entry.source, ConfigSource::Default);
assert_eq!(
entry.effective_value,
DEFAULT_MAX_SOURCE_FILE_SIZE.to_string()
);
}
#[test]
#[serial]
fn test_collect_snapshot_env_override() {
unsafe {
std::env::set_var("SQRY_MAX_SOURCE_FILE_SIZE", "104857600"); }
let snapshot = collect_snapshot();
let entry = snapshot.get("SQRY_MAX_SOURCE_FILE_SIZE").unwrap();
assert_eq!(entry.source, ConfigSource::Env);
assert_eq!(entry.effective_value, "104857600");
unsafe {
std::env::remove_var("SQRY_MAX_SOURCE_FILE_SIZE");
}
}
#[test]
fn test_validate_completeness_missing() {
let snapshot = ConfigSnapshot::new(); let result = validate_completeness(&snapshot);
assert!(result.is_err());
let missing = result.unwrap_err();
assert_eq!(missing.len(), CONFIG_INVENTORY.len());
}
#[test]
fn test_config_source_display() {
assert_eq!(ConfigSource::Cli.to_string(), "cli");
assert_eq!(ConfigSource::Env.to_string(), "env");
assert_eq!(ConfigSource::ProjectConfig.to_string(), "project_config");
assert_eq!(ConfigSource::Default.to_string(), "default");
}
#[test]
fn test_config_entry_serialization() {
let entry = ConfigEntry::new("TEST", "100", "50", ConfigSource::Env, ConfigScope::Global)
.with_range("10", "1000")
.with_risk(ConfigRisk::Dos);
let json = serde_json::to_string(&entry).unwrap();
let deserialized: ConfigEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, deserialized);
}
#[test]
fn test_config_snapshot_serialization() {
let snapshot = collect_snapshot();
let json = serde_json::to_string(&snapshot).unwrap();
let deserialized: ConfigSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snapshot.schema_version, deserialized.schema_version);
assert_eq!(snapshot.entries.len(), deserialized.entries.len());
}
#[test]
fn test_config_provenance_from_snapshot() {
let snapshot = collect_snapshot();
let provenance = ConfigProvenance::from_snapshot(&snapshot);
assert_eq!(provenance.schema_version, snapshot.schema_version);
assert_eq!(provenance.config_hash, snapshot.compute_hash());
assert_eq!(provenance.entries.len(), snapshot.entries.len());
}
#[test]
fn test_config_provenance_verify() {
let snapshot = collect_snapshot();
let provenance = ConfigProvenance::from_snapshot(&snapshot);
assert!(provenance.verify(&snapshot));
let mut modified = snapshot.clone();
modified.add_entry(ConfigEntry::new(
"NEW_ENTRY",
"value",
"default",
ConfigSource::Default,
ConfigScope::Global,
));
assert!(!provenance.verify(&modified));
}
#[test]
fn test_config_provenance_save_load() {
let snapshot = collect_snapshot();
let provenance = ConfigProvenance::from_snapshot(&snapshot);
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test-provenance.json");
provenance.save(&path).unwrap();
assert!(path.exists());
let loaded = ConfigProvenance::load(&path).unwrap();
assert_eq!(loaded.schema_version, provenance.schema_version);
assert_eq!(loaded.config_hash, provenance.config_hash);
assert_eq!(loaded.entries.len(), provenance.entries.len());
assert!(loaded.verify(&snapshot));
}
#[test]
fn test_config_entry_postcard_roundtrip_with_none() {
let entry_all_none = ConfigEntry {
name: "test.setting".to_string(),
effective_value: "123".to_string(),
default_value: "123".to_string(),
min_value: None,
max_value: None,
source: ConfigSource::Default,
scope: ConfigScope::Global,
risk: None,
description: None,
};
let entry_all_some = ConfigEntry {
name: "test.setting".to_string(),
effective_value: "456".to_string(),
default_value: "100".to_string(),
min_value: Some("1".to_string()),
max_value: Some("1000".to_string()),
source: ConfigSource::Env,
scope: ConfigScope::Project,
risk: Some(ConfigRisk::Dos),
description: Some("Test description".to_string()),
};
let entry_mixed = ConfigEntry {
name: "mixed.setting".to_string(),
effective_value: "789".to_string(),
default_value: "500".to_string(),
min_value: Some("100".to_string()),
max_value: None, source: ConfigSource::ProjectConfig,
scope: ConfigScope::Global,
risk: None,
description: Some("Mixed case".to_string()),
};
for (name, entry) in [
("all_none", entry_all_none),
("all_some", entry_all_some),
("mixed", entry_mixed),
] {
let serialized = postcard::to_allocvec(&entry)
.unwrap_or_else(|e| panic!("Failed to serialize {name}: {e}"));
let deserialized: ConfigEntry = postcard::from_bytes(&serialized)
.unwrap_or_else(|e| panic!("Failed to deserialize {name}: {e}"));
assert_eq!(
entry,
deserialized,
"Roundtrip failed for {name}: serialized {len} bytes",
len = serialized.len()
);
}
}
#[test]
fn test_config_snapshot_postcard_roundtrip() {
let mut snapshot = ConfigSnapshot::new();
snapshot.add_entry(
ConfigEntry::new(
"SETTING_1",
"value1",
"default1",
ConfigSource::Default,
ConfigScope::Global,
)
.with_range("0", "100")
.with_risk(ConfigRisk::Dos)
.with_description("First setting"),
);
snapshot.add_entry(ConfigEntry::new(
"SETTING_2",
"value2",
"default2",
ConfigSource::Env,
ConfigScope::Project,
));
snapshot.add_entry(
ConfigEntry::new(
"SETTING_3",
"value3",
"default3",
ConfigSource::ProjectConfig,
ConfigScope::Global,
)
.with_risk(ConfigRisk::Perf), );
let serialized = postcard::to_allocvec(&snapshot).expect("Failed to serialize snapshot");
let deserialized: ConfigSnapshot =
postcard::from_bytes(&serialized).expect("Failed to deserialize snapshot");
assert_eq!(snapshot.schema_version, deserialized.schema_version);
assert_eq!(snapshot.entries.len(), deserialized.entries.len());
for (original, restored) in snapshot.entries.iter().zip(deserialized.entries.iter()) {
assert_eq!(original, restored, "Entry mismatch for {}", original.name);
}
}
}