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#[derive(Debug, Clone)]
34pub struct Analysis {
35 pub contract: ContractIr,
37 pub local_schemas: LocalSchemaUniverse,
39}
40
41#[derive(Debug, Clone, PartialEq)]
43pub struct ValuePathExplanation {
44 pub path: String,
46 pub exact_uses: Vec<ContractUse>,
48 pub descendant_uses: Vec<ContractUse>,
50 pub value_path_facts: Option<ContractValuePathFacts>,
52 pub guard_predicates: Vec<ConditionalGuard>,
54 pub metadata_fields: Vec<MetadataFieldKind>,
56 pub type_hints: Vec<Value>,
58 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 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 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
129pub 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 #[must_use]
178 pub fn new(opts: GenerateOptions) -> Self {
179 Self::with_diagnostics(opts, DiagnosticSink::new())
180 }
181
182 #[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 pub fn analysis(&self) -> EngineResult<Analysis> {
203 Ok(self.prepared()?.analysis.clone())
204 }
205
206 pub fn contract_schema_signals(&self) -> EngineResult<ContractSchemaSignals> {
212 Ok(self.finalized_contract()?.schema_signals().clone())
213 }
214
215 pub fn contract_document(&self) -> EngineResult<ContractDocument> {
221 Ok(self.finalized_contract()?.document())
222 }
223
224 pub fn resolved_contract(&self) -> EngineResult<ResolvedContract> {
237 Ok((*self.resolved()?).clone())
238 }
239
240 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 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 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 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 normalized_values_path = helm_schema_core::ValuesPath::parse(&normalized_path);
322 let evidence = schema_signals.evidence_for(&normalized_values_path);
323
324 let exact_uses = uses
325 .iter()
326 .filter(|use_| use_.source_expr == normalized_values_path)
327 .cloned()
328 .collect();
329 let descendant_uses = uses
330 .iter()
331 .filter(|use_| use_.source_expr.is_descendant_of(&normalized_values_path))
332 .cloned()
333 .collect();
334 let value_path_facts = evidence.map(|evidence| evidence.facts);
335 let guard_predicates = evidence
336 .map(|evidence| evidence.guard_predicates.clone())
337 .unwrap_or_default();
338 let metadata_fields = evidence
339 .map(|evidence| evidence.metadata_field_kinds.iter().copied().collect())
340 .unwrap_or_default();
341 let type_hints: Vec<serde_json::Value> = evidence
342 .map(|evidence| {
343 let schema_types = &evidence.type_hints;
344 schema_types
345 .iter()
346 .map(|schema_type| serde_json::json!({ "type": schema_type }))
347 .collect()
348 })
349 .unwrap_or_default();
350 let has_default_fallback =
351 evidence.is_some_and(|evidence| evidence.requiredness.has_default_fallback);
352
353 Ok(ValuePathExplanation {
354 path: normalized_path,
355 exact_uses,
356 descendant_uses,
357 value_path_facts,
358 guard_predicates,
359 metadata_fields,
360 type_hints,
361 has_default_fallback,
362 })
363 }
364
365 fn prepared(&self) -> EngineResult<Arc<PreparedSession>> {
366 self.prepared
367 .get_or_try_init(|| PreparedSession::from_generate_options(&self.opts))
368 }
369
370 fn chart_base_dir(&self) -> &Path {
371 Path::new(self.opts.chart_dir.as_str())
372 }
373
374 fn finalized_contract(&self) -> EngineResult<Arc<FinalizedContract>> {
375 self.finalized_contract.get_or_try_init(|| {
376 let prepared = self.prepared()?;
377 let finalized = prepared.analysis.contract.clone().finalize();
378 emit_input_channel_diagnostics(finalized.schema_signals(), &self.diagnostics);
379 Ok(finalized)
380 })
381 }
382
383 fn resolved(&self) -> EngineResult<Arc<ResolvedContract>> {
384 self.resolved_contract.get_or_try_init(|| {
385 let prepared = self.prepared()?;
386 let finalized_contract = self.finalized_contract()?;
387 let mut provider_options = self.opts.provider.clone();
388 provider_options.local_schema_universe = prepared.analysis.local_schemas.clone();
389 let provider =
390 provider_builder::build_provider(&provider_options, Some(&self.diagnostics));
391
392 let (schema, emission_report) = generate_values_schema_with_report(
393 ValuesSchemaInput::new(finalized_contract.schema_signals(), &provider)
394 .with_values_documents(&prepared.values_documents)
395 .with_shadowed_input_paths(&prepared.shadowed_input_paths)
396 .with_values_descriptions(&prepared.values_descriptions)
397 .with_emission_policy(self.resolved_emission_policy()?.policy()),
398 );
399
400 Ok(ResolvedContract {
401 schema,
402 emission_report,
403 })
404 })
405 }
406
407 fn resolved_emission_policy(&self) -> EngineResult<helm_schema_gen::ResolvedEmissionPolicy> {
408 Ok(*self
409 .resolved_emission_policy
410 .get_or_try_init(|| Ok(self.opts.emission.resolve()?))?)
411 }
412
413 #[cfg(all(feature = "bench-support", test))]
414 pub(crate) fn benchmark_emission_policies(
415 &self,
416 policies: &[helm_schema_gen::bench_support::BenchmarkPolicy],
417 runs: std::num::NonZeroUsize,
418 ) -> EngineResult<helm_schema_gen::bench_support::MultiPolicyBenchmark> {
419 let prepared = self.prepared()?;
420 let finalized_contract = self.finalized_contract()?;
421 let mut provider_options = self.opts.provider.clone();
422 provider_options.local_schema_universe = prepared.analysis.local_schemas.clone();
423 let provider = provider_builder::build_provider(&provider_options, Some(&self.diagnostics));
424 let input = ValuesSchemaInput::new(finalized_contract.schema_signals(), &provider)
425 .with_values_documents(&prepared.values_documents)
426 .with_shadowed_input_paths(&prepared.shadowed_input_paths)
427 .with_values_descriptions(&prepared.values_descriptions);
428 Ok(helm_schema_gen::bench_support::benchmark_policies(
429 &input, policies, runs,
430 ))
431 }
432}
433
434pub(crate) fn emit_input_channel_diagnostics(
435 signals: &ContractSchemaSignals,
436 diagnostics: &DiagnosticSink,
437) {
438 for (value_path, evidence) in signals.schema_evidence_by_value_path() {
439 let base_is_ambiguous = evidence.facts.is_direct_ranged_source
440 && !evidence.facts.has_destructured_range_use
441 && !evidence.facts.has_json_decoded_range_use;
442 let guarded_is_ambiguous = evidence.conditional_overlays.iter().any(|overlay| {
443 overlay.evidence.facts.is_direct_ranged_source
444 && !overlay.evidence.facts.has_destructured_range_use
445 && !overlay.evidence.facts.has_json_decoded_range_use
446 });
447 if base_is_ambiguous || guarded_is_ambiguous {
448 diagnostics.push(Diagnostic::InputChannelNumericRangeAmbiguity {
449 value_path: value_path.encode(),
450 });
451 }
452 }
453}
454
455fn normalize_values_path(path: &str) -> String {
456 let path = path.trim();
457 if let Some(stripped) = path.strip_prefix(".Values.") {
458 stripped.to_string()
459 } else if path == ".Values" {
460 String::new()
461 } else {
462 path.to_string()
463 }
464}
465
466fn primary_kubernetes_version(opts: &GenerateOptions) -> Option<String> {
472 let token = opts.provider.k8s_versions.first()?;
473 let token = token.trim().strip_prefix('v').unwrap_or(token.trim());
474 let core: String = token
475 .chars()
476 .take_while(|c| c.is_ascii_digit() || *c == '.')
477 .collect();
478 let parts: Vec<&str> = core.split('.').collect();
479 if parts.is_empty()
480 || parts.len() > 3
481 || parts
482 .iter()
483 .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
484 {
485 return None;
486 }
487 Some(core)
488}