//! Multi-consumer config coherence verification. //! //! For each section in a project's `config_surface`, this module compares the //! fields present in the exported NCL JSON against the fields declared by each //! consumer. A field absent from all consumers is "unclaimed" — it exists in //! the config but nothing reads it. //! //! Coherence is checked from two directions: //! - **NCL-only fields**: present in NCL, not claimed by any consumer. //! - **Consumer-only fields**: a consumer declares a field that doesn't exist //! in the NCL export. The consumer either references a renamed/removed field //! or the NCL contract is incomplete. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use serde::{Deserialize, Serialize}; use tracing::warn; use crate::cache::NclCache; use crate::registry::{ConfigSection, ConfigSurface}; /// Merge optional NCL `_meta_*` record fields into a quickref section object. /// Only sets `rationale` when the section object currently has an empty value. fn merge_meta_into_section( obj: &mut serde_json::Map, meta_val: &serde_json::Value, ) { if let Some(rationale) = meta_val.get("rationale").and_then(|v| v.as_str()) { if obj["rationale"].as_str().unwrap_or("").is_empty() { obj["rationale"] = serde_json::Value::String(rationale.to_owned()); } } if let Some(alt) = meta_val.get("alternatives_rejected") { obj.insert("alternatives_rejected".to_owned(), alt.clone()); } if let Some(constraints) = meta_val.get("constraints") { obj.insert("constraints".to_owned(), constraints.clone()); } if let Some(see_also) = meta_val.get("see_also") { obj.insert("see_also".to_owned(), see_also.clone()); } } /// Status of a section's coherence check. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CoherenceStatus { /// All fields are claimed by at least one consumer, and no consumer /// references a field absent from the NCL export. Ok, /// Some fields are unclaimed or a consumer references missing fields — /// worth reviewing but not necessarily a bug. Warning, /// The NCL export failed; coherence could not be checked. Error, } /// Per-consumer coherence result within a section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConsumerCoherenceReport { pub consumer_id: String, pub kind: String, /// Fields the consumer declared but are absent in the NCL export. pub missing_in_ncl: Vec, /// Fields in the NCL export that this consumer doesn't declare. /// Non-empty when the consumer has an explicit field list (not "reads /// all"). pub extra_in_ncl: Vec, } /// Full coherence report for one config section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SectionCoherenceReport { pub section_id: String, /// All top-level keys in the NCL export for this section (excluding /// `_meta_*` and `_overrides_meta` keys). pub ncl_fields: Vec, pub consumers: Vec, /// Fields present in NCL but claimed by no consumer. pub unclaimed_fields: Vec, pub status: CoherenceStatus, } /// Coherence report for an entire project. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectCoherenceReport { pub project_slug: String, pub sections: Vec, pub has_config_surface: bool, } impl ProjectCoherenceReport { /// Overall status: worst status across all sections. pub fn overall_status(&self) -> CoherenceStatus { if self .sections .iter() .any(|s| s.status == CoherenceStatus::Error) { CoherenceStatus::Error } else if self .sections .iter() .any(|s| s.status == CoherenceStatus::Warning) { CoherenceStatus::Warning } else { CoherenceStatus::Ok } } } /// Run coherence check for all sections of a project's config surface. /// /// `section_filter` — if `Some`, only check this section id. pub async fn check_project( slug: &str, surface: &ConfigSurface, project_root: &Path, cache: &NclCache, import_path: Option<&str>, section_filter: Option<&str>, ) -> ProjectCoherenceReport { let sections_to_check: Vec<&ConfigSection> = surface .sections .iter() .filter(|s| section_filter.is_none_or(|f| s.id == f)) .collect(); let mut section_reports = Vec::with_capacity(sections_to_check.len()); for section in sections_to_check { let report = check_section(section, surface, project_root, cache, import_path).await; section_reports.push(report); } ProjectCoherenceReport { project_slug: slug.to_owned(), sections: section_reports, has_config_surface: true, } } async fn check_section( section: &ConfigSection, surface: &ConfigSurface, project_root: &Path, cache: &NclCache, import_path: Option<&str>, ) -> SectionCoherenceReport { let ncl_path = project_root.join(&surface.config_root).join(§ion.file); let ncl_export = cache.export(&ncl_path, import_path).await; let (json, _) = match ncl_export { Ok(pair) => pair, Err(e) => { warn!( section = %section.id, path = %ncl_path.display(), error = %e, "config coherence: nickel export failed" ); return SectionCoherenceReport { section_id: section.id.clone(), ncl_fields: vec![], consumers: vec![], unclaimed_fields: vec![], status: CoherenceStatus::Error, }; } }; // Collect top-level keys for this section from the export. // A section NCL file may export { server = { ... }, _meta_server = {...} } // We want the section key matching section.id; if the whole file is the // section value, use all keys. let ncl_fields: BTreeSet = extract_section_fields(&json, §ion.id); // Build consumer reports. let mut all_claimed: BTreeSet = BTreeSet::new(); let mut consumer_reports = Vec::with_capacity(section.consumers.len()); for consumer in §ion.consumers { let consumer_fields: BTreeSet = if consumer.fields.is_empty() { // Empty field list means the consumer claims all NCL fields. all_claimed.extend(ncl_fields.iter().cloned()); consumer_reports.push(ConsumerCoherenceReport { consumer_id: consumer.id.clone(), kind: format!("{:?}", consumer.kind), missing_in_ncl: vec![], extra_in_ncl: vec![], }); continue; } else { consumer.fields.iter().cloned().collect() }; all_claimed.extend(consumer_fields.iter().cloned()); let missing_in_ncl: Vec = consumer_fields.difference(&ncl_fields).cloned().collect(); let extra_in_ncl: Vec = ncl_fields.difference(&consumer_fields).cloned().collect(); consumer_reports.push(ConsumerCoherenceReport { consumer_id: consumer.id.clone(), kind: format!("{:?}", consumer.kind), missing_in_ncl, extra_in_ncl, }); } let unclaimed_fields: Vec = ncl_fields.difference(&all_claimed).cloned().collect(); let has_missing = consumer_reports .iter() .any(|c| !c.missing_in_ncl.is_empty()); let status = if !unclaimed_fields.is_empty() || has_missing { CoherenceStatus::Warning } else { CoherenceStatus::Ok }; SectionCoherenceReport { section_id: section.id.clone(), ncl_fields: ncl_fields.into_iter().collect(), consumers: consumer_reports, unclaimed_fields, status, } } /// Extract the field names for a section from the NCL export JSON. /// /// If the JSON has a top-level key matching `section_id`, returns the keys of /// that sub-object. Otherwise treats the entire top-level object as the section /// fields. Strips `_meta_*` and `_overrides_meta` keys from the result. fn extract_section_fields(json: &serde_json::Value, section_id: &str) -> BTreeSet { let obj = if let Some(sub) = json.get(section_id).and_then(|v| v.as_object()) { sub.keys().cloned().collect() } else if let Some(top) = json.as_object() { top.keys() .filter(|k| !k.starts_with("_meta_") && *k != "_overrides_meta") .cloned() .collect() } else { BTreeSet::new() }; obj } /// Generate a quickref document for a project's config surface. /// /// Combines: NCL export values + manifest metadata (descriptions, rationales, /// consumers) + override history from `_overrides_meta` + coherence status. pub async fn build_quickref( slug: &str, surface: &ConfigSurface, project_root: &Path, cache: &NclCache, import_path: Option<&str>, section_filter: Option<&str>, ) -> serde_json::Value { let entry_point = surface.entry_point_path(project_root); let full_export = cache.export(&entry_point, import_path).await.ok(); let coherence = check_project( slug, surface, project_root, cache, import_path, section_filter, ) .await; let coherence_by_id: BTreeMap = coherence .sections .iter() .map(|s| (s.section_id.clone(), s)) .collect(); let sections: Vec = surface .sections .iter() .filter(|s| section_filter.is_none_or(|f| s.id == f)) .map(|section| { let current_values = full_export .as_ref() .and_then(|(json, _)| json.get(§ion.id)) .cloned() .unwrap_or(serde_json::Value::Null); // Extract _meta_{section} from the section's own NCL file. let meta_key = format!("_meta_{}", section.id); let section_ncl_path = project_root.join(&surface.config_root).join(§ion.file); let meta = tokio::task::block_in_place(|| { // Use a sync export via the cached path — avoids async recursion. std::process::Command::new("nickel") .args(["export", "--format", "json"]) .arg(§ion_ncl_path) .current_dir(project_root) .output() .ok() .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) .and_then(|j| j.get(&meta_key).cloned()) }); // Extract override history from _overrides_meta if present. let overrides = current_values .as_object() .and(full_export.as_ref()) .and_then(|(j, _)| { j.get("_overrides_meta") .and_then(|m| m.get("entries")) .cloned() }) .unwrap_or(serde_json::Value::Array(vec![])); let coh = coherence_by_id.get(§ion.id); let coherence_summary = serde_json::json!({ "unclaimed_fields": coh.map(|c| c.unclaimed_fields.as_slice()).unwrap_or(&[]), "status": coh.map(|c| format!("{:?}", c.status)).unwrap_or_else(|| "unknown".into()), }); let consumers: Vec = section .consumers .iter() .map(|c| { serde_json::json!({ "id": c.id, "kind": format!("{:?}", c.kind), "ref": c.reference, "fields": c.fields, }) }) .collect(); let mut s = serde_json::json!({ "id": section.id, "file": section.file, "mutable": section.mutable, "description": section.description, "rationale": section.rationale, "contract": section.contract, "current_values": current_values, "overrides": overrides, "consumers": consumers, "coherence": coherence_summary, }); if let Some(meta_val) = meta { if let Some(obj) = s.as_object_mut() { merge_meta_into_section(obj, &meta_val); } } s }) .collect(); serde_json::json!({ "project": slug, "generated_at": std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), "config_root": surface.config_root.display().to_string(), "entry_point": surface.entry_point, "kind": format!("{:?}", surface.kind), "sections": sections, "overall_coherence": format!("{:?}", coherence.overall_status()), }) }