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}