Skip to main content

helm_schema_gen/
lib.rs

1//! JSON Schema lowering from normalized Helm contract signals.
2
3mod base_schema;
4mod condition_encoding;
5mod foreign_schema;
6mod merge;
7mod overlay_lowering;
8mod path_resolver;
9mod path_schema;
10mod program_wrapper;
11mod provider_definitions;
12mod provider_schema;
13mod quoted_serialization;
14pub mod required_inference;
15mod required_source_backprojection;
16mod resolve_policy;
17mod schema_model;
18mod schema_node;
19mod schema_tree;
20mod values_yaml;
21
22use std::collections::{BTreeMap, BTreeSet};
23
24use helm_schema_core::{ContractSchemaSignals, ResourceSchemaOracle};
25use serde_json::Value;
26use serde_yaml::Value as YamlValue;
27
28use base_schema::{ConditionalTargetIndex, classify_base};
29use condition_encoding::{
30    HELM_TRUTHY_DEFINITION_NAME, helm_truthy_definition_schema, value_references_helm_truthy,
31};
32use overlay_lowering::{
33    append_conditional_schemas, append_terminal_clauses, collect_conditional_schemas,
34};
35use path_resolver::PathSchemaResolver;
36use provider_definitions::{
37    extract_provider_definitions, extract_repeated_provider_payloads, insert_definitions_into_root,
38};
39use schema_tree::{SchemaDocument, draft07_root_document};
40
41/// Inputs for JSON Schema generation from the current contract schema signals.
42///
43/// The generated schema is derived from the contract-layer signal bundle plus
44/// optional structural signals collected by earlier analysis phases.
45/// Values-file descriptions are metadata only: they are applied only to schema
46/// nodes that already exist from template or values evidence.
47#[derive(Clone, Copy)]
48pub struct ValuesSchemaInput<'a> {
49    /// Path-local static-analysis facts prepared by contract finalization.
50    pub contract_schema_signals: &'a ContractSchemaSignals,
51    /// Resource-schema oracle used to constrain rendered Kubernetes fields.
52    pub provider: &'a dyn ResourceSchemaOracle,
53    /// Composed chart values defaults, when available.
54    pub values_yaml: Option<&'a str>,
55    /// ONLY the dependency charts' declared defaults, composed under
56    /// their value prefixes. A key present here fills at the SUBCHART's
57    /// coalesce stage even when the parent-level document misses it —
58    /// including after a parent-level null-deletion — so absence at such
59    /// paths reads as the subchart default instead of nil. When absent,
60    /// every missing key reads as nil.
61    pub dependency_values_yaml: Option<&'a str>,
62    /// Documentation strings keyed by canonical values path.
63    pub values_descriptions: Option<&'a BTreeMap<String, String>>,
64}
65
66impl<'a> ValuesSchemaInput<'a> {
67    /// Creates schema input with contract signals and a resource provider.
68    pub fn new(
69        contract_schema_signals: &'a ContractSchemaSignals,
70        provider: &'a dyn ResourceSchemaOracle,
71    ) -> Self {
72        Self {
73            contract_schema_signals,
74            provider,
75            values_yaml: None,
76            dependency_values_yaml: None,
77            values_descriptions: None,
78        }
79    }
80
81    /// Attaches composed chart values defaults.
82    #[must_use]
83    pub fn with_values_yaml(mut self, values_yaml: Option<&'a str>) -> Self {
84        self.values_yaml = values_yaml;
85        self
86    }
87
88    /// Attaches dependency defaults composed beneath subchart prefixes.
89    #[must_use]
90    pub fn with_dependency_values_yaml(mut self, dependency_values_yaml: Option<&'a str>) -> Self {
91        self.dependency_values_yaml = dependency_values_yaml;
92        self
93    }
94
95    /// Attaches values-file descriptions as output metadata.
96    #[must_use]
97    pub fn with_values_descriptions(
98        mut self,
99        values_descriptions: &'a BTreeMap<String, String>,
100    ) -> Self {
101        self.values_descriptions = Some(values_descriptions);
102        self
103    }
104}
105
106/// Generate a JSON Schema with chart-authored values-file descriptions.
107///
108/// The output schema has no `required` arrays inferred by helm-schema; callers
109/// that want that behaviour layer [`required_inference::apply_required_inference`]
110/// on top of the returned schema. Keeping required-inference outside this
111/// function isolates a heuristic feature from the core schema-generation
112/// pipeline.
113#[tracing::instrument(skip_all)]
114pub fn generate_values_schema(input: ValuesSchemaInput<'_>) -> Value {
115    let empty_values_descriptions = BTreeMap::new();
116    let values_descriptions = input
117        .values_descriptions
118        .unwrap_or(&empty_values_descriptions);
119
120    let mut values_yaml_doc = input
121        .values_yaml
122        .and_then(|s| serde_yaml::from_str::<YamlValue>(s).ok())
123        .unwrap_or(YamlValue::Null);
124    values_yaml::apply_values_default_sources(
125        &mut values_yaml_doc,
126        input.contract_schema_signals.values_default_sources(),
127    );
128    let mut subchart_defaults_doc = input
129        .dependency_values_yaml
130        .and_then(|s| serde_yaml::from_str::<YamlValue>(s).ok())
131        .unwrap_or(YamlValue::Null);
132    // Chart-internal root merges (`set $ "Values" (mustMergeOverwrite
133    // defaults .Values)`) fill their defaults at RENDER time, after any
134    // null-deletion, so absence at such paths reads as the merged default
135    // exactly like a dependency-owned key reads as its subchart default.
136    values_yaml::copy_values_default_sources(
137        &mut subchart_defaults_doc,
138        &values_yaml_doc,
139        input.contract_schema_signals.values_default_sources(),
140    );
141
142    let root_schema = build_root_schema(
143        input.contract_schema_signals,
144        &values_yaml_doc,
145        &subchart_defaults_doc,
146        values_descriptions,
147        input.provider,
148    );
149
150    draft07_root_document(root_schema)
151}
152
153/// The domain Go's `range` iterates without aborting: collections and nil
154/// render; integer counts iterate through Helm's `--set` int64 channel
155/// (JSON Schema cannot separate that from the failing values-file float64
156/// spelling, so the renderable channel wins) unless the loop body reads
157/// member structure integers cannot provide; strings and non-integral
158/// numbers fail in every channel.
159pub(crate) fn runtime_iterable_schema(allow_integer: bool) -> serde_json::Value {
160    let mut arms = vec![
161        serde_json::json!({ "type": "array" }),
162        serde_json::json!({ "type": "object" }),
163    ];
164    if allow_integer {
165        arms.push(serde_json::json!({ "type": "integer" }));
166    }
167    arms.push(serde_json::json!({ "type": "null" }));
168    serde_json::json!({ "anyOf": arms })
169}
170
171#[tracing::instrument(skip_all)]
172#[expect(
173    clippy::too_many_lines,
174    reason = "keeping this semantic lowering operation together makes its state transitions easier to audit"
175)]
176fn build_root_schema(
177    contract_schema_signals: &ContractSchemaSignals,
178    values_yaml_doc: &YamlValue,
179    subchart_defaults_doc: &YamlValue,
180    values_descriptions: &BTreeMap<String, String>,
181    provider: &dyn ResourceSchemaOracle,
182) -> Value {
183    let mut root_schema = SchemaDocument::new_root_object();
184    let path_resolver = PathSchemaResolver::new(contract_schema_signals, values_yaml_doc, provider);
185    let mut resolved_paths = path_resolver.resolve_all();
186    let mut conditional_schemas = collect_conditional_schemas(
187        &resolved_paths,
188        contract_schema_signals,
189        values_yaml_doc,
190        provider,
191    );
192    let provider_definitions = extract_provider_definitions(
193        &mut resolved_paths,
194        &mut conditional_schemas,
195        values_descriptions,
196    );
197    let conditional_targets = ConditionalTargetIndex::from_conditionals(&conditional_schemas);
198    let accepted_values_root_paths = contract_schema_signals
199        .schema_evidence_by_value_path()
200        .values()
201        .filter(|evidence| evidence.facts.accepted_values_root_fragment)
202        .map(|evidence| split_value_path(&evidence.value_path))
203        .collect::<Vec<_>>();
204    let no_owning_ancestors = BTreeSet::new();
205    let base_span = tracing::info_span!("base_path_insertion").entered();
206    let owning_paths = resolved_paths
207        .iter()
208        .filter(|resolved_path| {
209            classify_base(resolved_path, &conditional_targets, &no_owning_ancestors)
210                .owns_descendants()
211        })
212        .map(|resolved_path| resolved_path.path_segments.clone())
213        .collect::<BTreeSet<_>>();
214    for resolved_path in &resolved_paths {
215        let owner = classify_base(resolved_path, &conditional_targets, &owning_paths);
216        let Some(schema) = owner.schema(resolved_path) else {
217            continue;
218        };
219        if owner.replaces() {
220            root_schema.replace_path_schema(&resolved_path.path_segments, schema);
221        } else {
222            root_schema.insert_path_schema(&resolved_path.path_segments, schema);
223        }
224    }
225    drop(base_span);
226    append_conditional_schemas(
227        &mut root_schema,
228        conditional_schemas,
229        values_yaml_doc,
230        subchart_defaults_doc,
231    );
232    append_terminal_clauses(
233        &mut root_schema,
234        contract_schema_signals.terminal_clauses(),
235        values_yaml_doc,
236        subchart_defaults_doc,
237    );
238    // A serialized path's schema is deliberately unconstrained; the
239    // declared-default filler must keep the slot present without re-typing
240    // it, exactly like a conditional target.
241    let mut default_fill_skip_paths = conditional_targets.target_paths.clone();
242    for resolved_path in &resolved_paths {
243        if resolved_path.used_as_serialized {
244            default_fill_skip_paths.insert(resolved_path.path_segments.clone());
245        }
246    }
247    for (value_path, evidence) in contract_schema_signals.schema_evidence_by_value_path() {
248        if evidence.facts.used_as_yaml_serialized {
249            default_fill_skip_paths.insert(split_value_path(value_path));
250        }
251    }
252    // A directly ranged path accepts the runtime iterable domain, which is
253    // wider than any declared default; the filler must not re-type it.
254    for value_path in contract_schema_signals.direct_ranged_value_paths() {
255        default_fill_skip_paths.insert(split_value_path(value_path));
256    }
257    let fill_span = tracing::info_span!("default_fill_and_finish").entered();
258    {
259        let _span = tracing::info_span!("merge_missing_defaults").entered();
260        root_schema.merge_missing_values_yaml_defaults_under_roots(
261            values_yaml_doc,
262            &accepted_values_root_paths,
263            &default_fill_skip_paths,
264        );
265    }
266    root_schema.open_helm_global_namespace();
267
268    let mut root_schema = root_schema.into_value();
269    if let Ok(declared_defaults) = serde_json::to_value(values_yaml_doc)
270        && declared_defaults.is_object()
271    {
272        let _span = tracing::info_span!("preserve_declared_defaults").entered();
273        root_schema =
274            resolve_policy::preserve_declared_default_in_schema(root_schema, &declared_defaults);
275    }
276    let mut provider_definitions = provider_definitions;
277    {
278        let _span = tracing::info_span!("extract_repeated_provider_payloads").entered();
279        provider_definitions.extend(extract_repeated_provider_payloads(&mut root_schema));
280    }
281    let truthy_span = tracing::info_span!("helm_truthy_scan").entered();
282    if value_references_helm_truthy(&root_schema)
283        || provider_definitions
284            .values()
285            .any(value_references_helm_truthy)
286    {
287        provider_definitions.insert(
288            HELM_TRUTHY_DEFINITION_NAME.to_string(),
289            helm_truthy_definition_schema(),
290        );
291    }
292    for style in [
293        helm_schema_core::QuotedScalarStyle::Double,
294        helm_schema_core::QuotedScalarStyle::Single,
295    ] {
296        if quoted_serialization::value_references(&root_schema, style)
297            || provider_definitions
298                .values()
299                .any(|definition| quoted_serialization::value_references(definition, style))
300        {
301            provider_definitions.insert(
302                quoted_serialization::definition_name(style).to_string(),
303                quoted_serialization::definition_schema(style),
304            );
305        }
306    }
307    drop(truthy_span);
308    insert_definitions_into_root(&mut root_schema, provider_definitions);
309    {
310        let _span = tracing::info_span!("apply_program_wrappers").entered();
311        program_wrapper::apply_program_wrapper_alternatives(
312            &mut root_schema,
313            contract_schema_signals.values_program_wrappers(),
314            contract_schema_signals.values_program_wrapper_exclusions(),
315        );
316    }
317    {
318        let _span = tracing::info_span!("apply_values_descriptions").entered();
319        schema_tree::apply_values_descriptions(&mut root_schema, values_descriptions);
320    }
321    drop(fill_span);
322    root_schema
323}
324
325pub(crate) use helm_schema_core::split_value_path;
326
327fn common_prefix_len(left: &[String], right: &[String]) -> usize {
328    left.iter()
329        .zip(right.iter())
330        .take_while(|(left, right)| left == right)
331        .count()
332}
333
334#[cfg(test)]
335#[path = "tests/mod.rs"]
336mod tests;