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