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