Skip to main content

gx/
lib.rs

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