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