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