greentic_component/
config.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow, bail};
6use component_manifest::validate_config_schema;
7use serde::Serialize;
8use serde_json::{Map as JsonMap, Value as JsonValue};
9use wit_parser::{Resolve, Type, TypeDefKind, TypeOwner, WorldId, WorldItem};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub enum ConfigSchemaSource {
13    Manifest,
14    Wit { path: PathBuf },
15    SchemaFile { path: PathBuf },
16    Stub,
17}
18
19#[derive(Debug, Clone)]
20pub struct ConfigInferenceOptions {
21    pub allow_infer: bool,
22    pub write_schema: bool,
23    pub force_write_schema: bool,
24    pub validate: bool,
25}
26
27impl Default for ConfigInferenceOptions {
28    fn default() -> Self {
29        Self {
30            allow_infer: true,
31            write_schema: true,
32            force_write_schema: false,
33            validate: true,
34        }
35    }
36}
37
38#[derive(Debug, Clone)]
39pub struct ConfigOutcome {
40    pub manifest_path: PathBuf,
41    pub manifest: JsonValue,
42    pub schema: JsonValue,
43    pub source: ConfigSchemaSource,
44    pub schema_written: bool,
45    pub persist_schema: bool,
46}
47
48pub fn resolve_manifest_path(path: &Path) -> PathBuf {
49    if path.is_dir() {
50        path.join("component.manifest.json")
51    } else {
52        path.to_path_buf()
53    }
54}
55
56pub fn load_manifest_with_schema(
57    manifest_path: &Path,
58    opts: &ConfigInferenceOptions,
59) -> Result<ConfigOutcome> {
60    let manifest_text = fs::read_to_string(manifest_path)
61        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
62    let mut manifest: JsonValue = serde_json::from_str(&manifest_text)
63        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
64    let manifest_dir = manifest_path
65        .parent()
66        .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
67
68    let existing_schema = manifest.get("config_schema").cloned();
69    let use_existing = existing_schema.is_some() && !opts.force_write_schema;
70
71    let (schema, source) = if use_existing {
72        (
73            existing_schema.expect("guarded above"),
74            ConfigSchemaSource::Manifest,
75        )
76    } else {
77        if !opts.allow_infer {
78            bail!("config_schema missing and --no-infer-config set");
79        }
80
81        let wit_candidate = if let Some(world) = manifest.get("world").and_then(|v| v.as_str()) {
82            match infer_from_wit(manifest_dir, world) {
83                Ok(found) => found,
84                Err(err) => {
85                    eprintln!(
86                        "warning: failed to infer config_schema from WIT: {err:?}; falling back"
87                    );
88                    None
89                }
90            }
91        } else {
92            None
93        };
94
95        if let Some(inferred) = wit_candidate {
96            inferred
97        } else if let Some(local_schema) = try_read_local_schema(manifest_dir)? {
98            local_schema
99        } else {
100            (stub_schema(), ConfigSchemaSource::Stub)
101        }
102    };
103
104    if opts.validate {
105        validate_config_schema(&schema)
106            .map_err(|err| anyhow!("config_schema failed validation: {err}"))?;
107    }
108
109    let mut schema_written = false;
110    let persist_schema = opts.write_schema || use_existing;
111    manifest["config_schema"] = schema.clone();
112
113    let should_write = opts.write_schema && (!use_existing || opts.force_write_schema);
114    if should_write {
115        let formatted = serde_json::to_string_pretty(&manifest)?;
116        fs::write(manifest_path, formatted + "\n")
117            .with_context(|| format!("failed to write {}", manifest_path.display()))?;
118        schema_written = true;
119    }
120
121    Ok(ConfigOutcome {
122        manifest_path: manifest_path.to_path_buf(),
123        manifest,
124        schema,
125        source,
126        schema_written,
127        persist_schema,
128    })
129}
130
131fn try_read_local_schema(manifest_dir: &Path) -> Result<Option<(JsonValue, ConfigSchemaSource)>> {
132    let candidate = manifest_dir.join("schemas/component.schema.json");
133    if !candidate.exists() {
134        return Ok(None);
135    }
136    let text = fs::read_to_string(&candidate)
137        .with_context(|| format!("failed to read {}", candidate.display()))?;
138    let json: JsonValue = serde_json::from_str(&text)
139        .with_context(|| format!("failed to parse {}", candidate.display()))?;
140    Ok(Some((
141        json,
142        ConfigSchemaSource::SchemaFile { path: candidate },
143    )))
144}
145
146fn infer_from_wit(
147    manifest_dir: &Path,
148    manifest_world: &str,
149) -> Result<Option<(JsonValue, ConfigSchemaSource)>> {
150    let wit_dir = manifest_dir.join("wit");
151    if !wit_dir.exists() {
152        return Ok(None);
153    }
154
155    let mut resolve = Resolve::default();
156    let (pkg, _) = resolve
157        .push_dir(&wit_dir)
158        .with_context(|| format!("failed to parse WIT in {}", wit_dir.display()))?;
159
160    let world_id = select_world(&resolve, pkg, manifest_world)
161        .context("failed to locate WIT world for config inference")?;
162    let config_id = find_config_type(&resolve, world_id)?;
163
164    let schema = schema_from_record(&resolve, config_id)?;
165    Ok(Some((schema, ConfigSchemaSource::Wit { path: wit_dir })))
166}
167
168fn select_world(
169    resolve: &Resolve,
170    pkg: wit_parser::PackageId,
171    manifest_world: &str,
172) -> Result<WorldId> {
173    let target = parse_world_name(manifest_world);
174    if let Some(target_name) = target
175        && let Some((id, _)) = resolve
176            .worlds
177            .iter()
178            .find(|(_, world)| world.package == Some(pkg) && world.name == target_name)
179    {
180        return Ok(id);
181    }
182
183    resolve
184        .worlds
185        .iter()
186        .find(|(_, world)| world.package == Some(pkg))
187        .map(|(id, _)| id)
188        .ok_or_else(|| anyhow!("no world found in {}", resolve.packages[pkg].name.name))
189}
190
191fn parse_world_name(raw: &str) -> Option<String> {
192    let after_slash = raw.split('/').nth(1)?;
193    let without_version = after_slash.split('@').next()?;
194    Some(without_version.to_string())
195}
196
197fn find_config_type(resolve: &Resolve, world_id: WorldId) -> Result<wit_parser::TypeId> {
198    let interfaces = interfaces_in_world(resolve, world_id);
199    resolve
200        .types
201        .iter()
202        .find_map(|(id, ty)| {
203            let owned_here = match ty.owner {
204                TypeOwner::World(w) => w == world_id,
205                TypeOwner::Interface(i) => interfaces.contains(&i),
206                TypeOwner::None => false,
207            };
208            (owned_here && ty.name.as_deref() == Some("config")).then_some(id)
209        })
210        .ok_or_else(|| anyhow!("no `config` record found in WIT"))
211}
212
213fn interfaces_in_world(resolve: &Resolve, world_id: WorldId) -> HashSet<wit_parser::InterfaceId> {
214    let mut ids = HashSet::new();
215    let world = &resolve.worlds[world_id];
216    for item in world.imports.values().chain(world.exports.values()) {
217        if let WorldItem::Interface { id, .. } = item {
218            ids.insert(*id);
219        }
220    }
221    ids
222}
223
224fn schema_from_record(resolve: &Resolve, type_id: wit_parser::TypeId) -> Result<JsonValue> {
225    let type_def = &resolve.types[type_id];
226    let record = match &type_def.kind {
227        TypeDefKind::Record(record) => record,
228        TypeDefKind::Type(inner) => {
229            let shape = map_type(resolve, inner)?;
230            return Ok(shape.schema);
231        }
232        _ => bail!("config type must be a record"),
233    };
234
235    let mut properties = JsonMap::new();
236    let mut required = Vec::new();
237
238    for field in &record.fields {
239        let directives = DocDirectives::from_docs(&field.docs);
240        let shape = map_type(resolve, &field.ty)?;
241
242        let mut prop = shape.schema;
243        if let Some(desc) = directives.description {
244            prop["description"] = JsonValue::String(desc);
245        }
246        if let Some(default) = directives.default {
247            prop["default"] = default;
248        }
249        if directives.hidden {
250            prop["x_flow_hidden"] = JsonValue::Bool(true);
251        }
252
253        properties.insert(field.name.clone(), prop);
254        if !shape.optional {
255            required.push(JsonValue::String(field.name.clone()));
256        }
257    }
258
259    let mut schema = JsonMap::new();
260    schema.insert("type".into(), JsonValue::String("object".into()));
261    schema.insert("additionalProperties".into(), JsonValue::Bool(false));
262    schema.insert("properties".into(), JsonValue::Object(properties));
263    if !required.is_empty() {
264        schema.insert("required".into(), JsonValue::Array(required));
265    }
266
267    Ok(JsonValue::Object(schema))
268}
269
270struct TypeShape {
271    schema: JsonValue,
272    optional: bool,
273}
274
275fn map_type(resolve: &Resolve, ty: &Type) -> Result<TypeShape> {
276    match ty {
277        Type::Bool => Ok(TypeShape {
278            schema: json_type("boolean"),
279            optional: false,
280        }),
281        Type::String | Type::Char => Ok(TypeShape {
282            schema: json_type("string"),
283            optional: false,
284        }),
285        Type::U8
286        | Type::U16
287        | Type::U32
288        | Type::U64
289        | Type::S8
290        | Type::S16
291        | Type::S32
292        | Type::S64 => Ok(TypeShape {
293            schema: json_type("integer"),
294            optional: false,
295        }),
296        Type::F32 | Type::F64 => Ok(TypeShape {
297            schema: json_type("number"),
298            optional: false,
299        }),
300        Type::Id(id) => match &resolve.types[*id].kind {
301            TypeDefKind::Type(inner) => map_type(resolve, inner),
302            TypeDefKind::Option(inner) => {
303                let inner_shape = map_type(resolve, inner)?;
304                Ok(TypeShape {
305                    schema: inner_shape.schema,
306                    optional: true,
307                })
308            }
309            TypeDefKind::Enum(e) => {
310                let values = e
311                    .cases
312                    .iter()
313                    .map(|case| JsonValue::String(case.name.clone()))
314                    .collect();
315                Ok(TypeShape {
316                    schema: JsonValue::Object(
317                        [
318                            ("type".into(), JsonValue::String("string".into())),
319                            ("enum".into(), JsonValue::Array(values)),
320                        ]
321                        .into_iter()
322                        .collect(),
323                    ),
324                    optional: false,
325                })
326            }
327            TypeDefKind::List(inner) => {
328                let mapped = map_type(resolve, inner)?;
329                Ok(TypeShape {
330                    schema: JsonValue::Object(
331                        [
332                            ("type".into(), JsonValue::String("array".into())),
333                            ("items".into(), mapped.schema),
334                        ]
335                        .into_iter()
336                        .collect(),
337                    ),
338                    optional: false,
339                })
340            }
341            TypeDefKind::Record(record) => {
342                let mut properties = JsonMap::new();
343                let mut required = Vec::new();
344                for field in &record.fields {
345                    let shape = map_type(resolve, &field.ty)?;
346                    properties.insert(field.name.clone(), shape.schema);
347                    if !shape.optional {
348                        required.push(JsonValue::String(field.name.clone()));
349                    }
350                }
351                let mut schema = JsonMap::new();
352                schema.insert("type".into(), JsonValue::String("object".into()));
353                schema.insert("properties".into(), JsonValue::Object(properties));
354                if !required.is_empty() {
355                    schema.insert("required".into(), JsonValue::Array(required));
356                }
357                Ok(TypeShape {
358                    schema: JsonValue::Object(schema),
359                    optional: false,
360                })
361            }
362            _ => Ok(TypeShape {
363                schema: json_type("string"),
364                optional: false,
365            }),
366        },
367        _ => Ok(TypeShape {
368            schema: json_type("string"),
369            optional: false,
370        }),
371    }
372}
373
374fn json_type(kind: &str) -> JsonValue {
375    JsonValue::Object(
376        [("type".into(), JsonValue::String(kind.to_string()))]
377            .into_iter()
378            .collect(),
379    )
380}
381
382#[derive(Debug, Default)]
383struct DocDirectives {
384    description: Option<String>,
385    default: Option<JsonValue>,
386    hidden: bool,
387}
388
389impl DocDirectives {
390    fn from_docs(docs: &wit_parser::Docs) -> Self {
391        let Some(raw) = docs.contents.as_deref() else {
392            return Self::default();
393        };
394        let default = extract_default(raw);
395        let hidden = raw.contains("@flow:hidden");
396        let description = render_description(raw);
397        Self {
398            description,
399            default,
400            hidden,
401        }
402    }
403}
404
405fn extract_default(raw: &str) -> Option<JsonValue> {
406    let marker = "@default(";
407    let start = raw.find(marker)?;
408    let after = &raw[start + marker.len()..];
409    let end = after.find(')')?;
410    let body = after[..end].trim();
411    if body.is_empty() {
412        return None;
413    }
414    serde_json::from_str(body)
415        .ok()
416        .or_else(|| Some(JsonValue::String(body.to_string())))
417}
418
419fn render_description(raw: &str) -> Option<String> {
420    let lines = raw
421        .lines()
422        .filter(|line| !line.trim_start().starts_with('@'))
423        .map(str::trim_end)
424        .collect::<Vec<_>>();
425    if lines.is_empty() {
426        None
427    } else {
428        Some(lines.join("\n"))
429    }
430}
431
432fn stub_schema() -> JsonValue {
433    JsonValue::Object(
434        [
435            ("type".into(), JsonValue::String("object".into())),
436            ("properties".into(), JsonValue::Object(JsonMap::new())),
437            ("required".into(), JsonValue::Array(Vec::new())),
438            ("additionalProperties".into(), JsonValue::Bool(false)),
439        ]
440        .into_iter()
441        .collect(),
442    )
443}