Skip to main content

greentic_component/cmd/
doctor.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use clap::{Args, Parser, ValueEnum};
6use serde::Serialize;
7use serde_json::Value as JsonValue;
8use wasmtime::component::{Component, Func, Linker, Val};
9use wasmtime::{Engine, Store};
10use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
11
12use super::path::strip_file_scheme;
13use crate::cmd::component_world::is_fallback_world;
14use crate::embedded_compare::{compare_embedded_with_describe, compare_embedded_with_manifest};
15use crate::embedded_descriptor::{
16    VerifiedEmbeddedDescriptorV1, read_and_verify_embedded_component_manifest_section_v1,
17};
18use crate::test_harness::{HarnessConfig, TestHarness};
19use crate::{ComponentError, abi, loader, parse_manifest};
20
21use greentic_types::cbor::canonical;
22use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
23use greentic_types::schemas::component::v0_6_0::{
24    ComponentDescribe, ComponentInfo, ComponentQaSpec, QaMode, schema_hash,
25};
26use greentic_types::{EnvId, TenantCtx, TenantId};
27
28const COMPONENT_WORLD_V0_6_0: &str = "greentic:component/component@0.6.0";
29const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
30const EMPTY_CBOR_MAP: [u8; 1] = [0xa0];
31
32#[derive(Args, Debug, Clone)]
33#[command(about = "Run health checks against a Greentic component artifact")]
34pub struct DoctorArgs {
35    /// Path or identifier resolvable by the loader
36    pub target: String,
37    /// Explicit path to component.manifest.json when it is not adjacent to the wasm
38    #[arg(long)]
39    pub manifest: Option<PathBuf>,
40    /// Output format
41    #[arg(long, value_enum, default_value = "human")]
42    pub format: DoctorFormat,
43}
44
45#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DoctorFormat {
47    Human,
48    Json,
49}
50
51#[derive(Parser, Debug)]
52struct DoctorCli {
53    #[command(flatten)]
54    args: DoctorArgs,
55}
56
57pub fn parse_from_cli() -> DoctorArgs {
58    DoctorCli::parse().args
59}
60
61pub fn run(args: DoctorArgs) -> Result<(), ComponentError> {
62    let target_path = strip_file_scheme(Path::new(&args.target));
63    let wasm_path = resolve_wasm_path(&args.target, &target_path, args.manifest.as_deref())
64        .map_err(ComponentError::Doctor)?;
65    let manifest_path = discover_manifest_path(&wasm_path, &target_path, args.manifest.as_deref());
66
67    let report = DoctorReport::from_wasm(&wasm_path, manifest_path.as_deref())
68        .map_err(ComponentError::Doctor)?;
69    match args.format {
70        DoctorFormat::Human => report.emit_human(),
71        DoctorFormat::Json => report.emit_json()?,
72    }
73
74    if report.has_errors() {
75        return Err(ComponentError::Doctor("doctor checks failed".to_string()));
76    }
77    Ok(())
78}
79
80fn discover_manifest_path(
81    wasm_path: &Path,
82    target_path: &Path,
83    explicit: Option<&Path>,
84) -> Option<PathBuf> {
85    if let Some(path) = explicit {
86        return Some(path.to_path_buf());
87    }
88
89    let mut candidates = Vec::new();
90    if target_path.is_dir() {
91        candidates.push(target_path.join("component.manifest.json"));
92    }
93    if let Some(parent) = wasm_path.parent() {
94        candidates.push(parent.join("component.manifest.json"));
95        if let Some(grandparent) = parent.parent() {
96            candidates.push(grandparent.join("component.manifest.json"));
97        }
98    }
99
100    candidates.into_iter().find(|path| path.is_file())
101}
102
103fn resolve_wasm_path(
104    raw_target: &str,
105    target_path: &Path,
106    manifest: Option<&Path>,
107) -> Result<PathBuf, String> {
108    if let Some(manifest_path) = manifest {
109        let handle = loader::discover_with_manifest(raw_target, Some(manifest_path))
110            .map_err(|err| format!("failed to load manifest: {err}"))?;
111        return Ok(handle.wasm_path);
112    }
113
114    if target_path.is_file() {
115        if target_path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
116            return Ok(target_path.to_path_buf());
117        }
118        if target_path.extension().and_then(|ext| ext.to_str()) == Some("json") {
119            let handle = loader::discover_with_manifest(raw_target, Some(target_path))
120                .map_err(|err| format!("failed to load manifest: {err}"))?;
121            return Ok(handle.wasm_path);
122        }
123    }
124
125    if target_path.is_dir()
126        && let Some(found) = find_wasm_in_dir(target_path)?
127    {
128        return Ok(found);
129    }
130
131    Err(format!(
132        "doctor: unable to resolve wasm for '{}'; pass a .wasm file or --manifest",
133        raw_target
134    ))
135}
136
137fn find_wasm_in_dir(dir: &Path) -> Result<Option<PathBuf>, String> {
138    let mut candidates = Vec::new();
139    let dist = dir.join("dist");
140    if dist.is_dir() {
141        collect_wasm_files(&dist, &mut candidates)?;
142    }
143    let target = dir.join("target").join("wasm32-wasip2");
144    for profile in ["release", "debug"] {
145        let profile_dir = target.join(profile);
146        if profile_dir.is_dir() {
147            collect_wasm_files(&profile_dir, &mut candidates)?;
148        }
149    }
150
151    candidates.sort();
152    candidates.dedup();
153    match candidates.len() {
154        0 => Ok(None),
155        1 => Ok(Some(candidates.remove(0))),
156        _ => Err(format!(
157            "doctor: multiple wasm files found in {}; specify one explicitly",
158            dir.display()
159        )),
160    }
161}
162
163fn collect_wasm_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
164    for entry in
165        fs::read_dir(dir).map_err(|err| format!("failed to read {}: {err}", dir.display()))?
166    {
167        let entry = entry.map_err(|err| format!("failed to read {}: {err}", dir.display()))?;
168        let path = entry.path();
169        if path.extension().and_then(|ext| ext.to_str()) == Some("wasm") {
170            out.push(path);
171        }
172    }
173    Ok(())
174}
175
176#[derive(Default, Serialize)]
177struct DoctorReport {
178    diagnostics: Vec<DoctorDiagnostic>,
179}
180
181impl DoctorReport {
182    fn from_wasm(wasm_path: &Path, manifest_path: Option<&Path>) -> Result<Self, String> {
183        let mut report = DoctorReport::default();
184        report.validate_world(wasm_path);
185        let embedded = report.validate_embedded_metadata(wasm_path, manifest_path)?;
186
187        let mut caller = ComponentCaller::new(wasm_path)
188            .map_err(|err| format!("doctor: failed to load component: {err}"))?;
189
190        if !caller.has_interface("component-descriptor") && caller.has_interface("node") {
191            return report.validate_node_component(wasm_path, manifest_path, embedded.is_some());
192        }
193
194        let info_bytes = report.require_export_bytes(
195            &mut caller,
196            "component-descriptor",
197            "get-component-info",
198            &[],
199        );
200        let describe_bytes =
201            report.require_export_bytes(&mut caller, "component-descriptor", "describe", &[]);
202        let i18n_keys =
203            report.require_export_strings(&mut caller, "component-i18n", "i18n-keys", &[]);
204
205        report.require_export_call(
206            &mut caller,
207            "component-runtime",
208            "run",
209            &[
210                Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
211                Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
212            ],
213        );
214
215        let mut qa_specs = BTreeMap::new();
216        for (mode, mode_name) in qa_modes() {
217            let spec_bytes = report.require_export_bytes(
218                &mut caller,
219                "component-qa",
220                "qa-spec",
221                &[Val::Enum(mode_name.to_string())],
222            );
223            if let Some(bytes) = spec_bytes.as_deref() {
224                match decode_cbor::<ComponentQaSpec>(bytes) {
225                    Ok(spec) => {
226                        let compatible_default =
227                            mode == QaMode::Default && spec.mode == QaMode::Setup;
228                        if spec.mode != mode && !compatible_default {
229                            report.error(
230                                "doctor.qa.mode_mismatch",
231                                format!("qa-spec returned {:?} for mode {mode_name}", spec.mode),
232                                "qa-spec",
233                                None,
234                            );
235                        }
236                        qa_specs.insert(mode_name.to_string(), spec);
237                    }
238                    Err(err) => {
239                        report.error(
240                            "doctor.qa.decode_failed",
241                            format!("qa-spec({mode_name}) decode failed: {err}"),
242                            "qa-spec",
243                            None,
244                        );
245                    }
246                }
247            }
248        }
249
250        if let Some(bytes) = info_bytes {
251            match decode_cbor::<ComponentInfo>(&bytes) {
252                Ok(info) => report.validate_info(&info, "get-component-info"),
253                Err(err) => report.error(
254                    "doctor.describe.info_decode_failed",
255                    format!("get-component-info decode failed: {err}"),
256                    "get-component-info",
257                    None,
258                ),
259            }
260        }
261
262        if let Some(bytes) = describe_bytes {
263            match decode_cbor::<ComponentDescribe>(&bytes) {
264                Ok(describe) => {
265                    report.validate_info(&describe.info, "describe");
266                    report.validate_describe(&describe, &bytes);
267                    if let Some(embedded) = embedded.as_ref() {
268                        report.validate_embedded_against_describe(&embedded.manifest, &describe);
269                    } else {
270                        report.warning(
271                            "doctor.embedded.describe_unavailable",
272                            "embedded metadata unavailable for compare with describe()".to_string(),
273                            "embedded_manifest",
274                            None,
275                        );
276                    }
277                    report.validate_i18n(&i18n_keys, &qa_specs);
278                    report.validate_apply_answers(&mut caller, &describe, &bytes);
279                }
280                Err(err) => report.error(
281                    "doctor.describe.decode_failed",
282                    format!("describe decode failed: {err}"),
283                    "describe",
284                    None,
285                ),
286            }
287        }
288
289        report.finalize();
290        Ok(report)
291    }
292
293    fn validate_node_component(
294        mut self,
295        wasm_path: &Path,
296        manifest_path: Option<&Path>,
297        _embedded_present: bool,
298    ) -> Result<Self, String> {
299        let Some(manifest_path) = manifest_path else {
300            self.error(
301                "doctor.node.manifest_required",
302                "node-interface doctor checks require a component.manifest.json path".to_string(),
303                "manifest",
304                Some("pass --manifest or run doctor from the component project root".to_string()),
305            );
306            self.finalize();
307            return Ok(self);
308        };
309
310        let raw_manifest = fs::read_to_string(manifest_path)
311            .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
312        let manifest = parse_manifest(&raw_manifest)
313            .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
314        let harness = new_doctor_harness(wasm_path, &manifest)?;
315
316        let i18n_keys = match invoke_json(&harness, "i18n-keys", &serde_json::json!({})) {
317            Ok(value) => match json_array_to_string_set(&value) {
318                Ok(keys) => Some(keys),
319                Err(err) => {
320                    self.error(
321                        "doctor.export.invalid_strings",
322                        format!("node.i18n-keys returned invalid strings: {err}"),
323                        "node.i18n-keys",
324                        None,
325                    );
326                    None
327                }
328            },
329            Err(err) => {
330                self.error(
331                    "doctor.export.call_failed",
332                    format!("node.i18n-keys failed: {err}"),
333                    "node.i18n-keys",
334                    None,
335                );
336                None
337            }
338        };
339
340        let mut qa_specs = BTreeMap::new();
341        for (mode, mode_name) in qa_modes() {
342            match invoke_json(
343                &harness,
344                "qa-spec",
345                &serde_json::json!({ "mode": mode_name }),
346            ) {
347                Ok(value) => match serde_json::from_value::<ComponentQaSpec>(value) {
348                    Ok(spec) => {
349                        let compatible_default =
350                            mode == QaMode::Default && spec.mode == QaMode::Setup;
351                        if spec.mode != mode && !compatible_default {
352                            self.error(
353                                "doctor.qa.mode_mismatch",
354                                format!("qa-spec returned {:?} for mode {mode_name}", spec.mode),
355                                "qa-spec",
356                                None,
357                            );
358                        }
359                        qa_specs.insert(mode_name.to_string(), spec);
360                    }
361                    Err(err) => self.error(
362                        "doctor.qa.decode_failed",
363                        format!("qa-spec({mode_name}) decode failed: {err}"),
364                        "qa-spec",
365                        None,
366                    ),
367                },
368                Err(err) => self.error(
369                    "doctor.export.call_failed",
370                    format!("node.qa-spec failed: {err}"),
371                    "node.qa-spec",
372                    None,
373                ),
374            }
375        }
376        self.validate_i18n(&i18n_keys, &qa_specs);
377
378        for (_mode, mode_name) in qa_modes() {
379            let payload = sample_apply_answers_payload(mode_name);
380            match invoke_json(&harness, "apply-answers", &payload) {
381                Ok(value) => self.validate_apply_answers_value(mode_name, &value),
382                Err(err) => self.error(
383                    "doctor.export.call_failed",
384                    format!("node.apply-answers failed: {err}"),
385                    "node.apply-answers",
386                    None,
387                ),
388            }
389        }
390
391        if let Some(operation) = default_user_operation(&manifest) {
392            match invoke_json(
393                &harness,
394                operation,
395                &serde_json::json!({ "input": "doctor" }),
396            ) {
397                Ok(value) => {
398                    if !value.is_object() {
399                        self.error(
400                            "doctor.runtime.invalid_output",
401                            format!("{operation} returned non-object output"),
402                            format!("node.invoke.{operation}"),
403                            None,
404                        );
405                    }
406                }
407                Err(err) => self.error(
408                    "doctor.export.call_failed",
409                    format!("node.invoke({operation}) failed: {err}"),
410                    format!("node.invoke.{operation}"),
411                    None,
412                ),
413            }
414        }
415
416        self.finalize();
417        Ok(self)
418    }
419
420    fn validate_embedded_metadata(
421        &mut self,
422        wasm_path: &Path,
423        manifest_path: Option<&Path>,
424    ) -> Result<Option<VerifiedEmbeddedDescriptorV1>, String> {
425        let wasm_bytes = fs::read(wasm_path)
426            .map_err(|err| format!("failed to read {}: {err}", wasm_path.display()))?;
427        let embedded = read_and_verify_embedded_component_manifest_section_v1(&wasm_bytes)
428            .map_err(|err| format!("embedded manifest decode failed: {err}"))?;
429
430        let Some(embedded) = embedded else {
431            self.error(
432                "doctor.embedded.missing",
433                format!(
434                    "missing embedded manifest section {}",
435                    crate::EMBEDDED_COMPONENT_MANIFEST_SECTION_V1
436                ),
437                "embedded_manifest",
438                None,
439            );
440            return Ok(None);
441        };
442
443        if let Some(manifest_path) = manifest_path {
444            let raw_manifest = fs::read_to_string(manifest_path)
445                .map_err(|err| format!("failed to read {}: {err}", manifest_path.display()))?;
446            let manifest = parse_manifest(&raw_manifest)
447                .map_err(|err| format!("failed to parse {}: {err}", manifest_path.display()))?;
448            let comparison = compare_embedded_with_manifest(&embedded.manifest, &manifest);
449            for field in comparison
450                .fields
451                .into_iter()
452                .filter(|field| field.status != crate::ComparisonStatus::Match)
453            {
454                self.error(
455                    "doctor.embedded.manifest_mismatch",
456                    format!(
457                        "embedded manifest differs from canonical manifest for {}{}",
458                        field.field,
459                        field
460                            .detail
461                            .as_deref()
462                            .map(|detail| format!(": {detail}"))
463                            .unwrap_or_default()
464                    ),
465                    format!("embedded_manifest.{}", field.field),
466                    None,
467                );
468            }
469        } else {
470            self.warning(
471                "doctor.embedded.manifest_unavailable",
472                "external manifest unavailable; skipping embedded vs manifest comparison"
473                    .to_string(),
474                "embedded_manifest",
475                None,
476            );
477        }
478
479        Ok(Some(embedded))
480    }
481
482    fn validate_embedded_against_describe(
483        &mut self,
484        embedded: &crate::embedded_descriptor::EmbeddedComponentManifestV1,
485        describe: &ComponentDescribe,
486    ) {
487        let comparison = compare_embedded_with_describe(embedded, describe);
488        for field in comparison
489            .fields
490            .into_iter()
491            .filter(|field| field.status != crate::ComparisonStatus::Match)
492        {
493            self.error(
494                "doctor.embedded.describe_mismatch",
495                format!(
496                    "embedded manifest differs from describe() for {}{}",
497                    field.field,
498                    field
499                        .detail
500                        .as_deref()
501                        .map(|detail| format!(": {detail}"))
502                        .unwrap_or_default()
503                ),
504                format!("embedded_manifest.describe.{}", field.field),
505                None,
506            );
507        }
508    }
509
510    fn validate_world(&mut self, wasm_path: &Path) {
511        if let Err(err) = abi::check_world_base(wasm_path, COMPONENT_WORLD_V0_6_0) {
512            match err {
513                abi::AbiError::WorldMismatch { found, .. } if is_fallback_world(&found) => {}
514                other => self.error(
515                    "doctor.world.mismatch",
516                    format!("component world mismatch: {other}"),
517                    "world",
518                    Some("expected component@0.6.0 world".to_string()),
519                ),
520            }
521        }
522    }
523
524    fn validate_info(&mut self, info: &ComponentInfo, source: &str) {
525        if info.id.trim().is_empty() {
526            self.error(
527                "doctor.describe.info.id_empty",
528                format!("{source} info.id must be non-empty"),
529                "info.id",
530                None,
531            );
532        }
533        if info.version.trim().is_empty() {
534            self.error(
535                "doctor.describe.info.version_empty",
536                format!("{source} info.version must be non-empty"),
537                "info.version",
538                None,
539            );
540        }
541        if info.role.trim().is_empty() {
542            self.error(
543                "doctor.describe.info.role_empty",
544                format!("{source} info.role must be non-empty"),
545                "info.role",
546                None,
547            );
548        }
549    }
550
551    fn validate_describe(&mut self, describe: &ComponentDescribe, raw_bytes: &[u8]) {
552        if let Err(err) = ensure_canonical_allow_floats(raw_bytes) {
553            self.error(
554                "doctor.describe.non_canonical",
555                format!("describe CBOR is not canonical: {err}"),
556                "describe",
557                None,
558            );
559        }
560
561        if describe.operations.is_empty() {
562            self.error(
563                "doctor.describe.missing_operations",
564                "describe.operations must be non-empty".to_string(),
565                "operations",
566                None,
567            );
568        }
569
570        self.validate_schema_ir(&describe.config_schema, "config_schema");
571
572        for (idx, op) in describe.operations.iter().enumerate() {
573            if op.id.trim().is_empty() {
574                self.error(
575                    "doctor.describe.operation.id_empty",
576                    "operation id must be non-empty".to_string(),
577                    format!("operations[{idx}].id"),
578                    None,
579                );
580            }
581            self.validate_schema_ir(&op.input.schema, format!("operations[{idx}].input.schema"));
582            self.validate_schema_ir(
583                &op.output.schema,
584                format!("operations[{idx}].output.schema"),
585            );
586
587            match schema_hash(&op.input.schema, &op.output.schema, &describe.config_schema) {
588                Ok(expected) => {
589                    if op.schema_hash.trim().is_empty() {
590                        self.error(
591                            "doctor.describe.schema_hash.empty",
592                            "schema_hash must be non-empty".to_string(),
593                            format!("operations[{idx}].schema_hash"),
594                            None,
595                        );
596                    } else if op.schema_hash != expected {
597                        self.error(
598                            "doctor.describe.schema_hash.mismatch",
599                            format!(
600                                "schema_hash mismatch (expected {expected}, got {})",
601                                op.schema_hash
602                            ),
603                            format!("operations[{idx}].schema_hash"),
604                            None,
605                        );
606                    }
607                }
608                Err(err) => self.error(
609                    "doctor.describe.schema_hash.failed",
610                    format!("schema_hash computation failed: {err}"),
611                    format!("operations[{idx}].schema_hash"),
612                    None,
613                ),
614            }
615        }
616    }
617
618    fn validate_i18n(
619        &mut self,
620        i18n_keys: &Option<BTreeSet<String>>,
621        qa_specs: &BTreeMap<String, ComponentQaSpec>,
622    ) {
623        let Some(keys) = i18n_keys else {
624            self.error(
625                "doctor.i18n.missing_keys",
626                "i18n-keys export missing or failed".to_string(),
627                "component-i18n",
628                None,
629            );
630            return;
631        };
632
633        for (mode, spec) in qa_specs {
634            for key in spec.i18n_keys() {
635                if !keys.contains(&key) {
636                    self.error(
637                        "doctor.i18n.key_missing",
638                        format!("missing i18n key {key} referenced in qa-spec({mode})"),
639                        "component-i18n",
640                        None,
641                    );
642                }
643            }
644        }
645    }
646
647    fn validate_apply_answers(
648        &mut self,
649        caller: &mut ComponentCaller,
650        describe: &ComponentDescribe,
651        describe_bytes: &[u8],
652    ) {
653        let context = describe_hash_context(describe, describe_bytes);
654        for (_mode, mode_name) in qa_modes() {
655            let bytes = self.require_export_bytes(
656                caller,
657                "component-qa",
658                "apply-answers",
659                &[
660                    Val::Enum(mode_name.to_string()),
661                    Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
662                    Val::List(bytes_to_vals(&EMPTY_CBOR_MAP)),
663                ],
664            );
665            let Some(bytes) = bytes else {
666                continue;
667            };
668            if let Err(err) = ensure_canonical_allow_floats(&bytes) {
669                self.error(
670                    "doctor.qa.apply_answers.non_canonical",
671                    format!(
672                        "apply-answers({mode_name}) returned non-canonical CBOR: {err}; {context}"
673                    ),
674                    format!("apply-answers.{mode_name}"),
675                    None,
676                );
677            }
678            match decode_cbor::<JsonValue>(&bytes) {
679                Ok(value) => {
680                    let mut issues = Vec::new();
681                    validate_json_value(&describe.config_schema, &value, "$", &mut issues);
682                    if !issues.is_empty() {
683                        self.error(
684                            "doctor.qa.apply_answers.schema_invalid",
685                            format!(
686                                "apply-answers({mode_name}) violates config_schema: {}; {context}",
687                                format_validation_issues(&issues)
688                            ),
689                            format!("apply-answers.{mode_name}"),
690                            None,
691                        );
692                    }
693                }
694                Err(err) => {
695                    self.error(
696                        "doctor.qa.apply_answers.decode_failed",
697                        format!("apply-answers({mode_name}) decode failed: {err}; {context}"),
698                        "apply-answers",
699                        None,
700                    );
701                }
702            }
703        }
704    }
705
706    fn validate_apply_answers_value(&mut self, mode_name: &str, value: &JsonValue) {
707        let Some(object) = value.as_object() else {
708            self.error(
709                "doctor.qa.apply_answers.invalid_shape",
710                format!("apply-answers({mode_name}) returned non-object JSON"),
711                format!("apply-answers.{mode_name}"),
712                None,
713            );
714            return;
715        };
716
717        if !object.get("ok").is_some_and(JsonValue::is_boolean) {
718            self.error(
719                "doctor.qa.apply_answers.invalid_shape",
720                format!("apply-answers({mode_name}) must include boolean `ok`"),
721                format!("apply-answers.{mode_name}.ok"),
722                None,
723            );
724        }
725        if !object.get("warnings").is_some_and(|value| value.is_array()) {
726            self.error(
727                "doctor.qa.apply_answers.invalid_shape",
728                format!("apply-answers({mode_name}) must include array `warnings`"),
729                format!("apply-answers.{mode_name}.warnings"),
730                None,
731            );
732        }
733        if !object.get("errors").is_some_and(|value| value.is_array()) {
734            self.error(
735                "doctor.qa.apply_answers.invalid_shape",
736                format!("apply-answers({mode_name}) must include array `errors`"),
737                format!("apply-answers.{mode_name}.errors"),
738                None,
739            );
740        }
741    }
742
743    fn validate_schema_ir<P: Into<String>>(&mut self, schema: &SchemaIr, path: P) {
744        let path = path.into();
745        let mut errors = Vec::new();
746        collect_schema_issues(schema, &path, &mut errors);
747        for error in errors {
748            self.error(error.code, error.message, error.path, error.hint);
749        }
750    }
751
752    fn require_export_bytes(
753        &mut self,
754        caller: &mut ComponentCaller,
755        interface: &str,
756        func: &str,
757        params: &[Val],
758    ) -> Option<Vec<u8>> {
759        match caller.call(interface, func, params) {
760            Ok(values) => {
761                if let Some(val) = values.first() {
762                    match val_to_bytes(val) {
763                        Ok(bytes) => Some(bytes),
764                        Err(err) => {
765                            self.error(
766                                "doctor.export.invalid_bytes",
767                                format!("{interface}.{func} returned invalid bytes: {err}"),
768                                format!("{interface}.{func}"),
769                                None,
770                            );
771                            None
772                        }
773                    }
774                } else {
775                    self.error(
776                        "doctor.export.missing_result",
777                        format!("{interface}.{func} returned no value"),
778                        format!("{interface}.{func}"),
779                        None,
780                    );
781                    None
782                }
783            }
784            Err(err) => {
785                self.error(
786                    "doctor.export.call_failed",
787                    format!("{interface}.{func} failed: {err}"),
788                    format!("{interface}.{func}"),
789                    None,
790                );
791                None
792            }
793        }
794    }
795
796    fn require_export_strings(
797        &mut self,
798        caller: &mut ComponentCaller,
799        interface: &str,
800        func: &str,
801        params: &[Val],
802    ) -> Option<BTreeSet<String>> {
803        match caller.call(interface, func, params) {
804            Ok(values) => {
805                if let Some(val) = values.first() {
806                    match val_to_strings(val) {
807                        Ok(values) => Some(values.into_iter().collect()),
808                        Err(err) => {
809                            self.error(
810                                "doctor.export.invalid_strings",
811                                format!("{interface}.{func} returned invalid strings: {err}"),
812                                format!("{interface}.{func}"),
813                                None,
814                            );
815                            None
816                        }
817                    }
818                } else {
819                    self.error(
820                        "doctor.export.missing_result",
821                        format!("{interface}.{func} returned no value"),
822                        format!("{interface}.{func}"),
823                        None,
824                    );
825                    None
826                }
827            }
828            Err(err) => {
829                self.error(
830                    "doctor.export.call_failed",
831                    format!("{interface}.{func} failed: {err}"),
832                    format!("{interface}.{func}"),
833                    None,
834                );
835                None
836            }
837        }
838    }
839
840    fn require_export_call(
841        &mut self,
842        caller: &mut ComponentCaller,
843        interface: &str,
844        func: &str,
845        params: &[Val],
846    ) {
847        if let Err(err) = caller.call(interface, func, params) {
848            self.error(
849                "doctor.export.call_failed",
850                format!("{interface}.{func} failed: {err}"),
851                format!("{interface}.{func}"),
852                None,
853            );
854        }
855    }
856
857    fn error(
858        &mut self,
859        code: impl Into<String>,
860        message: impl Into<String>,
861        path: impl Into<String>,
862        hint: Option<String>,
863    ) {
864        self.diagnostics.push(DoctorDiagnostic {
865            severity: Severity::Error,
866            code: code.into(),
867            message: message.into(),
868            path: path.into(),
869            hint,
870        });
871    }
872
873    fn warning(
874        &mut self,
875        code: impl Into<String>,
876        message: impl Into<String>,
877        path: impl Into<String>,
878        hint: Option<String>,
879    ) {
880        self.diagnostics.push(DoctorDiagnostic {
881            severity: Severity::Warning,
882            code: code.into(),
883            message: message.into(),
884            path: path.into(),
885            hint,
886        });
887    }
888
889    fn finalize(&mut self) {
890        self.diagnostics
891            .sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.code.cmp(&b.code)));
892    }
893
894    fn has_errors(&self) -> bool {
895        self.diagnostics
896            .iter()
897            .any(|diag| diag.severity == Severity::Error)
898    }
899
900    fn emit_human(&self) {
901        if self.diagnostics.is_empty() {
902            println!("doctor: ok");
903            return;
904        }
905        for diag in &self.diagnostics {
906            let hint = diag
907                .hint
908                .as_deref()
909                .map(|hint| format!(" (hint: {hint})"))
910                .unwrap_or_default();
911            println!(
912                "{severity}[{code}] {path}: {message}{hint}",
913                severity = diag.severity,
914                code = diag.code,
915                path = diag.path,
916                message = diag.message,
917                hint = hint
918            );
919        }
920    }
921
922    fn emit_json(&self) -> Result<(), ComponentError> {
923        let payload = serde_json::to_string_pretty(&self)
924            .map_err(|err| ComponentError::Doctor(format!("failed to encode json: {err}")))?;
925        println!("{payload}");
926        Ok(())
927    }
928}
929
930#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
931#[serde(rename_all = "lowercase")]
932enum Severity {
933    Error,
934    Warning,
935}
936
937impl std::fmt::Display for Severity {
938    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939        match self {
940            Severity::Error => write!(f, "error"),
941            Severity::Warning => write!(f, "warning"),
942        }
943    }
944}
945
946#[derive(Debug, Clone, Serialize)]
947struct DoctorDiagnostic {
948    severity: Severity,
949    code: String,
950    message: String,
951    path: String,
952    #[serde(skip_serializing_if = "Option::is_none")]
953    hint: Option<String>,
954}
955
956struct ComponentCaller {
957    store: Store<DoctorWasi>,
958    instance: wasmtime::component::Instance,
959}
960
961impl ComponentCaller {
962    fn new(wasm_path: &Path) -> Result<Self, anyhow::Error> {
963        let mut config = wasmtime::Config::new();
964        config.wasm_component_model(true);
965        let engine =
966            Engine::new(&config).map_err(|err| anyhow::anyhow!("create engine failed: {err}"))?;
967
968        let component = Component::from_file(&engine, wasm_path).map_err(|err| {
969            anyhow::anyhow!("load component {} failed: {err}", wasm_path.display())
970        })?;
971        let mut linker = Linker::new(&engine);
972        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
973            .map_err(|err| anyhow::anyhow!("add wasi linker failed: {err}"))?;
974
975        let wasi = DoctorWasi::new()?;
976        let mut store = Store::new(&engine, wasi);
977        let instance = linker
978            .instantiate(&mut store, &component)
979            .map_err(|err| anyhow::anyhow!("instantiate component failed: {err}"))?;
980        Ok(Self { store, instance })
981    }
982
983    fn call(&mut self, interface: &str, func: &str, params: &[Val]) -> Result<Vec<Val>, String> {
984        let instance_index = resolve_interface_index(&self.instance, &mut self.store, interface)
985            .ok_or_else(|| format!("missing export interface {interface}"))?;
986        let func_index = self
987            .instance
988            .get_export_index(&mut self.store, Some(&instance_index), func)
989            .ok_or_else(|| format!("missing export {interface}.{func}"))?;
990        let func = self
991            .instance
992            .get_func(&mut self.store, func_index)
993            .ok_or_else(|| format!("export {interface}.{func} is not callable"))?;
994
995        call_component_func(&mut self.store, &func, params)
996    }
997
998    fn has_interface(&mut self, interface: &str) -> bool {
999        resolve_interface_index(&self.instance, &mut self.store, interface).is_some()
1000    }
1001}
1002
1003fn resolve_interface_index(
1004    instance: &wasmtime::component::Instance,
1005    store: &mut Store<DoctorWasi>,
1006    interface: &str,
1007) -> Option<wasmtime::component::ComponentExportIndex> {
1008    for candidate in interface_candidates(interface) {
1009        if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
1010            return Some(index);
1011        }
1012    }
1013    None
1014}
1015
1016fn interface_candidates(interface: &str) -> [String; 3] {
1017    [
1018        interface.to_string(),
1019        format!("greentic:component/{interface}@0.6.0"),
1020        format!("greentic:component/{interface}"),
1021    ]
1022}
1023
1024fn call_component_func(
1025    store: &mut Store<DoctorWasi>,
1026    func: &Func,
1027    params: &[Val],
1028) -> Result<Vec<Val>, String> {
1029    let results_len = func.ty(&mut *store).results().len();
1030    let mut results = vec![Val::Bool(false); results_len];
1031    func.call(&mut *store, params, &mut results)
1032        .map_err(|err| format!("call failed: {err}"))?;
1033    Ok(results)
1034}
1035
1036fn qa_modes() -> [(QaMode, &'static str); 4] {
1037    [
1038        (QaMode::Default, "default"),
1039        (QaMode::Setup, "setup"),
1040        (QaMode::Update, "update"),
1041        (QaMode::Remove, "remove"),
1042    ]
1043}
1044
1045fn bytes_to_vals(bytes: &[u8]) -> Vec<Val> {
1046    bytes.iter().map(|b| Val::U8(*b)).collect()
1047}
1048
1049fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
1050    match val {
1051        Val::List(items) => {
1052            let mut out = Vec::with_capacity(items.len());
1053            for item in items {
1054                match item {
1055                    Val::U8(byte) => out.push(*byte),
1056                    _ => {
1057                        return Err("expected list<u8>".to_string());
1058                    }
1059                }
1060            }
1061            Ok(out)
1062        }
1063        _ => Err("expected list<u8>".to_string()),
1064    }
1065}
1066
1067fn val_to_strings(val: &Val) -> Result<Vec<String>, String> {
1068    match val {
1069        Val::List(items) => {
1070            let mut out = Vec::with_capacity(items.len());
1071            for item in items {
1072                match item {
1073                    Val::String(value) => out.push(value.clone()),
1074                    _ => return Err("expected list<string>".to_string()),
1075                }
1076            }
1077            Ok(out)
1078        }
1079        _ => Err("expected list<string>".to_string()),
1080    }
1081}
1082
1083fn decode_cbor<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
1084    let payload = strip_self_describe_tag(bytes);
1085    canonical::from_cbor(payload).map_err(|err| format!("CBOR decode failed: {err}"))
1086}
1087
1088fn new_doctor_harness(
1089    wasm_path: &Path,
1090    manifest: &crate::manifest::ComponentManifest,
1091) -> Result<TestHarness, String> {
1092    let env: EnvId = "dev"
1093        .to_string()
1094        .try_into()
1095        .map_err(|err| format!("invalid doctor env: {err}"))?;
1096    let tenant: TenantId = "doctor"
1097        .to_string()
1098        .try_into()
1099        .map_err(|err| format!("invalid doctor tenant: {err}"))?;
1100    let tenant_ctx = TenantCtx::new(env, tenant)
1101        .with_flow("doctor")
1102        .with_node("doctor");
1103    let allowed_secrets = manifest
1104        .secret_requirements
1105        .iter()
1106        .map(|req| req.key.to_string())
1107        .chain(
1108            manifest
1109                .capabilities
1110                .host
1111                .secrets
1112                .as_ref()
1113                .into_iter()
1114                .flat_map(|spec| spec.required.iter().map(|req| req.key.to_string())),
1115        )
1116        .collect();
1117
1118    TestHarness::new(HarnessConfig {
1119        wasm_bytes: fs::read(wasm_path)
1120            .map_err(|err| format!("failed to read {}: {err}", wasm_path.display()))?,
1121        tenant_ctx,
1122        flow_id: "doctor".to_string(),
1123        node_id: Some("doctor".to_string()),
1124        state_prefix: "doctor".to_string(),
1125        state_seeds: Vec::new(),
1126        allow_state_read: true,
1127        allow_state_write: true,
1128        allow_state_delete: true,
1129        allow_secrets: true,
1130        allowed_secrets,
1131        secrets: Default::default(),
1132        wasi_preopens: Vec::new(),
1133        config: Some(serde_json::json!({})),
1134        allow_http: true,
1135        timeout_ms: 5_000,
1136        max_memory_bytes: 64 * 1024 * 1024,
1137    })
1138    .map_err(|err| format!("failed to initialize doctor harness: {err}"))
1139}
1140
1141fn invoke_json(
1142    harness: &TestHarness,
1143    operation: &str,
1144    payload: &JsonValue,
1145) -> Result<JsonValue, String> {
1146    let outcome = harness
1147        .invoke(operation, payload)
1148        .map_err(|err| format!("invoke component: {err}"))?;
1149    serde_json::from_str(&outcome.output_json)
1150        .map_err(|err| format!("decode operation output json failed: {err}"))
1151}
1152
1153fn json_array_to_string_set(value: &JsonValue) -> Result<BTreeSet<String>, String> {
1154    let array = value
1155        .as_array()
1156        .ok_or_else(|| "expected array<string>".to_string())?;
1157    let mut out = BTreeSet::new();
1158    for item in array {
1159        let Some(string) = item.as_str() else {
1160            return Err("expected array<string>".to_string());
1161        };
1162        out.insert(string.to_string());
1163    }
1164    Ok(out)
1165}
1166
1167fn sample_apply_answers_payload(mode_name: &str) -> JsonValue {
1168    let answers = match mode_name {
1169        "setup" | "default" => serde_json::json!({
1170            "api_key": "demo-key",
1171            "region": "eu",
1172            "webhook_base_url": "https://example.invalid/webhook",
1173            "enabled": "true"
1174        }),
1175        "remove" => serde_json::json!({
1176            "confirm_remove": "true"
1177        }),
1178        _ => serde_json::json!({
1179            "enabled": "true"
1180        }),
1181    };
1182    serde_json::json!({
1183        "mode": mode_name,
1184        "answers": answers,
1185        "current_config": {}
1186    })
1187}
1188
1189fn default_user_operation(manifest: &crate::manifest::ComponentManifest) -> Option<&str> {
1190    if let Some(default) = manifest.default_operation.as_deref() {
1191        return Some(default);
1192    }
1193
1194    manifest
1195        .operations
1196        .iter()
1197        .map(|op| op.name.as_str())
1198        .find(|name| !matches!(*name, "qa-spec" | "apply-answers" | "i18n-keys"))
1199}
1200
1201fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
1202    if bytes.starts_with(&SELF_DESCRIBE_TAG) {
1203        &bytes[SELF_DESCRIBE_TAG.len()..]
1204    } else {
1205        bytes
1206    }
1207}
1208
1209fn ensure_canonical_allow_floats(bytes: &[u8]) -> Result<(), String> {
1210    let payload = strip_self_describe_tag(bytes);
1211    let canonicalized = canonical::canonicalize_allow_floats(payload)
1212        .map_err(|err| format!("canonicalization failed: {err}"))?;
1213    if canonicalized.as_slice() != payload {
1214        return Err("payload is not canonical".to_string());
1215    }
1216    Ok(())
1217}
1218
1219#[derive(Debug, Clone)]
1220struct SchemaIssue {
1221    code: String,
1222    message: String,
1223    path: String,
1224    hint: Option<String>,
1225}
1226
1227fn collect_schema_issues(schema: &SchemaIr, path: &str, issues: &mut Vec<SchemaIssue>) {
1228    match schema {
1229        SchemaIr::Object {
1230            properties,
1231            required: _,
1232            additional,
1233        } => {
1234            if properties.is_empty() && matches!(additional, AdditionalProperties::Allow) {
1235                issues.push(SchemaIssue {
1236                    code: "doctor.schema.object.unconstrained".to_string(),
1237                    message: "object schema allows arbitrary additional properties without defined fields"
1238                        .to_string(),
1239                    path: path.to_string(),
1240                    hint: None,
1241                });
1242            }
1243            for (name, subschema) in properties {
1244                collect_schema_issues(subschema, &format!("{path}.{name}"), issues);
1245            }
1246            if let AdditionalProperties::Schema(schema) = additional {
1247                collect_schema_issues(schema, &format!("{path}.additional"), issues);
1248            }
1249        }
1250        SchemaIr::Array {
1251            items,
1252            min_items,
1253            max_items,
1254        } => {
1255            if min_items.is_none() && max_items.is_none() && is_unconstrained(items) {
1256                issues.push(SchemaIssue {
1257                    code: "doctor.schema.array.unconstrained".to_string(),
1258                    message: "array schema has no constraints".to_string(),
1259                    path: path.to_string(),
1260                    hint: None,
1261                });
1262            }
1263            collect_schema_issues(items, &format!("{path}.items"), issues);
1264        }
1265        SchemaIr::String {
1266            min_len,
1267            max_len,
1268            regex,
1269            format,
1270        } => {
1271            if min_len.is_none() && max_len.is_none() && regex.is_none() && format.is_none() {
1272                issues.push(SchemaIssue {
1273                    code: "doctor.schema.string.unconstrained".to_string(),
1274                    message: "string schema has no constraints".to_string(),
1275                    path: path.to_string(),
1276                    hint: None,
1277                });
1278            }
1279        }
1280        SchemaIr::Int { min, max } => {
1281            if min.is_none() && max.is_none() {
1282                issues.push(SchemaIssue {
1283                    code: "doctor.schema.int.unconstrained".to_string(),
1284                    message: "int schema has no constraints".to_string(),
1285                    path: path.to_string(),
1286                    hint: None,
1287                });
1288            }
1289        }
1290        SchemaIr::Float { min, max } => {
1291            if min.is_none() && max.is_none() {
1292                issues.push(SchemaIssue {
1293                    code: "doctor.schema.float.unconstrained".to_string(),
1294                    message: "float schema has no constraints".to_string(),
1295                    path: path.to_string(),
1296                    hint: None,
1297                });
1298            }
1299        }
1300        SchemaIr::Enum { values } => {
1301            if values.is_empty() {
1302                issues.push(SchemaIssue {
1303                    code: "doctor.schema.enum.empty".to_string(),
1304                    message: "enum schema must define at least one value".to_string(),
1305                    path: path.to_string(),
1306                    hint: None,
1307                });
1308            }
1309        }
1310        SchemaIr::OneOf { variants } => {
1311            if variants.is_empty() {
1312                issues.push(SchemaIssue {
1313                    code: "doctor.schema.oneof.empty".to_string(),
1314                    message: "oneof schema must define at least one variant".to_string(),
1315                    path: path.to_string(),
1316                    hint: None,
1317                });
1318            }
1319            for (idx, variant) in variants.iter().enumerate() {
1320                collect_schema_issues(variant, &format!("{path}.variants[{idx}]"), issues);
1321            }
1322        }
1323        SchemaIr::Ref { .. } => {
1324            issues.push(SchemaIssue {
1325                code: "doctor.schema.ref.unsupported".to_string(),
1326                message: "schema ref is not supported in strict mode".to_string(),
1327                path: path.to_string(),
1328                hint: None,
1329            });
1330        }
1331        SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes => {}
1332    }
1333}
1334
1335fn is_unconstrained(schema: &SchemaIr) -> bool {
1336    match schema {
1337        SchemaIr::Object {
1338            properties,
1339            additional,
1340            ..
1341        } => properties.is_empty() && matches!(additional, AdditionalProperties::Allow),
1342        SchemaIr::Array {
1343            min_items,
1344            max_items,
1345            items,
1346        } => min_items.is_none() && max_items.is_none() && is_unconstrained(items),
1347        SchemaIr::String {
1348            min_len,
1349            max_len,
1350            regex,
1351            format,
1352        } => min_len.is_none() && max_len.is_none() && regex.is_none() && format.is_none(),
1353        SchemaIr::Int { min, max } => min.is_none() && max.is_none(),
1354        SchemaIr::Float { min, max } => min.is_none() && max.is_none(),
1355        SchemaIr::Enum { values } => values.is_empty(),
1356        SchemaIr::OneOf { variants } => variants.is_empty(),
1357        SchemaIr::Ref { .. } => true,
1358        SchemaIr::Bool | SchemaIr::Null | SchemaIr::Bytes => false,
1359    }
1360}
1361
1362#[derive(Debug)]
1363struct ValueIssue {
1364    path: String,
1365    message: String,
1366}
1367
1368fn describe_hash_context(describe: &ComponentDescribe, describe_bytes: &[u8]) -> String {
1369    let describe_hash =
1370        compute_describe_hash(describe_bytes).unwrap_or_else(|err| format!("unavailable ({err})"));
1371    let schema_hashes = describe
1372        .operations
1373        .iter()
1374        .map(|op| format!("{}={}", op.id, op.schema_hash))
1375        .collect::<Vec<_>>();
1376    if schema_hashes.is_empty() {
1377        format!("describe_hash={describe_hash}")
1378    } else {
1379        format!(
1380            "describe_hash={describe_hash}; schema_hashes=[{}]",
1381            schema_hashes.join(", ")
1382        )
1383    }
1384}
1385
1386fn compute_describe_hash(raw_bytes: &[u8]) -> Result<String, String> {
1387    let payload = strip_self_describe_tag(raw_bytes);
1388    let canonicalized = canonical::canonicalize_allow_floats(payload)
1389        .map_err(|err| format!("canonicalization failed: {err}"))?;
1390    Ok(blake3::hash(&canonicalized).to_hex().to_string())
1391}
1392
1393fn format_validation_issues(issues: &[ValueIssue]) -> String {
1394    issues
1395        .iter()
1396        .take(8)
1397        .map(|issue| format!("{}: {}", issue.path, issue.message))
1398        .collect::<Vec<_>>()
1399        .join("; ")
1400}
1401
1402fn validate_json_value(
1403    schema: &SchemaIr,
1404    value: &JsonValue,
1405    path: &str,
1406    issues: &mut Vec<ValueIssue>,
1407) {
1408    match schema {
1409        SchemaIr::Object {
1410            properties,
1411            required,
1412            additional,
1413        } => {
1414            let Some(obj) = value.as_object() else {
1415                issues.push(ValueIssue {
1416                    path: path.to_string(),
1417                    message: "expected object".to_string(),
1418                });
1419                return;
1420            };
1421            for key in required {
1422                if !obj.contains_key(key) {
1423                    issues.push(ValueIssue {
1424                        path: format!("{path}/{key}"),
1425                        message: "required field missing".to_string(),
1426                    });
1427                }
1428            }
1429            for (key, subschema) in properties {
1430                if let Some(subvalue) = obj.get(key) {
1431                    validate_json_value(subschema, subvalue, &format!("{path}/{key}"), issues);
1432                }
1433            }
1434            for (key, subvalue) in obj {
1435                if properties.contains_key(key) {
1436                    continue;
1437                }
1438                match additional {
1439                    AdditionalProperties::Allow => {}
1440                    AdditionalProperties::Forbid => issues.push(ValueIssue {
1441                        path: format!("{path}/{key}"),
1442                        message: "additional property not allowed".to_string(),
1443                    }),
1444                    AdditionalProperties::Schema(extra_schema) => {
1445                        validate_json_value(
1446                            extra_schema,
1447                            subvalue,
1448                            &format!("{path}/{key}"),
1449                            issues,
1450                        );
1451                    }
1452                }
1453            }
1454        }
1455        SchemaIr::Array {
1456            items,
1457            min_items,
1458            max_items,
1459        } => {
1460            let Some(arr) = value.as_array() else {
1461                issues.push(ValueIssue {
1462                    path: path.to_string(),
1463                    message: "expected array".to_string(),
1464                });
1465                return;
1466            };
1467            if let Some(min) = min_items
1468                && arr.len() < *min as usize
1469            {
1470                issues.push(ValueIssue {
1471                    path: path.to_string(),
1472                    message: format!("expected at least {min} items"),
1473                });
1474            }
1475            if let Some(max) = max_items
1476                && arr.len() > *max as usize
1477            {
1478                issues.push(ValueIssue {
1479                    path: path.to_string(),
1480                    message: format!("expected at most {max} items"),
1481                });
1482            }
1483            for (idx, item) in arr.iter().enumerate() {
1484                validate_json_value(items, item, &format!("{path}/{idx}"), issues);
1485            }
1486        }
1487        SchemaIr::String {
1488            min_len,
1489            max_len,
1490            regex,
1491            ..
1492        } => {
1493            let Some(s) = value.as_str() else {
1494                issues.push(ValueIssue {
1495                    path: path.to_string(),
1496                    message: "expected string".to_string(),
1497                });
1498                return;
1499            };
1500            if let Some(min) = min_len
1501                && s.chars().count() < *min as usize
1502            {
1503                issues.push(ValueIssue {
1504                    path: path.to_string(),
1505                    message: format!("expected minimum length {min}"),
1506                });
1507            }
1508            if let Some(max) = max_len
1509                && s.chars().count() > *max as usize
1510            {
1511                issues.push(ValueIssue {
1512                    path: path.to_string(),
1513                    message: format!("expected maximum length {max}"),
1514                });
1515            }
1516            if let Some(pattern) = regex {
1517                match regex::Regex::new(pattern) {
1518                    Ok(re) => {
1519                        if !re.is_match(s) {
1520                            issues.push(ValueIssue {
1521                                path: path.to_string(),
1522                                message: format!("string does not match regex `{pattern}`"),
1523                            });
1524                        }
1525                    }
1526                    Err(err) => issues.push(ValueIssue {
1527                        path: path.to_string(),
1528                        message: format!("invalid schema regex `{pattern}`: {err}"),
1529                    }),
1530                }
1531            }
1532        }
1533        SchemaIr::Int { min, max } => {
1534            let Some(i) = value.as_i64() else {
1535                issues.push(ValueIssue {
1536                    path: path.to_string(),
1537                    message: "expected integer".to_string(),
1538                });
1539                return;
1540            };
1541            if let Some(min) = min
1542                && i < *min
1543            {
1544                issues.push(ValueIssue {
1545                    path: path.to_string(),
1546                    message: format!("expected value >= {min}"),
1547                });
1548            }
1549            if let Some(max) = max
1550                && i > *max
1551            {
1552                issues.push(ValueIssue {
1553                    path: path.to_string(),
1554                    message: format!("expected value <= {max}"),
1555                });
1556            }
1557        }
1558        SchemaIr::Float { min, max } => {
1559            let Some(f) = value.as_f64() else {
1560                issues.push(ValueIssue {
1561                    path: path.to_string(),
1562                    message: "expected number".to_string(),
1563                });
1564                return;
1565            };
1566            if let Some(min) = min
1567                && f < *min
1568            {
1569                issues.push(ValueIssue {
1570                    path: path.to_string(),
1571                    message: format!("expected value >= {min}"),
1572                });
1573            }
1574            if let Some(max) = max
1575                && f > *max
1576            {
1577                issues.push(ValueIssue {
1578                    path: path.to_string(),
1579                    message: format!("expected value <= {max}"),
1580                });
1581            }
1582        }
1583        SchemaIr::Enum { values } => match json_to_cbor_value(value) {
1584            Ok(cbor_value) => {
1585                if !values.iter().any(|candidate| candidate == &cbor_value) {
1586                    issues.push(ValueIssue {
1587                        path: path.to_string(),
1588                        message: "value not present in enum".to_string(),
1589                    });
1590                }
1591            }
1592            Err(err) => {
1593                issues.push(ValueIssue {
1594                    path: path.to_string(),
1595                    message: format!("failed to normalize enum value: {err}"),
1596                });
1597            }
1598        },
1599        SchemaIr::OneOf { variants } => {
1600            let any_match = variants.iter().any(|variant| {
1601                let mut inner = Vec::new();
1602                validate_json_value(variant, value, path, &mut inner);
1603                inner.is_empty()
1604            });
1605            if !any_match {
1606                issues.push(ValueIssue {
1607                    path: path.to_string(),
1608                    message: "value does not match any oneOf variant".to_string(),
1609                });
1610            }
1611        }
1612        SchemaIr::Bool => {
1613            if !value.is_boolean() {
1614                issues.push(ValueIssue {
1615                    path: path.to_string(),
1616                    message: "expected boolean".to_string(),
1617                });
1618            }
1619        }
1620        SchemaIr::Null => {
1621            if !value.is_null() {
1622                issues.push(ValueIssue {
1623                    path: path.to_string(),
1624                    message: "expected null".to_string(),
1625                });
1626            }
1627        }
1628        SchemaIr::Bytes => {
1629            if !value.is_string() && !value.is_array() {
1630                issues.push(ValueIssue {
1631                    path: path.to_string(),
1632                    message: "expected bytes-like value".to_string(),
1633                });
1634            }
1635        }
1636        SchemaIr::Ref { id } => {
1637            issues.push(ValueIssue {
1638                path: path.to_string(),
1639                message: format!("schema ref `{id}` is unsupported for strict validation"),
1640            });
1641        }
1642    }
1643}
1644
1645fn json_to_cbor_value(value: &JsonValue) -> Result<ciborium::Value, String> {
1646    let bytes = canonical::to_canonical_cbor_allow_floats(value)
1647        .map_err(|err| format!("CBOR encode failed: {err}"))?;
1648    canonical::from_cbor(&bytes).map_err(|err| format!("CBOR decode failed: {err}"))
1649}
1650
1651struct DoctorWasi {
1652    ctx: WasiCtx,
1653    table: ResourceTable,
1654}
1655
1656impl DoctorWasi {
1657    fn new() -> Result<Self, anyhow::Error> {
1658        let ctx = WasiCtxBuilder::new().build();
1659        Ok(Self {
1660            ctx,
1661            table: ResourceTable::new(),
1662        })
1663    }
1664}
1665
1666impl WasiView for DoctorWasi {
1667    fn ctx(&mut self) -> WasiCtxView<'_> {
1668        WasiCtxView {
1669            ctx: &mut self.ctx,
1670            table: &mut self.table,
1671        }
1672    }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677    use super::*;
1678    use greentic_types::i18n_text::I18nText;
1679    use greentic_types::schemas::component::v0_6_0::{
1680        ComponentDescribe, ComponentInfo, ComponentOperation, ComponentQaSpec, ComponentRunInput,
1681        ComponentRunOutput, QaMode, RedactionKind, RedactionRule,
1682    };
1683    use serde_json::json;
1684
1685    fn fixture_path(name: &str) -> PathBuf {
1686        Path::new(env!("CARGO_MANIFEST_DIR"))
1687            .join("tests")
1688            .join("fixtures")
1689            .join("doctor")
1690            .join(name)
1691    }
1692
1693    fn load_or_update_fixture(name: &str, expected: &[u8]) -> Vec<u8> {
1694        let path = fixture_path(name);
1695        if std::env::var("UPDATE_DOCTOR_FIXTURES").is_ok() {
1696            if let Some(parent) = path.parent() {
1697                fs::create_dir_all(parent).expect("create fixture dir");
1698            }
1699            fs::write(&path, expected).expect("write fixture");
1700        }
1701        fs::read(&path).expect("fixture exists")
1702    }
1703
1704    fn object_schema(props: Vec<(&str, SchemaIr)>) -> SchemaIr {
1705        let mut properties = BTreeMap::new();
1706        let mut required = Vec::new();
1707        for (name, schema) in props {
1708            properties.insert(name.to_string(), schema);
1709            required.push(name.to_string());
1710        }
1711        SchemaIr::Object {
1712            properties,
1713            required,
1714            additional: AdditionalProperties::Forbid,
1715        }
1716    }
1717
1718    fn good_describe() -> ComponentDescribe {
1719        let info = ComponentInfo {
1720            id: "com.greentic.demo".to_string(),
1721            version: "0.1.0".to_string(),
1722            role: "tool".to_string(),
1723            display_name: None,
1724        };
1725        let input_schema = object_schema(vec![(
1726            "name",
1727            SchemaIr::String {
1728                min_len: Some(1),
1729                max_len: None,
1730                regex: None,
1731                format: None,
1732            },
1733        )]);
1734        let output_schema = object_schema(vec![("ok", SchemaIr::Bool)]);
1735        let config_schema = object_schema(vec![("enabled", SchemaIr::Bool)]);
1736        let schema_hash =
1737            schema_hash(&input_schema, &output_schema, &config_schema).expect("schema hash");
1738        let operation = ComponentOperation {
1739            id: "run".to_string(),
1740            display_name: None,
1741            input: ComponentRunInput {
1742                schema: input_schema,
1743            },
1744            output: ComponentRunOutput {
1745                schema: output_schema,
1746            },
1747            defaults: BTreeMap::new(),
1748            redactions: Vec::new(),
1749            constraints: BTreeMap::new(),
1750            schema_hash,
1751        };
1752        ComponentDescribe {
1753            info,
1754            provided_capabilities: Vec::new(),
1755            required_capabilities: Vec::new(),
1756            metadata: BTreeMap::new(),
1757            operations: vec![operation],
1758            config_schema,
1759        }
1760    }
1761
1762    fn bad_missing_ops_describe() -> ComponentDescribe {
1763        let mut describe = good_describe();
1764        describe.operations.clear();
1765        describe
1766    }
1767
1768    fn bad_unconstrained_describe() -> ComponentDescribe {
1769        let info = ComponentInfo {
1770            id: "com.greentic.demo".to_string(),
1771            version: "0.1.0".to_string(),
1772            role: "tool".to_string(),
1773            display_name: None,
1774        };
1775        let input_schema = SchemaIr::String {
1776            min_len: None,
1777            max_len: None,
1778            regex: None,
1779            format: None,
1780        };
1781        let output_schema = SchemaIr::Bool;
1782        let config_schema = SchemaIr::Object {
1783            properties: BTreeMap::new(),
1784            required: Vec::new(),
1785            additional: AdditionalProperties::Allow,
1786        };
1787        let schema_hash =
1788            schema_hash(&input_schema, &output_schema, &config_schema).expect("schema hash");
1789        let operation = ComponentOperation {
1790            id: "run".to_string(),
1791            display_name: None,
1792            input: ComponentRunInput {
1793                schema: input_schema,
1794            },
1795            output: ComponentRunOutput {
1796                schema: output_schema,
1797            },
1798            defaults: BTreeMap::new(),
1799            redactions: vec![RedactionRule {
1800                json_pointer: "/secret".to_string(),
1801                kind: RedactionKind::Secret,
1802            }],
1803            constraints: BTreeMap::new(),
1804            schema_hash,
1805        };
1806        ComponentDescribe {
1807            info,
1808            provided_capabilities: Vec::new(),
1809            required_capabilities: Vec::new(),
1810            metadata: BTreeMap::new(),
1811            operations: vec![operation],
1812            config_schema,
1813        }
1814    }
1815
1816    fn bad_hash_describe() -> ComponentDescribe {
1817        let mut describe = good_describe();
1818        if let Some(op) = describe.operations.first_mut() {
1819            op.schema_hash = "deadbeef".to_string();
1820        }
1821        describe
1822    }
1823
1824    fn encode_describe(describe: &ComponentDescribe) -> Vec<u8> {
1825        canonical::to_canonical_cbor_allow_floats(describe).expect("encode cbor")
1826    }
1827
1828    fn has_code(report: &DoctorReport, code: &str) -> bool {
1829        report.diagnostics.iter().any(|diag| diag.code == code)
1830    }
1831
1832    #[test]
1833    fn fixtures_match_expected_payloads() {
1834        let good_bytes = encode_describe(&good_describe());
1835        let fixture = load_or_update_fixture("good_component_describe.cbor", &good_bytes);
1836        assert_eq!(fixture, good_bytes);
1837
1838        let missing_ops_bytes = encode_describe(&bad_missing_ops_describe());
1839        let fixture = load_or_update_fixture(
1840            "bad_component_describe_missing_ops.cbor",
1841            &missing_ops_bytes,
1842        );
1843        assert_eq!(fixture, missing_ops_bytes);
1844
1845        let unconstrained_bytes = encode_describe(&bad_unconstrained_describe());
1846        let fixture = load_or_update_fixture(
1847            "bad_component_describe_unconstrained_schema.cbor",
1848            &unconstrained_bytes,
1849        );
1850        assert_eq!(fixture, unconstrained_bytes);
1851
1852        let hash_bytes = encode_describe(&bad_hash_describe());
1853        let fixture =
1854            load_or_update_fixture("bad_component_describe_hash_mismatch.cbor", &hash_bytes);
1855        assert_eq!(fixture, hash_bytes);
1856    }
1857
1858    #[test]
1859    fn doctor_accepts_good_describe_fixture() {
1860        let bytes = load_or_update_fixture(
1861            "good_component_describe.cbor",
1862            &encode_describe(&good_describe()),
1863        );
1864        let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1865        let mut report = DoctorReport::default();
1866        report.validate_info(&describe.info, "describe");
1867        report.validate_describe(&describe, &bytes);
1868        report.finalize();
1869        assert!(
1870            !report.has_errors(),
1871            "expected no diagnostics, got {:?}",
1872            report.diagnostics
1873        );
1874    }
1875
1876    #[test]
1877    fn doctor_rejects_missing_ops_fixture() {
1878        let bytes = load_or_update_fixture(
1879            "bad_component_describe_missing_ops.cbor",
1880            &encode_describe(&bad_missing_ops_describe()),
1881        );
1882        let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1883        let mut report = DoctorReport::default();
1884        report.validate_describe(&describe, &bytes);
1885        report.finalize();
1886        assert!(has_code(&report, "doctor.describe.missing_operations"));
1887    }
1888
1889    #[test]
1890    fn doctor_rejects_unconstrained_schema_fixture() {
1891        let bytes = load_or_update_fixture(
1892            "bad_component_describe_unconstrained_schema.cbor",
1893            &encode_describe(&bad_unconstrained_describe()),
1894        );
1895        let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1896        let mut report = DoctorReport::default();
1897        report.validate_describe(&describe, &bytes);
1898        report.finalize();
1899        assert!(
1900            has_code(&report, "doctor.schema.object.unconstrained")
1901                || has_code(&report, "doctor.schema.string.unconstrained"),
1902            "expected unconstrained schema diagnostics, got {:?}",
1903            report.diagnostics
1904        );
1905    }
1906
1907    #[test]
1908    fn doctor_rejects_hash_mismatch_fixture() {
1909        let bytes = load_or_update_fixture(
1910            "bad_component_describe_hash_mismatch.cbor",
1911            &encode_describe(&bad_hash_describe()),
1912        );
1913        let describe: ComponentDescribe = decode_cbor(&bytes).expect("decode describe");
1914        let mut report = DoctorReport::default();
1915        report.validate_describe(&describe, &bytes);
1916        report.finalize();
1917        assert!(has_code(&report, "doctor.describe.schema_hash.mismatch"));
1918    }
1919
1920    #[test]
1921    fn doctor_flags_missing_i18n_keys() {
1922        let qa_spec = ComponentQaSpec {
1923            mode: QaMode::Default,
1924            title: I18nText::new("qa.title", None),
1925            description: Some(I18nText::new("qa.desc", None)),
1926            questions: vec![
1927                serde_json::from_value(serde_json::json!({
1928                    "id": "name",
1929                    "label": I18nText::new("qa.question.name", None),
1930                    "help": null,
1931                    "error": null,
1932                    "kind": {
1933                        "type": "choice",
1934                        "options": [{
1935                            "value": "one",
1936                            "label": I18nText::new("qa.option.one", None)
1937                        }]
1938                    },
1939                    "required": true,
1940                    "default": null
1941                }))
1942                .expect("question should deserialize"),
1943            ],
1944            defaults: BTreeMap::new(),
1945        };
1946        let mut qa_specs = BTreeMap::new();
1947        qa_specs.insert("default".to_string(), qa_spec);
1948
1949        let keys = BTreeSet::from_iter(["qa.title".to_string()]);
1950        let mut report = DoctorReport::default();
1951        report.validate_i18n(&Some(keys), &qa_specs);
1952        report.finalize();
1953        assert!(has_code(&report, "doctor.i18n.key_missing"));
1954    }
1955
1956    #[test]
1957    fn validation_issues_include_field_paths_and_hash_context() {
1958        let describe = good_describe();
1959        let describe_bytes = encode_describe(&describe);
1960        let context = describe_hash_context(&describe, &describe_bytes);
1961
1962        let mut issues = Vec::new();
1963        let invalid_config = json!({ "enabled": "true" });
1964        validate_json_value(&describe.config_schema, &invalid_config, "$", &mut issues);
1965        assert!(
1966            !issues.is_empty(),
1967            "expected at least one schema validation issue"
1968        );
1969
1970        let rendered = format_validation_issues(&issues);
1971        assert!(
1972            rendered.contains("$/enabled"),
1973            "issues should include field path"
1974        );
1975        assert!(
1976            rendered.contains("expected boolean"),
1977            "issues should include type mismatch message"
1978        );
1979        assert!(
1980            context.contains("describe_hash="),
1981            "context should include describe hash"
1982        );
1983        assert!(
1984            context.contains("schema_hashes=[run="),
1985            "context should include operation schema hash"
1986        );
1987    }
1988
1989    #[test]
1990    fn non_map_config_reports_object_error_with_hash_context() {
1991        let describe = good_describe();
1992        let describe_bytes = encode_describe(&describe);
1993        let context = describe_hash_context(&describe, &describe_bytes);
1994
1995        let mut issues = Vec::new();
1996        let non_map = json!(42);
1997        validate_json_value(&describe.config_schema, &non_map, "$", &mut issues);
1998
1999        let rendered = format_validation_issues(&issues);
2000        assert!(
2001            rendered.contains("$: expected object"),
2002            "non-map config should be rejected with object error"
2003        );
2004        let combined = format!(
2005            "apply-answers(update) violates config_schema: {}; {}",
2006            rendered, context
2007        );
2008        assert!(combined.contains("describe_hash="));
2009        assert!(combined.contains("schema_hashes=[run="));
2010    }
2011}