1mod catalog_repo;
2mod i18n;
3mod profile;
4mod wizard;
5
6use clap::{Args, Parser, Subcommand, ValueEnum, error::ErrorKind};
7use greentic_x_contracts::ContractManifest;
8use greentic_x_flow::{
9 EvidenceItem, FlowDefinition, FlowEngine, FlowError, NoopViewRenderer, OperationCallStep,
10 OperationResult, RenderSource, RenderSpec, ReturnStep, StaticFlowRuntime, Step, ValueSource,
11};
12use greentic_x_ops::OperationManifest;
13use greentic_x_types::{
14 ActorRef, InvocationStatus, OperationId, Provenance, ResolverCandidate, ResolverId,
15 ResolverResultEnvelope, ResolverStatus, ResourceRef,
16};
17use jsonschema::validator_for;
18use profile::{compile_profile, read_profile, validate_profile};
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use std::collections::{BTreeMap, BTreeSet, HashMap};
22use std::ffi::OsString;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26#[derive(Parser)]
27#[command(
28 name = "greentic-x",
29 about = "Greentic-X scaffold, validate, simulate, and inspect tooling",
30 version
31)]
32struct Cli {
33 #[arg(long, global = true)]
34 locale: Option<String>,
35 #[command(subcommand)]
36 command: Command,
37}
38
39#[derive(Subcommand)]
40enum Command {
41 Contract {
43 #[command(subcommand)]
44 command: ContractCommand,
45 },
46 Op {
48 #[command(subcommand)]
49 command: OpCommand,
50 },
51 Flow {
53 #[command(subcommand)]
54 command: FlowCommand,
55 },
56 Resolver {
58 #[command(subcommand)]
59 command: ResolverCommand,
60 },
61 View {
63 #[command(subcommand)]
64 command: ViewCommand,
65 },
66 Profile {
68 #[command(subcommand)]
69 command: ProfileCommand,
70 },
71 Simulate(SimulateArgs),
73 Doctor(DoctorArgs),
75 Catalog {
77 #[command(subcommand)]
78 command: CatalogCommand,
79 },
80 Wizard(WizardArgs),
82}
83
84#[derive(Subcommand)]
85enum ContractCommand {
86 New(NewContractArgs),
87 Validate(PathArgs),
88}
89
90#[derive(Subcommand)]
91enum OpCommand {
92 New(NewOpArgs),
93 Validate(PathArgs),
94}
95
96#[derive(Subcommand)]
97enum FlowCommand {
98 New(NewFlowArgs),
99 Validate(PathArgs),
100}
101
102#[derive(Subcommand)]
103enum ResolverCommand {
104 New(NewResolverArgs),
105 Validate(PathArgs),
106}
107
108#[derive(Subcommand)]
109enum ViewCommand {
110 New(NewViewArgs),
111 Validate(PathArgs),
112}
113
114#[derive(Subcommand)]
115enum ProfileCommand {
116 Validate(PathArgs),
117 Compile(CompileProfileArgs),
118}
119
120#[derive(Subcommand)]
121enum CatalogCommand {
122 Init(CatalogInitArgs),
123 Build(CatalogBuildArgs),
124 Validate(CatalogValidateArgs),
125 List(CatalogListArgs),
126}
127
128#[derive(Subcommand, Clone)]
129enum WizardCommand {
130 Run(WizardCommonArgs),
132 Validate(WizardCommonArgs),
134 Apply(WizardCommonArgs),
136}
137
138#[derive(Args)]
139struct PathArgs {
140 path: PathBuf,
141}
142
143#[derive(Args)]
144struct NewContractArgs {
145 path: PathBuf,
146 #[arg(long)]
147 contract_id: String,
148 #[arg(long, default_value = "resource")]
149 resource_type: String,
150 #[arg(long, default_value = "v1")]
151 version: String,
152}
153
154#[derive(Args)]
155struct NewOpArgs {
156 path: PathBuf,
157 #[arg(long)]
158 operation_id: String,
159 #[arg(long, default_value = "gx.resource")]
160 contract_id: String,
161 #[arg(long, default_value = "v1")]
162 version: String,
163}
164
165#[derive(Args)]
166struct NewFlowArgs {
167 path: PathBuf,
168 #[arg(long)]
169 flow_id: String,
170 #[arg(long, default_value = "v1")]
171 version: String,
172}
173
174#[derive(Args)]
175struct NewResolverArgs {
176 path: PathBuf,
177 #[arg(long)]
178 resolver_id: String,
179 #[arg(long, default_value = "gx.resolver.result.v1")]
180 output_spec: String,
181 #[arg(long, default_value = "v1")]
182 version: String,
183}
184
185#[derive(Args)]
186struct NewViewArgs {
187 path: PathBuf,
188 #[arg(long)]
189 view_id: String,
190 #[arg(long, default_value = "summary")]
191 view_type: String,
192 #[arg(long, default_value = "gx.view.v1")]
193 spec_ref: String,
194 #[arg(long, default_value = "v1")]
195 version: String,
196}
197
198#[derive(Args)]
199struct CompileProfileArgs {
200 path: PathBuf,
201 #[arg(long)]
202 out: Option<PathBuf>,
203}
204
205#[derive(Args)]
206struct SimulateArgs {
207 path: PathBuf,
208 #[arg(long)]
209 stubs: Option<PathBuf>,
210 #[arg(long)]
211 input: Option<PathBuf>,
212}
213
214#[derive(Args)]
215struct DoctorArgs {
216 #[arg(default_value = ".")]
217 path: PathBuf,
218}
219
220#[derive(Args)]
221struct CatalogListArgs {
222 #[arg(long)]
223 kind: Option<CatalogKind>,
224}
225
226#[derive(Args)]
227struct CatalogInitArgs {
228 repo_name: PathBuf,
229 #[arg(long)]
230 title: Option<String>,
231 #[arg(long)]
232 description: Option<String>,
233 #[arg(long, default_value_t = true)]
234 include_examples: bool,
235 #[arg(long, default_value_t = true)]
236 include_publish_workflow: bool,
237}
238
239#[derive(Args)]
240struct CatalogBuildArgs {
241 #[arg(long, default_value = ".")]
242 repo: PathBuf,
243 #[arg(long, default_value_t = false)]
244 check: bool,
245}
246
247#[derive(Args)]
248struct CatalogValidateArgs {
249 #[arg(long, default_value = ".")]
250 repo: PathBuf,
251}
252
253#[derive(Args, Clone)]
254struct WizardArgs {
255 #[arg(long, default_value_t = false)]
256 schema: bool,
257 #[command(subcommand)]
258 command: Option<WizardCommand>,
259 #[command(flatten)]
260 common: WizardCommonArgs,
261}
262
263#[derive(Args, Clone)]
264struct WizardCommonArgs {
265 #[arg(long = "catalog")]
266 catalog: Vec<String>,
267 #[arg(long)]
268 answers: Option<PathBuf>,
269 #[arg(long = "emit-answers")]
270 emit_answers: Option<PathBuf>,
271 #[arg(long, default_value_t = false)]
272 dry_run: bool,
273 #[arg(long)]
274 locale: Option<String>,
275 #[arg(long)]
276 mode: Option<String>,
277 #[arg(long = "schema-version")]
278 schema_version: Option<String>,
279 #[arg(long, default_value_t = false)]
280 migrate: bool,
281 #[arg(long, default_value_t = false)]
283 bundle_handoff: bool,
284}
285
286#[derive(Clone, Copy)]
287enum WizardAction {
288 Run,
289 Validate,
290 Apply,
291}
292
293#[derive(Clone, Copy, Deserialize, Serialize)]
294#[serde(rename_all = "snake_case")]
295enum WizardExecutionMode {
296 DryRun,
297 Execute,
298}
299
300#[derive(Deserialize, Serialize)]
301struct WizardPlanEnvelope {
302 metadata: WizardPlanMetadata,
303 requested_action: String,
304 target_root: String,
305 normalized_input_summary: BTreeMap<String, Value>,
306 ordered_step_list: Vec<WizardPlanStep>,
307 expected_file_writes: Vec<String>,
308 warnings: Vec<String>,
309}
310
311#[derive(Deserialize, Serialize)]
312struct WizardPlanMetadata {
313 wizard_id: String,
314 schema_id: String,
315 schema_version: String,
316 locale: String,
317 execution: WizardExecutionMode,
318}
319
320#[derive(Deserialize, Serialize)]
321struct WizardPlanStep {
322 kind: String,
323 description: String,
324}
325
326#[derive(Clone, Debug, Deserialize, Serialize)]
327struct WizardAnswerDocument {
328 wizard_id: String,
329 schema_id: String,
330 schema_version: String,
331 locale: String,
332 #[serde(default)]
333 answers: serde_json::Map<String, Value>,
334 #[serde(default)]
335 locks: serde_json::Map<String, Value>,
336}
337
338#[derive(Clone)]
339struct CompositionRequest {
340 mode: String,
341 template_mode: String,
342 template_entry_id: Option<String>,
343 template_display_name: Option<String>,
344 assistant_template_ref: Option<String>,
345 domain_template_ref: Option<String>,
346 solution_name: String,
347 solution_id: String,
348 description: String,
349 output_dir: String,
350 provider_selection: String,
351 provider_preset_entry_id: Option<String>,
352 provider_preset_display_name: Option<String>,
353 provider_refs: Vec<String>,
354 overlay_entry_id: Option<String>,
355 overlay_display_name: Option<String>,
356 overlay_default_locale: Option<String>,
357 overlay_tenant_id: Option<String>,
358 catalog_oci_refs: Vec<String>,
359 catalog_resolution_policy: String,
360 bundle_output_path: String,
361 solution_manifest_path: String,
362 toolchain_handoff_path: String,
363 launcher_answers_path: String,
364 pack_input_path: String,
365 bundle_plan_path: String,
366 bundle_answers_path: String,
367 setup_answers_path: String,
368 gtc_setup_handoff_path: String,
369 gtc_start_handoff_path: String,
370 readme_path: String,
371 existing_solution_path: Option<String>,
372}
373
374#[derive(Clone, Debug, Deserialize, Serialize)]
375struct ResolvedSolutionIntent {
376 schema_id: String,
377 schema_version: String,
378 solution_id: String,
379 solution_name: String,
380 description: String,
381 output_dir: String,
382 #[serde(default = "default_solution_kind")]
383 solution_kind: String,
384 template: Value,
385 provider_presets: Vec<Value>,
386 #[serde(default)]
387 overlay: Option<Value>,
388 #[serde(default)]
389 catalog_refs: Vec<String>,
390 #[serde(default)]
391 catalog_sources: Vec<String>,
392 #[serde(default)]
393 required_capabilities: Vec<String>,
394 #[serde(default)]
395 required_contracts: Vec<String>,
396 #[serde(default)]
397 suggested_flows: Vec<String>,
398 #[serde(default = "default_solution_defaults")]
399 defaults: Value,
400 #[serde(default)]
401 notes: Vec<String>,
402}
403
404#[derive(Clone, Debug, Deserialize, Serialize)]
405struct CatalogProvenance {
406 source_type: String,
407 source_ref: String,
408 #[serde(default)]
409 resolved_digest: Option<String>,
410}
411
412#[derive(Clone, Debug, Deserialize, Serialize)]
413struct AssistantTemplateCatalogEntry {
414 entry_id: String,
415 kind: String,
416 version: String,
417 display_name: String,
418 #[serde(default)]
419 description: String,
420 assistant_template_ref: String,
421 #[serde(default)]
422 domain_template_ref: Option<String>,
423 #[serde(default)]
424 bundle_ref: Option<String>,
425 #[serde(default)]
426 provenance: Option<CatalogProvenance>,
427}
428
429#[derive(Clone, Debug, Deserialize, Serialize)]
430struct ProviderPresetCatalogEntry {
431 entry_id: String,
432 kind: String,
433 version: String,
434 display_name: String,
435 #[serde(default)]
436 description: String,
437 provider_refs: Vec<String>,
438 #[serde(default)]
439 bundle_ref: Option<String>,
440 #[serde(default)]
441 provenance: Option<CatalogProvenance>,
442}
443
444#[derive(Clone, Debug, Deserialize, Serialize)]
445struct OverlayCatalogEntry {
446 entry_id: String,
447 kind: String,
448 version: String,
449 display_name: String,
450 #[serde(default)]
451 description: String,
452 #[serde(default)]
453 default_locale: Option<String>,
454 #[serde(default)]
455 tenant_id: Option<String>,
456 #[serde(default)]
457 branding: Option<Value>,
458 #[serde(default)]
459 provenance: Option<CatalogProvenance>,
460}
461
462#[derive(Clone, Debug, Default)]
463struct WizardCatalogSet {
464 templates: Vec<AssistantTemplateCatalogEntry>,
465 provider_presets: Vec<ProviderPresetCatalogEntry>,
466 overlays: Vec<OverlayCatalogEntry>,
467}
468
469#[derive(Clone, Debug, Deserialize, Serialize)]
470struct BundlePlan {
471 schema_id: String,
472 schema_version: String,
473 solution_id: String,
474 bundle_output_path: String,
475 bundle_answers_path: String,
476 steps: Vec<Value>,
477}
478
479#[derive(Clone, Debug, Deserialize, Serialize)]
480struct SetupAnswers {
481 schema_id: String,
482 schema_version: String,
483 solution_id: String,
484 setup_mode: String,
485 provider_refs: Vec<String>,
486 #[serde(default)]
487 overlay: Option<Value>,
488}
489
490#[derive(Clone, Debug, Deserialize, Serialize)]
491struct DownstreamHandoffArtifacts {
492 toolchain_handoff: ToolchainHandoff,
493 pack_input: PackInputDocument,
494 bundle_plan: BundlePlan,
495 bundle_answers: WizardAnswerDocument,
496 setup_answers: SetupAnswers,
497 gtc_setup_handoff: GtcExtensionSetupHandoff,
498 gtc_start_handoff: GtcExtensionStartHandoff,
499}
500
501#[derive(Clone, Debug, Deserialize, Serialize)]
502struct ToolchainHandoff {
503 schema_id: String,
504 schema_version: String,
505 solution_id: String,
506 solution_intent_ref: String,
507 bundle_handoff: BundleHandoff,
508 #[serde(default)]
509 launcher_handoff: Option<LauncherHandoff>,
510 #[serde(default)]
511 pack_handoff: Option<PackHandoff>,
512 #[serde(default)]
513 gtc_handoff: Option<GtcHandoff>,
514 provenance: HandoffProvenance,
515 #[serde(default)]
516 locks: serde_json::Map<String, Value>,
517}
518
519#[derive(Clone, Debug, Deserialize, Serialize)]
520struct BundleHandoff {
521 tool: String,
522 bundle_output_path: String,
523 bundle_plan_path: String,
524 bundle_answers_path: String,
525 setup_answers_path: String,
526}
527
528#[derive(Clone, Debug, Deserialize, Serialize)]
529struct LauncherHandoff {
530 tool: String,
531 launcher_answers_path: String,
532 selected_action: String,
533 delegated_schema_id: String,
534 delegated_wizard_id: String,
535}
536
537#[derive(Clone, Debug, Deserialize, Serialize)]
538struct PackHandoff {
539 tool: String,
540 pack_input_path: String,
541}
542
543#[derive(Clone, Debug, Deserialize, Serialize)]
544struct GtcHandoff {
545 tool: String,
546 setup_handoff_path: String,
547 start_handoff_path: String,
548}
549
550#[derive(Clone, Debug, Deserialize, Serialize)]
551struct GtcExtensionSetupHandoff {
552 schema_id: String,
553 schema_version: String,
554 bundle_ref: String,
555 #[serde(default)]
556 answers_path: Option<String>,
557 #[serde(default)]
558 tenant: Option<String>,
559 #[serde(default)]
560 team: Option<String>,
561 #[serde(default)]
562 env: Option<String>,
563 #[serde(default)]
564 setup_args: Vec<String>,
565}
566
567#[derive(Clone, Debug, Deserialize, Serialize)]
568struct GtcExtensionStartHandoff {
569 schema_id: String,
570 schema_version: String,
571 bundle_ref: String,
572 #[serde(default)]
573 start_args: Vec<String>,
574}
575
576#[derive(Clone, Debug, Deserialize, Serialize)]
577struct PackInputDocument {
578 schema_id: String,
579 schema_version: String,
580 solution_id: String,
581 solution_intent_ref: String,
582 provider_refs: Vec<String>,
583 required_capability_offers: Vec<String>,
584 required_contracts: Vec<String>,
585 suggested_flows: Vec<String>,
586 provider_hints: Vec<PackProviderHint>,
587 template_selection: PackTemplateSelection,
588 defaults: Value,
589 unresolved_downstream_work: Vec<String>,
590 greentic_cap_mapping: Vec<PackCapabilityMapping>,
591 notes: Vec<String>,
592}
593
594#[derive(Clone, Debug, Deserialize, Serialize)]
595struct PackProviderHint {
596 #[serde(default)]
597 entry_id: Option<String>,
598 #[serde(default)]
599 display_name: Option<String>,
600 #[serde(default)]
601 provider_refs: Vec<String>,
602}
603
604#[derive(Clone, Debug, Deserialize, Serialize)]
605struct PackTemplateSelection {
606 #[serde(default)]
607 entry_id: Option<String>,
608 #[serde(default)]
609 display_name: Option<String>,
610 #[serde(default)]
611 assistant_template_ref: Option<String>,
612 #[serde(default)]
613 domain_template_ref: Option<String>,
614}
615
616#[derive(Clone, Debug, Deserialize, Serialize)]
617struct PackCapabilityMapping {
618 gx_requirement: String,
619 greentic_cap_concept: String,
620 status: String,
621}
622
623#[derive(Clone, Debug, Deserialize, Serialize)]
624struct HandoffProvenance {
625 producer: String,
626 produced_by: String,
627 ownership_boundary: String,
628}
629
630fn default_solution_kind() -> String {
631 "assistant".to_owned()
632}
633
634fn default_solution_defaults() -> Value {
635 Value::Object(serde_json::Map::new())
636}
637
638#[derive(Clone)]
639enum WizardNormalizedAnswers {
640 Composition(CompositionRequest),
641}
642
643const GX_WIZARD_ID: &str = "greentic-bundle.wizard.run";
644const GX_WIZARD_SCHEMA_ID: &str = "greentic-bundle.wizard.answers";
645const GX_WIZARD_SCHEMA_VERSION: &str = "1.0.0";
646
647#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
648enum CatalogKind {
649 Contracts,
650 Resolvers,
651 Ops,
652 Views,
653 FlowTemplates,
654}
655
656#[derive(Debug, Deserialize)]
657struct FlowPackageManifest {
658 flow_id: String,
659 version: String,
660 description: String,
661 flow: String,
662 #[serde(default)]
663 stubs: Option<String>,
664}
665
666#[derive(Debug, Deserialize)]
667struct ResolverPackageManifest {
668 resolver_id: String,
669 version: String,
670 description: String,
671 query_schema: SchemaFileRef,
672 output_spec: String,
673}
674
675#[derive(Debug, Deserialize)]
676struct ViewPackageManifest {
677 view_id: String,
678 version: String,
679 view_type: String,
680 spec_ref: String,
681 description: String,
682 template: String,
683}
684
685#[derive(Debug, Deserialize)]
686struct SchemaFileRef {
687 schema_id: String,
688 version: String,
689 #[serde(default)]
690 uri: Option<String>,
691}
692
693#[derive(Debug, Deserialize, Default)]
694struct SimulationStubs {
695 #[serde(default)]
696 operations: Vec<OperationStub>,
697 #[serde(default)]
698 resolvers: Vec<ResolverStub>,
699}
700
701#[derive(Debug, Deserialize)]
702struct OperationStub {
703 operation_id: String,
704 #[serde(default)]
705 invocation_id: Option<String>,
706 output: Value,
707 #[serde(default)]
708 evidence: Vec<EvidenceItem>,
709 #[serde(default)]
710 warnings: Vec<String>,
711}
712
713#[derive(Debug, Deserialize)]
714struct ResolverStub {
715 resolver_id: String,
716 status: ResolverStatus,
717 #[serde(default)]
718 selected: Option<ResolverStubCandidate>,
719 #[serde(default)]
720 candidates: Vec<ResolverStubCandidate>,
721 #[serde(default)]
722 warnings: Vec<String>,
723}
724
725#[derive(Debug, Deserialize)]
726struct ResolverStubCandidate {
727 resource: ResourceRef,
728 #[serde(default)]
729 display: Option<String>,
730 #[serde(default)]
731 confidence: Option<f64>,
732 #[serde(default)]
733 metadata: Option<Value>,
734}
735
736#[derive(Debug, Deserialize)]
737struct LegacyCatalogIndex {
738 #[serde(default)]
739 entries: Vec<Value>,
740}
741
742#[derive(Clone, Debug, Deserialize, Serialize)]
743struct RootCatalogIndex {
744 schema: String,
745 id: String,
746 version: String,
747 title: String,
748 #[serde(default)]
749 description: String,
750 #[serde(default)]
751 entries: Vec<RootCatalogEntry>,
752}
753
754#[derive(Clone, Debug, Deserialize, Serialize)]
755struct RootCatalogEntry {
756 id: String,
757 kind: String,
758 #[serde(rename = "ref")]
759 ref_path: String,
760 #[serde(default)]
761 title: String,
762 #[serde(default)]
763 description: String,
764 #[serde(default)]
765 tags: Vec<String>,
766 #[serde(default)]
767 version: String,
768 #[serde(default)]
769 source: String,
770 #[serde(default)]
771 metadata: Value,
772}
773
774#[derive(Default)]
775struct Diagnostics {
776 warnings: Vec<String>,
777 errors: Vec<String>,
778}
779
780impl Diagnostics {
781 fn warning(&mut self, message: impl Into<String>) {
782 self.warnings.push(message.into());
783 }
784
785 fn error(&mut self, message: impl Into<String>) {
786 self.errors.push(message.into());
787 }
788
789 fn extend(&mut self, other: Diagnostics) {
790 self.warnings.extend(other.warnings);
791 self.errors.extend(other.errors);
792 }
793
794 fn into_result(self, ok_message: impl Into<String>) -> Result<String, String> {
795 if self.errors.is_empty() {
796 let mut lines = vec![ok_message.into()];
797 if !self.warnings.is_empty() {
798 lines.push(format!("warnings: {}", self.warnings.len()));
799 for warning in self.warnings {
800 lines.push(format!("- {warning}"));
801 }
802 }
803 Ok(lines.join("\n"))
804 } else {
805 let mut lines = vec![format!("errors: {}", self.errors.len())];
806 for error in self.errors {
807 lines.push(format!("- {error}"));
808 }
809 if !self.warnings.is_empty() {
810 lines.push(format!("warnings: {}", self.warnings.len()));
811 for warning in self.warnings {
812 lines.push(format!("- {warning}"));
813 }
814 }
815 Err(lines.join("\n"))
816 }
817 }
818}
819
820pub fn run<I>(args: I, cwd: std::io::Result<PathBuf>) -> Result<String, String>
821where
822 I: IntoIterator<Item = OsString>,
823{
824 let cwd = cwd.map_err(|err| format!("failed to determine current directory: {err}"))?;
825 let argv = args.into_iter().collect::<Vec<_>>();
826 if let Some(help) = maybe_render_top_level_help(&argv) {
827 return Ok(help);
828 }
829 let cli = match Cli::try_parse_from(&argv) {
830 Ok(cli) => cli,
831 Err(err)
832 if matches!(
833 err.kind(),
834 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
835 ) =>
836 {
837 return Ok(err.to_string().trim_end().to_owned());
838 }
839 Err(err) => return Err(err.to_string()),
840 };
841 run_command(cli.command, &cwd)
842}
843
844fn maybe_render_top_level_help(args: &[OsString]) -> Option<String> {
845 if !requests_help(args) || first_subcommand(args).is_some() {
846 return None;
847 }
848 let locale = i18n::resolve_locale(parse_locale_arg(args).as_deref(), None);
849 Some(render_top_level_help(&locale))
850}
851
852fn requests_help(args: &[OsString]) -> bool {
853 args.iter()
854 .skip(1)
855 .filter_map(|arg| arg.to_str())
856 .any(|arg| arg == "--help" || arg == "-h")
857}
858
859fn parse_locale_arg(args: &[OsString]) -> Option<String> {
860 let mut values = args.iter().skip(1);
861 while let Some(arg) = values.next() {
862 let Some(text) = arg.to_str() else {
863 continue;
864 };
865 if let Some(value) = text.strip_prefix("--locale=") {
866 return Some(value.to_owned());
867 }
868 if text == "--locale" {
869 return values
870 .next()
871 .and_then(|value| value.to_str())
872 .map(ToOwned::to_owned);
873 }
874 }
875 None
876}
877
878fn first_subcommand(args: &[OsString]) -> Option<String> {
879 let mut values = args.iter().skip(1);
880 while let Some(arg) = values.next() {
881 let Some(text) = arg.to_str() else {
882 continue;
883 };
884 if text == "--locale" {
885 let _ = values.next();
886 continue;
887 }
888 if text.starts_with("--locale=") || text == "--help" || text == "-h" {
889 continue;
890 }
891 if text.starts_with('-') {
892 continue;
893 }
894 return Some(text.to_owned());
895 }
896 None
897}
898
899fn render_top_level_help(locale: &str) -> String {
900 let commands = [
901 (
902 "contract",
903 i18n::tr(locale, "cli.help.command.contract.description"),
904 ),
905 ("op", i18n::tr(locale, "cli.help.command.op.description")),
906 (
907 "flow",
908 i18n::tr(locale, "cli.help.command.flow.description"),
909 ),
910 (
911 "resolver",
912 i18n::tr(locale, "cli.help.command.resolver.description"),
913 ),
914 (
915 "view",
916 i18n::tr(locale, "cli.help.command.view.description"),
917 ),
918 (
919 "profile",
920 i18n::tr(locale, "cli.help.command.profile.description"),
921 ),
922 (
923 "simulate",
924 i18n::tr(locale, "cli.help.command.simulate.description"),
925 ),
926 (
927 "doctor",
928 i18n::tr(locale, "cli.help.command.doctor.description"),
929 ),
930 (
931 "catalog",
932 i18n::tr(locale, "cli.help.command.catalog.description"),
933 ),
934 (
935 "wizard",
936 i18n::tr(locale, "cli.help.command.wizard.description"),
937 ),
938 (
939 "help",
940 i18n::tr(locale, "cli.help.command.help.description"),
941 ),
942 ];
943
944 let command_width = commands
945 .iter()
946 .map(|(name, _)| name.len())
947 .max()
948 .unwrap_or(4)
949 + 2;
950
951 let mut lines = vec![
952 i18n::tr(locale, "cli.help.about"),
953 String::new(),
954 format!(
955 "{} greentic-x [OPTIONS] <COMMAND>",
956 i18n::tr(locale, "cli.help.usage")
957 ),
958 String::new(),
959 i18n::tr(locale, "cli.help.commands"),
960 ];
961
962 for (name, description) in commands {
963 lines.push(format!(" {name:command_width$}{description}"));
964 }
965
966 lines.push(String::new());
967 lines.push(i18n::tr(locale, "cli.help.options"));
968 lines.push(format!(
969 " -h, --help{}{}",
970 " ".repeat(13),
971 i18n::tr(locale, "cli.help.option.help")
972 ));
973 lines.push(format!(
974 " --locale <LOCALE>{}{}",
975 " ".repeat(7),
976 i18n::tr(locale, "cli.help.option.locale")
977 ));
978 lines.join("\n")
979}
980
981fn run_command(command: Command, cwd: &Path) -> Result<String, String> {
982 match command {
983 Command::Contract {
984 command: ContractCommand::New(args),
985 } => {
986 let path = cwd.join(&args.path);
987 scaffold_contract(path, args)
988 }
989 Command::Contract {
990 command: ContractCommand::Validate(args),
991 } => validate_contract_dir(&cwd.join(args.path)).into_result("contract validation passed"),
992 Command::Op {
993 command: OpCommand::New(args),
994 } => {
995 let path = cwd.join(&args.path);
996 scaffold_op(path, args)
997 }
998 Command::Op {
999 command: OpCommand::Validate(args),
1000 } => validate_op_dir(&cwd.join(args.path)).into_result("op validation passed"),
1001 Command::Flow {
1002 command: FlowCommand::New(args),
1003 } => {
1004 let path = cwd.join(&args.path);
1005 scaffold_flow(path, args)
1006 }
1007 Command::Flow {
1008 command: FlowCommand::Validate(args),
1009 } => validate_flow_package(&cwd.join(args.path)).into_result("flow validation passed"),
1010 Command::Resolver {
1011 command: ResolverCommand::New(args),
1012 } => {
1013 let path = cwd.join(&args.path);
1014 scaffold_resolver(path, args)
1015 }
1016 Command::Resolver {
1017 command: ResolverCommand::Validate(args),
1018 } => validate_resolver_dir(&cwd.join(args.path)).into_result("resolver validation passed"),
1019 Command::View {
1020 command: ViewCommand::New(args),
1021 } => {
1022 let path = cwd.join(&args.path);
1023 scaffold_view(path, args)
1024 }
1025 Command::View {
1026 command: ViewCommand::Validate(args),
1027 } => validate_view_dir(&cwd.join(args.path)).into_result("view validation passed"),
1028 Command::Profile {
1029 command: ProfileCommand::Validate(args),
1030 } => validate_profile_file(&cwd.join(args.path)).into_result("profile validation passed"),
1031 Command::Profile {
1032 command: ProfileCommand::Compile(args),
1033 } => compile_profile_path(&cwd.join(args.path), args.out.map(|path| cwd.join(path))),
1034 Command::Simulate(args) => simulate_flow(
1035 &cwd.join(args.path),
1036 args.stubs.map(|path| cwd.join(path)),
1037 args.input.map(|path| cwd.join(path)),
1038 ),
1039 Command::Doctor(args) => doctor(&cwd.join(args.path)),
1040 Command::Catalog {
1041 command: CatalogCommand::Init(args),
1042 } => {
1043 let path = cwd.join(&args.repo_name);
1044 catalog_repo::init_catalog_repo(
1045 &path,
1046 &path_file_name(&path),
1047 args.title,
1048 args.description,
1049 args.include_examples,
1050 args.include_publish_workflow,
1051 )
1052 }
1053 Command::Catalog {
1054 command: CatalogCommand::Build(args),
1055 } => catalog_repo::build_catalog_repo(&cwd.join(args.repo), args.check),
1056 Command::Catalog {
1057 command: CatalogCommand::Validate(args),
1058 } => catalog_repo::validate_catalog_repo(&cwd.join(args.repo)),
1059 Command::Catalog {
1060 command: CatalogCommand::List(args),
1061 } => list_catalog(cwd, args.kind),
1062 Command::Wizard(args) => {
1063 if args.schema {
1064 wizard::render_answer_schema(cwd, args.common)
1065 } else {
1066 match args.command {
1067 Some(WizardCommand::Run(common)) => {
1068 wizard::run_wizard(cwd, WizardAction::Run, common)
1069 }
1070 Some(WizardCommand::Validate(common)) => {
1071 wizard::run_wizard(cwd, WizardAction::Validate, common)
1072 }
1073 Some(WizardCommand::Apply(common)) => {
1074 wizard::run_wizard(cwd, WizardAction::Apply, common)
1075 }
1076 None => wizard::run_default_wizard(cwd, args.common),
1077 }
1078 }
1079 }
1080 }
1081}
1082
1083fn scaffold_contract(path: PathBuf, args: NewContractArgs) -> Result<String, String> {
1084 ensure_scaffold_dir(&path)?;
1085 write_json(
1086 &path.join("contract.json"),
1087 &json!({
1088 "contract_id": args.contract_id,
1089 "version": args.version,
1090 "description": "Describe the generic purpose of this contract.",
1091 "resources": [{
1092 "resource_type": args.resource_type,
1093 "schema": {
1094 "schema_id": format!("greentic-x://contracts/{}/resources/{}", path_file_name(&path), "resource"),
1095 "version": "v1",
1096 "uri": "schemas/resource.schema.json"
1097 },
1098 "patch_rules": [{"path": "/title", "kind": "allow"}],
1099 "append_collections": [],
1100 "transitions": [{"from_state": "new", "to_state": "ready"}]
1101 }],
1102 "compatibility": [{
1103 "schema": {
1104 "schema_id": format!("greentic-x://contracts/{}/compatibility", path_file_name(&path)),
1105 "version": "v1"
1106 },
1107 "mode": "backward_compatible"
1108 }],
1109 "event_declarations": [{"event_type": "resource_created"}]
1110 }),
1111 )?;
1112 write_json(
1113 &path.join("schemas/resource.schema.json"),
1114 &json!({
1115 "$schema": "https://json-schema.org/draft/2020-12/schema",
1116 "title": "Generic resource",
1117 "type": "object",
1118 "required": ["title", "state"],
1119 "properties": {
1120 "title": {"type": "string"},
1121 "state": {"type": "string"}
1122 }
1123 }),
1124 )?;
1125 write_json(
1126 &path.join("examples/resource.json"),
1127 &json!({
1128 "title": "Example resource",
1129 "state": "new"
1130 }),
1131 )?;
1132 fs::write(
1133 path.join("README.md"),
1134 "# Contract Package\n\nFill in the contract description, schemas, and examples before publishing.\n",
1135 )
1136 .map_err(|err| format!("failed to write README: {err}"))?;
1137 Ok(format!("scaffolded contract at {}", path.display()))
1138}
1139
1140fn scaffold_op(path: PathBuf, args: NewOpArgs) -> Result<String, String> {
1141 ensure_scaffold_dir(&path)?;
1142 write_json(
1143 &path.join("op.json"),
1144 &json!({
1145 "operation_id": args.operation_id,
1146 "version": args.version,
1147 "description": "Describe the generic purpose of this operation.",
1148 "input_schema": {
1149 "schema_id": format!("greentic-x://ops/{}/input", path_file_name(&path)),
1150 "version": "v1",
1151 "uri": "schemas/input.schema.json"
1152 },
1153 "output_schema": {
1154 "schema_id": format!("greentic-x://ops/{}/output", path_file_name(&path)),
1155 "version": "v1",
1156 "uri": "schemas/output.schema.json"
1157 },
1158 "supported_contracts": [{
1159 "contract_id": args.contract_id,
1160 "version": "v1"
1161 }],
1162 "permissions": [{
1163 "capability": "resource:read",
1164 "scope": "generic"
1165 }],
1166 "examples": [{
1167 "name": "basic invocation",
1168 "input": {"title": "Example resource"},
1169 "output": {"summary": "Example result"}
1170 }]
1171 }),
1172 )?;
1173 write_json(
1174 &path.join("schemas/input.schema.json"),
1175 &json!({
1176 "$schema": "https://json-schema.org/draft/2020-12/schema",
1177 "type": "object",
1178 "properties": {
1179 "title": {"type": "string"}
1180 }
1181 }),
1182 )?;
1183 write_json(
1184 &path.join("schemas/output.schema.json"),
1185 &json!({
1186 "$schema": "https://json-schema.org/draft/2020-12/schema",
1187 "type": "object",
1188 "properties": {
1189 "summary": {"type": "string"}
1190 }
1191 }),
1192 )?;
1193 write_json(
1194 &path.join("examples/example.json"),
1195 &json!({
1196 "input": {"title": "Example resource"},
1197 "output": {"summary": "Example result"}
1198 }),
1199 )?;
1200 fs::write(
1201 path.join("source.md"),
1202 "# Source Notes\n\nDocument where the operation logic will come from and any downstream adapters it needs.\n",
1203 )
1204 .map_err(|err| format!("failed to write source notes: {err}"))?;
1205 fs::write(
1206 path.join("README.md"),
1207 "# Operation Package\n\nFill in schemas, examples, and downstream adapter details before packaging.\n",
1208 )
1209 .map_err(|err| format!("failed to write README: {err}"))?;
1210 Ok(format!("scaffolded op at {}", path.display()))
1211}
1212
1213fn scaffold_flow(path: PathBuf, args: NewFlowArgs) -> Result<String, String> {
1214 ensure_scaffold_dir(&path)?;
1215 let operation_id = OperationId::new("present.summary")
1216 .map_err(|err| format!("failed to build scaffold operation id: {err}"))?;
1217 let flow = FlowDefinition {
1218 flow_id: args.flow_id.clone(),
1219 steps: vec![
1220 Step::call(
1221 "present",
1222 OperationCallStep::new(
1223 operation_id,
1224 json!({ "summary": "Example summary" }),
1225 "present_result",
1226 ),
1227 ),
1228 Step::return_output(
1229 "return",
1230 ReturnStep::new(ValueSource::context("present_result.output")).with_render(
1231 RenderSpec {
1232 renderer_id: "noop.summary".to_owned(),
1233 source: RenderSource::EvidenceRefs,
1234 view_id: "summary-card".to_owned(),
1235 title: "Simulation Summary".to_owned(),
1236 summary: "Rendered from the final flow output".to_owned(),
1237 },
1238 ),
1239 ),
1240 ],
1241 };
1242 write_json(
1243 &path.join("manifest.json"),
1244 &json!({
1245 "flow_id": args.flow_id,
1246 "version": args.version,
1247 "description": "Generic GX flow scaffold with stubbed simulation data.",
1248 "flow": "flow.json",
1249 "stubs": "stubs.json"
1250 }),
1251 )?;
1252 let flow_value = serde_json::to_value(flow)
1253 .map_err(|err| format!("failed to serialize flow scaffold: {err}"))?;
1254 write_json(&path.join("flow.json"), &flow_value)?;
1255 write_json(
1256 &path.join("stubs.json"),
1257 &json!({
1258 "operations": [{
1259 "operation_id": "present.summary",
1260 "output": { "summary": "Example summary" },
1261 "evidence": [{
1262 "evidence_id": "evidence-1",
1263 "evidence_type": "summary",
1264 "producer": "present.summary",
1265 "timestamp": "2026-01-01T00:00:00Z",
1266 "summary": "Example evidence emitted during simulation"
1267 }]
1268 }]
1269 }),
1270 )?;
1271 fs::write(
1272 path.join("README.md"),
1273 "# Flow Package\n\nUse `gx flow validate` and `gx simulate` while iterating on this flow.\n",
1274 )
1275 .map_err(|err| format!("failed to write README: {err}"))?;
1276 Ok(format!("scaffolded flow at {}", path.display()))
1277}
1278
1279fn scaffold_resolver(path: PathBuf, args: NewResolverArgs) -> Result<String, String> {
1280 ensure_scaffold_dir(&path)?;
1281 write_json(
1282 &path.join("resolver.json"),
1283 &json!({
1284 "resolver_id": args.resolver_id,
1285 "version": args.version,
1286 "description": "Describe what this resolver matches and how downstream adapters should implement it.",
1287 "query_schema": {
1288 "schema_id": format!("greentic-x://resolvers/{}/query", path_file_name(&path)),
1289 "version": "v1",
1290 "uri": "schemas/query.schema.json"
1291 },
1292 "output_spec": args.output_spec
1293 }),
1294 )?;
1295 write_json(
1296 &path.join("schemas/query.schema.json"),
1297 &json!({
1298 "$schema": "https://json-schema.org/draft/2020-12/schema",
1299 "type": "object",
1300 "properties": {
1301 "query": {"type": "string"}
1302 }
1303 }),
1304 )?;
1305 write_json(
1306 &path.join("examples/query.json"),
1307 &json!({
1308 "query": "example"
1309 }),
1310 )?;
1311 fs::write(
1312 path.join("README.md"),
1313 "# Resolver Package\n\nDocument the matching strategy, evidence sources, and downstream adapter requirements.\n",
1314 )
1315 .map_err(|err| format!("failed to write README: {err}"))?;
1316 Ok(format!("scaffolded resolver at {}", path.display()))
1317}
1318
1319fn scaffold_view(path: PathBuf, args: NewViewArgs) -> Result<String, String> {
1320 ensure_scaffold_dir(&path)?;
1321 write_json(
1322 &path.join("view.json"),
1323 &json!({
1324 "view_id": args.view_id,
1325 "version": args.version,
1326 "view_type": args.view_type,
1327 "spec_ref": args.spec_ref,
1328 "description": "Describe the neutral view and downstream channel mappings.",
1329 "template": "template.json"
1330 }),
1331 )?;
1332 write_json(
1333 &path.join("template.json"),
1334 &json!({
1335 "title": "Replace with a neutral title template",
1336 "summary": "Replace with a neutral summary template",
1337 "body": {
1338 "kind": "table",
1339 "columns": ["name", "value"]
1340 }
1341 }),
1342 )?;
1343 fs::write(
1344 path.join("README.md"),
1345 "# View Package\n\nDocument how this neutral view maps into downstream channels without coupling GX to one UI surface.\n",
1346 )
1347 .map_err(|err| format!("failed to write README: {err}"))?;
1348 Ok(format!("scaffolded view at {}", path.display()))
1349}
1350
1351fn validate_contract_dir(path: &Path) -> Diagnostics {
1352 let mut diagnostics = Diagnostics::default();
1353 let manifest_path = path.join("contract.json");
1354 let manifest = match read_json::<ContractManifest>(&manifest_path) {
1355 Ok(manifest) => manifest,
1356 Err(err) => {
1357 diagnostics.error(err);
1358 return diagnostics;
1359 }
1360 };
1361 if manifest.version.as_str().is_empty() {
1362 diagnostics.error(format!(
1363 "{}: version must not be empty",
1364 manifest_path.display()
1365 ));
1366 }
1367 for issue in manifest.validate() {
1368 diagnostics.error(format!("{}: {:?}", manifest_path.display(), issue));
1369 }
1370 for resource in &manifest.resources {
1371 check_schema_uri(
1372 path,
1373 resource.schema.uri.as_deref(),
1374 "resource schema",
1375 &mut diagnostics,
1376 );
1377 for collection in &resource.append_collections {
1378 check_schema_uri(
1379 path,
1380 collection.item_schema.uri.as_deref(),
1381 "append collection schema",
1382 &mut diagnostics,
1383 );
1384 }
1385 }
1386 check_examples_dir(path, &mut diagnostics);
1387 diagnostics
1388}
1389
1390fn validate_op_dir(path: &Path) -> Diagnostics {
1391 let mut diagnostics = Diagnostics::default();
1392 let manifest_path = path.join("op.json");
1393 let manifest = match read_json::<OperationManifest>(&manifest_path) {
1394 Ok(manifest) => manifest,
1395 Err(err) => {
1396 diagnostics.error(err);
1397 return diagnostics;
1398 }
1399 };
1400 if manifest.version.as_str().is_empty() {
1401 diagnostics.error(format!(
1402 "{}: version must not be empty",
1403 manifest_path.display()
1404 ));
1405 }
1406 for issue in manifest.validate() {
1407 diagnostics.error(format!("{}: {:?}", manifest_path.display(), issue));
1408 }
1409 check_schema_uri(
1410 path,
1411 manifest.input_schema.uri.as_deref(),
1412 "input schema",
1413 &mut diagnostics,
1414 );
1415 check_schema_uri(
1416 path,
1417 manifest.output_schema.uri.as_deref(),
1418 "output schema",
1419 &mut diagnostics,
1420 );
1421 check_examples_dir(path, &mut diagnostics);
1422 diagnostics
1423}
1424
1425fn validate_flow_package(path: &Path) -> Diagnostics {
1426 let mut diagnostics = Diagnostics::default();
1427 let (package_root, manifest) = match read_flow_manifest(path) {
1428 Ok(value) => value,
1429 Err(err) => {
1430 diagnostics.error(err);
1431 return diagnostics;
1432 }
1433 };
1434 if manifest.version.trim().is_empty() {
1435 diagnostics.error(format!(
1436 "{}: version metadata is missing",
1437 package_root.join("manifest.json").display()
1438 ));
1439 }
1440 if manifest.description.trim().is_empty() {
1441 diagnostics.warning(format!(
1442 "{}: description is empty",
1443 package_root.join("manifest.json").display()
1444 ));
1445 }
1446 let flow_path = package_root.join(&manifest.flow);
1447 let flow = match read_json::<FlowDefinition>(&flow_path) {
1448 Ok(flow) => flow,
1449 Err(err) => {
1450 diagnostics.error(err);
1451 return diagnostics;
1452 }
1453 };
1454 if flow.flow_id != manifest.flow_id {
1455 diagnostics.error(format!(
1456 "{}: flow_id {} does not match manifest flow_id {}",
1457 flow_path.display(),
1458 flow.flow_id,
1459 manifest.flow_id
1460 ));
1461 }
1462 diagnostics.extend(validate_flow_definition(&flow, &flow_path));
1463 if let Some(stubs) = manifest.stubs.as_deref() {
1464 let stubs_path = package_root.join(stubs);
1465 if !stubs_path.exists() {
1466 diagnostics.error(format!(
1467 "{}: declared stubs file does not exist",
1468 stubs_path.display()
1469 ));
1470 } else if let Err(err) = read_json::<SimulationStubs>(&stubs_path) {
1471 diagnostics.error(err);
1472 }
1473 }
1474 diagnostics
1475}
1476
1477fn validate_resolver_dir(path: &Path) -> Diagnostics {
1478 let mut diagnostics = Diagnostics::default();
1479 let manifest_path = path.join("resolver.json");
1480 let manifest = match read_json::<ResolverPackageManifest>(&manifest_path) {
1481 Ok(manifest) => manifest,
1482 Err(err) => {
1483 diagnostics.error(err);
1484 return diagnostics;
1485 }
1486 };
1487 if manifest.resolver_id.trim().is_empty() {
1488 diagnostics.error(format!(
1489 "{}: resolver_id must not be empty",
1490 manifest_path.display()
1491 ));
1492 }
1493 if manifest.version.trim().is_empty() {
1494 diagnostics.error(format!(
1495 "{}: version must not be empty",
1496 manifest_path.display()
1497 ));
1498 }
1499 if manifest.description.trim().is_empty() {
1500 diagnostics.warning(format!("{}: description is empty", manifest_path.display()));
1501 }
1502 if manifest.output_spec.trim().is_empty() {
1503 diagnostics.error(format!(
1504 "{}: output_spec must not be empty",
1505 manifest_path.display()
1506 ));
1507 }
1508 if manifest.query_schema.schema_id.trim().is_empty() {
1509 diagnostics.error(format!(
1510 "{}: query_schema.schema_id must not be empty",
1511 manifest_path.display()
1512 ));
1513 }
1514 if manifest.query_schema.version.trim().is_empty() {
1515 diagnostics.error(format!(
1516 "{}: query_schema.version must not be empty",
1517 manifest_path.display()
1518 ));
1519 }
1520 check_schema_uri(
1521 path,
1522 manifest.query_schema.uri.as_deref(),
1523 "query schema",
1524 &mut diagnostics,
1525 );
1526 if let Some(uri) = manifest.query_schema.uri.as_deref() {
1527 check_json_schema_file(&path.join(uri), "query schema", &mut diagnostics);
1528 }
1529 check_examples_dir(path, &mut diagnostics);
1530 diagnostics
1531}
1532
1533fn validate_view_dir(path: &Path) -> Diagnostics {
1534 let mut diagnostics = Diagnostics::default();
1535 let manifest_path = path.join("view.json");
1536 let manifest = match read_json::<ViewPackageManifest>(&manifest_path) {
1537 Ok(manifest) => manifest,
1538 Err(err) => {
1539 diagnostics.error(err);
1540 return diagnostics;
1541 }
1542 };
1543 if manifest.view_id.trim().is_empty() {
1544 diagnostics.error(format!(
1545 "{}: view_id must not be empty",
1546 manifest_path.display()
1547 ));
1548 }
1549 if manifest.version.trim().is_empty() {
1550 diagnostics.error(format!(
1551 "{}: version must not be empty",
1552 manifest_path.display()
1553 ));
1554 }
1555 if manifest.view_type.trim().is_empty() {
1556 diagnostics.error(format!(
1557 "{}: view_type must not be empty",
1558 manifest_path.display()
1559 ));
1560 }
1561 if manifest.spec_ref.trim().is_empty() {
1562 diagnostics.error(format!(
1563 "{}: spec_ref must not be empty",
1564 manifest_path.display()
1565 ));
1566 }
1567 if manifest.description.trim().is_empty() {
1568 diagnostics.warning(format!("{}: description is empty", manifest_path.display()));
1569 }
1570 let template_path = path.join(&manifest.template);
1571 if !template_path.exists() {
1572 diagnostics.error(format!(
1573 "{}: template file {} does not exist",
1574 manifest_path.display(),
1575 template_path.display()
1576 ));
1577 } else {
1578 match read_json::<Value>(&template_path) {
1579 Ok(template) => {
1580 if template.get("title").and_then(Value::as_str).is_none() {
1581 diagnostics.error(format!(
1582 "{}: template must contain a string title",
1583 template_path.display()
1584 ));
1585 }
1586 if template.get("summary").and_then(Value::as_str).is_none() {
1587 diagnostics.error(format!(
1588 "{}: template must contain a string summary",
1589 template_path.display()
1590 ));
1591 }
1592 }
1593 Err(err) => diagnostics.error(err),
1594 }
1595 }
1596 diagnostics
1597}
1598
1599fn validate_profile_file(path: &Path) -> Diagnostics {
1600 let mut diagnostics = Diagnostics::default();
1601 let profile = match read_profile(path) {
1602 Ok(profile) => profile,
1603 Err(err) => {
1604 diagnostics.error(err);
1605 return diagnostics;
1606 }
1607 };
1608 for issue in validate_profile(&profile) {
1609 diagnostics.error(format!("{}: {}", path.display(), issue));
1610 }
1611 diagnostics
1612}
1613
1614fn compile_profile_path(path: &Path, out: Option<PathBuf>) -> Result<String, String> {
1615 let profile = read_profile(path)?;
1616 let flow = compile_profile(&profile)?;
1617 let output = serde_json::to_value(&flow)
1618 .map_err(|err| format!("failed to serialize compiled flow: {err}"))?;
1619 match out {
1620 Some(path) => {
1621 write_json(&path, &output)?;
1622 Ok(format!("compiled profile to {}", path.display()))
1623 }
1624 None => serde_json::to_string_pretty(&output)
1625 .map_err(|err| format!("failed to render compiled flow: {err}")),
1626 }
1627}
1628
1629fn validate_flow_definition(flow: &FlowDefinition, flow_path: &Path) -> Diagnostics {
1630 let mut diagnostics = Diagnostics::default();
1631 let mut ids = BTreeSet::new();
1632 let mut split_ids = BTreeSet::new();
1633 let mut has_return = false;
1634 for step in &flow.steps {
1635 if !ids.insert(step.id.clone()) {
1636 diagnostics.error(format!(
1637 "{}: duplicate step id {}",
1638 flow_path.display(),
1639 step.id
1640 ));
1641 }
1642 match &step.kind {
1643 greentic_x_flow::StepKind::Branch(branch) => {
1644 for case in &branch.cases {
1645 if !flow
1646 .steps
1647 .iter()
1648 .any(|candidate| candidate.id == case.next_step_id)
1649 {
1650 diagnostics.error(format!(
1651 "{}: branch {} references missing step {}",
1652 flow_path.display(),
1653 step.id,
1654 case.next_step_id
1655 ));
1656 }
1657 }
1658 if let Some(default) = &branch.default_next_step_id
1659 && !flow.steps.iter().any(|candidate| candidate.id == *default)
1660 {
1661 diagnostics.error(format!(
1662 "{}: branch {} default references missing step {}",
1663 flow_path.display(),
1664 step.id,
1665 default
1666 ));
1667 }
1668 }
1669 greentic_x_flow::StepKind::Split(split) => {
1670 split_ids.insert(step.id.clone());
1671 let mut branch_ids = BTreeSet::new();
1672 for branch in &split.branches {
1673 if !branch_ids.insert(branch.branch_id.clone()) {
1674 diagnostics.error(format!(
1675 "{}: split {} has duplicate branch id {}",
1676 flow_path.display(),
1677 step.id,
1678 branch.branch_id
1679 ));
1680 }
1681 let mut nested_ids = BTreeSet::new();
1682 for nested in &branch.steps {
1683 if !nested_ids.insert(nested.id.clone()) {
1684 diagnostics.error(format!(
1685 "{}: split {} branch {} has duplicate nested step id {}",
1686 flow_path.display(),
1687 step.id,
1688 branch.branch_id,
1689 nested.id
1690 ));
1691 }
1692 }
1693 }
1694 }
1695 greentic_x_flow::StepKind::Join(join) => {
1696 if !split_ids.contains(&join.split_step_id) {
1697 diagnostics.error(format!(
1698 "{}: join {} references missing or later split {}",
1699 flow_path.display(),
1700 step.id,
1701 join.split_step_id
1702 ));
1703 }
1704 }
1705 greentic_x_flow::StepKind::Return(return_step) => {
1706 has_return = true;
1707 if let Some(render) = &return_step.render {
1708 if render.renderer_id.trim().is_empty() {
1709 diagnostics.error(format!(
1710 "{}: return {} has empty renderer_id",
1711 flow_path.display(),
1712 step.id
1713 ));
1714 }
1715 if render.view_id.trim().is_empty() {
1716 diagnostics.error(format!(
1717 "{}: return {} has empty view_id",
1718 flow_path.display(),
1719 step.id
1720 ));
1721 }
1722 }
1723 }
1724 _ => {}
1725 }
1726 }
1727 if !has_return {
1728 diagnostics.error(format!(
1729 "{}: flow must include at least one return step",
1730 flow_path.display()
1731 ));
1732 }
1733 diagnostics
1734}
1735
1736fn simulate_flow(
1737 path: &Path,
1738 stubs_override: Option<PathBuf>,
1739 input_override: Option<PathBuf>,
1740) -> Result<String, String> {
1741 let (package_root, manifest) = read_flow_manifest(path)?;
1742 let flow_path = package_root.join(&manifest.flow);
1743 let flow = read_json::<FlowDefinition>(&flow_path)?;
1744 let input = match input_override {
1745 Some(path) => read_json::<Value>(&path)?,
1746 None => {
1747 let default_input = package_root.join("input.json");
1748 if default_input.exists() {
1749 read_json::<Value>(&default_input)?
1750 } else {
1751 json!({})
1752 }
1753 }
1754 };
1755 let stubs_path = match stubs_override {
1756 Some(path) => path,
1757 None => package_root.join(
1758 manifest
1759 .stubs
1760 .as_deref()
1761 .ok_or_else(|| format!("{}: no stubs file configured", flow_path.display()))?,
1762 ),
1763 };
1764 let stubs = read_json::<SimulationStubs>(&stubs_path)?;
1765 let mut operations = HashMap::new();
1766 for stub in stubs.operations {
1767 let operation_id = OperationId::new(stub.operation_id.clone())
1768 .map_err(|err| format!("invalid operation id {}: {err}", stub.operation_id))?;
1769 operations.insert(
1770 stub.operation_id.clone(),
1771 OperationResult {
1772 envelope: greentic_x_types::OperationResultEnvelope {
1773 invocation_id: stub
1774 .invocation_id
1775 .unwrap_or_else(|| format!("invoke-{}", stub.operation_id)),
1776 operation_id,
1777 status: InvocationStatus::Succeeded,
1778 output: Some(stub.output),
1779 evidence_refs: Vec::new(),
1780 warnings: stub.warnings,
1781 view_hints: Vec::new(),
1782 },
1783 evidence: stub.evidence,
1784 },
1785 );
1786 }
1787 let mut resolvers = HashMap::new();
1788 for stub in stubs.resolvers {
1789 let resolver_id = ResolverId::new(stub.resolver_id.clone())
1790 .map_err(|err| format!("invalid resolver id {}: {err}", stub.resolver_id))?;
1791 resolvers.insert(
1792 stub.resolver_id,
1793 ResolverResultEnvelope {
1794 resolver_id,
1795 status: stub.status,
1796 selected: stub.selected.map(into_candidate),
1797 candidates: stub.candidates.into_iter().map(into_candidate).collect(),
1798 warnings: stub.warnings,
1799 },
1800 );
1801 }
1802
1803 let provenance = Provenance::new(
1804 ActorRef::service("gx-cli").map_err(|err| format!("invalid actor id gx-cli: {err}"))?,
1805 );
1806 let mut runtime = StaticFlowRuntime::with_operations(operations);
1807 for (resolver_id, result) in resolvers {
1808 runtime.insert_resolver(resolver_id, result);
1809 }
1810 let mut evidence_store = greentic_x_flow::InMemoryEvidenceStore::default();
1811 let mut engine = FlowEngine::default();
1812 let run = engine
1813 .execute(
1814 &flow,
1815 input,
1816 provenance,
1817 &mut runtime,
1818 &mut evidence_store,
1819 &NoopViewRenderer,
1820 )
1821 .map_err(format_flow_error)?;
1822 serde_json::to_string_pretty(&run).map_err(|err| format!("failed to serialize run: {err}"))
1823}
1824
1825fn doctor(path: &Path) -> Result<String, String> {
1826 let mut diagnostics = Diagnostics::default();
1827 let contract_dirs = discover_dirs(path, "contracts", "contract.json");
1828 let op_dirs = discover_dirs(path, "ops", "op.json");
1829 let resolver_dirs = discover_dirs(path, "resolvers", "resolver.json");
1830 let view_dirs = discover_dirs(path, "views", "view.json");
1831 let flow_dirs = discover_dirs(path, "flows", "manifest.json");
1832 let example_flow_dirs = discover_dirs(path, "examples", "manifest.json");
1833 let profile_files = discover_files(path, "examples", "profile.json");
1834
1835 let mut known_contracts = BTreeSet::new();
1836 for dir in &contract_dirs {
1837 let manifest_path = dir.join("contract.json");
1838 if let Ok(manifest) = read_json::<ContractManifest>(&manifest_path) {
1839 known_contracts.insert(manifest.contract_id.to_string());
1840 }
1841 diagnostics.extend(validate_contract_dir(dir));
1842 }
1843
1844 let known_resolvers = load_catalog_ids(path, CatalogKind::Resolvers, &["resolver_id"])?;
1845 let mut known_ops = load_catalog_ids(path, CatalogKind::Ops, &["operation_id"])?;
1846 for dir in &resolver_dirs {
1847 let manifest_path = dir.join("resolver.json");
1848 match read_json::<ResolverPackageManifest>(&manifest_path) {
1849 Ok(manifest) => {
1850 if !catalog_entry_exists(
1851 path,
1852 CatalogKind::Resolvers,
1853 "resolver_id",
1854 &manifest.resolver_id,
1855 )? {
1856 diagnostics.warning(format!(
1857 "{}: resolver {} is not present in catalog/core/resolvers/index.json",
1858 manifest_path.display(),
1859 manifest.resolver_id
1860 ));
1861 }
1862 }
1863 Err(err) => diagnostics.error(err),
1864 }
1865 diagnostics.extend(validate_resolver_dir(dir));
1866 }
1867 for dir in &op_dirs {
1868 let manifest_path = dir.join("op.json");
1869 match read_json::<OperationManifest>(&manifest_path) {
1870 Ok(manifest) => {
1871 for supported in &manifest.supported_contracts {
1872 if !known_contracts.is_empty()
1873 && !known_contracts.contains(&supported.contract_id.to_string())
1874 {
1875 diagnostics.error(format!(
1876 "{}: supported contract {} is not present under contracts/",
1877 manifest_path.display(),
1878 supported.contract_id
1879 ));
1880 }
1881 }
1882 known_ops.insert(manifest.operation_id.to_string());
1883 }
1884 Err(err) => diagnostics.error(err),
1885 }
1886 diagnostics.extend(validate_op_dir(dir));
1887 }
1888
1889 let known_views = load_catalog_ids(path, CatalogKind::Views, &["view_id"])?;
1890 for dir in &view_dirs {
1891 let manifest_path = dir.join("view.json");
1892 match read_json::<ViewPackageManifest>(&manifest_path) {
1893 Ok(manifest) => {
1894 if !catalog_entry_exists(path, CatalogKind::Views, "view_id", &manifest.view_id)? {
1895 diagnostics.warning(format!(
1896 "{}: view {} is not present in catalog/core/views/index.json",
1897 manifest_path.display(),
1898 manifest.view_id
1899 ));
1900 }
1901 }
1902 Err(err) => diagnostics.error(err),
1903 }
1904 diagnostics.extend(validate_view_dir(dir));
1905 }
1906 for dir in flow_dirs.iter().chain(example_flow_dirs.iter()) {
1907 diagnostics.extend(validate_flow_package(dir));
1908 if let Ok((package_root, manifest)) = read_flow_manifest(dir) {
1909 let flow_path = package_root.join(&manifest.flow);
1910 if let Ok(flow) = read_json::<FlowDefinition>(&flow_path) {
1911 for step in &flow.steps {
1912 match &step.kind {
1913 greentic_x_flow::StepKind::Resolve(resolve) => {
1914 if !known_resolvers.contains(&resolve.resolver_id.to_string()) {
1915 diagnostics.error(format!(
1916 "{}: step {} references unknown resolver {}",
1917 flow_path.display(),
1918 step.id,
1919 resolve.resolver_id
1920 ));
1921 }
1922 }
1923 greentic_x_flow::StepKind::Call(call) => {
1924 if !known_ops.contains(&call.operation_id.to_string()) {
1925 diagnostics.error(format!(
1926 "{}: step {} references unknown operation {}",
1927 flow_path.display(),
1928 step.id,
1929 call.operation_id
1930 ));
1931 }
1932 }
1933 greentic_x_flow::StepKind::Return(return_step) => {
1934 if let Some(render) = &return_step.render
1935 && !known_views.is_empty()
1936 && !known_views.contains(&render.view_id)
1937 {
1938 diagnostics.warning(format!(
1939 "{}: return step {} uses non-catalog view {}",
1940 flow_path.display(),
1941 step.id,
1942 render.view_id
1943 ));
1944 }
1945 }
1946 _ => {}
1947 }
1948 }
1949 }
1950 }
1951 }
1952
1953 for profile_path in &profile_files {
1954 diagnostics.extend(validate_profile_file(profile_path));
1955 if let Ok(profile) = read_profile(profile_path) {
1956 match compile_profile(&profile) {
1957 Ok(compiled) => {
1958 let flow_path = profile_path
1959 .parent()
1960 .map(|parent| parent.join("flow.json"))
1961 .unwrap_or_else(|| PathBuf::from("flow.json"));
1962 if flow_path.exists() {
1963 match read_json::<FlowDefinition>(&flow_path) {
1964 Ok(existing) => {
1965 if existing != compiled {
1966 diagnostics.error(format!(
1967 "{}: compiled profile output differs from checked-in flow.json",
1968 profile_path.display()
1969 ));
1970 }
1971 }
1972 Err(err) => diagnostics.error(err),
1973 }
1974 }
1975 }
1976 Err(err) => diagnostics.error(format!("{}: {err}", profile_path.display())),
1977 }
1978 }
1979 }
1980
1981 diagnostics.into_result("doctor checks passed")
1982}
1983
1984fn list_catalog(cwd: &Path, kind: Option<CatalogKind>) -> Result<String, String> {
1985 let kinds = match kind {
1986 Some(kind) => vec![kind],
1987 None => vec![
1988 CatalogKind::Contracts,
1989 CatalogKind::Resolvers,
1990 CatalogKind::Ops,
1991 CatalogKind::Views,
1992 CatalogKind::FlowTemplates,
1993 ],
1994 };
1995 let mut lines = Vec::new();
1996 for kind in kinds {
1997 let index_path = catalog_index_path(cwd, kind);
1998 let index = read_json::<LegacyCatalogIndex>(&index_path)?;
1999 lines.push(format!("[{}]", catalog_kind_name(kind)));
2000 for entry in index.entries {
2001 let summary = entry_summary(&entry);
2002 lines.push(format!("- {summary}"));
2003 }
2004 }
2005 Ok(lines.join("\n"))
2006}
2007
2008fn load_catalog_ids(
2009 root: &Path,
2010 kind: CatalogKind,
2011 preferred_keys: &[&str],
2012) -> Result<BTreeSet<String>, String> {
2013 let index = read_json::<LegacyCatalogIndex>(&catalog_index_path(root, kind))?;
2014 let mut ids = BTreeSet::new();
2015 for entry in index.entries {
2016 for key in preferred_keys {
2017 if let Some(value) = entry.get(*key).and_then(Value::as_str) {
2018 ids.insert(value.to_owned());
2019 break;
2020 }
2021 }
2022 }
2023 Ok(ids)
2024}
2025
2026fn catalog_entry_exists(
2027 root: &Path,
2028 kind: CatalogKind,
2029 key: &str,
2030 expected: &str,
2031) -> Result<bool, String> {
2032 let index = read_json::<LegacyCatalogIndex>(&catalog_index_path(root, kind))?;
2033 Ok(index.entries.iter().any(|entry| {
2034 entry
2035 .get(key)
2036 .and_then(Value::as_str)
2037 .map(|value| value == expected)
2038 .unwrap_or(false)
2039 }))
2040}
2041
2042fn discover_dirs(root: &Path, container: &str, marker: &str) -> Vec<PathBuf> {
2043 if root.join(marker).exists() {
2044 return vec![root.to_path_buf()];
2045 }
2046 let base = root.join(container);
2047 let Ok(entries) = fs::read_dir(&base) else {
2048 return Vec::new();
2049 };
2050 let mut dirs = entries
2051 .filter_map(Result::ok)
2052 .map(|entry| entry.path())
2053 .filter(|path| path.join(marker).exists())
2054 .collect::<Vec<_>>();
2055 dirs.sort();
2056 dirs
2057}
2058
2059fn discover_files(root: &Path, container: &str, marker: &str) -> Vec<PathBuf> {
2060 let base = root.join(container);
2061 let Ok(entries) = fs::read_dir(&base) else {
2062 return Vec::new();
2063 };
2064 let mut files = entries
2065 .filter_map(Result::ok)
2066 .map(|entry| entry.path())
2067 .map(|path| path.join(marker))
2068 .filter(|path| path.exists())
2069 .collect::<Vec<_>>();
2070 files.sort();
2071 files
2072}
2073
2074fn read_flow_manifest(path: &Path) -> Result<(PathBuf, FlowPackageManifest), String> {
2075 let package_root = if path.is_dir() {
2076 path.to_path_buf()
2077 } else {
2078 path.parent()
2079 .ok_or_else(|| format!("{}: cannot determine parent directory", path.display()))?
2080 .to_path_buf()
2081 };
2082 let manifest_path = package_root.join("manifest.json");
2083 let manifest = read_json::<FlowPackageManifest>(&manifest_path)?;
2084 Ok((package_root, manifest))
2085}
2086
2087fn read_json<T>(path: &Path) -> Result<T, String>
2088where
2089 T: for<'de> Deserialize<'de>,
2090{
2091 let data = fs::read_to_string(path)
2092 .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
2093 serde_json::from_str(&data).map_err(|err| format!("failed to parse {}: {err}", path.display()))
2094}
2095
2096fn write_json(path: &Path, value: &Value) -> Result<(), String> {
2097 if let Some(parent) = path.parent() {
2098 fs::create_dir_all(parent)
2099 .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
2100 }
2101 let content = serde_json::to_string_pretty(value)
2102 .map_err(|err| format!("failed to serialize {}: {err}", path.display()))?;
2103 fs::write(path, content).map_err(|err| format!("failed to write {}: {err}", path.display()))
2104}
2105
2106fn ensure_scaffold_dir(path: &Path) -> Result<(), String> {
2107 if path.exists() {
2108 let mut entries = fs::read_dir(path)
2109 .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
2110 if entries.next().is_some() {
2111 return Err(format!(
2112 "{} already exists and is not empty",
2113 path.display()
2114 ));
2115 }
2116 } else {
2117 fs::create_dir_all(path)
2118 .map_err(|err| format!("failed to create {}: {err}", path.display()))?;
2119 }
2120 fs::create_dir_all(path.join("schemas"))
2121 .map_err(|err| format!("failed to create schemas dir: {err}"))?;
2122 fs::create_dir_all(path.join("examples"))
2123 .map_err(|err| format!("failed to create examples dir: {err}"))?;
2124 Ok(())
2125}
2126
2127fn path_file_name(path: &Path) -> String {
2128 path.file_name()
2129 .and_then(|name| name.to_str())
2130 .map(ToOwned::to_owned)
2131 .unwrap_or_else(|| "package".to_owned())
2132}
2133
2134fn check_schema_uri(path: &Path, uri: Option<&str>, label: &str, diagnostics: &mut Diagnostics) {
2135 match uri {
2136 Some(uri) => {
2137 let schema_path = path.join(uri);
2138 if !schema_path.exists() {
2139 diagnostics.error(format!(
2140 "{}: {} file {} does not exist",
2141 path.display(),
2142 label,
2143 schema_path.display()
2144 ));
2145 }
2146 }
2147 None => diagnostics.warning(format!("{}: {label} uri is not set", path.display())),
2148 }
2149}
2150
2151fn check_examples_dir(path: &Path, diagnostics: &mut Diagnostics) {
2152 let examples_dir = path.join("examples");
2153 let Ok(entries) = fs::read_dir(&examples_dir) else {
2154 diagnostics.error(format!(
2155 "{}: examples directory is missing",
2156 examples_dir.display()
2157 ));
2158 return;
2159 };
2160 let count = entries
2161 .filter_map(Result::ok)
2162 .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
2163 .count();
2164 if count == 0 {
2165 diagnostics.error(format!(
2166 "{}: examples directory does not contain any json examples",
2167 examples_dir.display()
2168 ));
2169 }
2170}
2171
2172fn check_json_schema_file(path: &Path, label: &str, diagnostics: &mut Diagnostics) {
2173 match read_json::<Value>(path) {
2174 Ok(schema) => {
2175 if let Err(err) = validator_for(&schema) {
2176 diagnostics.error(format!(
2177 "{}: {label} is not a valid JSON Schema: {err}",
2178 path.display()
2179 ));
2180 }
2181 }
2182 Err(err) => diagnostics.error(err),
2183 }
2184}
2185
2186fn catalog_index_path(root: &Path, kind: CatalogKind) -> PathBuf {
2187 let suffix = match kind {
2188 CatalogKind::Contracts => "contracts",
2189 CatalogKind::Resolvers => "resolvers",
2190 CatalogKind::Ops => "ops",
2191 CatalogKind::Views => "views",
2192 CatalogKind::FlowTemplates => "flow-templates",
2193 };
2194 root.join("catalog")
2195 .join("core")
2196 .join(suffix)
2197 .join("index.json")
2198}
2199
2200fn catalog_kind_name(kind: CatalogKind) -> &'static str {
2201 match kind {
2202 CatalogKind::Contracts => "contracts",
2203 CatalogKind::Resolvers => "resolvers",
2204 CatalogKind::Ops => "ops",
2205 CatalogKind::Views => "views",
2206 CatalogKind::FlowTemplates => "flow-templates",
2207 }
2208}
2209
2210fn entry_summary(entry: &Value) -> String {
2211 let ordered = [
2212 "entry_id",
2213 "resolver_id",
2214 "operation_id",
2215 "view_id",
2216 "template_id",
2217 ];
2218 for key in ordered {
2219 if let Some(value) = entry.get(key).and_then(Value::as_str) {
2220 return value.to_owned();
2221 }
2222 }
2223 match serde_json::to_string(entry) {
2224 Ok(value) => value,
2225 Err(_) => "<invalid-entry>".to_owned(),
2226 }
2227}
2228
2229fn into_candidate(candidate: ResolverStubCandidate) -> ResolverCandidate {
2230 ResolverCandidate {
2231 resource: candidate.resource,
2232 display: candidate.display,
2233 confidence: candidate.confidence,
2234 metadata: candidate.metadata,
2235 }
2236}
2237
2238fn format_flow_error(err: FlowError) -> String {
2239 match err {
2240 FlowError::InvalidFlow(message)
2241 | FlowError::MissingValue(message)
2242 | FlowError::MissingStep(message)
2243 | FlowError::Resolver(message)
2244 | FlowError::Operation(message)
2245 | FlowError::Join(message)
2246 | FlowError::Render(message)
2247 | FlowError::Evidence(message) => message,
2248 }
2249}
2250
2251#[cfg(test)]
2252mod tests {
2253 use super::*;
2254 use std::error::Error;
2255 use tempfile::TempDir;
2256
2257 fn run_ok(args: &[&str], cwd: &Path) -> Result<String, String> {
2258 let argv = std::iter::once("greentic-x".to_owned())
2259 .chain(args.iter().map(|item| (*item).to_owned()))
2260 .map(OsString::from)
2261 .collect::<Vec<_>>();
2262 run(argv, Ok(cwd.to_path_buf()))
2263 }
2264
2265 #[test]
2266 fn top_level_help_respects_locale() -> Result<(), Box<dyn Error>> {
2267 let temp = TempDir::new()?;
2268 let cwd = temp.path();
2269 let output = run_ok(&["--help", "--locale", "nl"], cwd)?;
2270 assert!(output.contains("Gebruik:"));
2271 assert!(output.contains("contract"));
2272 assert!(output.contains("wizard"));
2273 assert!(output.contains("--locale <LOCALE>"));
2274 assert!(output.contains("Oplossingen samenstellen en bundelgeneratie delegeren"));
2275 Ok(())
2276 }
2277
2278 #[test]
2279 fn top_level_version_uses_cargo_version() -> Result<(), Box<dyn Error>> {
2280 let temp = TempDir::new()?;
2281 let cwd = temp.path();
2282 let output = run_ok(&["--version"], cwd)?;
2283 assert_eq!(
2284 output.trim(),
2285 format!("greentic-x {}", env!("CARGO_PKG_VERSION"))
2286 );
2287 Ok(())
2288 }
2289
2290 #[test]
2291 fn scaffolds_contract_op_flow_resolver_and_view() -> Result<(), Box<dyn Error>> {
2292 let temp = TempDir::new()?;
2293 let cwd = temp.path();
2294
2295 let result = run_ok(
2296 &[
2297 "contract",
2298 "new",
2299 "contracts/example-contract",
2300 "--contract-id",
2301 "gx.example",
2302 "--resource-type",
2303 "example",
2304 ],
2305 cwd,
2306 )?;
2307 assert!(result.contains("scaffolded contract"));
2308 let contract = fs::read_to_string(cwd.join("contracts/example-contract/contract.json"))?;
2309 assert!(contract.contains("\"contract_id\": \"gx.example\""));
2310
2311 let result = run_ok(
2312 &[
2313 "op",
2314 "new",
2315 "ops/example-op",
2316 "--operation-id",
2317 "analyse.example",
2318 "--contract-id",
2319 "gx.example",
2320 ],
2321 cwd,
2322 )?;
2323 assert!(result.contains("scaffolded op"));
2324 let op = fs::read_to_string(cwd.join("ops/example-op/op.json"))?;
2325 assert!(op.contains("\"operation_id\": \"analyse.example\""));
2326
2327 let result = run_ok(
2328 &[
2329 "flow",
2330 "new",
2331 "flows/example-flow",
2332 "--flow-id",
2333 "example.flow",
2334 ],
2335 cwd,
2336 )?;
2337 assert!(result.contains("scaffolded flow"));
2338 let flow = fs::read_to_string(cwd.join("flows/example-flow/flow.json"))?;
2339 assert!(flow.contains("\"flow_id\": \"example.flow\""));
2340
2341 let result = run_ok(
2342 &[
2343 "resolver",
2344 "new",
2345 "resolvers/example-resolver",
2346 "--resolver-id",
2347 "resolve.example",
2348 ],
2349 cwd,
2350 )?;
2351 assert!(result.contains("scaffolded resolver"));
2352 let resolver = fs::read_to_string(cwd.join("resolvers/example-resolver/resolver.json"))?;
2353 assert!(resolver.contains("\"resolver_id\": \"resolve.example\""));
2354
2355 let result = run_ok(
2356 &[
2357 "view",
2358 "new",
2359 "views/example-view",
2360 "--view-id",
2361 "summary-card",
2362 ],
2363 cwd,
2364 )?;
2365 assert!(result.contains("scaffolded view"));
2366 let view = fs::read_to_string(cwd.join("views/example-view/view.json"))?;
2367 assert!(view.contains("\"view_id\": \"summary-card\""));
2368
2369 let resolver_validation =
2370 run_ok(&["resolver", "validate", "resolvers/example-resolver"], cwd)?;
2371 assert!(resolver_validation.contains("resolver validation passed"));
2372
2373 let view_validation = run_ok(&["view", "validate", "views/example-view"], cwd)?;
2374 assert!(view_validation.contains("view validation passed"));
2375 Ok(())
2376 }
2377
2378 #[test]
2379 fn validates_and_simulates_scaffolded_flow() -> Result<(), Box<dyn Error>> {
2380 let temp = TempDir::new()?;
2381 let cwd = temp.path();
2382 let _ = run_ok(
2383 &[
2384 "flow",
2385 "new",
2386 "flows/example-flow",
2387 "--flow-id",
2388 "example.flow",
2389 ],
2390 cwd,
2391 )?;
2392
2393 let validation = run_ok(&["flow", "validate", "flows/example-flow"], cwd)?;
2394 assert!(validation.contains("flow validation passed"));
2395
2396 let output = run_ok(&["simulate", "flows/example-flow"], cwd)?;
2397 assert!(
2398 output.contains("\"status\": \"succeeded\"")
2399 || output.contains("\"status\": \"partial\"")
2400 );
2401 assert!(output.contains("\"view_id\": \"summary-card\""));
2402 Ok(())
2403 }
2404
2405 #[test]
2406 fn compiles_observability_profiles() -> Result<(), Box<dyn Error>> {
2407 let temp = TempDir::new()?;
2408 let cwd = temp.path();
2409 fs::create_dir_all(cwd.join("profiles"))?;
2410 write_json(
2411 &cwd.join("profiles/example.json"),
2412 &json!({
2413 "profile_id": "example.profile",
2414 "resolver": "resolve.by_name",
2415 "query_ops": ["query.resource"],
2416 "analysis_ops": ["analyse.threshold"],
2417 "present_op": "present.summary",
2418 "split_join": null
2419 }),
2420 )?;
2421 let output = run_ok(&["profile", "compile", "profiles/example.json"], cwd)?;
2422 assert!(output.contains("\"flow_id\": \"example.profile\""));
2423
2424 write_json(
2425 &cwd.join("profiles/split.json"),
2426 &json!({
2427 "profile_id": "split.profile",
2428 "resolver": "resolve.by_name",
2429 "query_ops": [],
2430 "analysis_ops": [],
2431 "present_op": "present.summary",
2432 "split_join": {
2433 "branches": [
2434 {
2435 "branch_id": "left",
2436 "query_ops": ["query.resource"],
2437 "analysis_ops": ["analyse.threshold"]
2438 },
2439 {
2440 "branch_id": "right",
2441 "query_ops": ["query.linked"],
2442 "analysis_ops": ["analyse.percentile"]
2443 }
2444 ]
2445 }
2446 }),
2447 )?;
2448 let output = run_ok(&["profile", "compile", "profiles/split.json"], cwd)?;
2449 assert!(output.contains("\"type\": \"split\""));
2450 assert!(output.contains("\"type\": \"join\""));
2451 Ok(())
2452 }
2453
2454 #[test]
2455 fn generic_reference_examples_simulate_successfully() -> Result<(), Box<dyn Error>> {
2456 let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
2457 .parent()
2458 .and_then(Path::parent)
2459 .ok_or("failed to resolve repo root")?;
2460 let example_dirs = [
2461 "examples/top-contributors-generic",
2462 "examples/entity-utilisation-generic",
2463 "examples/change-correlation-generic",
2464 "examples/root-cause-split-join-generic",
2465 ];
2466
2467 for dir in example_dirs {
2468 let validation = run_ok(&["flow", "validate", dir], repo_root)?;
2469 assert!(validation.contains("flow validation passed"));
2470
2471 let simulation = run_ok(&["simulate", dir], repo_root)?;
2472 let run_value: Value = serde_json::from_str(&simulation)?;
2473 let expected_view: Value =
2474 read_json(&repo_root.join(dir).join("expected.view.json")).map_err(io_error)?;
2475 let expected_evidence: Value =
2476 read_json(&repo_root.join(dir).join("expected.evidence.json")).map_err(io_error)?;
2477
2478 assert_eq!(
2479 run_value["view"], expected_view,
2480 "unexpected view for {dir}"
2481 );
2482
2483 let actual_evidence_ids = run_value["view"]["primary_data_refs"].clone();
2484 let expected_evidence_ids = expected_evidence
2485 .as_array()
2486 .ok_or("expected evidence should be an array")?
2487 .iter()
2488 .map(|item| item["evidence_id"].clone())
2489 .collect::<Vec<_>>();
2490 assert_eq!(
2491 actual_evidence_ids,
2492 Value::Array(expected_evidence_ids),
2493 "unexpected evidence refs for {dir}"
2494 );
2495 }
2496 Ok(())
2497 }
2498
2499 #[test]
2500 fn doctor_catches_broken_references() -> Result<(), Box<dyn Error>> {
2501 let temp = TempDir::new()?;
2502 let cwd = temp.path();
2503
2504 fs::create_dir_all(cwd.join("catalog/core/resolvers"))?;
2505 fs::create_dir_all(cwd.join("catalog/core/ops"))?;
2506 fs::create_dir_all(cwd.join("catalog/core/views"))?;
2507 write_json(
2508 &cwd.join("catalog/core/resolvers/index.json"),
2509 &json!({"entries": []}),
2510 )?;
2511 write_json(
2512 &cwd.join("catalog/core/ops/index.json"),
2513 &json!({"entries": []}),
2514 )?;
2515 write_json(
2516 &cwd.join("catalog/core/views/index.json"),
2517 &json!({"entries": []}),
2518 )?;
2519
2520 let _ = run_ok(
2521 &[
2522 "flow",
2523 "new",
2524 "flows/example-flow",
2525 "--flow-id",
2526 "example.flow",
2527 ],
2528 cwd,
2529 )?;
2530 let doctor = run_ok(&["doctor", "."], cwd);
2531 assert!(doctor.is_err());
2532 let message = match doctor {
2533 Ok(value) => value,
2534 Err(err) => err,
2535 };
2536 assert!(message.contains("unknown operation"));
2537 Ok(())
2538 }
2539
2540 #[test]
2541 fn flow_validation_catches_broken_join() -> Result<(), Box<dyn Error>> {
2542 let temp = TempDir::new()?;
2543 let cwd = temp.path();
2544 fs::create_dir_all(cwd.join("flows/broken-flow"))?;
2545 write_json(
2546 &cwd.join("flows/broken-flow/manifest.json"),
2547 &json!({
2548 "flow_id": "broken.flow",
2549 "version": "v1",
2550 "description": "broken",
2551 "flow": "flow.json"
2552 }),
2553 )?;
2554 write_json(
2555 &cwd.join("flows/broken-flow/flow.json"),
2556 &json!({
2557 "flow_id": "broken.flow",
2558 "steps": [
2559 {
2560 "id": "join",
2561 "kind": {
2562 "type": "join",
2563 "split_step_id": "missing-split",
2564 "mode": "all",
2565 "output_key": "merged"
2566 }
2567 },
2568 {
2569 "id": "return",
2570 "kind": {
2571 "type": "return",
2572 "output": {"kind": "literal", "value": {"ok": true}}
2573 }
2574 }
2575 ]
2576 }),
2577 )?;
2578
2579 let validation = run_ok(&["flow", "validate", "flows/broken-flow"], cwd);
2580 assert!(validation.is_err());
2581 let message = match validation {
2582 Ok(value) => value,
2583 Err(err) => err,
2584 };
2585 assert!(message.contains("references missing or later split"));
2586 Ok(())
2587 }
2588
2589 #[test]
2590 fn wizard_run_outputs_composition_plan() -> Result<(), Box<dyn Error>> {
2591 let temp = TempDir::new()?;
2592 let cwd = temp.path();
2593 let output = run_ok(&["wizard", "run", "--dry-run"], cwd)?;
2594 let value: Value = serde_json::from_str(&output)?;
2595 assert_eq!(value["requested_action"], "run");
2596 assert_eq!(value["metadata"]["execution"], "dry_run");
2597 assert_eq!(
2598 value["normalized_input_summary"]["workflow"],
2599 "compose_solution"
2600 );
2601 assert_eq!(
2602 value["normalized_input_summary"]["solution_id"],
2603 "gx-solution"
2604 );
2605 Ok(())
2606 }
2607
2608 #[test]
2609 fn wizard_help_mentions_schema_option() -> Result<(), Box<dyn Error>> {
2610 let temp = TempDir::new()?;
2611 let cwd = temp.path();
2612 let output = run_ok(&["wizard", "--help"], cwd)?;
2613 assert!(output.contains("--schema"));
2614 Ok(())
2615 }
2616
2617 #[test]
2618 fn wizard_schema_outputs_answer_document_schema() -> Result<(), Box<dyn Error>> {
2619 let temp = TempDir::new()?;
2620 let cwd = temp.path();
2621 let output = run_ok(&["wizard", "--schema"], cwd)?;
2622 let value: Value = serde_json::from_str(&output)?;
2623 assert_eq!(
2624 value["properties"]["wizard_id"]["const"],
2625 "greentic-bundle.wizard.run"
2626 );
2627 assert_eq!(
2628 value["properties"]["schema_id"]["const"],
2629 "greentic-bundle.wizard.answers"
2630 );
2631 assert_eq!(
2632 value["$defs"]["gx_answers"]["properties"]["mode"]["enum"][0],
2633 "create"
2634 );
2635 assert_eq!(
2636 value["$defs"]["gx_runtime_form"]["id"],
2637 "gx.wizard.composition"
2638 );
2639 Ok(())
2640 }
2641
2642 #[test]
2643 fn wizard_plan_is_deterministic_for_dry_run() -> Result<(), Box<dyn Error>> {
2644 let temp = TempDir::new()?;
2645 let cwd = temp.path();
2646 let first: Value = serde_json::from_str(&run_ok(&["wizard", "run", "--dry-run"], cwd)?)?;
2647 let second: Value = serde_json::from_str(&run_ok(&["wizard", "run", "--dry-run"], cwd)?)?;
2648 assert_eq!(first, second);
2649 Ok(())
2650 }
2651
2652 #[test]
2653 fn wizard_validate_is_always_dry_run() -> Result<(), Box<dyn Error>> {
2654 let temp = TempDir::new()?;
2655 let cwd = temp.path();
2656 let output = run_ok(&["wizard", "validate"], cwd)?;
2657 let value: Value = serde_json::from_str(&output)?;
2658 assert_eq!(value["requested_action"], "validate");
2659 assert_eq!(value["metadata"]["execution"], "dry_run");
2660 Ok(())
2661 }
2662
2663 #[test]
2664 fn wizard_apply_plan_reports_compatibility_bridge_warning() -> Result<(), Box<dyn Error>> {
2665 let temp = TempDir::new()?;
2666 let cwd = temp.path();
2667 let output = run_ok(&["wizard", "apply", "--dry-run"], cwd)?;
2668 let value: Value = serde_json::from_str(&output)?;
2669 let warnings = value["warnings"].as_array().expect("warnings");
2670 assert!(
2671 warnings.iter().any(|item| {
2672 item.as_str()
2673 .is_some_and(|text| text.contains("compatibility bridge"))
2674 }),
2675 "expected compatibility bridge warning in {warnings:?}"
2676 );
2677 Ok(())
2678 }
2679
2680 #[test]
2681 fn wizard_emit_answers_writes_answer_document() -> Result<(), Box<dyn Error>> {
2682 let temp = TempDir::new()?;
2683 let cwd = temp.path();
2684 let output = run_ok(
2685 &[
2686 "wizard",
2687 "run",
2688 "--dry-run",
2689 "--emit-answers",
2690 "wizard.answers.json",
2691 ],
2692 cwd,
2693 )?;
2694 let emitted: Value =
2695 serde_json::from_str(&fs::read_to_string(cwd.join("wizard.answers.json"))?)?;
2696 assert_eq!(emitted["wizard_id"], "greentic-bundle.wizard.run");
2697 assert_eq!(emitted["schema_id"], "greentic-bundle.wizard.answers");
2698 assert_eq!(emitted["answers"]["workflow"], "compose_solution");
2699 assert_eq!(emitted["answers"]["solution_id"], "gx-solution");
2700 let plan: Value = serde_json::from_str(&output)?;
2701 assert!(plan["expected_file_writes"].as_array().is_some());
2702 Ok(())
2703 }
2704
2705 #[test]
2706 fn wizard_run_emits_launcher_compatibility_answers() -> Result<(), Box<dyn Error>> {
2707 let temp = TempDir::new()?;
2708 let cwd = temp.path();
2709 write_json(
2710 &cwd.join("input.answers.json"),
2711 &json!({
2712 "wizard_id": "greentic-bundle.wizard.run",
2713 "schema_id": "greentic-bundle.wizard.answers",
2714 "schema_version": "1.0.0",
2715 "locale": "en",
2716 "answers": {
2717 "solution_name": "Network Assistant"
2718 },
2719 "locks": {}
2720 }),
2721 )?;
2722
2723 run_ok(&["wizard", "run", "--answers", "input.answers.json"], cwd)?;
2724
2725 let emitted: Value = serde_json::from_str(&fs::read_to_string(
2726 cwd.join("dist/network-assistant.launcher.answers.json"),
2727 )?)?;
2728 assert_eq!(emitted["wizard_id"], "greentic-dev.wizard.launcher.main");
2729 assert_eq!(emitted["schema_id"], "greentic-dev.launcher.main");
2730 assert_eq!(emitted["answers"]["selected_action"], "bundle");
2731 assert_eq!(
2732 emitted["answers"]["delegate_answer_document"]["wizard_id"],
2733 "greentic-bundle.wizard.run"
2734 );
2735 assert_eq!(
2736 emitted["answers"]["delegate_answer_document"]["schema_id"],
2737 "greentic-bundle.wizard.answers"
2738 );
2739 Ok(())
2740 }
2741
2742 #[test]
2743 fn wizard_run_emits_pack_compatibility_input() -> Result<(), Box<dyn Error>> {
2744 let temp = TempDir::new()?;
2745 let cwd = temp.path();
2746 write_json(
2747 &cwd.join("input.answers.json"),
2748 &json!({
2749 "wizard_id": "greentic-bundle.wizard.run",
2750 "schema_id": "greentic-bundle.wizard.answers",
2751 "schema_version": "1.0.0",
2752 "locale": "en",
2753 "answers": {
2754 "solution_name": "Network Assistant"
2755 },
2756 "locks": {}
2757 }),
2758 )?;
2759
2760 run_ok(&["wizard", "run", "--answers", "input.answers.json"], cwd)?;
2761
2762 let pack_input: Value = serde_json::from_str(&fs::read_to_string(
2763 cwd.join("dist/network-assistant.pack.input.json"),
2764 )?)?;
2765 assert_eq!(pack_input["schema_id"], "gx.pack.input");
2766 assert_eq!(pack_input["solution_id"], "network-assistant");
2767 assert!(pack_input["unresolved_downstream_work"].is_array());
2768
2769 let handoff: Value = serde_json::from_str(&fs::read_to_string(
2770 cwd.join("dist/network-assistant.toolchain-handoff.json"),
2771 )?)?;
2772 assert_eq!(handoff["pack_handoff"]["tool"], "greentic-pack");
2773 assert_eq!(
2774 handoff["pack_handoff"]["pack_input_path"],
2775 "dist/network-assistant.pack.input.json"
2776 );
2777 Ok(())
2778 }
2779
2780 #[test]
2781 fn wizard_catalog_flag_is_emitted_into_answers() -> Result<(), Box<dyn Error>> {
2782 let temp = TempDir::new()?;
2783 let cwd = temp.path();
2784 write_json(
2785 &cwd.join("input.answers.json"),
2786 &json!({
2787 "wizard_id": "greentic-bundle.wizard.run",
2788 "schema_id": "greentic-bundle.wizard.answers",
2789 "schema_version": "1.0.0",
2790 "locale": "en",
2791 "answers": {
2792 "solution_name": "Network Assistant"
2793 },
2794 "locks": {}
2795 }),
2796 )?;
2797 let _ = run_ok(
2798 &[
2799 "wizard",
2800 "run",
2801 "--dry-run",
2802 "--answers",
2803 "input.answers.json",
2804 "--catalog",
2805 "oci://ghcr.io/greenticai/catalogs/zain-x/catalog.json:latest",
2806 "--emit-answers",
2807 "output.answers.json",
2808 ],
2809 cwd,
2810 )?;
2811 let emitted: Value =
2812 serde_json::from_str(&fs::read_to_string(cwd.join("output.answers.json"))?)?;
2813 assert_eq!(
2814 emitted["answers"]["catalog_oci_refs"][0],
2815 "oci://ghcr.io/greenticai/catalogs/zain-x/catalog.json:latest"
2816 );
2817 Ok(())
2818 }
2819
2820 #[test]
2821 fn catalog_build_and_validate_commands_work() -> Result<(), Box<dyn Error>> {
2822 let temp = TempDir::new()?;
2823 let cwd = temp.path();
2824 let _ = run_ok(&["catalog", "init", "zain-x"], cwd)?;
2825 let _ = run_ok(&["catalog", "build", "--repo", "zain-x"], cwd)?;
2826 let _ = run_ok(&["catalog", "validate", "--repo", "zain-x"], cwd)?;
2827 Ok(())
2828 }
2829
2830 #[test]
2831 fn wizard_rejects_schema_version_change_without_migrate() -> Result<(), Box<dyn Error>> {
2832 let temp = TempDir::new()?;
2833 let cwd = temp.path();
2834 write_json(
2835 &cwd.join("input.answers.json"),
2836 &json!({
2837 "wizard_id": "greentic-bundle.wizard.run",
2838 "schema_id": "greentic-bundle.wizard.answers",
2839 "schema_version": "0.9.0",
2840 "locale": "en",
2841 "answers": {},
2842 "locks": {}
2843 }),
2844 )?;
2845 let err = run_ok(
2846 &[
2847 "wizard",
2848 "run",
2849 "--answers",
2850 "input.answers.json",
2851 "--schema-version",
2852 "1.0.0",
2853 ],
2854 cwd,
2855 )
2856 .expect_err("expected migration error");
2857 assert!(err.contains("--migrate"));
2858 Ok(())
2859 }
2860
2861 #[test]
2862 fn wizard_migrate_updates_schema_version() -> Result<(), Box<dyn Error>> {
2863 let temp = TempDir::new()?;
2864 let cwd = temp.path();
2865 write_json(
2866 &cwd.join("input.answers.json"),
2867 &json!({
2868 "wizard_id": "greentic-bundle.wizard.run",
2869 "schema_id": "greentic-bundle.wizard.answers",
2870 "schema_version": "0.9.0",
2871 "locale": "en",
2872 "answers": {},
2873 "locks": {}
2874 }),
2875 )?;
2876 let _ = run_ok(
2877 &[
2878 "wizard",
2879 "run",
2880 "--answers",
2881 "input.answers.json",
2882 "--schema-version",
2883 "1.1.0",
2884 "--migrate",
2885 "--emit-answers",
2886 "output.answers.json",
2887 ],
2888 cwd,
2889 )?;
2890 let emitted: Value =
2891 serde_json::from_str(&fs::read_to_string(cwd.join("output.answers.json"))?)?;
2892 assert_eq!(emitted["schema_version"], "1.1.0");
2893 Ok(())
2894 }
2895
2896 #[test]
2897 fn wizard_handoff_invocation_uses_answers_path() -> Result<(), Box<dyn Error>> {
2898 let invocation = wizard::bundle_handoff_invocation(Path::new("/tmp/bundle.answers.json"));
2899 let parts = invocation
2900 .iter()
2901 .map(|value| value.to_string_lossy().to_string())
2902 .collect::<Vec<_>>();
2903 assert_eq!(
2904 parts,
2905 vec![
2906 "wizard".to_owned(),
2907 "apply".to_owned(),
2908 "--answers".to_owned(),
2909 "/tmp/bundle.answers.json".to_owned(),
2910 ]
2911 );
2912 Ok(())
2913 }
2914
2915 #[test]
2916 fn wizard_update_mode_prefills_existing_solution() -> Result<(), Box<dyn Error>> {
2917 let temp = TempDir::new()?;
2918 let cwd = temp.path();
2919 fs::create_dir_all(cwd.join("dist"))?;
2920 write_json(
2921 &cwd.join("dist/network-assistant.solution.json"),
2922 &json!({
2923 "schema_id": "gx.solution.manifest",
2924 "schema_version": "1.0.0",
2925 "solution_id": "network-assistant",
2926 "solution_name": "Network Assistant",
2927 "description": "Automates network diagnostics",
2928 "output_dir": "dist",
2929 "template": {
2930 "entry_id": "assistant.network.phase1",
2931 "display_name": "Network Assistant Phase 1"
2932 },
2933 "provider_presets": [{
2934 "entry_id": "builtin.teams",
2935 "display_name": "Teams",
2936 "provider_refs": ["oci://ghcr.io/greenticai/packs/messaging/messaging-teams:latest"]
2937 }]
2938 }),
2939 )?;
2940 write_json(
2941 &cwd.join("input.answers.json"),
2942 &json!({
2943 "wizard_id": "greentic-bundle.wizard.run",
2944 "schema_id": "greentic-bundle.wizard.answers",
2945 "schema_version": "1.0.0",
2946 "locale": "en",
2947 "answers": {
2948 "mode": "update",
2949 "existing_solution_path": "dist/network-assistant.solution.json"
2950 },
2951 "locks": {}
2952 }),
2953 )?;
2954 let output = run_ok(
2955 &[
2956 "wizard",
2957 "run",
2958 "--answers",
2959 "input.answers.json",
2960 "--dry-run",
2961 ],
2962 cwd,
2963 )?;
2964 let value: Value = serde_json::from_str(&output)?;
2965 assert_eq!(
2966 value["normalized_input_summary"]["solution_name"],
2967 "Network Assistant"
2968 );
2969 assert_eq!(
2970 value["normalized_input_summary"]["solution_id"],
2971 "network-assistant"
2972 );
2973 Ok(())
2974 }
2975
2976 #[test]
2977 fn wizard_locale_nl_keeps_plan_serializable() -> Result<(), Box<dyn Error>> {
2978 let temp = TempDir::new()?;
2979 let cwd = temp.path();
2980 let output = run_ok(&["wizard", "run", "--dry-run", "--locale", "nl-NL"], cwd)?;
2981 let value: Value = serde_json::from_str(&output)?;
2982 assert_eq!(value["metadata"]["locale"], "nl");
2983 Ok(())
2984 }
2985
2986 fn io_error(message: String) -> Box<dyn Error> {
2987 Box::new(std::io::Error::other(message))
2988 }
2989}