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 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
469fn 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}