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