Skip to main content

gx/
lib.rs

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