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