//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies). //! //! Per ADR-165 §6 Q5, `.storage/core.config_entries` format is undocumented //! and version-gated. P1 reads the envelope and emits: //! - count of config entries //! - list of integration domains represented //! //! Conversion to HOMECORE plugin manifests is P2. //! //! Note: `config_entries` uses a different `minor_version` track from //! `entity_registry`. As of HA 2025.1 it is typically minor_version=1 or 2. //! We accept any minor_version ≤ MAX_SUPPORTED_MINOR and hard-error above it. use std::path::Path; use serde::{Deserialize, Serialize}; use crate::{storage::read_envelope, MigrateError}; /// Maximum `minor_version` we claim to understand for config_entries. const MAX_SUPPORTED_MINOR: u32 = 4; /// Diagnostic summary produced by P1 inspection. #[derive(Clone, Debug, Serialize)] pub struct ConfigEntriesSummary { pub count: usize, pub domains: Vec, } /// Minimal fields we read from each config-entry row. #[derive(Debug, Deserialize)] struct HaConfigEntryRow { domain: String, #[allow(dead_code)] entry_id: String, /// Title shown in HA UI (informational only in P1). #[serde(default)] #[allow(dead_code)] title: Option, /// Source of the entry: "user" | "discovery" | "import" etc. #[serde(default)] #[allow(dead_code)] source: Option, /// State: "loaded" | "setup_error" etc. #[serde(default)] #[allow(dead_code)] state: Option, } #[derive(Debug, Deserialize)] struct HaConfigEntriesData { entries: Vec, } /// Read `core.config_entries` from `path` and return a diagnostic summary. pub fn inspect_config_entries(path: &Path) -> Result { let env = read_envelope(path)?; let file_str = path.display().to_string(); // config_entries has version=1 and minor_version in 1..MAX_SUPPORTED_MINOR. if env.version != 1 || env.minor_version > MAX_SUPPORTED_MINOR { return Err(MigrateError::UnsupportedSchemaVersion { file: file_str.clone(), version: env.version, minor_version: env.minor_version, }); } let data: HaConfigEntriesData = serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse { path: file_str, source: e, })?; let mut domains: Vec = data.entries.iter().map(|e| e.domain.clone()).collect(); domains.sort(); domains.dedup(); Ok(ConfigEntriesSummary { count: data.entries.len(), domains, }) } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::NamedTempFile; const FIXTURE: &str = r#"{ "version": 1, "minor_version": 1, "key": "core.config_entries", "data": { "entries": [ {"domain": "hue", "entry_id": "ce_001", "title": "Philips Hue", "source": "user", "state": "loaded"}, {"domain": "zha", "entry_id": "ce_002", "title": "ZHA", "source": "user", "state": "loaded"}, {"domain": "hue", "entry_id": "ce_003", "title": "Hue 2", "source": "user", "state": "setup_error"} ] } }"#; #[test] fn inspect_emits_count_and_domains() { let mut f = NamedTempFile::new().unwrap(); f.write_all(FIXTURE.as_bytes()).unwrap(); let summary = inspect_config_entries(f.path()).unwrap(); assert_eq!(summary.count, 3); assert_eq!(summary.domains, vec!["hue", "zha"]); } #[test] fn unknown_minor_version_hard_errors() { let json = r#"{ "version": 1, "minor_version": 99, "key": "core.config_entries", "data": {"entries": []} }"#; let mut f = NamedTempFile::new().unwrap(); f.write_all(json.as_bytes()).unwrap(); let err = inspect_config_entries(f.path()).unwrap_err(); assert!(matches!( err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. } )); } }