Skip to main content

helm_schema/
session.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5use helm_schema_core::{
6    ConditionalGuard, ContractSchemaSignals, ContractUse, ContractValuePathFacts, MetadataFieldKind,
7};
8use helm_schema_gen::{
9    PreparedValuesDocuments, ValuesSchemaInput, generate_values_schema_with_report,
10};
11use helm_schema_ir::{ContractDocument, ContractIr, FinalizedContract};
12use helm_schema_k8s::{Diagnostic, DiagnosticSink, LocalSchemaUniverse};
13use serde_json::Value;
14
15use crate::analysis::analyze_charts;
16use crate::chart;
17use crate::error::EngineResult;
18use crate::generation::{GenerateOptions, GeneratedSchema, ResolvedContract};
19use crate::output_pipeline::{
20    EmitRequest, FinalOutputPolicy, PolicyInputOptions, PreparedEmitRequest,
21    apply_schema_output_pipeline, load_emit_request, prepare_emit_request,
22};
23use crate::provider_builder;
24use crate::values_roots;
25
26/// Public analysis artifact produced by [`AnalysisSession`].
27///
28/// This keeps the core analysis artifact small and non-duplicated:
29/// the guarded contract graph plus the chart-local schema universe extracted
30/// from sources such as static and template-rendered CRDs. Typed schema
31/// lowering evidence stays available as its own memoized session query via
32/// [`AnalysisSession::contract_schema_signals`].
33#[derive(Debug, Clone)]
34pub struct Analysis {
35    /// Guarded contract graph recovered from chart templates.
36    pub contract: ContractIr,
37    /// Resource schemas declared by the chart's CRDs.
38    pub local_schemas: LocalSchemaUniverse,
39}
40
41/// Session-level explanation for one values path.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ValuePathExplanation {
44    /// Canonical values path described by the explanation.
45    pub path: String,
46    /// Contract uses that read exactly this path.
47    pub exact_uses: Vec<ContractUse>,
48    /// Contract uses that read descendants of this path.
49    pub descendant_uses: Vec<ContractUse>,
50    /// Aggregate behavioral facts for the path, when analysis found evidence.
51    pub value_path_facts: Option<ContractValuePathFacts>,
52    /// Values-decidable guards attached to the path.
53    pub guard_predicates: Vec<ConditionalGuard>,
54    /// Kubernetes metadata roles reached from the path.
55    pub metadata_fields: Vec<MetadataFieldKind>,
56    /// JSON Schema type hints derived from strict consumers.
57    pub type_hints: Vec<Value>,
58    /// Whether a defaulting operation supplies an absent value.
59    pub has_default_fallback: bool,
60}
61
62struct PreparedSession {
63    analysis: Analysis,
64    values_documents: PreparedValuesDocuments,
65    shadowed_input_paths: BTreeSet<String>,
66    explicit_value_paths: BTreeSet<String>,
67    values_descriptions: BTreeMap<String, String>,
68}
69
70impl PreparedSession {
71    fn from_generate_options(opts: &GenerateOptions) -> EngineResult<Self> {
72        let charts = &chart::discover_chart_contexts(&opts.chart_dir)?;
73        chart::reject_legacy_boolean_alias_keys(charts, &opts.values_files)?;
74
75        let defines = chart::build_define_index(charts, opts.include_tests)?;
76        let composed_values =
77            chart::build_composed_values_document(charts, opts.include_subchart_values)?;
78        // The dependency charts' own declared defaults: schema generation
79        // distinguishes parent-owned absence (helm null-deletion, nil at
80        // render) from subchart-declared absence (the subchart's default
81        // fills at its own coalesce stage, even after a parent-level
82        // null-deletion).
83        let dependency_values_yaml = if opts.include_subchart_values {
84            chart::build_dependency_values_document(charts)?
85        } else {
86            serde_yaml::Value::Null
87        };
88        // What a DELETED dependency root refills with, which the subtracted
89        // document above cannot say: it drops exactly the parent-declared
90        // keys the refill also drops.
91        let dependency_refill_values_yaml = if opts.include_subchart_values {
92            chart::build_dependency_refill_values_document(charts)?
93        } else {
94            serde_yaml::Value::Null
95        };
96        let values_roots = values_roots::ValuesRoots::from_values_document(&composed_values);
97        let values_descriptions = chart::build_composed_values_descriptions(
98            charts,
99            opts.include_subchart_values,
100            &opts.values_files,
101        )?;
102        let kubernetes_version = primary_kubernetes_version(opts);
103        let chart_analysis = analyze_charts(
104            charts,
105            &defines,
106            opts.include_tests,
107            &values_roots,
108            kubernetes_version.as_deref(),
109        )?;
110        let shadowed_input_paths = chart_analysis.shadowed_input_paths;
111
112        Ok(Self {
113            analysis: Analysis {
114                contract: chart_analysis.contract,
115                local_schemas: chart_analysis.local_schema_universe,
116            },
117            values_documents: PreparedValuesDocuments::new(
118                composed_values,
119                dependency_values_yaml,
120                dependency_refill_values_yaml,
121            ),
122            shadowed_input_paths,
123            explicit_value_paths: values_roots.explicit_paths,
124            values_descriptions,
125        })
126    }
127}
128
129/// Memoized facade over chart analysis and schema lowering.
130///
131/// The session keeps chart loading and analysis results available for later
132/// queries without forcing callers to re-run discovery, values composition,
133/// contract extraction, and chart-local schema collection manually.
134pub struct AnalysisSession {
135    opts: GenerateOptions,
136    diagnostics: DiagnosticSink,
137    prepared: SessionCache<PreparedSession>,
138    finalized_contract: SessionCache<FinalizedContract>,
139    resolved_contract: SessionCache<ResolvedContract>,
140    generated_schema: SessionCache<GeneratedSchema>,
141    resolved_emission_policy: SessionCache<helm_schema_gen::ResolvedEmissionPolicy>,
142}
143
144struct SessionCache<T> {
145    value: Mutex<Option<Arc<T>>>,
146}
147
148impl<T> SessionCache<T> {
149    fn new() -> Self {
150        Self {
151            value: Mutex::new(None),
152        }
153    }
154
155    fn get_or_try_init(&self, init: impl FnOnce() -> EngineResult<T>) -> EngineResult<Arc<T>> {
156        {
157            let guard = self
158                .value
159                .lock()
160                .unwrap_or_else(std::sync::PoisonError::into_inner);
161            if let Some(value) = guard.as_ref() {
162                return Ok(Arc::clone(value));
163            }
164        }
165
166        let value = Arc::new(init()?);
167        let mut guard = self
168            .value
169            .lock()
170            .unwrap_or_else(std::sync::PoisonError::into_inner);
171        Ok(Arc::clone(guard.get_or_insert_with(|| Arc::clone(&value))))
172    }
173}
174
175impl AnalysisSession {
176    /// Creates a memoized session with an internal diagnostic sink.
177    #[must_use]
178    pub fn new(opts: GenerateOptions) -> Self {
179        Self::with_diagnostics(opts, DiagnosticSink::new())
180    }
181
182    /// Creates a memoized session that emits diagnostics into `diagnostics`.
183    #[must_use]
184    pub fn with_diagnostics(opts: GenerateOptions, diagnostics: DiagnosticSink) -> Self {
185        Self {
186            opts,
187            diagnostics,
188            prepared: SessionCache::new(),
189            finalized_contract: SessionCache::new(),
190            resolved_contract: SessionCache::new(),
191            generated_schema: SessionCache::new(),
192            resolved_emission_policy: SessionCache::new(),
193        }
194    }
195
196    /// Return the memoized chart analysis artifact.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error when chart discovery, source loading, parsing, or
201    /// structural analysis fails.
202    pub fn analysis(&self) -> EngineResult<Analysis> {
203        Ok(self.prepared()?.analysis.clone())
204    }
205
206    /// Return typed schema-lowering evidence derived from the guarded contract.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error when preparing or finalizing chart analysis fails.
211    pub fn contract_schema_signals(&self) -> EngineResult<ContractSchemaSignals> {
212        Ok(self.finalized_contract()?.schema_signals().clone())
213    }
214
215    /// Return the stable versioned contract export document.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when preparing or finalizing chart analysis fails.
220    pub fn contract_document(&self) -> EngineResult<ContractDocument> {
221        Ok(self.finalized_contract()?.document())
222    }
223
224    /// Return the provider-resolved contract schema prior to optional
225    /// required-inference and final output-pipeline transforms.
226    ///
227    /// This query exposes the stage boundary the architecture document calls
228    /// `resolved_contract(policy)`: structural contract facts have already
229    /// been resolved against providers, but the later heuristic
230    /// `--infer-required` mutation has not yet run.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error when chart analysis, values composition, or provider
235    /// schema resolution fails.
236    pub fn resolved_contract(&self) -> EngineResult<ResolvedContract> {
237        Ok((*self.resolved()?).clone())
238    }
239
240    /// Return the memoized generated values schema: the resolved contract
241    /// schema plus the optional `--infer-required` post-pass.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error when resolving the contract or preparing chart values fails.
246    pub fn generated_schema(&self) -> EngineResult<GeneratedSchema> {
247        Ok((*self.generated_schema.get_or_try_init(|| {
248            let resolved = self.resolved()?;
249            let mut schema = resolved.schema.clone();
250            if self.opts.infer_required {
251                helm_schema_gen::required_inference::apply_required_inference(
252                    &mut schema,
253                    self.finalized_contract()?
254                        .schema_signals()
255                        .schema_evidence_by_value_path(),
256                    &self.prepared()?.explicit_value_paths,
257                );
258            }
259            Ok(GeneratedSchema {
260                schema,
261                emission_report: resolved.emission_report.clone(),
262            })
263        })?)
264        .clone())
265    }
266
267    /// Emit the final JSON Schema document through the output pipeline.
268    ///
269    /// This is the session-level counterpart to the CLI's final output stage:
270    /// it starts from the memoized generated schema, applies override/policy
271    /// inputs, resolves reference mode, and returns the final document callers
272    /// would write to disk.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error when generated-schema preparation, override merging,
277    /// or reference processing fails.
278    pub fn emit(&self, request: EmitRequest) -> EngineResult<Value> {
279        let generated = self.generated_schema()?;
280        apply_schema_output_pipeline(
281            generated.schema,
282            PreparedEmitRequest::empty(request),
283            self.chart_base_dir(),
284            FinalOutputPolicy::new(self.resolved_emission_policy()?, self.opts.infer_required),
285        )
286    }
287
288    /// Load policy inputs from override paths, then emit the final document.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error when an override cannot be loaded or prepared, or
293    /// when final output transforms fail.
294    pub fn emit_with_policy_paths(
295        &self,
296        override_paths: &[PathBuf],
297        policy_input_options: PolicyInputOptions,
298        request: EmitRequest,
299    ) -> EngineResult<Value> {
300        let loaded = load_emit_request(override_paths, &policy_input_options, request)?;
301        let generated = self.generated_schema()?;
302        let prepared = prepare_emit_request(loaded, &policy_input_options, &generated.schema)?;
303        apply_schema_output_pipeline(
304            generated.schema,
305            prepared,
306            self.chart_base_dir(),
307            FinalOutputPolicy::new(self.resolved_emission_policy()?, self.opts.infer_required),
308        )
309    }
310
311    /// Explain one values path using the current contract and chart evidence.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error when chart analysis or contract finalization fails.
316    pub fn explain(&self, path: &str) -> EngineResult<ValuePathExplanation> {
317        let normalized_path = normalize_values_path(path);
318        let finalized_contract = self.finalized_contract()?;
319        let uses = finalized_contract.uses();
320        let schema_signals = finalized_contract.schema_signals();
321        let evidence = schema_signals.evidence_for(&normalized_path);
322
323        let exact_uses = uses
324            .iter()
325            .filter(|use_| use_.source_expr == normalized_path)
326            .cloned()
327            .collect();
328        let descendant_uses = uses
329            .iter()
330            .filter(|use_| {
331                use_.source_expr
332                    .strip_prefix(&normalized_path)
333                    .is_some_and(|suffix| suffix.starts_with('.'))
334            })
335            .cloned()
336            .collect();
337        let value_path_facts = evidence.map(|evidence| evidence.facts);
338        let guard_predicates = evidence
339            .map(|evidence| evidence.guard_predicates.clone())
340            .unwrap_or_default();
341        let metadata_fields = evidence
342            .map(|evidence| evidence.metadata_field_kinds.iter().copied().collect())
343            .unwrap_or_default();
344        let type_hints: Vec<serde_json::Value> = evidence
345            .map(|evidence| {
346                let schema_types = &evidence.type_hints;
347                schema_types
348                    .iter()
349                    .map(|schema_type| serde_json::json!({ "type": schema_type }))
350                    .collect()
351            })
352            .unwrap_or_default();
353        let has_default_fallback =
354            evidence.is_some_and(|evidence| evidence.requiredness.has_default_fallback);
355
356        Ok(ValuePathExplanation {
357            path: normalized_path,
358            exact_uses,
359            descendant_uses,
360            value_path_facts,
361            guard_predicates,
362            metadata_fields,
363            type_hints,
364            has_default_fallback,
365        })
366    }
367
368    fn prepared(&self) -> EngineResult<Arc<PreparedSession>> {
369        self.prepared
370            .get_or_try_init(|| PreparedSession::from_generate_options(&self.opts))
371    }
372
373    fn chart_base_dir(&self) -> &Path {
374        Path::new(self.opts.chart_dir.as_str())
375    }
376
377    fn finalized_contract(&self) -> EngineResult<Arc<FinalizedContract>> {
378        self.finalized_contract.get_or_try_init(|| {
379            let prepared = self.prepared()?;
380            let finalized = prepared.analysis.contract.clone().finalize();
381            emit_input_channel_diagnostics(finalized.schema_signals(), &self.diagnostics);
382            Ok(finalized)
383        })
384    }
385
386    fn resolved(&self) -> EngineResult<Arc<ResolvedContract>> {
387        self.resolved_contract.get_or_try_init(|| {
388            let prepared = self.prepared()?;
389            let finalized_contract = self.finalized_contract()?;
390            let mut provider_options = self.opts.provider.clone();
391            provider_options.local_schema_universe = prepared.analysis.local_schemas.clone();
392            let provider =
393                provider_builder::build_provider(&provider_options, Some(&self.diagnostics));
394
395            let (schema, emission_report) = generate_values_schema_with_report(
396                ValuesSchemaInput::new(finalized_contract.schema_signals(), &provider)
397                    .with_values_documents(&prepared.values_documents)
398                    .with_shadowed_input_paths(&prepared.shadowed_input_paths)
399                    .with_values_descriptions(&prepared.values_descriptions)
400                    .with_emission_policy(self.resolved_emission_policy()?.policy()),
401            );
402
403            Ok(ResolvedContract {
404                schema,
405                emission_report,
406            })
407        })
408    }
409
410    fn resolved_emission_policy(&self) -> EngineResult<helm_schema_gen::ResolvedEmissionPolicy> {
411        Ok(*self
412            .resolved_emission_policy
413            .get_or_try_init(|| Ok(self.opts.emission.resolve()?))?)
414    }
415
416    #[cfg(all(feature = "bench-support", test))]
417    pub(crate) fn benchmark_emission_policies(
418        &self,
419        policies: &[helm_schema_gen::bench_support::BenchmarkPolicy],
420        runs: std::num::NonZeroUsize,
421    ) -> EngineResult<helm_schema_gen::bench_support::MultiPolicyBenchmark> {
422        let prepared = self.prepared()?;
423        let finalized_contract = self.finalized_contract()?;
424        let mut provider_options = self.opts.provider.clone();
425        provider_options.local_schema_universe = prepared.analysis.local_schemas.clone();
426        let provider = provider_builder::build_provider(&provider_options, Some(&self.diagnostics));
427        let input = ValuesSchemaInput::new(finalized_contract.schema_signals(), &provider)
428            .with_values_documents(&prepared.values_documents)
429            .with_shadowed_input_paths(&prepared.shadowed_input_paths)
430            .with_values_descriptions(&prepared.values_descriptions);
431        Ok(helm_schema_gen::bench_support::benchmark_policies(
432            &input, policies, runs,
433        ))
434    }
435}
436
437pub(crate) fn emit_input_channel_diagnostics(
438    signals: &ContractSchemaSignals,
439    diagnostics: &DiagnosticSink,
440) {
441    for (value_path, evidence) in signals.schema_evidence_by_value_path() {
442        let base_is_ambiguous = evidence.facts.is_direct_ranged_source
443            && !evidence.facts.has_destructured_range_use
444            && !evidence.facts.has_json_decoded_range_use;
445        let guarded_is_ambiguous = evidence.conditional_overlays.iter().any(|overlay| {
446            overlay.evidence.facts.is_direct_ranged_source
447                && !overlay.evidence.facts.has_destructured_range_use
448                && !overlay.evidence.facts.has_json_decoded_range_use
449        });
450        if base_is_ambiguous || guarded_is_ambiguous {
451            diagnostics.push(Diagnostic::InputChannelNumericRangeAmbiguity {
452                value_path: value_path.clone(),
453            });
454        }
455    }
456}
457
458fn normalize_values_path(path: &str) -> String {
459    let path = path.trim();
460    if let Some(stripped) = path.strip_prefix(".Values.") {
461        stripped.to_string()
462    } else if path == ".Values" {
463        String::new()
464    } else {
465        path.to_string()
466    }
467}
468
469/// The normalized numeric core of the primary configured Kubernetes
470/// version (`v1.29.0-standalone-strict` → `1.29.0`): the value
471/// `.Capabilities.KubeVersion` conditions evaluate against under this
472/// run's provider policy. `None` when no version is configured — the
473/// capabilities lanes then abstain instead of guessing a cluster.
474fn primary_kubernetes_version(opts: &GenerateOptions) -> Option<String> {
475    let token = opts.provider.k8s_versions.first()?;
476    let token = token.trim().strip_prefix('v').unwrap_or(token.trim());
477    let core: String = token
478        .chars()
479        .take_while(|c| c.is_ascii_digit() || *c == '.')
480        .collect();
481    let parts: Vec<&str> = core.split('.').collect();
482    if parts.is_empty()
483        || parts.len() > 3
484        || parts
485            .iter()
486            .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
487    {
488        return None;
489    }
490    Some(core)
491}