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