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#[derive(Debug, Clone)]
32pub struct Analysis {
33 pub contract: ContractIr,
35 pub local_schemas: LocalSchemaUniverse,
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct ValuePathExplanation {
42 pub path: String,
44 pub exact_uses: Vec<ContractUse>,
46 pub descendant_uses: Vec<ContractUse>,
48 pub value_path_facts: Option<ContractValuePathFacts>,
50 pub guard_predicates: Vec<ConditionalGuard>,
52 pub metadata_fields: Vec<MetadataFieldKind>,
54 pub type_hints: Vec<Value>,
56 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 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
118pub 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 #[must_use]
166 pub fn new(opts: GenerateOptions) -> Self {
167 Self::with_diagnostics(opts, DiagnosticSink::new())
168 }
169
170 #[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 pub fn analysis(&self) -> EngineResult<Analysis> {
190 Ok(self.prepared()?.analysis.clone())
191 }
192
193 pub fn contract_schema_signals(&self) -> EngineResult<ContractSchemaSignals> {
199 Ok(self.finalized_contract()?.schema_signals().clone())
200 }
201
202 pub fn contract_document(&self) -> EngineResult<ContractDocument> {
208 Ok(self.finalized_contract()?.document())
209 }
210
211 pub fn resolved_contract(&self) -> EngineResult<ResolvedContract> {
224 Ok((*self.resolved()?).clone())
225 }
226
227 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 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 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 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
427fn 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}