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