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) if !split_ids.contains(&join.split_step_id) => {
1696                diagnostics.error(format!(
1697                    "{}: join {} references missing or later split {}",
1698                    flow_path.display(),
1699                    step.id,
1700                    join.split_step_id
1701                ));
1702            }
1703            greentic_x_flow::StepKind::Return(return_step) => {
1704                has_return = true;
1705                if let Some(render) = &return_step.render {
1706                    if render.renderer_id.trim().is_empty() {
1707                        diagnostics.error(format!(
1708                            "{}: return {} has empty renderer_id",
1709                            flow_path.display(),
1710                            step.id
1711                        ));
1712                    }
1713                    if render.view_id.trim().is_empty() {
1714                        diagnostics.error(format!(
1715                            "{}: return {} has empty view_id",
1716                            flow_path.display(),
1717                            step.id
1718                        ));
1719                    }
1720                }
1721            }
1722            _ => {}
1723        }
1724    }
1725    if !has_return {
1726        diagnostics.error(format!(
1727            "{}: flow must include at least one return step",
1728            flow_path.display()
1729        ));
1730    }
1731    diagnostics
1732}
1733
1734fn simulate_flow(
1735    path: &Path,
1736    stubs_override: Option<PathBuf>,
1737    input_override: Option<PathBuf>,
1738) -> Result<String, String> {
1739    let (package_root, manifest) = read_flow_manifest(path)?;
1740    let flow_path = package_root.join(&manifest.flow);
1741    let flow = read_json::<FlowDefinition>(&flow_path)?;
1742    let input = match input_override {
1743        Some(path) => read_json::<Value>(&path)?,
1744        None => {
1745            let default_input = package_root.join("input.json");
1746            if default_input.exists() {
1747                read_json::<Value>(&default_input)?
1748            } else {
1749                json!({})
1750            }
1751        }
1752    };
1753    let stubs_path = match stubs_override {
1754        Some(path) => path,
1755        None => package_root.join(
1756            manifest
1757                .stubs
1758                .as_deref()
1759                .ok_or_else(|| format!("{}: no stubs file configured", flow_path.display()))?,
1760        ),
1761    };
1762    let stubs = read_json::<SimulationStubs>(&stubs_path)?;
1763    let mut operations = HashMap::new();
1764    for stub in stubs.operations {
1765        let operation_id = OperationId::new(stub.operation_id.clone())
1766            .map_err(|err| format!("invalid operation id {}: {err}", stub.operation_id))?;
1767        operations.insert(
1768            stub.operation_id.clone(),
1769            OperationResult {
1770                envelope: greentic_x_types::OperationResultEnvelope {
1771                    invocation_id: stub
1772                        .invocation_id
1773                        .unwrap_or_else(|| format!("invoke-{}", stub.operation_id)),
1774                    operation_id,
1775                    status: InvocationStatus::Succeeded,
1776                    output: Some(stub.output),
1777                    evidence_refs: Vec::new(),
1778                    warnings: stub.warnings,
1779                    view_hints: Vec::new(),
1780                },
1781                evidence: stub.evidence,
1782            },
1783        );
1784    }
1785    let mut resolvers = HashMap::new();
1786    for stub in stubs.resolvers {
1787        let resolver_id = ResolverId::new(stub.resolver_id.clone())
1788            .map_err(|err| format!("invalid resolver id {}: {err}", stub.resolver_id))?;
1789        resolvers.insert(
1790            stub.resolver_id,
1791            ResolverResultEnvelope {
1792                resolver_id,
1793                status: stub.status,
1794                selected: stub.selected.map(into_candidate),
1795                candidates: stub.candidates.into_iter().map(into_candidate).collect(),
1796                warnings: stub.warnings,
1797            },
1798        );
1799    }
1800
1801    let provenance = Provenance::new(
1802        ActorRef::service("gx-cli").map_err(|err| format!("invalid actor id gx-cli: {err}"))?,
1803    );
1804    let mut runtime = StaticFlowRuntime::with_operations(operations);
1805    for (resolver_id, result) in resolvers {
1806        runtime.insert_resolver(resolver_id, result);
1807    }
1808    let mut evidence_store = greentic_x_flow::InMemoryEvidenceStore::default();
1809    let mut engine = FlowEngine::default();
1810    let run = engine
1811        .execute(
1812            &flow,
1813            input,
1814            provenance,
1815            &mut runtime,
1816            &mut evidence_store,
1817            &NoopViewRenderer,
1818        )
1819        .map_err(format_flow_error)?;
1820    serde_json::to_string_pretty(&run).map_err(|err| format!("failed to serialize run: {err}"))
1821}
1822
1823fn doctor(path: &Path) -> Result<String, String> {
1824    let mut diagnostics = Diagnostics::default();
1825    let contract_dirs = discover_dirs(path, "contracts", "contract.json");
1826    let op_dirs = discover_dirs(path, "ops", "op.json");
1827    let resolver_dirs = discover_dirs(path, "resolvers", "resolver.json");
1828    let view_dirs = discover_dirs(path, "views", "view.json");
1829    let flow_dirs = discover_dirs(path, "flows", "manifest.json");
1830    let example_flow_dirs = discover_dirs(path, "examples", "manifest.json");
1831    let profile_files = discover_files(path, "examples", "profile.json");
1832
1833    let mut known_contracts = BTreeSet::new();
1834    for dir in &contract_dirs {
1835        let manifest_path = dir.join("contract.json");
1836        if let Ok(manifest) = read_json::<ContractManifest>(&manifest_path) {
1837            known_contracts.insert(manifest.contract_id.to_string());
1838        }
1839        diagnostics.extend(validate_contract_dir(dir));
1840    }
1841
1842    let known_resolvers = load_catalog_ids(path, CatalogKind::Resolvers, &["resolver_id"])?;
1843    let mut known_ops = load_catalog_ids(path, CatalogKind::Ops, &["operation_id"])?;
1844    for dir in &resolver_dirs {
1845        let manifest_path = dir.join("resolver.json");
1846        match read_json::<ResolverPackageManifest>(&manifest_path) {
1847            Ok(manifest) => {
1848                if !catalog_entry_exists(
1849                    path,
1850                    CatalogKind::Resolvers,
1851                    "resolver_id",
1852                    &manifest.resolver_id,
1853                )? {
1854                    diagnostics.warning(format!(
1855                        "{}: resolver {} is not present in catalog/core/resolvers/index.json",
1856                        manifest_path.display(),
1857                        manifest.resolver_id
1858                    ));
1859                }
1860            }
1861            Err(err) => diagnostics.error(err),
1862        }
1863        diagnostics.extend(validate_resolver_dir(dir));
1864    }
1865    for dir in &op_dirs {
1866        let manifest_path = dir.join("op.json");
1867        match read_json::<OperationManifest>(&manifest_path) {
1868            Ok(manifest) => {
1869                for supported in &manifest.supported_contracts {
1870                    if !known_contracts.is_empty()
1871                        && !known_contracts.contains(&supported.contract_id.to_string())
1872                    {
1873                        diagnostics.error(format!(
1874                            "{}: supported contract {} is not present under contracts/",
1875                            manifest_path.display(),
1876                            supported.contract_id
1877                        ));
1878                    }
1879                }
1880                known_ops.insert(manifest.operation_id.to_string());
1881            }
1882            Err(err) => diagnostics.error(err),
1883        }
1884        diagnostics.extend(validate_op_dir(dir));
1885    }
1886
1887    let known_views = load_catalog_ids(path, CatalogKind::Views, &["view_id"])?;
1888    for dir in &view_dirs {
1889        let manifest_path = dir.join("view.json");
1890        match read_json::<ViewPackageManifest>(&manifest_path) {
1891            Ok(manifest) => {
1892                if !catalog_entry_exists(path, CatalogKind::Views, "view_id", &manifest.view_id)? {
1893                    diagnostics.warning(format!(
1894                        "{}: view {} is not present in catalog/core/views/index.json",
1895                        manifest_path.display(),
1896                        manifest.view_id
1897                    ));
1898                }
1899            }
1900            Err(err) => diagnostics.error(err),
1901        }
1902        diagnostics.extend(validate_view_dir(dir));
1903    }
1904    for dir in flow_dirs.iter().chain(example_flow_dirs.iter()) {
1905        diagnostics.extend(validate_flow_package(dir));
1906        if let Ok((package_root, manifest)) = read_flow_manifest(dir) {
1907            let flow_path = package_root.join(&manifest.flow);
1908            if let Ok(flow) = read_json::<FlowDefinition>(&flow_path) {
1909                for step in &flow.steps {
1910                    match &step.kind {
1911                        greentic_x_flow::StepKind::Resolve(resolve)
1912                            if !known_resolvers.contains(&resolve.resolver_id.to_string()) =>
1913                        {
1914                            diagnostics.error(format!(
1915                                "{}: step {} references unknown resolver {}",
1916                                flow_path.display(),
1917                                step.id,
1918                                resolve.resolver_id
1919                            ));
1920                        }
1921                        greentic_x_flow::StepKind::Call(call)
1922                            if !known_ops.contains(&call.operation_id.to_string()) =>
1923                        {
1924                            diagnostics.error(format!(
1925                                "{}: step {} references unknown operation {}",
1926                                flow_path.display(),
1927                                step.id,
1928                                call.operation_id
1929                            ));
1930                        }
1931                        greentic_x_flow::StepKind::Return(return_step) => {
1932                            if let Some(render) = &return_step.render
1933                                && !known_views.is_empty()
1934                                && !known_views.contains(&render.view_id)
1935                            {
1936                                diagnostics.warning(format!(
1937                                    "{}: return step {} uses non-catalog view {}",
1938                                    flow_path.display(),
1939                                    step.id,
1940                                    render.view_id
1941                                ));
1942                            }
1943                        }
1944                        _ => {}
1945                    }
1946                }
1947            }
1948        }
1949    }
1950
1951    for profile_path in &profile_files {
1952        diagnostics.extend(validate_profile_file(profile_path));
1953        if let Ok(profile) = read_profile(profile_path) {
1954            match compile_profile(&profile) {
1955                Ok(compiled) => {
1956                    let flow_path = profile_path
1957                        .parent()
1958                        .map(|parent| parent.join("flow.json"))
1959                        .unwrap_or_else(|| PathBuf::from("flow.json"));
1960                    if flow_path.exists() {
1961                        match read_json::<FlowDefinition>(&flow_path) {
1962                            Ok(existing) => {
1963                                if existing != compiled {
1964                                    diagnostics.error(format!(
1965                                        "{}: compiled profile output differs from checked-in flow.json",
1966                                        profile_path.display()
1967                                    ));
1968                                }
1969                            }
1970                            Err(err) => diagnostics.error(err),
1971                        }
1972                    }
1973                }
1974                Err(err) => diagnostics.error(format!("{}: {err}", profile_path.display())),
1975            }
1976        }
1977    }
1978
1979    diagnostics.into_result("doctor checks passed")
1980}
1981
1982fn list_catalog(cwd: &Path, kind: Option<CatalogKind>) -> Result<String, String> {
1983    let kinds = match kind {
1984        Some(kind) => vec![kind],
1985        None => vec![
1986            CatalogKind::Contracts,
1987            CatalogKind::Resolvers,
1988            CatalogKind::Ops,
1989            CatalogKind::Views,
1990            CatalogKind::FlowTemplates,
1991        ],
1992    };
1993    let mut lines = Vec::new();
1994    for kind in kinds {
1995        let index_path = catalog_index_path(cwd, kind);
1996        let index = read_json::<LegacyCatalogIndex>(&index_path)?;
1997        lines.push(format!("[{}]", catalog_kind_name(kind)));
1998        for entry in index.entries {
1999            let summary = entry_summary(&entry);
2000            lines.push(format!("- {summary}"));
2001        }
2002    }
2003    Ok(lines.join("\n"))
2004}
2005
2006fn load_catalog_ids(
2007    root: &Path,
2008    kind: CatalogKind,
2009    preferred_keys: &[&str],
2010) -> Result<BTreeSet<String>, String> {
2011    let index = read_json::<LegacyCatalogIndex>(&catalog_index_path(root, kind))?;
2012    let mut ids = BTreeSet::new();
2013    for entry in index.entries {
2014        for key in preferred_keys {
2015            if let Some(value) = entry.get(*key).and_then(Value::as_str) {
2016                ids.insert(value.to_owned());
2017                break;
2018            }
2019        }
2020    }
2021    Ok(ids)
2022}
2023
2024fn catalog_entry_exists(
2025    root: &Path,
2026    kind: CatalogKind,
2027    key: &str,
2028    expected: &str,
2029) -> Result<bool, String> {
2030    let index = read_json::<LegacyCatalogIndex>(&catalog_index_path(root, kind))?;
2031    Ok(index.entries.iter().any(|entry| {
2032        entry
2033            .get(key)
2034            .and_then(Value::as_str)
2035            .map(|value| value == expected)
2036            .unwrap_or(false)
2037    }))
2038}
2039
2040fn discover_dirs(root: &Path, container: &str, marker: &str) -> Vec<PathBuf> {
2041    if root.join(marker).exists() {
2042        return vec![root.to_path_buf()];
2043    }
2044    let base = root.join(container);
2045    let Ok(entries) = fs::read_dir(&base) else {
2046        return Vec::new();
2047    };
2048    let mut dirs = entries
2049        .filter_map(Result::ok)
2050        .map(|entry| entry.path())
2051        .filter(|path| path.join(marker).exists())
2052        .collect::<Vec<_>>();
2053    dirs.sort();
2054    dirs
2055}
2056
2057fn discover_files(root: &Path, container: &str, marker: &str) -> Vec<PathBuf> {
2058    let base = root.join(container);
2059    let Ok(entries) = fs::read_dir(&base) else {
2060        return Vec::new();
2061    };
2062    let mut files = entries
2063        .filter_map(Result::ok)
2064        .map(|entry| entry.path())
2065        .map(|path| path.join(marker))
2066        .filter(|path| path.exists())
2067        .collect::<Vec<_>>();
2068    files.sort();
2069    files
2070}
2071
2072fn read_flow_manifest(path: &Path) -> Result<(PathBuf, FlowPackageManifest), String> {
2073    let package_root = if path.is_dir() {
2074        path.to_path_buf()
2075    } else {
2076        path.parent()
2077            .ok_or_else(|| format!("{}: cannot determine parent directory", path.display()))?
2078            .to_path_buf()
2079    };
2080    let manifest_path = package_root.join("manifest.json");
2081    let manifest = read_json::<FlowPackageManifest>(&manifest_path)?;
2082    Ok((package_root, manifest))
2083}
2084
2085fn read_json<T>(path: &Path) -> Result<T, String>
2086where
2087    T: for<'de> Deserialize<'de>,
2088{
2089    let data = fs::read_to_string(path)
2090        .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
2091    serde_json::from_str(&data).map_err(|err| format!("failed to parse {}: {err}", path.display()))
2092}
2093
2094fn write_json(path: &Path, value: &Value) -> Result<(), String> {
2095    if let Some(parent) = path.parent() {
2096        fs::create_dir_all(parent)
2097            .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
2098    }
2099    let content = serde_json::to_string_pretty(value)
2100        .map_err(|err| format!("failed to serialize {}: {err}", path.display()))?;
2101    fs::write(path, content).map_err(|err| format!("failed to write {}: {err}", path.display()))
2102}
2103
2104fn ensure_scaffold_dir(path: &Path) -> Result<(), String> {
2105    if path.exists() {
2106        let mut entries = fs::read_dir(path)
2107            .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
2108        if entries.next().is_some() {
2109            return Err(format!(
2110                "{} already exists and is not empty",
2111                path.display()
2112            ));
2113        }
2114    } else {
2115        fs::create_dir_all(path)
2116            .map_err(|err| format!("failed to create {}: {err}", path.display()))?;
2117    }
2118    fs::create_dir_all(path.join("schemas"))
2119        .map_err(|err| format!("failed to create schemas dir: {err}"))?;
2120    fs::create_dir_all(path.join("examples"))
2121        .map_err(|err| format!("failed to create examples dir: {err}"))?;
2122    Ok(())
2123}
2124
2125fn path_file_name(path: &Path) -> String {
2126    path.file_name()
2127        .and_then(|name| name.to_str())
2128        .map(ToOwned::to_owned)
2129        .unwrap_or_else(|| "package".to_owned())
2130}
2131
2132fn check_schema_uri(path: &Path, uri: Option<&str>, label: &str, diagnostics: &mut Diagnostics) {
2133    match uri {
2134        Some(uri) => {
2135            let schema_path = path.join(uri);
2136            if !schema_path.exists() {
2137                diagnostics.error(format!(
2138                    "{}: {} file {} does not exist",
2139                    path.display(),
2140                    label,
2141                    schema_path.display()
2142                ));
2143            }
2144        }
2145        None => diagnostics.warning(format!("{}: {label} uri is not set", path.display())),
2146    }
2147}
2148
2149fn check_examples_dir(path: &Path, diagnostics: &mut Diagnostics) {
2150    let examples_dir = path.join("examples");
2151    let Ok(entries) = fs::read_dir(&examples_dir) else {
2152        diagnostics.error(format!(
2153            "{}: examples directory is missing",
2154            examples_dir.display()
2155        ));
2156        return;
2157    };
2158    let count = entries
2159        .filter_map(Result::ok)
2160        .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
2161        .count();
2162    if count == 0 {
2163        diagnostics.error(format!(
2164            "{}: examples directory does not contain any json examples",
2165            examples_dir.display()
2166        ));
2167    }
2168}
2169
2170fn check_json_schema_file(path: &Path, label: &str, diagnostics: &mut Diagnostics) {
2171    match read_json::<Value>(path) {
2172        Ok(schema) => {
2173            if let Err(err) = validator_for(&schema) {
2174                diagnostics.error(format!(
2175                    "{}: {label} is not a valid JSON Schema: {err}",
2176                    path.display()
2177                ));
2178            }
2179        }
2180        Err(err) => diagnostics.error(err),
2181    }
2182}
2183
2184fn catalog_index_path(root: &Path, kind: CatalogKind) -> PathBuf {
2185    let suffix = match kind {
2186        CatalogKind::Contracts => "contracts",
2187        CatalogKind::Resolvers => "resolvers",
2188        CatalogKind::Ops => "ops",
2189        CatalogKind::Views => "views",
2190        CatalogKind::FlowTemplates => "flow-templates",
2191    };
2192    root.join("catalog")
2193        .join("core")
2194        .join(suffix)
2195        .join("index.json")
2196}
2197
2198fn catalog_kind_name(kind: CatalogKind) -> &'static str {
2199    match kind {
2200        CatalogKind::Contracts => "contracts",
2201        CatalogKind::Resolvers => "resolvers",
2202        CatalogKind::Ops => "ops",
2203        CatalogKind::Views => "views",
2204        CatalogKind::FlowTemplates => "flow-templates",
2205    }
2206}
2207
2208fn entry_summary(entry: &Value) -> String {
2209    let ordered = [
2210        "entry_id",
2211        "resolver_id",
2212        "operation_id",
2213        "view_id",
2214        "template_id",
2215    ];
2216    for key in ordered {
2217        if let Some(value) = entry.get(key).and_then(Value::as_str) {
2218            return value.to_owned();
2219        }
2220    }
2221    match serde_json::to_string(entry) {
2222        Ok(value) => value,
2223        Err(_) => "<invalid-entry>".to_owned(),
2224    }
2225}
2226
2227fn into_candidate(candidate: ResolverStubCandidate) -> ResolverCandidate {
2228    ResolverCandidate {
2229        resource: candidate.resource,
2230        display: candidate.display,
2231        confidence: candidate.confidence,
2232        metadata: candidate.metadata,
2233    }
2234}
2235
2236fn format_flow_error(err: FlowError) -> String {
2237    match err {
2238        FlowError::InvalidFlow(message)
2239        | FlowError::MissingValue(message)
2240        | FlowError::MissingStep(message)
2241        | FlowError::Resolver(message)
2242        | FlowError::Operation(message)
2243        | FlowError::Join(message)
2244        | FlowError::Render(message)
2245        | FlowError::Evidence(message) => message,
2246    }
2247}
2248
2249#[cfg(test)]
2250mod tests {
2251    use super::*;
2252    use std::error::Error;
2253    use tempfile::TempDir;
2254
2255    fn run_ok(args: &[&str], cwd: &Path) -> Result<String, String> {
2256        let argv = std::iter::once("greentic-x".to_owned())
2257            .chain(args.iter().map(|item| (*item).to_owned()))
2258            .map(OsString::from)
2259            .collect::<Vec<_>>();
2260        run(argv, Ok(cwd.to_path_buf()))
2261    }
2262
2263    #[test]
2264    fn top_level_help_respects_locale() -> Result<(), Box<dyn Error>> {
2265        let temp = TempDir::new()?;
2266        let cwd = temp.path();
2267        let output = run_ok(&["--help", "--locale", "nl"], cwd)?;
2268        assert!(output.contains("Gebruik:"));
2269        assert!(output.contains("contract"));
2270        assert!(output.contains("wizard"));
2271        assert!(output.contains("--locale <LOCALE>"));
2272        assert!(output.contains("Oplossingen samenstellen en bundelgeneratie delegeren"));
2273        Ok(())
2274    }
2275
2276    #[test]
2277    fn top_level_version_uses_cargo_version() -> Result<(), Box<dyn Error>> {
2278        let temp = TempDir::new()?;
2279        let cwd = temp.path();
2280        let output = run_ok(&["--version"], cwd)?;
2281        assert_eq!(
2282            output.trim(),
2283            format!("greentic-x {}", env!("CARGO_PKG_VERSION"))
2284        );
2285        Ok(())
2286    }
2287
2288    #[test]
2289    fn scaffolds_contract_op_flow_resolver_and_view() -> Result<(), Box<dyn Error>> {
2290        let temp = TempDir::new()?;
2291        let cwd = temp.path();
2292
2293        let result = run_ok(
2294            &[
2295                "contract",
2296                "new",
2297                "contracts/example-contract",
2298                "--contract-id",
2299                "gx.example",
2300                "--resource-type",
2301                "example",
2302            ],
2303            cwd,
2304        )?;
2305        assert!(result.contains("scaffolded contract"));
2306        let contract = fs::read_to_string(cwd.join("contracts/example-contract/contract.json"))?;
2307        assert!(contract.contains("\"contract_id\": \"gx.example\""));
2308
2309        let result = run_ok(
2310            &[
2311                "op",
2312                "new",
2313                "ops/example-op",
2314                "--operation-id",
2315                "analyse.example",
2316                "--contract-id",
2317                "gx.example",
2318            ],
2319            cwd,
2320        )?;
2321        assert!(result.contains("scaffolded op"));
2322        let op = fs::read_to_string(cwd.join("ops/example-op/op.json"))?;
2323        assert!(op.contains("\"operation_id\": \"analyse.example\""));
2324
2325        let result = run_ok(
2326            &[
2327                "flow",
2328                "new",
2329                "flows/example-flow",
2330                "--flow-id",
2331                "example.flow",
2332            ],
2333            cwd,
2334        )?;
2335        assert!(result.contains("scaffolded flow"));
2336        let flow = fs::read_to_string(cwd.join("flows/example-flow/flow.json"))?;
2337        assert!(flow.contains("\"flow_id\": \"example.flow\""));
2338
2339        let result = run_ok(
2340            &[
2341                "resolver",
2342                "new",
2343                "resolvers/example-resolver",
2344                "--resolver-id",
2345                "resolve.example",
2346            ],
2347            cwd,
2348        )?;
2349        assert!(result.contains("scaffolded resolver"));
2350        let resolver = fs::read_to_string(cwd.join("resolvers/example-resolver/resolver.json"))?;
2351        assert!(resolver.contains("\"resolver_id\": \"resolve.example\""));
2352
2353        let result = run_ok(
2354            &[
2355                "view",
2356                "new",
2357                "views/example-view",
2358                "--view-id",
2359                "summary-card",
2360            ],
2361            cwd,
2362        )?;
2363        assert!(result.contains("scaffolded view"));
2364        let view = fs::read_to_string(cwd.join("views/example-view/view.json"))?;
2365        assert!(view.contains("\"view_id\": \"summary-card\""));
2366
2367        let resolver_validation =
2368            run_ok(&["resolver", "validate", "resolvers/example-resolver"], cwd)?;
2369        assert!(resolver_validation.contains("resolver validation passed"));
2370
2371        let view_validation = run_ok(&["view", "validate", "views/example-view"], cwd)?;
2372        assert!(view_validation.contains("view validation passed"));
2373        Ok(())
2374    }
2375
2376    #[test]
2377    fn validates_and_simulates_scaffolded_flow() -> Result<(), Box<dyn Error>> {
2378        let temp = TempDir::new()?;
2379        let cwd = temp.path();
2380        let _ = run_ok(
2381            &[
2382                "flow",
2383                "new",
2384                "flows/example-flow",
2385                "--flow-id",
2386                "example.flow",
2387            ],
2388            cwd,
2389        )?;
2390
2391        let validation = run_ok(&["flow", "validate", "flows/example-flow"], cwd)?;
2392        assert!(validation.contains("flow validation passed"));
2393
2394        let output = run_ok(&["simulate", "flows/example-flow"], cwd)?;
2395        assert!(
2396            output.contains("\"status\": \"succeeded\"")
2397                || output.contains("\"status\": \"partial\"")
2398        );
2399        assert!(output.contains("\"view_id\": \"summary-card\""));
2400        Ok(())
2401    }
2402
2403    #[test]
2404    fn compiles_observability_profiles() -> Result<(), Box<dyn Error>> {
2405        let temp = TempDir::new()?;
2406        let cwd = temp.path();
2407        fs::create_dir_all(cwd.join("profiles"))?;
2408        write_json(
2409            &cwd.join("profiles/example.json"),
2410            &json!({
2411                "profile_id": "example.profile",
2412                "resolver": "resolve.by_name",
2413                "query_ops": ["query.resource"],
2414                "analysis_ops": ["analyse.threshold"],
2415                "present_op": "present.summary",
2416                "split_join": null
2417            }),
2418        )?;
2419        let output = run_ok(&["profile", "compile", "profiles/example.json"], cwd)?;
2420        assert!(output.contains("\"flow_id\": \"example.profile\""));
2421
2422        write_json(
2423            &cwd.join("profiles/split.json"),
2424            &json!({
2425                "profile_id": "split.profile",
2426                "resolver": "resolve.by_name",
2427                "query_ops": [],
2428                "analysis_ops": [],
2429                "present_op": "present.summary",
2430                "split_join": {
2431                    "branches": [
2432                        {
2433                            "branch_id": "left",
2434                            "query_ops": ["query.resource"],
2435                            "analysis_ops": ["analyse.threshold"]
2436                        },
2437                        {
2438                            "branch_id": "right",
2439                            "query_ops": ["query.linked"],
2440                            "analysis_ops": ["analyse.percentile"]
2441                        }
2442                    ]
2443                }
2444            }),
2445        )?;
2446        let output = run_ok(&["profile", "compile", "profiles/split.json"], cwd)?;
2447        assert!(output.contains("\"type\": \"split\""));
2448        assert!(output.contains("\"type\": \"join\""));
2449        Ok(())
2450    }
2451
2452    #[test]
2453    fn generic_reference_examples_simulate_successfully() -> Result<(), Box<dyn Error>> {
2454        let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
2455            .parent()
2456            .and_then(Path::parent)
2457            .ok_or("failed to resolve repo root")?;
2458        let example_dirs = [
2459            "examples/top-contributors-generic",
2460            "examples/entity-utilisation-generic",
2461            "examples/change-correlation-generic",
2462            "examples/root-cause-split-join-generic",
2463        ];
2464
2465        for dir in example_dirs {
2466            let validation = run_ok(&["flow", "validate", dir], repo_root)?;
2467            assert!(validation.contains("flow validation passed"));
2468
2469            let simulation = run_ok(&["simulate", dir], repo_root)?;
2470            let run_value: Value = serde_json::from_str(&simulation)?;
2471            let expected_view: Value =
2472                read_json(&repo_root.join(dir).join("expected.view.json")).map_err(io_error)?;
2473            let expected_evidence: Value =
2474                read_json(&repo_root.join(dir).join("expected.evidence.json")).map_err(io_error)?;
2475
2476            assert_eq!(
2477                run_value["view"], expected_view,
2478                "unexpected view for {dir}"
2479            );
2480
2481            let actual_evidence_ids = run_value["view"]["primary_data_refs"].clone();
2482            let expected_evidence_ids = expected_evidence
2483                .as_array()
2484                .ok_or("expected evidence should be an array")?
2485                .iter()
2486                .map(|item| item["evidence_id"].clone())
2487                .collect::<Vec<_>>();
2488            assert_eq!(
2489                actual_evidence_ids,
2490                Value::Array(expected_evidence_ids),
2491                "unexpected evidence refs for {dir}"
2492            );
2493        }
2494        Ok(())
2495    }
2496
2497    #[test]
2498    fn doctor_catches_broken_references() -> Result<(), Box<dyn Error>> {
2499        let temp = TempDir::new()?;
2500        let cwd = temp.path();
2501
2502        fs::create_dir_all(cwd.join("catalog/core/resolvers"))?;
2503        fs::create_dir_all(cwd.join("catalog/core/ops"))?;
2504        fs::create_dir_all(cwd.join("catalog/core/views"))?;
2505        write_json(
2506            &cwd.join("catalog/core/resolvers/index.json"),
2507            &json!({"entries": []}),
2508        )?;
2509        write_json(
2510            &cwd.join("catalog/core/ops/index.json"),
2511            &json!({"entries": []}),
2512        )?;
2513        write_json(
2514            &cwd.join("catalog/core/views/index.json"),
2515            &json!({"entries": []}),
2516        )?;
2517
2518        let _ = run_ok(
2519            &[
2520                "flow",
2521                "new",
2522                "flows/example-flow",
2523                "--flow-id",
2524                "example.flow",
2525            ],
2526            cwd,
2527        )?;
2528        let doctor = run_ok(&["doctor", "."], cwd);
2529        assert!(doctor.is_err());
2530        let message = match doctor {
2531            Ok(value) => value,
2532            Err(err) => err,
2533        };
2534        assert!(message.contains("unknown operation"));
2535        Ok(())
2536    }
2537
2538    #[test]
2539    fn flow_validation_catches_broken_join() -> Result<(), Box<dyn Error>> {
2540        let temp = TempDir::new()?;
2541        let cwd = temp.path();
2542        fs::create_dir_all(cwd.join("flows/broken-flow"))?;
2543        write_json(
2544            &cwd.join("flows/broken-flow/manifest.json"),
2545            &json!({
2546                "flow_id": "broken.flow",
2547                "version": "v1",
2548                "description": "broken",
2549                "flow": "flow.json"
2550            }),
2551        )?;
2552        write_json(
2553            &cwd.join("flows/broken-flow/flow.json"),
2554            &json!({
2555                "flow_id": "broken.flow",
2556                "steps": [
2557                    {
2558                        "id": "join",
2559                        "kind": {
2560                            "type": "join",
2561                            "split_step_id": "missing-split",
2562                            "mode": "all",
2563                            "output_key": "merged"
2564                        }
2565                    },
2566                    {
2567                        "id": "return",
2568                        "kind": {
2569                            "type": "return",
2570                            "output": {"kind": "literal", "value": {"ok": true}}
2571                        }
2572                    }
2573                ]
2574            }),
2575        )?;
2576
2577        let validation = run_ok(&["flow", "validate", "flows/broken-flow"], cwd);
2578        assert!(validation.is_err());
2579        let message = match validation {
2580            Ok(value) => value,
2581            Err(err) => err,
2582        };
2583        assert!(message.contains("references missing or later split"));
2584        Ok(())
2585    }
2586
2587    #[test]
2588    fn wizard_run_outputs_composition_plan() -> Result<(), Box<dyn Error>> {
2589        let temp = TempDir::new()?;
2590        let cwd = temp.path();
2591        let output = run_ok(&["wizard", "run", "--dry-run"], cwd)?;
2592        let value: Value = serde_json::from_str(&output)?;
2593        assert_eq!(value["requested_action"], "run");
2594        assert_eq!(value["metadata"]["execution"], "dry_run");
2595        assert_eq!(
2596            value["normalized_input_summary"]["workflow"],
2597            "compose_solution"
2598        );
2599        assert_eq!(
2600            value["normalized_input_summary"]["solution_id"],
2601            "gx-solution"
2602        );
2603        Ok(())
2604    }
2605
2606    #[test]
2607    fn wizard_help_mentions_schema_option() -> Result<(), Box<dyn Error>> {
2608        let temp = TempDir::new()?;
2609        let cwd = temp.path();
2610        let output = run_ok(&["wizard", "--help"], cwd)?;
2611        assert!(output.contains("--schema"));
2612        Ok(())
2613    }
2614
2615    #[test]
2616    fn wizard_schema_outputs_answer_document_schema() -> Result<(), Box<dyn Error>> {
2617        let temp = TempDir::new()?;
2618        let cwd = temp.path();
2619        let output = run_ok(&["wizard", "--schema"], cwd)?;
2620        let value: Value = serde_json::from_str(&output)?;
2621        assert_eq!(
2622            value["properties"]["wizard_id"]["const"],
2623            "greentic-bundle.wizard.run"
2624        );
2625        assert_eq!(
2626            value["properties"]["schema_id"]["const"],
2627            "greentic-bundle.wizard.answers"
2628        );
2629        assert_eq!(
2630            value["$defs"]["gx_answers"]["properties"]["mode"]["enum"][0],
2631            "create"
2632        );
2633        assert_eq!(
2634            value["$defs"]["gx_runtime_form"]["id"],
2635            "gx.wizard.composition"
2636        );
2637        Ok(())
2638    }
2639
2640    #[test]
2641    fn wizard_plan_is_deterministic_for_dry_run() -> Result<(), Box<dyn Error>> {
2642        let temp = TempDir::new()?;
2643        let cwd = temp.path();
2644        let first: Value = serde_json::from_str(&run_ok(&["wizard", "run", "--dry-run"], cwd)?)?;
2645        let second: Value = serde_json::from_str(&run_ok(&["wizard", "run", "--dry-run"], cwd)?)?;
2646        assert_eq!(first, second);
2647        Ok(())
2648    }
2649
2650    #[test]
2651    fn wizard_validate_is_always_dry_run() -> Result<(), Box<dyn Error>> {
2652        let temp = TempDir::new()?;
2653        let cwd = temp.path();
2654        let output = run_ok(&["wizard", "validate"], cwd)?;
2655        let value: Value = serde_json::from_str(&output)?;
2656        assert_eq!(value["requested_action"], "validate");
2657        assert_eq!(value["metadata"]["execution"], "dry_run");
2658        Ok(())
2659    }
2660
2661    #[test]
2662    fn wizard_apply_plan_reports_compatibility_bridge_warning() -> Result<(), Box<dyn Error>> {
2663        let temp = TempDir::new()?;
2664        let cwd = temp.path();
2665        let output = run_ok(&["wizard", "apply", "--dry-run"], cwd)?;
2666        let value: Value = serde_json::from_str(&output)?;
2667        let warnings = value["warnings"].as_array().expect("warnings");
2668        assert!(
2669            warnings.iter().any(|item| {
2670                item.as_str()
2671                    .is_some_and(|text| text.contains("compatibility bridge"))
2672            }),
2673            "expected compatibility bridge warning in {warnings:?}"
2674        );
2675        Ok(())
2676    }
2677
2678    #[test]
2679    fn wizard_emit_answers_writes_answer_document() -> Result<(), Box<dyn Error>> {
2680        let temp = TempDir::new()?;
2681        let cwd = temp.path();
2682        let output = run_ok(
2683            &[
2684                "wizard",
2685                "run",
2686                "--dry-run",
2687                "--emit-answers",
2688                "wizard.answers.json",
2689            ],
2690            cwd,
2691        )?;
2692        let emitted: Value =
2693            serde_json::from_str(&fs::read_to_string(cwd.join("wizard.answers.json"))?)?;
2694        assert_eq!(emitted["wizard_id"], "greentic-bundle.wizard.run");
2695        assert_eq!(emitted["schema_id"], "greentic-bundle.wizard.answers");
2696        assert_eq!(emitted["answers"]["workflow"], "compose_solution");
2697        assert_eq!(emitted["answers"]["solution_id"], "gx-solution");
2698        let plan: Value = serde_json::from_str(&output)?;
2699        assert!(plan["expected_file_writes"].as_array().is_some());
2700        Ok(())
2701    }
2702
2703    #[test]
2704    fn wizard_run_emits_launcher_compatibility_answers() -> Result<(), Box<dyn Error>> {
2705        let temp = TempDir::new()?;
2706        let cwd = temp.path();
2707        write_json(
2708            &cwd.join("input.answers.json"),
2709            &json!({
2710                "wizard_id": "greentic-bundle.wizard.run",
2711                "schema_id": "greentic-bundle.wizard.answers",
2712                "schema_version": "1.0.0",
2713                "locale": "en",
2714                "answers": {
2715                    "solution_name": "Network Assistant"
2716                },
2717                "locks": {}
2718            }),
2719        )?;
2720
2721        run_ok(&["wizard", "run", "--answers", "input.answers.json"], cwd)?;
2722
2723        let emitted: Value = serde_json::from_str(&fs::read_to_string(
2724            cwd.join("dist/network-assistant.launcher.answers.json"),
2725        )?)?;
2726        assert_eq!(emitted["wizard_id"], "greentic-dev.wizard.launcher.main");
2727        assert_eq!(emitted["schema_id"], "greentic-dev.launcher.main");
2728        assert_eq!(emitted["answers"]["selected_action"], "bundle");
2729        assert_eq!(
2730            emitted["answers"]["delegate_answer_document"]["wizard_id"],
2731            "greentic-bundle.wizard.run"
2732        );
2733        assert_eq!(
2734            emitted["answers"]["delegate_answer_document"]["schema_id"],
2735            "greentic-bundle.wizard.answers"
2736        );
2737        Ok(())
2738    }
2739
2740    #[test]
2741    fn wizard_run_emits_pack_compatibility_input() -> Result<(), Box<dyn Error>> {
2742        let temp = TempDir::new()?;
2743        let cwd = temp.path();
2744        write_json(
2745            &cwd.join("input.answers.json"),
2746            &json!({
2747                "wizard_id": "greentic-bundle.wizard.run",
2748                "schema_id": "greentic-bundle.wizard.answers",
2749                "schema_version": "1.0.0",
2750                "locale": "en",
2751                "answers": {
2752                    "solution_name": "Network Assistant"
2753                },
2754                "locks": {}
2755            }),
2756        )?;
2757
2758        run_ok(&["wizard", "run", "--answers", "input.answers.json"], cwd)?;
2759
2760        let pack_input: Value = serde_json::from_str(&fs::read_to_string(
2761            cwd.join("dist/network-assistant.pack.input.json"),
2762        )?)?;
2763        assert_eq!(pack_input["schema_id"], "gx.pack.input");
2764        assert_eq!(pack_input["solution_id"], "network-assistant");
2765        assert!(pack_input["unresolved_downstream_work"].is_array());
2766
2767        let handoff: Value = serde_json::from_str(&fs::read_to_string(
2768            cwd.join("dist/network-assistant.toolchain-handoff.json"),
2769        )?)?;
2770        assert_eq!(handoff["pack_handoff"]["tool"], "greentic-pack");
2771        assert_eq!(
2772            handoff["pack_handoff"]["pack_input_path"],
2773            "dist/network-assistant.pack.input.json"
2774        );
2775        Ok(())
2776    }
2777
2778    #[test]
2779    fn wizard_catalog_flag_is_emitted_into_answers() -> Result<(), Box<dyn Error>> {
2780        let temp = TempDir::new()?;
2781        let cwd = temp.path();
2782        write_json(
2783            &cwd.join("input.answers.json"),
2784            &json!({
2785                "wizard_id": "greentic-bundle.wizard.run",
2786                "schema_id": "greentic-bundle.wizard.answers",
2787                "schema_version": "1.0.0",
2788                "locale": "en",
2789                "answers": {
2790                    "solution_name": "Network Assistant"
2791                },
2792                "locks": {}
2793            }),
2794        )?;
2795        let _ = run_ok(
2796            &[
2797                "wizard",
2798                "run",
2799                "--dry-run",
2800                "--answers",
2801                "input.answers.json",
2802                "--catalog",
2803                "oci://ghcr.io/greenticai/catalogs/zain-x/catalog.json:stable",
2804                "--emit-answers",
2805                "output.answers.json",
2806            ],
2807            cwd,
2808        )?;
2809        let emitted: Value =
2810            serde_json::from_str(&fs::read_to_string(cwd.join("output.answers.json"))?)?;
2811        assert_eq!(
2812            emitted["answers"]["catalog_oci_refs"][0],
2813            "oci://ghcr.io/greenticai/catalogs/zain-x/catalog.json:stable"
2814        );
2815        Ok(())
2816    }
2817
2818    #[test]
2819    fn catalog_build_and_validate_commands_work() -> Result<(), Box<dyn Error>> {
2820        let temp = TempDir::new()?;
2821        let cwd = temp.path();
2822        let _ = run_ok(&["catalog", "init", "zain-x"], cwd)?;
2823        let _ = run_ok(&["catalog", "build", "--repo", "zain-x"], cwd)?;
2824        let _ = run_ok(&["catalog", "validate", "--repo", "zain-x"], cwd)?;
2825        Ok(())
2826    }
2827
2828    #[test]
2829    fn wizard_rejects_schema_version_change_without_migrate() -> Result<(), Box<dyn Error>> {
2830        let temp = TempDir::new()?;
2831        let cwd = temp.path();
2832        write_json(
2833            &cwd.join("input.answers.json"),
2834            &json!({
2835                "wizard_id": "greentic-bundle.wizard.run",
2836                "schema_id": "greentic-bundle.wizard.answers",
2837                "schema_version": "0.9.0",
2838                "locale": "en",
2839                "answers": {},
2840                "locks": {}
2841            }),
2842        )?;
2843        let err = run_ok(
2844            &[
2845                "wizard",
2846                "run",
2847                "--answers",
2848                "input.answers.json",
2849                "--schema-version",
2850                "1.0.0",
2851            ],
2852            cwd,
2853        )
2854        .expect_err("expected migration error");
2855        assert!(err.contains("--migrate"));
2856        Ok(())
2857    }
2858
2859    #[test]
2860    fn wizard_migrate_updates_schema_version() -> Result<(), Box<dyn Error>> {
2861        let temp = TempDir::new()?;
2862        let cwd = temp.path();
2863        write_json(
2864            &cwd.join("input.answers.json"),
2865            &json!({
2866                "wizard_id": "greentic-bundle.wizard.run",
2867                "schema_id": "greentic-bundle.wizard.answers",
2868                "schema_version": "0.9.0",
2869                "locale": "en",
2870                "answers": {},
2871                "locks": {}
2872            }),
2873        )?;
2874        let _ = run_ok(
2875            &[
2876                "wizard",
2877                "run",
2878                "--answers",
2879                "input.answers.json",
2880                "--schema-version",
2881                "1.1.0",
2882                "--migrate",
2883                "--emit-answers",
2884                "output.answers.json",
2885            ],
2886            cwd,
2887        )?;
2888        let emitted: Value =
2889            serde_json::from_str(&fs::read_to_string(cwd.join("output.answers.json"))?)?;
2890        assert_eq!(emitted["schema_version"], "1.1.0");
2891        Ok(())
2892    }
2893
2894    #[test]
2895    fn wizard_handoff_invocation_uses_answers_path() -> Result<(), Box<dyn Error>> {
2896        let invocation = wizard::bundle_handoff_invocation(Path::new("/tmp/bundle.answers.json"));
2897        let parts = invocation
2898            .iter()
2899            .map(|value| value.to_string_lossy().to_string())
2900            .collect::<Vec<_>>();
2901        assert_eq!(
2902            parts,
2903            vec![
2904                "wizard".to_owned(),
2905                "apply".to_owned(),
2906                "--answers".to_owned(),
2907                "/tmp/bundle.answers.json".to_owned(),
2908            ]
2909        );
2910        Ok(())
2911    }
2912
2913    #[test]
2914    fn wizard_update_mode_prefills_existing_solution() -> Result<(), Box<dyn Error>> {
2915        let temp = TempDir::new()?;
2916        let cwd = temp.path();
2917        fs::create_dir_all(cwd.join("dist"))?;
2918        write_json(
2919            &cwd.join("dist/network-assistant.solution.json"),
2920            &json!({
2921                "schema_id": "gx.solution.manifest",
2922                "schema_version": "1.0.0",
2923                "solution_id": "network-assistant",
2924                "solution_name": "Network Assistant",
2925                "description": "Automates network diagnostics",
2926                "output_dir": "dist",
2927                "template": {
2928                    "entry_id": "assistant.network.phase1",
2929                    "display_name": "Network Assistant Phase 1"
2930                },
2931                "provider_presets": [{
2932                    "entry_id": "builtin.teams",
2933                    "display_name": "Teams",
2934                    "provider_refs": ["oci://ghcr.io/greenticai/packs/messaging/messaging-teams:stable"]
2935                }]
2936            }),
2937        )?;
2938        write_json(
2939            &cwd.join("input.answers.json"),
2940            &json!({
2941                "wizard_id": "greentic-bundle.wizard.run",
2942                "schema_id": "greentic-bundle.wizard.answers",
2943                "schema_version": "1.0.0",
2944                "locale": "en",
2945                "answers": {
2946                    "mode": "update",
2947                    "existing_solution_path": "dist/network-assistant.solution.json"
2948                },
2949                "locks": {}
2950            }),
2951        )?;
2952        let output = run_ok(
2953            &[
2954                "wizard",
2955                "run",
2956                "--answers",
2957                "input.answers.json",
2958                "--dry-run",
2959            ],
2960            cwd,
2961        )?;
2962        let value: Value = serde_json::from_str(&output)?;
2963        assert_eq!(
2964            value["normalized_input_summary"]["solution_name"],
2965            "Network Assistant"
2966        );
2967        assert_eq!(
2968            value["normalized_input_summary"]["solution_id"],
2969            "network-assistant"
2970        );
2971        Ok(())
2972    }
2973
2974    #[test]
2975    fn wizard_locale_nl_keeps_plan_serializable() -> Result<(), Box<dyn Error>> {
2976        let temp = TempDir::new()?;
2977        let cwd = temp.path();
2978        let output = run_ok(&["wizard", "run", "--dry-run", "--locale", "nl-NL"], cwd)?;
2979        let value: Value = serde_json::from_str(&output)?;
2980        assert_eq!(value["metadata"]["locale"], "nl");
2981        Ok(())
2982    }
2983
2984    fn io_error(message: String) -> Box<dyn Error> {
2985        Box::new(std::io::Error::other(message))
2986    }
2987}