Skip to main content

greentic_sorla_lib/
lib.rs

1#![cfg_attr(
2    any(not(feature = "cli"), not(feature = "pack-zip")),
3    allow(dead_code, unused_imports)
4)]
5
6use clap::{ArgAction, Args, Parser, Subcommand};
7#[cfg(feature = "cli")]
8use greentic_qa_lib::{
9    AnswerProvider, I18nConfig, QaLibError, ResolvedI18nMap, WizardDriver, WizardFrontend,
10    WizardRunConfig,
11};
12pub use greentic_sorla_pack::{
13    AgentEndpointActionCatalogDocument, DEFAULT_DESIGNER_COMPONENT_OPERATION,
14    DEFAULT_DESIGNER_COMPONENT_REF, DesignerNodeType, DesignerNodeTypesDocument,
15};
16use greentic_sorla_pack::{
17    DesignerNodeTypeGenerationOptions, PROVIDER_BINDINGS_TEMPLATE_FILENAME,
18    RUNTIME_TEMPLATE_FILENAME, SORX_COMPATIBILITY_SCHEMA, SORX_EXPOSURE_POLICY_SCHEMA,
19    SORX_VALIDATION_SCHEMA, START_SCHEMA_FILENAME, SorlaGtpackInspection, SorlaGtpackOptions,
20    build_handoff_artifacts_from_yaml, generate_agent_endpoint_action_catalog_from_ir,
21    generate_designer_node_types_from_ir, generate_sorx_validation_manifest_from_ir,
22    ontology_schema_json, retrieval_bindings_schema_json, sorx_validation_schema_json,
23};
24#[cfg(feature = "pack-zip")]
25use greentic_sorla_pack::{build_sorla_gtpack, doctor_sorla_gtpack, inspect_sorla_gtpack};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use std::collections::{BTreeMap, BTreeSet};
29use std::ffi::OsString;
30use std::fs;
31use std::io::{self, Write};
32use std::path::{Path, PathBuf};
33
34mod embedded_i18n {
35    include!(concat!(env!("OUT_DIR"), "/embedded_i18n.rs"));
36}
37
38const GENERATED_BEGIN: &str = "# --- BEGIN GREENTIC-SORLA GENERATED ---";
39const GENERATED_END: &str = "# --- END GREENTIC-SORLA GENERATED ---";
40const LOCK_FILENAME: &str = "answers.lock.json";
41const LEGACY_PACKAGE_MANIFEST_FILENAME: &str = "package-manifest.json";
42const LAUNCHER_HANDOFF_FILENAME: &str = "launcher-handoff.json";
43
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct NormalizeOptions;
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct ValidateOptions;
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct PreviewOptions;
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct PackBuildOptions {
55    pub name: Option<String>,
56    pub version: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct DesignerNodeTypeOptions {
61    pub component_ref: String,
62    pub operation: String,
63}
64
65impl Default for DesignerNodeTypeOptions {
66    fn default() -> Self {
67        Self {
68            component_ref: DEFAULT_DESIGNER_COMPONENT_REF.to_string(),
69            operation: DEFAULT_DESIGNER_COMPONENT_OPERATION.to_string(),
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct NormalizedSorlaModel {
76    pub package_name: String,
77    pub package_version: String,
78    pub locale: String,
79    pub source_yaml: String,
80    pub normalized_answers: serde_json::Value,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct SorlaDiagnostic {
85    pub severity: DiagnosticSeverity,
86    pub code: String,
87    pub message: String,
88    pub path: Option<String>,
89    pub suggestion: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum DiagnosticSeverity {
95    Info,
96    Warning,
97    Error,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct SorlaValidationReport {
102    pub diagnostics: Vec<SorlaDiagnostic>,
103}
104
105impl SorlaValidationReport {
106    pub fn has_errors(&self) -> bool {
107        self.diagnostics
108            .iter()
109            .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct SorlaPreview {
115    pub summary: SorlaPreviewSummary,
116    pub cards: Vec<SorlaPreviewCard>,
117    pub graph: Option<SorlaPreviewGraph>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121pub struct SorlaPreviewSummary {
122    pub package_name: String,
123    pub package_version: String,
124    pub records: usize,
125    pub events: usize,
126    pub projections: usize,
127    pub agent_endpoints: usize,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct SorlaPreviewCard {
132    pub title: String,
133    pub items: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct SorlaPreviewGraph {
138    pub nodes: usize,
139    pub edges: usize,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct PackBuildBytes {
144    pub filename: String,
145    #[serde(skip_serializing)]
146    pub bytes: Vec<u8>,
147    pub sha256: String,
148    pub metadata: PackBuildMetadata,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct PackBuildMetadata {
153    pub pack_id: String,
154    pub pack_version: String,
155    pub sorla_package_name: String,
156    pub sorla_package_version: String,
157    pub ir_hash: String,
158    pub assets: Vec<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct PackEntry {
163    pub path: String,
164    #[serde(skip_serializing)]
165    pub bytes: Vec<u8>,
166    pub sha256: String,
167}
168
169pub type SorlaError = String;
170pub type PackBuildResult = greentic_sorla_pack::SorlaGtpackBuildSummary;
171
172#[cfg(not(feature = "pack-zip"))]
173fn build_sorla_gtpack(_options: &SorlaGtpackOptions) -> Result<PackBuildResult, String> {
174    Err("gtpack ZIP byte generation requires the `pack-zip` feature; use build_gtpack_entries for wasm-safe planning".to_string())
175}
176
177#[cfg(not(feature = "pack-zip"))]
178fn inspect_sorla_gtpack(_path: &Path) -> Result<SorlaGtpackInspection, String> {
179    Err("gtpack inspection from ZIP files requires the `pack-zip` feature".to_string())
180}
181
182#[cfg(not(feature = "pack-zip"))]
183fn doctor_sorla_gtpack(
184    _path: &Path,
185) -> Result<greentic_sorla_pack::SorlaGtpackDoctorReport, String> {
186    Err("gtpack doctor from ZIP files requires the `pack-zip` feature".to_string())
187}
188
189#[derive(Debug, Parser)]
190#[command(
191    name = "greentic-sorla",
192    about = "Wizard-first tooling for Greentic SoRLa source layouts and handoff artifacts.",
193    long_about = "greentic-sorla is a wizard-first tool for authoring SoRLa source layouts, extension handoff artifacts, and deterministic handoff packs.\n\nSupported product surface:\n  greentic-sorla wizard --schema\n  greentic-sorla wizard --answers <file>\n  greentic-sorla wizard --answers <file> --pack-out <file.gtpack>\n  greentic-sorla pack <file> --name <name> --version <version> --out <file.gtpack>\n",
194    after_help = "Internal helper commands may exist, but the supported UX is the wizard flow plus deterministic pack handoff."
195)]
196pub struct Cli {
197    #[command(subcommand)]
198    command: Commands,
199}
200
201#[derive(Debug, Subcommand)]
202enum Commands {
203    /// Generate wizard schema or apply a saved answers document.
204    Wizard(WizardArgs),
205    /// Build, inspect, or doctor deterministic SoRLa gtpack handoff artifacts.
206    Pack(PackArgs),
207    #[command(name = "__inspect-product-shape", hide = true)]
208    InspectProductShape,
209}
210
211#[derive(Debug, Args)]
212struct WizardArgs {
213    /// Emit the wizard schema as deterministic JSON.
214    #[arg(long, action = ArgAction::SetTrue)]
215    schema: bool,
216    /// Locale used for wizard schema metadata and interactive prompts.
217    #[arg(long)]
218    locale: Option<String>,
219    /// Apply a saved answers document.
220    #[arg(long, value_name = "FILE")]
221    answers: Option<PathBuf>,
222    /// Also build a deterministic .gtpack from the generated sorla.yaml.
223    #[arg(long, value_name = "FILE")]
224    pack_out: Option<PathBuf>,
225}
226
227#[derive(Debug, Args)]
228struct PackArgs {
229    /// SoRLa YAML input to package.
230    #[arg(value_name = "FILE")]
231    input: Option<PathBuf>,
232    /// Pack name to write into the gtpack manifest.
233    #[arg(long)]
234    name: Option<String>,
235    /// Pack semantic version.
236    #[arg(long)]
237    version: Option<String>,
238    /// Output .gtpack path.
239    #[arg(long, value_name = "FILE")]
240    out: Option<PathBuf>,
241    #[command(subcommand)]
242    command: Option<PackCommand>,
243}
244
245#[derive(Debug, Subcommand)]
246enum PackCommand {
247    /// Validate a generated SoRLa gtpack.
248    Doctor(PackPathArgs),
249    /// Inspect a generated SoRLa gtpack as deterministic JSON.
250    Inspect(PackPathArgs),
251    /// Emit deterministic JSON schemas for SORX handoff metadata.
252    Schema(PackSchemaArgs),
253    /// Inspect embedded SORX validation metadata as deterministic JSON.
254    ValidationInspect(PackPathArgs),
255    /// Validate embedded SORX validation metadata using pack doctor checks.
256    ValidationDoctor(PackPathArgs),
257}
258
259#[derive(Debug, Args)]
260struct PackSchemaArgs {
261    #[command(subcommand)]
262    command: PackSchemaCommand,
263}
264
265#[derive(Debug, Subcommand)]
266enum PackSchemaCommand {
267    /// Emit the greentic.sorx.validation.v1 schema.
268    Validation,
269    /// Emit the greentic.sorx.exposure-policy.v1 schema.
270    ExposurePolicy,
271    /// Emit the greentic.sorx.compatibility.v1 schema.
272    Compatibility,
273    /// Emit the greentic.sorla.ontology.v1 schema.
274    Ontology,
275    /// Emit the greentic.sorla.retrieval-bindings.v1 schema.
276    RetrievalBindings,
277}
278
279#[derive(Debug, Args)]
280struct PackPathArgs {
281    /// .gtpack file to inspect or validate.
282    #[arg(value_name = "FILE")]
283    path: PathBuf,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
287#[serde(rename_all = "kebab-case")]
288pub enum SchemaFlow {
289    Create,
290    Update,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
294pub struct WizardSchema {
295    pub schema_version: &'static str,
296    pub wizard_version: &'static str,
297    pub package_version: &'static str,
298    pub locale: String,
299    pub fallback_locale: &'static str,
300    pub supported_modes: Vec<SchemaFlow>,
301    pub provider_repo: &'static str,
302    pub generated_content_strategy: &'static str,
303    pub user_content_strategy: &'static str,
304    pub artifact_references: Vec<&'static str>,
305    pub sections: Vec<WizardSection>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct WizardSection {
310    pub id: &'static str,
311    pub title_key: &'static str,
312    pub description_key: &'static str,
313    pub flows: Vec<SchemaFlow>,
314    pub questions: Vec<WizardQuestion>,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
318pub struct WizardQuestion {
319    pub id: &'static str,
320    pub label_key: &'static str,
321    pub help_key: Option<&'static str>,
322    pub kind: WizardQuestionKind,
323    pub required: bool,
324    pub default_value: Option<&'static str>,
325    pub choices: Vec<WizardChoice>,
326    pub visibility: Option<SchemaVisibility>,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
330#[serde(rename_all = "kebab-case")]
331pub enum WizardQuestionKind {
332    Text,
333    TextList,
334    Boolean,
335    SingleSelect,
336    MultiSelect,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
340pub struct WizardChoice {
341    pub value: &'static str,
342    pub label_key: &'static str,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
346pub struct SchemaVisibility {
347    pub depends_on: &'static str,
348    pub equals: &'static str,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
352struct ExecutionSummary {
353    mode: &'static str,
354    output_dir: String,
355    package_name: String,
356    locale: String,
357    written_files: Vec<String>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pack_path: Option<String>,
360    preserved_user_content: bool,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364struct AnswersDocument {
365    schema_version: String,
366    flow: String,
367    output_dir: String,
368    #[serde(default)]
369    locale: Option<String>,
370    #[serde(default)]
371    package: Option<PackageAnswers>,
372    #[serde(default)]
373    providers: Option<ProviderAnswers>,
374    #[serde(default)]
375    records: Option<RecordAnswers>,
376    #[serde(default)]
377    ontology: Option<OntologyAnswers>,
378    #[serde(default)]
379    semantic_aliases: Option<SemanticAliasesAnswer>,
380    #[serde(default)]
381    entity_linking: Option<EntityLinkingAnswer>,
382    #[serde(default)]
383    retrieval_bindings: Option<RetrievalBindingsAnswer>,
384    #[serde(default)]
385    actions: Vec<NamedAnswer>,
386    #[serde(default)]
387    events: Option<EventAnswers>,
388    #[serde(default)]
389    projections: Option<ProjectionAnswers>,
390    #[serde(default)]
391    provider_requirements: Vec<ProviderRequirementAnswer>,
392    #[serde(default)]
393    policies: Vec<NamedAnswer>,
394    #[serde(default)]
395    approvals: Vec<NamedAnswer>,
396    #[serde(default)]
397    migrations: Option<MigrationAnswers>,
398    #[serde(default)]
399    agent_endpoints: Option<AgentEndpointAnswers>,
400    #[serde(default)]
401    output: Option<OutputAnswers>,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
405struct PackageAnswers {
406    #[serde(default)]
407    name: Option<String>,
408    #[serde(default)]
409    version: Option<String>,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
413struct ProviderAnswers {
414    #[serde(default)]
415    storage_category: Option<String>,
416    #[serde(default)]
417    external_ref_category: Option<String>,
418    #[serde(default)]
419    hints: Option<Vec<String>>,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
423struct RecordAnswers {
424    #[serde(default)]
425    default_source: Option<String>,
426    #[serde(default)]
427    external_ref_system: Option<String>,
428    #[serde(default)]
429    items: Vec<RecordItemAnswer>,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
433struct RecordItemAnswer {
434    name: String,
435    #[serde(default)]
436    source: Option<String>,
437    #[serde(default)]
438    external_ref: Option<ExternalRefAnswer>,
439    #[serde(default)]
440    fields: Vec<FieldAnswer>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
444struct ExternalRefAnswer {
445    system: String,
446    key: String,
447    #[serde(default)]
448    authoritative: bool,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
452struct FieldAnswer {
453    name: String,
454    #[serde(rename = "type")]
455    type_name: String,
456    #[serde(default)]
457    required: Option<bool>,
458    #[serde(default)]
459    sensitive: Option<bool>,
460    #[serde(default)]
461    enum_values: Vec<String>,
462    #[serde(default)]
463    references: Option<FieldReferenceAnswer>,
464    #[serde(default)]
465    authority: Option<String>,
466    #[serde(default)]
467    description: Option<String>,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
471struct FieldReferenceAnswer {
472    record: String,
473    field: String,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
477struct OntologyAnswers {
478    #[serde(default)]
479    schema: Option<String>,
480    #[serde(default)]
481    concepts: Vec<OntologyConceptAnswer>,
482    #[serde(default)]
483    relationships: Vec<OntologyRelationshipAnswer>,
484    #[serde(default)]
485    constraints: Vec<OntologyConstraintAnswer>,
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
489struct OntologyConceptAnswer {
490    id: String,
491    kind: String,
492    #[serde(default)]
493    description: Option<String>,
494    #[serde(default)]
495    extends: Vec<String>,
496    #[serde(default)]
497    backed_by: Option<OntologyBackingAnswer>,
498    #[serde(default)]
499    sensitivity: Option<OntologySensitivityAnswer>,
500    #[serde(default)]
501    policy_hooks: Vec<OntologyPolicyHookAnswer>,
502    #[serde(default)]
503    provider_requirements: Vec<ProviderRequirementAnswer>,
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
507struct OntologyRelationshipAnswer {
508    id: String,
509    #[serde(default)]
510    label: Option<String>,
511    from: String,
512    to: String,
513    #[serde(default)]
514    cardinality: Option<OntologyCardinalityAnswer>,
515    #[serde(default)]
516    backed_by: Option<OntologyBackingAnswer>,
517    #[serde(default)]
518    sensitivity: Option<OntologySensitivityAnswer>,
519    #[serde(default)]
520    policy_hooks: Vec<OntologyPolicyHookAnswer>,
521    #[serde(default)]
522    provider_requirements: Vec<ProviderRequirementAnswer>,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
526struct OntologyCardinalityAnswer {
527    from: String,
528    to: String,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
532struct OntologyBackingAnswer {
533    record: String,
534    #[serde(default)]
535    from_field: Option<String>,
536    #[serde(default)]
537    to_field: Option<String>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
541struct OntologySensitivityAnswer {
542    #[serde(default)]
543    classification: Option<String>,
544    #[serde(default)]
545    pii: Option<bool>,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
549struct OntologyPolicyHookAnswer {
550    policy: String,
551    #[serde(default)]
552    reason: Option<String>,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
556struct OntologyConstraintAnswer {
557    id: String,
558    applies_to: OntologyConstraintTargetAnswer,
559    #[serde(default)]
560    requires_policy: Option<String>,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
564struct OntologyConstraintTargetAnswer {
565    concept: String,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
569struct SemanticAliasesAnswer {
570    #[serde(default)]
571    concepts: BTreeMap<String, Vec<String>>,
572    #[serde(default)]
573    relationships: BTreeMap<String, Vec<String>>,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
577struct EntityLinkingAnswer {
578    #[serde(default)]
579    strategies: Vec<EntityLinkingStrategyAnswer>,
580}
581
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583struct EntityLinkingStrategyAnswer {
584    id: String,
585    applies_to: String,
586    #[serde(default)]
587    source_type: Option<String>,
588    #[serde(rename = "match")]
589    match_fields: EntityLinkingMatchAnswer,
590    confidence: serde_json::Number,
591    #[serde(default)]
592    sensitivity: Option<OntologySensitivityAnswer>,
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596struct EntityLinkingMatchAnswer {
597    source_field: String,
598    target_field: String,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
602struct RetrievalBindingsAnswer {
603    #[serde(default)]
604    schema: Option<String>,
605    #[serde(default)]
606    providers: Vec<RetrievalProviderAnswer>,
607    #[serde(default)]
608    scopes: Vec<RetrievalScopeAnswer>,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
612struct RetrievalProviderAnswer {
613    id: String,
614    category: String,
615    #[serde(default)]
616    required_capabilities: Vec<String>,
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
620struct RetrievalScopeAnswer {
621    id: String,
622    applies_to: RetrievalScopeTargetAnswer,
623    provider: String,
624    #[serde(default)]
625    filters: Option<RetrievalFilterAnswer>,
626    #[serde(default)]
627    permission: Option<String>,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
631struct RetrievalScopeTargetAnswer {
632    #[serde(default)]
633    concept: Option<String>,
634    #[serde(default)]
635    relationship: Option<String>,
636}
637
638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
639struct RetrievalFilterAnswer {
640    #[serde(default)]
641    entity_scope: Option<EntityScopeAnswer>,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
645struct EntityScopeAnswer {
646    #[serde(default)]
647    include_self: Option<bool>,
648    #[serde(default)]
649    include_related: Vec<RelationshipTraversalAnswer>,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
653struct RelationshipTraversalAnswer {
654    relationship: String,
655    direction: String,
656    max_depth: u8,
657}
658
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
660struct EventAnswers {
661    #[serde(default)]
662    enabled: Option<bool>,
663    #[serde(default)]
664    items: Vec<EventItemAnswer>,
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
668struct EventItemAnswer {
669    name: String,
670    record: String,
671    #[serde(default)]
672    kind: Option<String>,
673    #[serde(default)]
674    emits: Vec<EventFieldAnswer>,
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
678struct EventFieldAnswer {
679    name: String,
680    #[serde(rename = "type")]
681    type_name: String,
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
685struct ProjectionAnswers {
686    #[serde(default)]
687    mode: Option<String>,
688    #[serde(default)]
689    items: Vec<ProjectionItemAnswer>,
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
693struct ProjectionItemAnswer {
694    name: String,
695    record: String,
696    source_event: String,
697    #[serde(default)]
698    mode: Option<String>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
702struct MigrationAnswers {
703    #[serde(default)]
704    compatibility: Option<String>,
705    #[serde(default)]
706    items: Vec<MigrationItemAnswer>,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
710struct MigrationItemAnswer {
711    name: String,
712    #[serde(default)]
713    compatibility: Option<String>,
714    #[serde(default)]
715    projection_updates: Vec<String>,
716    #[serde(default)]
717    backfills: Vec<MigrationBackfillAnswer>,
718    #[serde(default)]
719    idempotence_key: Option<String>,
720    #[serde(default)]
721    notes: Option<String>,
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
725struct MigrationBackfillAnswer {
726    record: String,
727    field: String,
728    #[serde(default)]
729    default: serde_json::Value,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
733struct ProviderRequirementAnswer {
734    category: String,
735    #[serde(default)]
736    capabilities: Vec<String>,
737}
738
739#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
740struct NamedAnswer {
741    name: String,
742}
743
744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
745struct AgentEndpointAnswers {
746    #[serde(default)]
747    enabled: Option<bool>,
748    #[serde(default)]
749    ids: Option<Vec<String>>,
750    #[serde(default)]
751    default_risk: Option<String>,
752    #[serde(default)]
753    default_approval: Option<String>,
754    #[serde(default)]
755    exports: Option<Vec<String>>,
756    #[serde(default)]
757    provider_category: Option<String>,
758    #[serde(default)]
759    items: Vec<AgentEndpointItemAnswer>,
760}
761
762#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
763struct AgentEndpointItemAnswer {
764    id: String,
765    title: String,
766    intent: String,
767    #[serde(default)]
768    description: Option<String>,
769    #[serde(default)]
770    inputs: Vec<FieldAnswer>,
771    #[serde(default)]
772    outputs: Vec<FieldAnswer>,
773    #[serde(default)]
774    side_effects: Vec<String>,
775    #[serde(default)]
776    emits: Option<AgentEndpointEmitAnswer>,
777    #[serde(default)]
778    risk: Option<String>,
779    #[serde(default)]
780    approval: Option<String>,
781    #[serde(default)]
782    provider_requirements: Vec<ProviderRequirementAnswer>,
783    #[serde(default)]
784    backing: AgentEndpointBackingAnswer,
785    #[serde(default)]
786    agent_visibility: Option<AgentVisibilityAnswer>,
787    #[serde(default)]
788    examples: Vec<AgentEndpointExampleAnswer>,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
792struct AgentEndpointEmitAnswer {
793    event: String,
794    stream: String,
795    #[serde(default)]
796    payload: serde_json::Value,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
800struct AgentEndpointBackingAnswer {
801    #[serde(default)]
802    actions: Vec<String>,
803    #[serde(default)]
804    events: Vec<String>,
805    #[serde(default)]
806    flows: Vec<String>,
807    #[serde(default)]
808    policies: Vec<String>,
809    #[serde(default)]
810    approvals: Vec<String>,
811}
812
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
814struct AgentVisibilityAnswer {
815    #[serde(default)]
816    openapi: Option<bool>,
817    #[serde(default)]
818    arazzo: Option<bool>,
819    #[serde(default)]
820    mcp: Option<bool>,
821    #[serde(default)]
822    llms_txt: Option<bool>,
823}
824
825#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
826struct AgentEndpointExampleAnswer {
827    name: String,
828    summary: String,
829    #[serde(default)]
830    input: serde_json::Value,
831    #[serde(default)]
832    expected_output: serde_json::Value,
833}
834
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
836struct OutputAnswers {
837    #[serde(default)]
838    include_agent_tools: Option<bool>,
839    #[serde(default)]
840    artifacts: Option<Vec<String>>,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
844struct ResolvedAnswers {
845    schema_version: String,
846    flow: String,
847    output_dir: String,
848    locale: String,
849    package_name: String,
850    package_version: String,
851    storage_category: String,
852    external_ref_category: Option<String>,
853    provider_hints: Vec<String>,
854    default_source: String,
855    external_ref_system: Option<String>,
856    #[serde(default)]
857    record_items: Vec<RecordItemAnswer>,
858    #[serde(default)]
859    ontology: Option<OntologyAnswers>,
860    #[serde(default)]
861    semantic_aliases: Option<SemanticAliasesAnswer>,
862    #[serde(default)]
863    entity_linking: Option<EntityLinkingAnswer>,
864    #[serde(default)]
865    retrieval_bindings: Option<RetrievalBindingsAnswer>,
866    #[serde(default)]
867    actions: Vec<NamedAnswer>,
868    #[serde(default)]
869    event_items: Vec<EventItemAnswer>,
870    #[serde(default)]
871    projection_items: Vec<ProjectionItemAnswer>,
872    #[serde(default)]
873    provider_requirements: Vec<ProviderRequirementAnswer>,
874    #[serde(default)]
875    policies: Vec<NamedAnswer>,
876    #[serde(default)]
877    approvals: Vec<NamedAnswer>,
878    #[serde(default)]
879    migration_items: Vec<MigrationItemAnswer>,
880    events_enabled: bool,
881    projection_mode: String,
882    compatibility_mode: String,
883    #[serde(default)]
884    agent_endpoints_enabled: bool,
885    #[serde(default)]
886    agent_endpoint_ids: Vec<String>,
887    #[serde(default = "default_agent_endpoint_risk")]
888    agent_endpoint_default_risk: String,
889    #[serde(default = "default_agent_endpoint_approval")]
890    agent_endpoint_default_approval: String,
891    #[serde(default)]
892    agent_endpoint_exports: Vec<String>,
893    #[serde(default)]
894    agent_endpoint_provider_category: Option<String>,
895    #[serde(default)]
896    agent_endpoint_items: Vec<AgentEndpointItemAnswer>,
897    include_agent_tools: bool,
898    artifacts: Vec<String>,
899}
900
901pub fn main() -> std::process::ExitCode {
902    match run(std::env::args_os()) {
903        Ok(()) => std::process::ExitCode::SUCCESS,
904        Err(message) => {
905            eprintln!("{message}");
906            std::process::ExitCode::from(2)
907        }
908    }
909}
910
911pub fn schema_for_answers() -> Result<serde_json::Value, SorlaError> {
912    serde_json::to_value(default_schema()).map_err(|err| err.to_string())
913}
914
915pub fn normalize_answers(
916    input: serde_json::Value,
917    _options: NormalizeOptions,
918) -> Result<NormalizedSorlaModel, SorlaError> {
919    let answers: AnswersDocument = serde_json::from_value(input)
920        .map_err(|err| format!("failed to parse answers document: {err}"))?;
921    let schema = default_schema();
922    validate_answers_document(&answers, &schema)?;
923    let resolved = match answers.flow.as_str() {
924        "create" => resolve_create_answers(&answers)?,
925        "update" => {
926            let output_dir = PathBuf::from(&answers.output_dir);
927            let lock_path = output_dir
928                .join(".greentic-sorla")
929                .join("generated")
930                .join(LOCK_FILENAME);
931            let previous = read_lock_file(&lock_path)?;
932            resolve_update_answers(&answers, previous)?
933        }
934        _ => unreachable!("validated earlier"),
935    };
936    let source_yaml = render_package_yaml(&resolved);
937    let normalized_answers = serde_json::to_value(&resolved).map_err(|err| err.to_string())?;
938    Ok(NormalizedSorlaModel {
939        package_name: resolved.package_name,
940        package_version: resolved.package_version,
941        locale: resolved.locale,
942        source_yaml,
943        normalized_answers,
944    })
945}
946
947pub fn validate_model(
948    model: &NormalizedSorlaModel,
949    _options: ValidateOptions,
950) -> SorlaValidationReport {
951    match build_handoff_artifacts_from_yaml(&model.source_yaml) {
952        Ok(_) => SorlaValidationReport {
953            diagnostics: Vec::new(),
954        },
955        Err(message) => SorlaValidationReport {
956            diagnostics: vec![SorlaDiagnostic {
957                severity: DiagnosticSeverity::Error,
958                code: "sorla.validation".to_string(),
959                message,
960                path: None,
961                suggestion: Some("Update the SoRLa model and run validation again.".to_string()),
962            }],
963        },
964    }
965}
966
967pub fn generate_preview(
968    model: &NormalizedSorlaModel,
969    _options: PreviewOptions,
970) -> Result<SorlaPreview, SorlaError> {
971    let artifacts = build_handoff_artifacts_from_yaml(&model.source_yaml)?;
972    let ir = artifacts.ir;
973    let ontology_nodes = ir
974        .ontology
975        .as_ref()
976        .map(|ontology| ontology.concepts.len())
977        .unwrap_or(0);
978    let ontology_edges = ir
979        .ontology
980        .as_ref()
981        .map(|ontology| ontology.relationships.len())
982        .unwrap_or(0);
983    Ok(SorlaPreview {
984        summary: SorlaPreviewSummary {
985            package_name: ir.package.name.clone(),
986            package_version: ir.package.version.clone(),
987            records: ir.records.len(),
988            events: ir.events.len(),
989            projections: ir.projections.len(),
990            agent_endpoints: ir.agent_endpoints.len(),
991        },
992        cards: vec![
993            SorlaPreviewCard {
994                title: "Records".to_string(),
995                items: ir
996                    .records
997                    .iter()
998                    .map(|record| record.name.clone())
999                    .collect(),
1000            },
1001            SorlaPreviewCard {
1002                title: "Agent endpoints".to_string(),
1003                items: ir
1004                    .agent_endpoints
1005                    .iter()
1006                    .map(|endpoint| endpoint.id.clone())
1007                    .collect(),
1008            },
1009        ],
1010        graph: Some(SorlaPreviewGraph {
1011            nodes: ir.records.len() + ontology_nodes,
1012            edges: ir.projections.len() + ontology_edges,
1013        }),
1014    })
1015}
1016
1017pub fn list_designer_node_types(
1018    model: &NormalizedSorlaModel,
1019    options: DesignerNodeTypeOptions,
1020) -> Result<DesignerNodeTypesDocument, SorlaError> {
1021    let artifacts = build_handoff_artifacts_from_yaml(&model.source_yaml)?;
1022    generate_designer_node_types_from_ir(
1023        &artifacts.ir,
1024        &DesignerNodeTypeGenerationOptions {
1025            component_ref: options.component_ref,
1026            operation: options.operation,
1027        },
1028    )
1029}
1030
1031pub fn agent_endpoint_action_catalog(
1032    model: &NormalizedSorlaModel,
1033) -> Result<AgentEndpointActionCatalogDocument, SorlaError> {
1034    let artifacts = build_handoff_artifacts_from_yaml(&model.source_yaml)?;
1035    generate_agent_endpoint_action_catalog_from_ir(&artifacts.ir)
1036}
1037
1038#[cfg(feature = "pack-zip")]
1039pub fn build_gtpack_bytes(
1040    model: &NormalizedSorlaModel,
1041    options: PackBuildOptions,
1042) -> Result<PackBuildBytes, SorlaError> {
1043    let filename = format!(
1044        "{}.gtpack",
1045        options
1046            .name
1047            .clone()
1048            .unwrap_or_else(|| model.package_name.clone())
1049    );
1050    let temp_path = deterministic_temp_path(&filename);
1051    let summary = build_gtpack_file(model, &temp_path, options)?;
1052    let bytes = fs::read(&temp_path).map_err(|err| {
1053        format!(
1054            "failed to read generated gtpack {}: {err}",
1055            temp_path.display()
1056        )
1057    })?;
1058    let _ = fs::remove_file(&temp_path);
1059    Ok(PackBuildBytes {
1060        filename,
1061        sha256: sha256_hex_public(&bytes),
1062        bytes,
1063        metadata: PackBuildMetadata {
1064            pack_id: summary.name,
1065            pack_version: summary.version,
1066            sorla_package_name: summary.sorla_package_name,
1067            sorla_package_version: summary.sorla_package_version,
1068            ir_hash: summary.ir_hash,
1069            assets: summary.assets,
1070        },
1071    })
1072}
1073
1074pub fn build_gtpack_entries(
1075    model: &NormalizedSorlaModel,
1076    _options: PackBuildOptions,
1077) -> Result<Vec<PackEntry>, SorlaError> {
1078    let artifacts = build_handoff_artifacts_from_yaml(&model.source_yaml)?;
1079    let mut entries = Vec::new();
1080    entries.push(PackEntry {
1081        path: "assets/sorla/inspect.json".to_string(),
1082        bytes: artifacts.inspect_json.as_bytes().to_vec(),
1083        sha256: sha256_hex_public(artifacts.inspect_json.as_bytes()),
1084    });
1085    entries.push(PackEntry {
1086        path: "assets/sorla/agent-tools.json".to_string(),
1087        bytes: artifacts.agent_tools_json.as_bytes().to_vec(),
1088        sha256: sha256_hex_public(artifacts.agent_tools_json.as_bytes()),
1089    });
1090    entries.push(PackEntry {
1091        path: "assets/sorla/executable-contract.json".to_string(),
1092        bytes: artifacts.executable_contract_json.as_bytes().to_vec(),
1093        sha256: sha256_hex_public(artifacts.executable_contract_json.as_bytes()),
1094    });
1095    if !artifacts.ir.agent_endpoints.is_empty() {
1096        entries.push(PackEntry {
1097            path: "assets/sorla/designer-node-types.json".to_string(),
1098            bytes: artifacts.designer_node_types_json.as_bytes().to_vec(),
1099            sha256: sha256_hex_public(artifacts.designer_node_types_json.as_bytes()),
1100        });
1101        entries.push(PackEntry {
1102            path: "assets/sorla/agent-endpoint-action-catalog.json".to_string(),
1103            bytes: artifacts
1104                .agent_endpoint_action_catalog_json
1105                .as_bytes()
1106                .to_vec(),
1107            sha256: sha256_hex_public(artifacts.agent_endpoint_action_catalog_json.as_bytes()),
1108        });
1109    }
1110    for (name, bytes) in artifacts.cbor_artifacts {
1111        entries.push(PackEntry {
1112            path: format!("assets/sorla/{name}"),
1113            sha256: sha256_hex_public(&bytes),
1114            bytes,
1115        });
1116    }
1117    entries.sort_by(|left, right| left.path.cmp(&right.path));
1118    Ok(entries)
1119}
1120
1121#[cfg(feature = "pack-zip")]
1122pub fn build_gtpack_file(
1123    model: &NormalizedSorlaModel,
1124    output_path: &Path,
1125    options: PackBuildOptions,
1126) -> Result<PackBuildResult, SorlaError> {
1127    let yaml_path = deterministic_temp_path("model.sorla.yaml");
1128    fs::write(&yaml_path, &model.source_yaml)
1129        .map_err(|err| format!("failed to write temporary SoRLa model: {err}"))?;
1130    let result = build_sorla_gtpack(&SorlaGtpackOptions {
1131        input_path: yaml_path.clone(),
1132        name: options.name.unwrap_or_else(|| model.package_name.clone()),
1133        version: options
1134            .version
1135            .unwrap_or_else(|| model.package_version.clone()),
1136        out_path: output_path.to_path_buf(),
1137    });
1138    let _ = fs::remove_file(&yaml_path);
1139    result
1140}
1141
1142#[cfg(feature = "pack-zip")]
1143pub fn inspect_gtpack_bytes(bytes: &[u8]) -> Result<SorlaGtpackInspection, SorlaError> {
1144    let path = deterministic_temp_path("inspect.gtpack");
1145    fs::write(&path, bytes).map_err(|err| format!("failed to write temporary gtpack: {err}"))?;
1146    let result = inspect_sorla_gtpack(&path);
1147    let _ = fs::remove_file(&path);
1148    result
1149}
1150
1151#[cfg(feature = "pack-zip")]
1152pub fn doctor_gtpack_bytes(bytes: &[u8]) -> SorlaValidationReport {
1153    let path = deterministic_temp_path("doctor.gtpack");
1154    if let Err(err) = fs::write(&path, bytes) {
1155        return SorlaValidationReport {
1156            diagnostics: vec![SorlaDiagnostic {
1157                severity: DiagnosticSeverity::Error,
1158                code: "sorla.gtpack.write".to_string(),
1159                message: format!("failed to write temporary gtpack: {err}"),
1160                path: None,
1161                suggestion: None,
1162            }],
1163        };
1164    }
1165    let result = doctor_sorla_gtpack(&path);
1166    let _ = fs::remove_file(&path);
1167    match result {
1168        Ok(report) if report.status == "ok" => SorlaValidationReport {
1169            diagnostics: Vec::new(),
1170        },
1171        Ok(report) => SorlaValidationReport {
1172            diagnostics: vec![SorlaDiagnostic {
1173                severity: DiagnosticSeverity::Error,
1174                code: "sorla.gtpack.doctor".to_string(),
1175                message: format!("gtpack doctor returned status `{}`", report.status),
1176                path: Some(report.path),
1177                suggestion: None,
1178            }],
1179        },
1180        Err(message) => SorlaValidationReport {
1181            diagnostics: vec![SorlaDiagnostic {
1182                severity: DiagnosticSeverity::Error,
1183                code: "sorla.gtpack.doctor".to_string(),
1184                message,
1185                path: None,
1186                suggestion: None,
1187            }],
1188        },
1189    }
1190}
1191
1192fn deterministic_temp_path(filename: &str) -> PathBuf {
1193    let safe_name = filename.replace(['/', '\\'], "_");
1194    std::env::temp_dir().join(format!(
1195        "greentic-sorla-lib-{}-{safe_name}",
1196        std::process::id()
1197    ))
1198}
1199
1200fn sha256_hex_public(bytes: &[u8]) -> String {
1201    let digest = Sha256::digest(bytes);
1202    digest.iter().map(|byte| format!("{byte:02x}")).collect()
1203}
1204
1205pub fn run<I, T>(args: I) -> Result<(), String>
1206where
1207    I: IntoIterator<Item = T>,
1208    T: Into<OsString> + Clone,
1209{
1210    let args = args.into_iter().map(Into::into).collect::<Vec<OsString>>();
1211    if let Some(help) = localized_help_for_args(&args) {
1212        println!("{help}");
1213        return Ok(());
1214    }
1215
1216    let cli = Cli::parse_from(args);
1217
1218    match cli.command {
1219        Commands::Wizard(args) => run_wizard(args),
1220        Commands::Pack(args) => run_pack(args),
1221        Commands::InspectProductShape => {
1222            println!("wizard-first-plus-pack");
1223            Ok(())
1224        }
1225    }
1226}
1227
1228fn localized_help_for_args(args: &[OsString]) -> Option<String> {
1229    if !args.iter().any(|arg| {
1230        let arg = arg.to_string_lossy();
1231        arg == "--help" || arg == "-h"
1232    }) {
1233        return None;
1234    }
1235
1236    let locale = explicit_locale_arg(args).or_else(|| std::env::var("SORLA_LOCALE").ok())?;
1237    let locale = locale.trim();
1238    if locale.is_empty() {
1239        return None;
1240    }
1241
1242    let localized = locale_catalog(locale)?;
1243    let fallback = locale_catalog("en").unwrap_or_default();
1244
1245    match help_command(args) {
1246        HelpCommand::Wizard => Some(render_wizard_help(&localized, &fallback)),
1247        HelpCommand::Pack => Some(render_pack_help(&localized, &fallback)),
1248        HelpCommand::Root => Some(render_root_help(&localized, &fallback)),
1249    }
1250}
1251
1252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1253enum HelpCommand {
1254    Root,
1255    Wizard,
1256    Pack,
1257}
1258
1259fn help_command(args: &[OsString]) -> HelpCommand {
1260    let mut iter = args.iter().skip(1);
1261    while let Some(arg) = iter.next() {
1262        let arg = arg.to_string_lossy();
1263        match arg.as_ref() {
1264            "wizard" => return HelpCommand::Wizard,
1265            "pack" => return HelpCommand::Pack,
1266            "--help" | "-h" => return HelpCommand::Root,
1267            "--locale" => {
1268                let _ = iter.next();
1269            }
1270            value if value.starts_with("--locale=") => {}
1271            value if value.starts_with('-') => {}
1272            _ => return HelpCommand::Root,
1273        }
1274    }
1275    HelpCommand::Root
1276}
1277
1278fn explicit_locale_arg(args: &[OsString]) -> Option<String> {
1279    let mut iter = args.iter();
1280    while let Some(arg) = iter.next() {
1281        let arg = arg.to_string_lossy();
1282        if arg == "--locale" {
1283            return iter.next().map(|value| value.to_string_lossy().to_string());
1284        }
1285        if let Some(value) = arg.strip_prefix("--locale=") {
1286            return Some(value.to_string());
1287        }
1288    }
1289    None
1290}
1291
1292fn locale_catalog(locale: &str) -> Option<BTreeMap<String, String>> {
1293    serde_json::from_str(included_locale_json(locale)?).ok()
1294}
1295
1296fn catalog_text<'a>(
1297    catalog: &'a BTreeMap<String, String>,
1298    fallback: &'a BTreeMap<String, String>,
1299    key: &str,
1300    default: &'static str,
1301) -> &'a str {
1302    catalog
1303        .get(key)
1304        .or_else(|| fallback.get(key))
1305        .map(String::as_str)
1306        .unwrap_or(default)
1307}
1308
1309fn render_root_help(
1310    catalog: &BTreeMap<String, String>,
1311    fallback: &BTreeMap<String, String>,
1312) -> String {
1313    format!(
1314        "{title}\n\n{description}\n\n{usage}: greentic-sorla <COMMAND>\n\n{commands}:\n  wizard  {wizard_description}\n  pack    {pack_description}\n  help    {help_description}\n\n{options}:\n  -h, --help  {help_option}",
1315        title = catalog_text(catalog, fallback, "wizard.title", "SoRLa wizard"),
1316        description = catalog_text(
1317            catalog,
1318            fallback,
1319            "wizard.description",
1320            "Generate schema or apply answers documents."
1321        ),
1322        wizard_description = catalog_text(
1323            catalog,
1324            fallback,
1325            "wizard.description",
1326            "Generate schema or apply answers documents."
1327        ),
1328        pack_description = catalog_text(
1329            catalog,
1330            fallback,
1331            "cli.commands.pack.description",
1332            "Build, inspect, or doctor deterministic SoRLa gtpack handoff artifacts."
1333        ),
1334        help_description = catalog_text(
1335            catalog,
1336            fallback,
1337            "cli.commands.help.description",
1338            "Print this message or the help of the given subcommand(s)."
1339        ),
1340        usage = catalog_text(catalog, fallback, "cli.usage", "Usage"),
1341        commands = catalog_text(catalog, fallback, "cli.commands", "Commands"),
1342        options = catalog_text(catalog, fallback, "cli.options", "Options"),
1343        help_option = catalog_text(
1344            catalog,
1345            fallback,
1346            "cli.options.help.description",
1347            "Print help"
1348        )
1349    )
1350}
1351
1352fn render_wizard_help(
1353    catalog: &BTreeMap<String, String>,
1354    fallback: &BTreeMap<String, String>,
1355) -> String {
1356    format!(
1357        "{description}\n\n{usage}: greentic-sorla wizard [{options_placeholder}]\n\n{options}:\n      --schema           {schema_description}\n      --locale <LOCALE>  {locale_description}\n      --answers <FILE>   {answers_description}\n      --pack-out <FILE>  {pack_out_description}\n  -h, --help             {help_option}\n\n{core_prompts}:\n  {flow_label}\n  {output_dir_label}\n  {package_name_label}\n  {package_version_label}\n  {storage_provider_label}\n  {default_source_label}\n  {events_enabled_label}\n  {projection_mode_label}\n  {compatibility_mode_label}\n  {include_agent_tools_label}",
1358        description = catalog_text(
1359            catalog,
1360            fallback,
1361            "wizard.description",
1362            "Generate schema or apply answers documents."
1363        ),
1364        usage = catalog_text(catalog, fallback, "cli.usage", "Usage"),
1365        options_placeholder = catalog_text(catalog, fallback, "cli.options.placeholder", "OPTIONS"),
1366        options = catalog_text(catalog, fallback, "cli.options", "Options"),
1367        schema_description = catalog_text(
1368            catalog,
1369            fallback,
1370            "cli.wizard.options.schema.description",
1371            "Emit the wizard schema as deterministic JSON"
1372        ),
1373        locale_description = catalog_text(
1374            catalog,
1375            fallback,
1376            "cli.wizard.options.locale.description",
1377            "Locale used for wizard schema metadata and interactive prompts"
1378        ),
1379        answers_description = catalog_text(
1380            catalog,
1381            fallback,
1382            "cli.wizard.options.answers.description",
1383            "Apply a saved answers document"
1384        ),
1385        pack_out_description = catalog_text(
1386            catalog,
1387            fallback,
1388            "cli.wizard.options.pack_out.description",
1389            "Also build a deterministic .gtpack from the generated sorla.yaml"
1390        ),
1391        help_option = catalog_text(
1392            catalog,
1393            fallback,
1394            "cli.options.help.description",
1395            "Print help"
1396        ),
1397        core_prompts = catalog_text(catalog, fallback, "cli.wizard.core_prompts", "Core prompts"),
1398        flow_label = catalog_text(catalog, fallback, "wizard.flow.label", "Wizard flow"),
1399        output_dir_label = catalog_text(
1400            catalog,
1401            fallback,
1402            "wizard.output_dir.label",
1403            "Output directory"
1404        ),
1405        package_name_label = catalog_text(
1406            catalog,
1407            fallback,
1408            "wizard.questions.package_name.label",
1409            "Package name"
1410        ),
1411        package_version_label = catalog_text(
1412            catalog,
1413            fallback,
1414            "wizard.questions.package_version.label",
1415            "Package version"
1416        ),
1417        storage_provider_label = catalog_text(
1418            catalog,
1419            fallback,
1420            "wizard.questions.storage_provider.label",
1421            "Storage provider category"
1422        ),
1423        default_source_label = catalog_text(
1424            catalog,
1425            fallback,
1426            "wizard.questions.default_source.label",
1427            "Default record source"
1428        ),
1429        events_enabled_label = catalog_text(
1430            catalog,
1431            fallback,
1432            "wizard.questions.events_enabled.label",
1433            "Enable event declarations"
1434        ),
1435        projection_mode_label = catalog_text(
1436            catalog,
1437            fallback,
1438            "wizard.questions.projection_mode.label",
1439            "Projection mode"
1440        ),
1441        compatibility_mode_label = catalog_text(
1442            catalog,
1443            fallback,
1444            "wizard.questions.compatibility_mode.label",
1445            "Compatibility mode"
1446        ),
1447        include_agent_tools_label = catalog_text(
1448            catalog,
1449            fallback,
1450            "wizard.questions.include_agent_tools.label",
1451            "Include agent tools output"
1452        )
1453    )
1454}
1455
1456fn render_pack_help(
1457    catalog: &BTreeMap<String, String>,
1458    fallback: &BTreeMap<String, String>,
1459) -> String {
1460    format!(
1461        "{description}\n\n{usage}: greentic-sorla pack [{options_placeholder}] [FILE] [COMMAND]\n\n{commands}:\n  doctor               {doctor_description}\n  inspect              {inspect_description}\n  schema               {schema_command_description}\n  validation-inspect   {validation_inspect_description}\n  validation-doctor    {validation_doctor_description}\n  help                 {help_description}\n\n{options}:\n      --name <NAME>     {name_description}\n      --version <VER>   {version_description}\n      --out <FILE>      {out_description}\n  -h, --help            {help_option}",
1462        description = catalog_text(
1463            catalog,
1464            fallback,
1465            "cli.commands.pack.description",
1466            "Build, inspect, or doctor deterministic SoRLa gtpack handoff artifacts."
1467        ),
1468        usage = catalog_text(catalog, fallback, "cli.usage", "Usage"),
1469        options_placeholder = catalog_text(catalog, fallback, "cli.options.placeholder", "OPTIONS"),
1470        commands = catalog_text(catalog, fallback, "cli.commands", "Commands"),
1471        options = catalog_text(catalog, fallback, "cli.options", "Options"),
1472        doctor_description = catalog_text(
1473            catalog,
1474            fallback,
1475            "cli.pack.commands.doctor.description",
1476            "Validate a generated SoRLa gtpack."
1477        ),
1478        inspect_description = catalog_text(
1479            catalog,
1480            fallback,
1481            "cli.pack.commands.inspect.description",
1482            "Inspect a generated SoRLa gtpack as deterministic JSON."
1483        ),
1484        schema_command_description = catalog_text(
1485            catalog,
1486            fallback,
1487            "cli.pack.commands.schema.description",
1488            "Emit deterministic JSON schemas for SORX handoff metadata."
1489        ),
1490        validation_inspect_description = catalog_text(
1491            catalog,
1492            fallback,
1493            "cli.pack.commands.validation_inspect.description",
1494            "Inspect embedded SORX validation metadata as deterministic JSON."
1495        ),
1496        validation_doctor_description = catalog_text(
1497            catalog,
1498            fallback,
1499            "cli.pack.commands.validation_doctor.description",
1500            "Validate embedded SORX validation metadata using pack doctor checks."
1501        ),
1502        help_description = catalog_text(
1503            catalog,
1504            fallback,
1505            "cli.commands.help.description",
1506            "Print this message or the help of the given subcommand(s)."
1507        ),
1508        name_description = catalog_text(
1509            catalog,
1510            fallback,
1511            "cli.pack.options.name.description",
1512            "Pack name to write into the gtpack manifest."
1513        ),
1514        version_description = catalog_text(
1515            catalog,
1516            fallback,
1517            "cli.pack.options.version.description",
1518            "Pack semantic version."
1519        ),
1520        out_description = catalog_text(
1521            catalog,
1522            fallback,
1523            "cli.pack.options.out.description",
1524            "Output .gtpack path."
1525        ),
1526        help_option = catalog_text(
1527            catalog,
1528            fallback,
1529            "cli.options.help.description",
1530            "Print help"
1531        )
1532    )
1533}
1534
1535fn run_pack(args: PackArgs) -> Result<(), String> {
1536    match args.command {
1537        Some(PackCommand::Doctor(path_args)) => {
1538            let report = doctor_sorla_gtpack(&path_args.path)?;
1539            let rendered = serde_json::to_string_pretty(&report).map_err(|err| err.to_string())?;
1540            println!("{rendered}");
1541            Ok(())
1542        }
1543        Some(PackCommand::Inspect(path_args)) => {
1544            let inspection = inspect_sorla_gtpack(&path_args.path)?;
1545            let rendered =
1546                serde_json::to_string_pretty(&inspection).map_err(|err| err.to_string())?;
1547            println!("{rendered}");
1548            Ok(())
1549        }
1550        Some(PackCommand::Schema(schema_args)) => {
1551            let schema = match schema_args.command {
1552                PackSchemaCommand::Validation => sorx_validation_schema_json(),
1553                PackSchemaCommand::ExposurePolicy => sorx_exposure_policy_schema_json(),
1554                PackSchemaCommand::Compatibility => sorx_compatibility_schema_json(),
1555                PackSchemaCommand::Ontology => ontology_schema_json(),
1556                PackSchemaCommand::RetrievalBindings => retrieval_bindings_schema_json(),
1557            };
1558            let rendered = serde_json::to_string_pretty(&schema).map_err(|err| err.to_string())?;
1559            println!("{rendered}");
1560            Ok(())
1561        }
1562        Some(PackCommand::ValidationInspect(path_args)) => {
1563            let inspection = inspect_sorla_gtpack(&path_args.path)?;
1564            let rendered = serde_json::to_string_pretty(&validation_inspection_json(&inspection))
1565                .map_err(|err| err.to_string())?;
1566            println!("{rendered}");
1567            Ok(())
1568        }
1569        Some(PackCommand::ValidationDoctor(path_args)) => {
1570            let report = doctor_sorla_gtpack(&path_args.path)?;
1571            let rendered = serde_json::to_string_pretty(&report).map_err(|err| err.to_string())?;
1572            println!("{rendered}");
1573            Ok(())
1574        }
1575        None => {
1576            let input = args
1577                .input
1578                .ok_or_else(|| "pack requires a SoRLa input file".to_string())?;
1579            let name = args
1580                .name
1581                .ok_or_else(|| "pack requires `--name <name>`".to_string())?;
1582            let version = args
1583                .version
1584                .ok_or_else(|| "pack requires `--version <semver>`".to_string())?;
1585            let out = args
1586                .out
1587                .ok_or_else(|| "pack requires `--out <file.gtpack>`".to_string())?;
1588            let summary = build_sorla_gtpack(&SorlaGtpackOptions {
1589                input_path: input,
1590                name,
1591                version,
1592                out_path: out,
1593            })?;
1594            let rendered = serde_json::to_string_pretty(&summary).map_err(|err| err.to_string())?;
1595            println!("{rendered}");
1596            Ok(())
1597        }
1598    }
1599}
1600
1601fn validation_inspection_json(inspection: &SorlaGtpackInspection) -> serde_json::Value {
1602    serde_json::json!({
1603        "schema": inspection
1604            .validation
1605            .as_ref()
1606            .map(|validation| validation.schema.clone())
1607            .unwrap_or_else(|| SORX_VALIDATION_SCHEMA.to_string()),
1608        "package": {
1609            "name": inspection.sorla_package_name,
1610            "version": inspection.sorla_package_version,
1611            "ir_hash": inspection.ir_hash
1612        },
1613        "validation": inspection.validation,
1614        "exposure": inspection.exposure_policy,
1615        "compatibility": inspection.compatibility,
1616        "ontology": inspection.ontology,
1617        "retrieval_bindings": inspection.retrieval_bindings
1618    })
1619}
1620
1621fn sorx_exposure_policy_schema_json() -> serde_json::Value {
1622    serde_json::json!({
1623        "$schema": "https://json-schema.org/draft/2020-12/schema",
1624        "$id": SORX_EXPOSURE_POLICY_SCHEMA,
1625        "title": "SORX exposure policy",
1626        "type": "object",
1627        "additionalProperties": false,
1628        "required": [
1629            "schema",
1630            "default_visibility",
1631            "promotion_requires",
1632            "allowed_route_prefixes",
1633            "forbidden_route_prefixes",
1634            "endpoints"
1635        ],
1636        "properties": {
1637            "schema": { "const": SORX_EXPOSURE_POLICY_SCHEMA },
1638            "default_visibility": {
1639                "type": "string",
1640                "enum": ["private", "internal", "public_candidate"]
1641            },
1642            "promotion_requires": {
1643                "type": "array",
1644                "items": { "type": "string", "minLength": 1 }
1645            },
1646            "allowed_route_prefixes": {
1647                "type": "array",
1648                "items": { "type": "string" }
1649            },
1650            "forbidden_route_prefixes": {
1651                "type": "array",
1652                "items": { "type": "string" }
1653            },
1654            "endpoints": {
1655                "type": "array",
1656                "items": { "$ref": "#/$defs/endpoint" }
1657            }
1658        },
1659        "$defs": {
1660            "endpoint": {
1661                "type": "object",
1662                "additionalProperties": false,
1663                "required": [
1664                    "endpoint_id",
1665                    "visibility",
1666                    "requires_approval",
1667                    "export_surfaces",
1668                    "route_prefixes"
1669                ],
1670                "properties": {
1671                    "endpoint_id": { "type": "string", "minLength": 1 },
1672                    "visibility": {
1673                        "type": "string",
1674                        "enum": ["private", "internal", "public_candidate"]
1675                    },
1676                    "requires_approval": { "type": "boolean" },
1677                    "risk": { "type": "string" },
1678                    "export_surfaces": {
1679                        "type": "array",
1680                        "items": {
1681                            "type": "string",
1682                            "enum": ["openapi", "arazzo", "mcp", "llms_txt"]
1683                        }
1684                    },
1685                    "route_prefixes": {
1686                        "type": "array",
1687                        "items": { "type": "string" }
1688                    }
1689                }
1690            }
1691        }
1692    })
1693}
1694
1695fn sorx_compatibility_schema_json() -> serde_json::Value {
1696    serde_json::json!({
1697        "$schema": "https://json-schema.org/draft/2020-12/schema",
1698        "$id": SORX_COMPATIBILITY_SCHEMA,
1699        "title": "SORX compatibility manifest",
1700        "type": "object",
1701        "additionalProperties": false,
1702        "required": [
1703            "schema",
1704            "package",
1705            "api_compatibility",
1706            "state_compatibility",
1707            "provider_compatibility",
1708            "migration_compatibility"
1709        ],
1710        "properties": {
1711            "schema": { "const": SORX_COMPATIBILITY_SCHEMA },
1712            "package": {
1713                "type": "object",
1714                "additionalProperties": false,
1715                "required": ["name", "version"],
1716                "properties": {
1717                    "name": { "type": "string", "minLength": 1 },
1718                    "version": { "type": "string", "minLength": 1 },
1719                    "ir_hash": { "type": "string", "minLength": 1 }
1720                }
1721            },
1722            "api_compatibility": {
1723                "type": "string",
1724                "enum": ["additive", "backward_compatible", "breaking", "unknown"]
1725            },
1726            "state_compatibility": {
1727                "type": "string",
1728                "enum": ["isolated_required", "shared_allowed", "shared_requires_migration", "unknown"]
1729            },
1730            "provider_compatibility": {
1731                "type": "array",
1732                "items": { "$ref": "#/$defs/provider" }
1733            },
1734            "migration_compatibility": {
1735                "type": "array",
1736                "items": { "$ref": "#/$defs/migration" }
1737            }
1738        },
1739        "$defs": {
1740            "provider": {
1741                "type": "object",
1742                "additionalProperties": false,
1743                "required": [
1744                    "category",
1745                    "required_capabilities",
1746                    "contract_version_range",
1747                    "required"
1748                ],
1749                "properties": {
1750                    "category": { "type": "string", "minLength": 1 },
1751                    "required_capabilities": {
1752                        "type": "array",
1753                        "items": { "type": "string", "minLength": 1 }
1754                    },
1755                    "contract_version_range": { "type": "string", "minLength": 1 },
1756                    "required": { "type": "boolean" }
1757                }
1758            },
1759            "migration": {
1760                "type": "object",
1761                "additionalProperties": false,
1762                "required": ["name", "mode", "projection_updates", "backfill_count"],
1763                "properties": {
1764                    "name": { "type": "string", "minLength": 1 },
1765                    "mode": {
1766                        "type": "string",
1767                        "enum": ["additive", "backward_compatible", "breaking", "unknown"]
1768                    },
1769                    "projection_updates": {
1770                        "type": "array",
1771                        "items": { "type": "string", "minLength": 1 }
1772                    },
1773                    "backfill_count": { "type": "integer", "minimum": 0 },
1774                    "idempotence_key": { "type": "string", "minLength": 1 }
1775                }
1776            }
1777        }
1778    })
1779}
1780
1781fn run_wizard(args: WizardArgs) -> Result<(), String> {
1782    let pack_out = args.pack_out;
1783    match (args.schema, args.answers) {
1784        (true, None) => {
1785            if pack_out.is_some() {
1786                return Err("`--pack-out` can only be used when applying answers or running the interactive wizard".to_string());
1787            }
1788            let schema = default_schema_for_locale(&selected_locale(args.locale.as_deref(), None));
1789            let rendered = serde_json::to_string_pretty(&schema).map_err(|err| err.to_string())?;
1790            println!("{rendered}");
1791            Ok(())
1792        }
1793        (false, Some(path)) => {
1794            let contents = fs::read_to_string(&path)
1795                .map_err(|err| format!("failed to read answers file {}: {err}", path.display()))?;
1796            let mut answers: AnswersDocument = serde_json::from_str(&contents)
1797                .map_err(|err| format!("failed to parse answers file {}: {err}", path.display()))?;
1798            if args.locale.is_some() {
1799                answers.locale = args.locale;
1800            }
1801            let summary = apply_answers(answers, pack_out)?;
1802            let rendered = serde_json::to_string_pretty(&summary).map_err(|err| err.to_string())?;
1803            println!("{rendered}");
1804            Ok(())
1805        }
1806        (true, Some(_)) => {
1807            Err("choose one wizard mode: use either `--schema` or `--answers <file>`".to_string())
1808        }
1809        (false, None) => run_interactive_wizard(args.locale.as_deref(), pack_out),
1810    }
1811}
1812
1813#[cfg(feature = "cli")]
1814fn run_interactive_wizard(
1815    requested_locale: Option<&str>,
1816    pack_out: Option<PathBuf>,
1817) -> Result<(), String> {
1818    let locale = selected_locale(requested_locale, None);
1819    let mut provider = |question_id: &str, question: &serde_json::Value| {
1820        prompt_interactive_answer(question_id, question)
1821    };
1822    let summary = run_interactive_wizard_with_provider(&locale, &mut provider, pack_out)?;
1823    let rendered = serde_json::to_string_pretty(&summary).map_err(|err| err.to_string())?;
1824    println!("{rendered}");
1825    Ok(())
1826}
1827
1828#[cfg(not(feature = "cli"))]
1829fn run_interactive_wizard(
1830    _requested_locale: Option<&str>,
1831    _pack_out: Option<PathBuf>,
1832) -> Result<(), String> {
1833    Err("interactive wizard requires the `cli` feature".to_string())
1834}
1835
1836#[cfg(feature = "cli")]
1837fn run_interactive_wizard_with_provider(
1838    locale: &str,
1839    answer_provider: &mut AnswerProvider,
1840    pack_out: Option<PathBuf>,
1841) -> Result<ExecutionSummary, String> {
1842    let spec_json = serde_json::to_string_pretty(&build_interactive_qa_spec(locale))
1843        .map_err(|err| err.to_string())?;
1844    let mut driver = WizardDriver::new(WizardRunConfig {
1845        spec_json,
1846        initial_answers_json: None,
1847        frontend: WizardFrontend::JsonUi,
1848        i18n: I18nConfig {
1849            locale: Some(locale.to_string()),
1850            resolved: load_interactive_i18n(locale),
1851            debug: false,
1852        },
1853        verbose: false,
1854    })
1855    .map_err(format_qa_error)?;
1856
1857    loop {
1858        let ui_raw = driver.next_payload_json().map_err(format_qa_error)?;
1859        let ui: serde_json::Value = serde_json::from_str(&ui_raw).map_err(|err| err.to_string())?;
1860        if driver.is_complete() {
1861            break;
1862        }
1863
1864        let question_id = ui
1865            .get("next_question_id")
1866            .and_then(serde_json::Value::as_str)
1867            .ok_or_else(|| "wizard QA flow failed: missing next_question_id".to_string())?;
1868        let question = ui
1869            .get("questions")
1870            .and_then(serde_json::Value::as_array)
1871            .and_then(|questions| {
1872                questions.iter().find(|question| {
1873                    question.get("id").and_then(serde_json::Value::as_str) == Some(question_id)
1874                })
1875            })
1876            .ok_or_else(|| format!("wizard QA flow failed: missing question `{question_id}`"))?;
1877
1878        let answer = answer_provider(question_id, question).map_err(format_qa_error)?;
1879        let patch = serde_json::json!({ question_id: answer }).to_string();
1880        let submit = driver.submit_patch_json(&patch).map_err(format_qa_error)?;
1881        if submit.status == "error" {
1882            let submit_value: serde_json::Value =
1883                serde_json::from_str(&submit.response_json).map_err(|err| err.to_string())?;
1884            if submit_value.get("next_question_id").is_none() {
1885                return Err(format!("wizard QA flow failed: {}", submit.response_json));
1886            }
1887        }
1888    }
1889
1890    let result = driver.finish().map_err(format_qa_error)?;
1891
1892    let answers = answers_document_from_qa_answers(result.answer_set.answers)?;
1893    apply_answers(answers, pack_out)
1894}
1895
1896fn apply_answers(
1897    answers: AnswersDocument,
1898    pack_out: Option<PathBuf>,
1899) -> Result<ExecutionSummary, String> {
1900    let schema = default_schema();
1901    validate_answers_document(&answers, &schema)?;
1902
1903    let output_dir = PathBuf::from(&answers.output_dir);
1904    let generated_dir = output_dir.join(".greentic-sorla").join("generated");
1905    let lock_path = generated_dir.join(LOCK_FILENAME);
1906
1907    let resolved = match answers.flow.as_str() {
1908        "create" => {
1909            if lock_path.exists() {
1910                return Err(
1911                    "output directory already contains wizard state; use `flow: update` instead of `create`".to_string(),
1912                );
1913            }
1914            resolve_create_answers(&answers)
1915        }
1916        "update" => {
1917            let previous = read_lock_file(&lock_path)?;
1918            resolve_update_answers(&answers, previous)
1919        }
1920        _ => unreachable!("validated earlier"),
1921    }?;
1922
1923    fs::create_dir_all(&generated_dir).map_err(|err| {
1924        format!(
1925            "failed to create generated directory {}: {err}",
1926            generated_dir.display()
1927        )
1928    })?;
1929
1930    let package_path = output_dir.join("sorla.yaml");
1931    let generated_yaml = render_package_yaml(&resolved);
1932    let preserved_user_content = write_generated_block(&package_path, &generated_yaml)?;
1933
1934    let mut written_files = vec![relative_to_output(&output_dir, &package_path)];
1935    write_lock_file(&lock_path, &resolved)?;
1936    written_files.push(relative_to_output(&output_dir, &lock_path));
1937
1938    let manifest_json = serde_json::to_vec_pretty(&build_launcher_handoff_manifest(&resolved))
1939        .map_err(|err| err.to_string())?;
1940    let manifest_paths = write_generated_json_aliases(
1941        &generated_dir,
1942        &[LAUNCHER_HANDOFF_FILENAME, LEGACY_PACKAGE_MANIFEST_FILENAME],
1943        &manifest_json,
1944    )?;
1945    written_files.extend(
1946        manifest_paths
1947            .iter()
1948            .map(|path| relative_to_output(&output_dir, path)),
1949    );
1950
1951    let provider_requirements_path = generated_dir.join("provider-requirements.json");
1952    let provider_requirements_json =
1953        serde_json::to_vec_pretty(&build_provider_handoff_manifest(&resolved))
1954            .map_err(|err| err.to_string())?;
1955    fs::write(&provider_requirements_path, provider_requirements_json).map_err(|err| {
1956        format!(
1957            "failed to write generated file {}: {err}",
1958            provider_requirements_path.display()
1959        )
1960    })?;
1961    written_files.push(relative_to_output(&output_dir, &provider_requirements_path));
1962
1963    let locale_manifest_path = generated_dir.join("locale-manifest.json");
1964    let locale_manifest_json = serde_json::to_vec_pretty(&build_locale_handoff_manifest(&resolved))
1965        .map_err(|err| err.to_string())?;
1966    fs::write(&locale_manifest_path, locale_manifest_json).map_err(|err| {
1967        format!(
1968            "failed to write generated file {}: {err}",
1969            locale_manifest_path.display()
1970        )
1971    })?;
1972    written_files.push(relative_to_output(&output_dir, &locale_manifest_path));
1973
1974    let artifact_paths = sync_generated_artifacts(&generated_dir, &resolved)?;
1975    written_files.extend(
1976        artifact_paths
1977            .into_iter()
1978            .map(|path| relative_to_output(&output_dir, &path)),
1979    );
1980
1981    let pack_path = if let Some(pack_out) = pack_out {
1982        let validation_manifest_path =
1983            write_generated_sorx_validation_manifest(&generated_dir, &generated_yaml)?;
1984        written_files.push(relative_to_output(&output_dir, &validation_manifest_path));
1985
1986        let pack_path = if pack_out.is_relative() {
1987            output_dir.join(pack_out)
1988        } else {
1989            pack_out
1990        };
1991        build_sorla_gtpack(&SorlaGtpackOptions {
1992            input_path: package_path.clone(),
1993            name: resolved.package_name.clone(),
1994            version: resolved.package_version.clone(),
1995            out_path: pack_path.clone(),
1996        })?;
1997        written_files.push(relative_to_output(&output_dir, &pack_path));
1998        Some(relative_to_output(&output_dir, &pack_path))
1999    } else {
2000        None
2001    };
2002
2003    written_files.sort();
2004    written_files.dedup();
2005
2006    Ok(ExecutionSummary {
2007        mode: if resolved.flow == "create" {
2008            "create"
2009        } else {
2010            "update"
2011        },
2012        output_dir: resolved.output_dir.clone(),
2013        package_name: resolved.package_name.clone(),
2014        locale: resolved.locale.clone(),
2015        written_files,
2016        pack_path,
2017        preserved_user_content,
2018    })
2019}
2020
2021fn write_generated_sorx_validation_manifest(
2022    generated_dir: &Path,
2023    generated_yaml: &str,
2024) -> Result<PathBuf, String> {
2025    let artifacts = build_handoff_artifacts_from_yaml(generated_yaml)?;
2026    let manifest = generate_sorx_validation_manifest_from_ir(
2027        &artifacts.ir,
2028        Some(&artifacts.canonical_hash),
2029        vec![
2030            START_SCHEMA_FILENAME.to_string(),
2031            RUNTIME_TEMPLATE_FILENAME.to_string(),
2032            PROVIDER_BINDINGS_TEMPLATE_FILENAME.to_string(),
2033        ],
2034    );
2035    manifest.validate_static().map_err(|err| err.to_string())?;
2036
2037    let path = generated_dir
2038        .join("assets")
2039        .join("sorx")
2040        .join("tests")
2041        .join("test-manifest.json");
2042    if let Some(parent) = path.parent() {
2043        fs::create_dir_all(parent)
2044            .map_err(|err| format!("failed to create directory {}: {err}", parent.display()))?;
2045    }
2046    let bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| err.to_string())?;
2047    fs::write(&path, bytes)
2048        .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
2049    Ok(path)
2050}
2051
2052fn validate_answers_document(
2053    answers: &AnswersDocument,
2054    schema: &WizardSchema,
2055) -> Result<(), String> {
2056    if answers.schema_version != schema.schema_version && answers.schema_version != "0.4" {
2057        return Err(format!(
2058            "schema_version mismatch: expected {} or 0.4, got {}",
2059            schema.schema_version, answers.schema_version
2060        ));
2061    }
2062
2063    if answers.output_dir.trim().is_empty() {
2064        return Err("answers field `output_dir` is required".to_string());
2065    }
2066
2067    let flow = match answers.flow.as_str() {
2068        "create" => SchemaFlow::Create,
2069        "update" => SchemaFlow::Update,
2070        other => {
2071            return Err(format!(
2072                "answers field `flow` must be `create` or `update`, got `{other}`"
2073            ));
2074        }
2075    };
2076
2077    if !schema.supported_modes.contains(&flow) {
2078        return Err(format!("schema does not support flow `{}`", answers.flow));
2079    }
2080
2081    if answers.flow == "create" {
2082        let package = answers.package.as_ref().ok_or_else(|| {
2083            "create flow requires the `package` section with at least `package.name` and `package.version`".to_string()
2084        })?;
2085        if package.name.as_deref().unwrap_or("").trim().is_empty() {
2086            return Err("create flow requires `package.name`".to_string());
2087        }
2088        if package.version.as_deref().unwrap_or("").trim().is_empty() {
2089            return Err("create flow requires `package.version`".to_string());
2090        }
2091    }
2092
2093    validate_choice(
2094        answers
2095            .records
2096            .as_ref()
2097            .and_then(|records| records.default_source.as_deref()),
2098        &["native", "external", "hybrid"],
2099        "records.default_source",
2100    )?;
2101    validate_choice(
2102        answers
2103            .projections
2104            .as_ref()
2105            .and_then(|projections| projections.mode.as_deref()),
2106        &["current-state", "audit-trail"],
2107        "projections.mode",
2108    )?;
2109    validate_choice(
2110        answers
2111            .migrations
2112            .as_ref()
2113            .and_then(|migrations| migrations.compatibility.as_deref()),
2114        &["additive", "backward-compatible", "breaking"],
2115        "migrations.compatibility",
2116    )?;
2117    validate_choice(
2118        answers
2119            .providers
2120            .as_ref()
2121            .and_then(|providers| providers.storage_category.as_deref()),
2122        &["storage"],
2123        "providers.storage_category",
2124    )?;
2125    validate_choice(
2126        answers
2127            .providers
2128            .as_ref()
2129            .and_then(|providers| providers.external_ref_category.as_deref()),
2130        &["external-ref"],
2131        "providers.external_ref_category",
2132    )?;
2133    validate_choice(
2134        answers
2135            .agent_endpoints
2136            .as_ref()
2137            .and_then(|agent_endpoints| agent_endpoints.default_risk.as_deref()),
2138        &["low", "medium", "high"],
2139        "agent_endpoints.default_risk",
2140    )?;
2141    validate_choice(
2142        answers
2143            .agent_endpoints
2144            .as_ref()
2145            .and_then(|agent_endpoints| agent_endpoints.default_approval.as_deref()),
2146        &["none", "optional", "required", "policy-driven"],
2147        "agent_endpoints.default_approval",
2148    )?;
2149    if let Some(agent_endpoints) = &answers.agent_endpoints {
2150        for (index, endpoint) in agent_endpoints.items.iter().enumerate() {
2151            validate_choice(
2152                endpoint.risk.as_deref(),
2153                &["low", "medium", "high"],
2154                &format!("agent_endpoints.items[{index}].risk"),
2155            )?;
2156            validate_choice(
2157                endpoint.approval.as_deref(),
2158                &["none", "optional", "required", "policy-driven"],
2159                &format!("agent_endpoints.items[{index}].approval"),
2160            )?;
2161        }
2162
2163        let enabled = agent_endpoints
2164            .enabled
2165            .unwrap_or(!agent_endpoints.items.is_empty());
2166        if enabled {
2167            let ids = normalize_text_list(agent_endpoints.ids.clone().unwrap_or_default());
2168            if ids.is_empty() && agent_endpoints.items.is_empty() {
2169                return Err(
2170                    "field `agent_endpoints.ids` or `agent_endpoints.items` requires at least one endpoint when agent endpoints are enabled"
2171                        .to_string(),
2172                );
2173            }
2174
2175            let risk = agent_endpoints.default_risk.as_deref().unwrap_or("medium");
2176            let approval = agent_endpoints
2177                .default_approval
2178                .as_deref()
2179                .unwrap_or("policy-driven");
2180            if risk == "high" && !matches!(approval, "required" | "policy-driven") {
2181                return Err(
2182                    "field `agent_endpoints.default_approval` must be `required` or `policy-driven` when `agent_endpoints.default_risk` is `high`"
2183                        .to_string(),
2184                );
2185            }
2186
2187            if let Some(exports) = &agent_endpoints.exports {
2188                for export in exports {
2189                    if !["openapi", "arazzo", "mcp", "llms_txt"].contains(&export.as_str()) {
2190                        return Err(format!(
2191                            "agent_endpoints.exports contains unsupported export `{export}`"
2192                        ));
2193                    }
2194                }
2195            }
2196        }
2197    }
2198
2199    validate_rich_answers(answers)?;
2200
2201    if let Some(output) = &answers.output
2202        && let Some(artifacts) = &output.artifacts
2203    {
2204        let allowed: BTreeSet<&str> = schema.artifact_references.iter().copied().collect();
2205        for artifact in artifacts {
2206            if !allowed.contains(artifact.as_str()) {
2207                return Err(format!(
2208                    "output.artifacts contains unsupported artifact `{artifact}`"
2209                ));
2210            }
2211        }
2212    }
2213
2214    Ok(())
2215}
2216
2217fn validate_choice(value: Option<&str>, allowed: &[&str], field: &str) -> Result<(), String> {
2218    if let Some(value) = value
2219        && !allowed.contains(&value)
2220    {
2221        return Err(format!(
2222            "field `{field}` must be one of {}, got `{value}`",
2223            allowed.join(", ")
2224        ));
2225    }
2226    Ok(())
2227}
2228
2229fn validate_rich_answers(answers: &AnswersDocument) -> Result<(), String> {
2230    let mut record_fields = BTreeMap::<String, BTreeSet<String>>::new();
2231    if let Some(records) = &answers.records {
2232        let mut record_names = BTreeSet::new();
2233        for (record_index, record) in records.items.iter().enumerate() {
2234            require_non_empty(&record.name, &format!("records.items[{record_index}].name"))?;
2235            validate_choice(
2236                record.source.as_deref(),
2237                &["native", "external", "hybrid"],
2238                &format!("records.items[{record_index}].source"),
2239            )?;
2240            if !record_names.insert(record.name.clone()) {
2241                return Err(format!(
2242                    "records.items[{record_index}].name duplicates record `{}`",
2243                    record.name
2244                ));
2245            }
2246
2247            let mut field_names = BTreeSet::new();
2248            for (field_index, field) in record.fields.iter().enumerate() {
2249                require_non_empty(
2250                    &field.name,
2251                    &format!("records.items[{record_index}].fields[{field_index}].name"),
2252                )?;
2253                require_non_empty(
2254                    &field.type_name,
2255                    &format!("records.items[{record_index}].fields[{field_index}].type"),
2256                )?;
2257                validate_choice(
2258                    field.authority.as_deref(),
2259                    &["local", "external"],
2260                    &format!("records.items[{record_index}].fields[{field_index}].authority"),
2261                )?;
2262                validate_enum_values(
2263                    &field.enum_values,
2264                    &format!("records.items[{record_index}].fields[{field_index}].enum_values"),
2265                )?;
2266                if !field_names.insert(field.name.clone()) {
2267                    return Err(format!(
2268                        "records.items[{record_index}].fields[{field_index}].name duplicates field `{}`",
2269                        field.name
2270                    ));
2271                }
2272            }
2273            record_fields.insert(record.name.clone(), field_names);
2274        }
2275
2276        for (record_index, record) in records.items.iter().enumerate() {
2277            for (field_index, field) in record.fields.iter().enumerate() {
2278                if let Some(reference) = &field.references {
2279                    let Some(target_fields) = record_fields.get(&reference.record) else {
2280                        return Err(format!(
2281                            "records.items[{record_index}].fields[{field_index}].references.record points to unknown record `{}`",
2282                            reference.record
2283                        ));
2284                    };
2285                    if !target_fields.contains(&reference.field) {
2286                        return Err(format!(
2287                            "records.items[{record_index}].fields[{field_index}].references.field points to unknown field `{}` on record `{}`",
2288                            reference.field, reference.record
2289                        ));
2290                    }
2291                }
2292            }
2293        }
2294    }
2295
2296    validate_ontology_answers(answers.ontology.as_ref(), &record_fields)?;
2297    validate_semantic_alias_answers(answers.semantic_aliases.as_ref(), answers.ontology.as_ref())?;
2298    validate_entity_linking_answers(
2299        answers.entity_linking.as_ref(),
2300        answers.ontology.as_ref(),
2301        &record_fields,
2302    )?;
2303    validate_retrieval_binding_answers(
2304        answers.retrieval_bindings.as_ref(),
2305        answers.ontology.as_ref(),
2306    )?;
2307
2308    let action_names = validate_named_answers(&answers.actions, "actions")?;
2309    let policy_names = validate_named_answers(&answers.policies, "policies")?;
2310    let approval_names = validate_named_answers(&answers.approvals, "approvals")?;
2311
2312    let mut event_names = BTreeSet::new();
2313    if let Some(events) = &answers.events {
2314        for (event_index, event) in events.items.iter().enumerate() {
2315            require_non_empty(&event.name, &format!("events.items[{event_index}].name"))?;
2316            require_non_empty(
2317                &event.record,
2318                &format!("events.items[{event_index}].record"),
2319            )?;
2320            validate_choice(
2321                event.kind.as_deref(),
2322                &["domain", "integration"],
2323                &format!("events.items[{event_index}].kind"),
2324            )?;
2325            if !record_fields.is_empty() && !record_fields.contains_key(&event.record) {
2326                return Err(format!(
2327                    "events.items[{event_index}].record points to unknown record `{}`",
2328                    event.record
2329                ));
2330            }
2331            if !event_names.insert(event.name.clone()) {
2332                return Err(format!(
2333                    "events.items[{event_index}].name duplicates event `{}`",
2334                    event.name
2335                ));
2336            }
2337            for (field_index, field) in event.emits.iter().enumerate() {
2338                require_non_empty(
2339                    &field.name,
2340                    &format!("events.items[{event_index}].emits[{field_index}].name"),
2341                )?;
2342                require_non_empty(
2343                    &field.type_name,
2344                    &format!("events.items[{event_index}].emits[{field_index}].type"),
2345                )?;
2346            }
2347        }
2348    }
2349
2350    if let Some(projections) = &answers.projections {
2351        let mut projection_names = BTreeSet::new();
2352        for (projection_index, projection) in projections.items.iter().enumerate() {
2353            require_non_empty(
2354                &projection.name,
2355                &format!("projections.items[{projection_index}].name"),
2356            )?;
2357            validate_choice(
2358                projection.mode.as_deref(),
2359                &["current-state", "audit-trail"],
2360                &format!("projections.items[{projection_index}].mode"),
2361            )?;
2362            if !record_fields.is_empty() && !record_fields.contains_key(&projection.record) {
2363                return Err(format!(
2364                    "projections.items[{projection_index}].record points to unknown record `{}`",
2365                    projection.record
2366                ));
2367            }
2368            if !event_names.is_empty() && !event_names.contains(&projection.source_event) {
2369                return Err(format!(
2370                    "projections.items[{projection_index}].source_event points to unknown event `{}`",
2371                    projection.source_event
2372                ));
2373            }
2374            if !projection_names.insert(projection.name.clone()) {
2375                return Err(format!(
2376                    "projections.items[{projection_index}].name duplicates projection `{}`",
2377                    projection.name
2378                ));
2379            }
2380        }
2381    }
2382
2383    for (requirement_index, requirement) in answers.provider_requirements.iter().enumerate() {
2384        validate_provider_requirement_answer(
2385            requirement,
2386            &format!("provider_requirements[{requirement_index}]"),
2387        )?;
2388    }
2389
2390    if let Some(migrations) = &answers.migrations {
2391        let mut migration_names = BTreeSet::new();
2392        for (migration_index, migration) in migrations.items.iter().enumerate() {
2393            require_non_empty(
2394                &migration.name,
2395                &format!("migrations.items[{migration_index}].name"),
2396            )?;
2397            validate_choice(
2398                migration.compatibility.as_deref(),
2399                &["additive", "backward-compatible", "breaking"],
2400                &format!("migrations.items[{migration_index}].compatibility"),
2401            )?;
2402            if !migration_names.insert(migration.name.clone()) {
2403                return Err(format!(
2404                    "migrations.items[{migration_index}].name duplicates migration `{}`",
2405                    migration.name
2406                ));
2407            }
2408        }
2409    }
2410
2411    if let Some(agent_endpoints) = &answers.agent_endpoints {
2412        let mut endpoint_ids = BTreeSet::new();
2413        for (endpoint_index, endpoint) in agent_endpoints.items.iter().enumerate() {
2414            require_non_empty(
2415                &endpoint.id,
2416                &format!("agent_endpoints.items[{endpoint_index}].id"),
2417            )?;
2418            require_non_empty(
2419                &endpoint.title,
2420                &format!("agent_endpoints.items[{endpoint_index}].title"),
2421            )?;
2422            require_non_empty(
2423                &endpoint.intent,
2424                &format!("agent_endpoints.items[{endpoint_index}].intent"),
2425            )?;
2426            if !endpoint_ids.insert(endpoint.id.clone()) {
2427                return Err(format!(
2428                    "agent_endpoints.items[{endpoint_index}].id duplicates endpoint `{}`",
2429                    endpoint.id
2430                ));
2431            }
2432            validate_endpoint_fields(
2433                &endpoint.inputs,
2434                &format!("agent_endpoints.items[{endpoint_index}].inputs"),
2435            )?;
2436            validate_endpoint_fields(
2437                &endpoint.outputs,
2438                &format!("agent_endpoints.items[{endpoint_index}].outputs"),
2439            )?;
2440            for (requirement_index, requirement) in
2441                endpoint.provider_requirements.iter().enumerate()
2442            {
2443                validate_provider_requirement_answer(
2444                    requirement,
2445                    &format!(
2446                        "agent_endpoints.items[{endpoint_index}].provider_requirements[{requirement_index}]"
2447                    ),
2448                )?;
2449            }
2450            if let Some(emits) = &endpoint.emits {
2451                require_non_empty(
2452                    &emits.event,
2453                    &format!("agent_endpoints.items[{endpoint_index}].emits.event"),
2454                )?;
2455                require_non_empty(
2456                    &emits.stream,
2457                    &format!("agent_endpoints.items[{endpoint_index}].emits.stream"),
2458                )?;
2459                if !event_names.is_empty() && !event_names.contains(&emits.event) {
2460                    return Err(format!(
2461                        "agent_endpoints.items[{endpoint_index}].emits.event points to unknown event `{}`",
2462                        emits.event
2463                    ));
2464                }
2465            }
2466            validate_declared_references(
2467                &endpoint.backing.actions,
2468                &action_names,
2469                &format!("agent_endpoints.items[{endpoint_index}].backing.actions"),
2470            )?;
2471            validate_declared_references(
2472                &endpoint.backing.events,
2473                &event_names,
2474                &format!("agent_endpoints.items[{endpoint_index}].backing.events"),
2475            )?;
2476            validate_declared_references(
2477                &endpoint.backing.policies,
2478                &policy_names,
2479                &format!("agent_endpoints.items[{endpoint_index}].backing.policies"),
2480            )?;
2481            validate_declared_references(
2482                &endpoint.backing.approvals,
2483                &approval_names,
2484                &format!("agent_endpoints.items[{endpoint_index}].backing.approvals"),
2485            )?;
2486        }
2487    }
2488
2489    Ok(())
2490}
2491
2492fn validate_ontology_answers(
2493    ontology: Option<&OntologyAnswers>,
2494    record_fields: &BTreeMap<String, BTreeSet<String>>,
2495) -> Result<(), String> {
2496    let Some(ontology) = ontology else {
2497        return Ok(());
2498    };
2499
2500    let schema = ontology
2501        .schema
2502        .as_deref()
2503        .unwrap_or("greentic.sorla.ontology.v1");
2504    if schema != "greentic.sorla.ontology.v1" {
2505        return Err(format!(
2506            "ontology.schema must be `greentic.sorla.ontology.v1`, got `{schema}`"
2507        ));
2508    }
2509
2510    let mut concept_ids = BTreeSet::new();
2511    for (concept_index, concept) in ontology.concepts.iter().enumerate() {
2512        let path = format!("ontology.concepts[{concept_index}]");
2513        validate_url_safe_id(&concept.id, &format!("{path}.id"))?;
2514        validate_choice(
2515            Some(concept.kind.as_str()),
2516            &["abstract", "entity"],
2517            &format!("{path}.kind"),
2518        )?;
2519        if !concept_ids.insert(concept.id.clone()) {
2520            return Err(format!("{path}.id duplicates concept `{}`", concept.id));
2521        }
2522        for (extends_index, parent) in concept.extends.iter().enumerate() {
2523            validate_url_safe_id(parent, &format!("{path}.extends[{extends_index}]"))?;
2524        }
2525        if let Some(backing) = &concept.backed_by {
2526            validate_ontology_backing_answer(backing, record_fields, &format!("{path}.backed_by"))?;
2527        }
2528        for (hook_index, hook) in concept.policy_hooks.iter().enumerate() {
2529            require_non_empty(
2530                &hook.policy,
2531                &format!("{path}.policy_hooks[{hook_index}].policy"),
2532            )?;
2533        }
2534        for (requirement_index, requirement) in concept.provider_requirements.iter().enumerate() {
2535            validate_provider_requirement_answer(
2536                requirement,
2537                &format!("{path}.provider_requirements[{requirement_index}]"),
2538            )?;
2539        }
2540    }
2541
2542    for (concept_index, concept) in ontology.concepts.iter().enumerate() {
2543        let path = format!("ontology.concepts[{concept_index}]");
2544        for (extends_index, parent) in concept.extends.iter().enumerate() {
2545            if !concept_ids.contains(parent) {
2546                return Err(format!(
2547                    "{path}.extends[{extends_index}] points to unknown concept `{parent}`"
2548                ));
2549            }
2550        }
2551    }
2552    validate_ontology_answer_cycles(ontology)?;
2553
2554    let mut relationship_ids = BTreeSet::new();
2555    for (relationship_index, relationship) in ontology.relationships.iter().enumerate() {
2556        let path = format!("ontology.relationships[{relationship_index}]");
2557        validate_url_safe_id(&relationship.id, &format!("{path}.id"))?;
2558        if !relationship_ids.insert(relationship.id.clone()) {
2559            return Err(format!(
2560                "{path}.id duplicates relationship `{}`",
2561                relationship.id
2562            ));
2563        }
2564        if !concept_ids.contains(&relationship.from) {
2565            return Err(format!(
2566                "{path}.from points to unknown concept `{}`",
2567                relationship.from
2568            ));
2569        }
2570        if !concept_ids.contains(&relationship.to) {
2571            return Err(format!(
2572                "{path}.to points to unknown concept `{}`",
2573                relationship.to
2574            ));
2575        }
2576        if let Some(cardinality) = &relationship.cardinality {
2577            validate_choice(
2578                Some(cardinality.from.as_str()),
2579                &["one", "many"],
2580                &format!("{path}.cardinality.from"),
2581            )?;
2582            validate_choice(
2583                Some(cardinality.to.as_str()),
2584                &["one", "many"],
2585                &format!("{path}.cardinality.to"),
2586            )?;
2587        }
2588        if let Some(backing) = &relationship.backed_by {
2589            validate_ontology_backing_answer(backing, record_fields, &format!("{path}.backed_by"))?;
2590        }
2591        for (hook_index, hook) in relationship.policy_hooks.iter().enumerate() {
2592            require_non_empty(
2593                &hook.policy,
2594                &format!("{path}.policy_hooks[{hook_index}].policy"),
2595            )?;
2596        }
2597        for (requirement_index, requirement) in
2598            relationship.provider_requirements.iter().enumerate()
2599        {
2600            validate_provider_requirement_answer(
2601                requirement,
2602                &format!("{path}.provider_requirements[{requirement_index}]"),
2603            )?;
2604        }
2605    }
2606
2607    let mut constraint_ids = BTreeSet::new();
2608    for (constraint_index, constraint) in ontology.constraints.iter().enumerate() {
2609        let path = format!("ontology.constraints[{constraint_index}]");
2610        validate_url_safe_id(&constraint.id, &format!("{path}.id"))?;
2611        if !constraint_ids.insert(constraint.id.clone()) {
2612            return Err(format!(
2613                "{path}.id duplicates constraint `{}`",
2614                constraint.id
2615            ));
2616        }
2617        if !concept_ids.contains(&constraint.applies_to.concept) {
2618            return Err(format!(
2619                "{path}.applies_to.concept points to unknown concept `{}`",
2620                constraint.applies_to.concept
2621            ));
2622        }
2623        if let Some(policy) = &constraint.requires_policy {
2624            require_non_empty(policy, &format!("{path}.requires_policy"))?;
2625        }
2626    }
2627
2628    Ok(())
2629}
2630
2631fn validate_ontology_backing_answer(
2632    backing: &OntologyBackingAnswer,
2633    record_fields: &BTreeMap<String, BTreeSet<String>>,
2634    path: &str,
2635) -> Result<(), String> {
2636    require_non_empty(&backing.record, &format!("{path}.record"))?;
2637    let Some(fields) = record_fields.get(&backing.record) else {
2638        return Err(format!(
2639            "{path}.record points to unknown record `{}`",
2640            backing.record
2641        ));
2642    };
2643    if let Some(from_field) = &backing.from_field {
2644        require_non_empty(from_field, &format!("{path}.from_field"))?;
2645        if !fields.contains(from_field) {
2646            return Err(format!(
2647                "{path}.from_field points to unknown field `{from_field}` on record `{}`",
2648                backing.record
2649            ));
2650        }
2651    }
2652    if let Some(to_field) = &backing.to_field {
2653        require_non_empty(to_field, &format!("{path}.to_field"))?;
2654        if !fields.contains(to_field) {
2655            return Err(format!(
2656                "{path}.to_field points to unknown field `{to_field}` on record `{}`",
2657                backing.record
2658            ));
2659        }
2660    }
2661    Ok(())
2662}
2663
2664fn validate_semantic_alias_answers(
2665    aliases: Option<&SemanticAliasesAnswer>,
2666    ontology: Option<&OntologyAnswers>,
2667) -> Result<(), String> {
2668    let Some(aliases) = aliases else {
2669        return Ok(());
2670    };
2671    let ontology =
2672        ontology.ok_or_else(|| "semantic_aliases require `ontology` answers".to_string())?;
2673    let concept_ids = ontology
2674        .concepts
2675        .iter()
2676        .map(|concept| concept.id.clone())
2677        .collect::<BTreeSet<_>>();
2678    let relationship_ids = ontology
2679        .relationships
2680        .iter()
2681        .map(|relationship| relationship.id.clone())
2682        .collect::<BTreeSet<_>>();
2683    validate_alias_answer_map(
2684        &aliases.concepts,
2685        &concept_ids,
2686        "semantic_aliases.concepts",
2687        "concept",
2688    )?;
2689    validate_alias_answer_map(
2690        &aliases.relationships,
2691        &relationship_ids,
2692        "semantic_aliases.relationships",
2693        "relationship",
2694    )
2695}
2696
2697fn validate_alias_answer_map(
2698    aliases: &BTreeMap<String, Vec<String>>,
2699    known_targets: &BTreeSet<String>,
2700    path: &str,
2701    target_kind: &str,
2702) -> Result<(), String> {
2703    let mut normalized_targets = BTreeMap::new();
2704    for (target, values) in aliases {
2705        if !known_targets.contains(target) {
2706            return Err(format!(
2707                "{path}.{target} points to unknown {target_kind} `{target}`"
2708            ));
2709        }
2710        for (alias_index, alias) in values.iter().enumerate() {
2711            require_non_empty(alias, &format!("{path}.{target}[{alias_index}]"))?;
2712            let normalized = normalize_semantic_alias(alias);
2713            if let Some(existing) = normalized_targets.insert(normalized, target.clone())
2714                && existing != *target
2715            {
2716                return Err(format!(
2717                    "{path}.{target}[{alias_index}] collides with alias target `{existing}`"
2718                ));
2719            }
2720        }
2721    }
2722    Ok(())
2723}
2724
2725fn validate_entity_linking_answers(
2726    entity_linking: Option<&EntityLinkingAnswer>,
2727    ontology: Option<&OntologyAnswers>,
2728    record_fields: &BTreeMap<String, BTreeSet<String>>,
2729) -> Result<(), String> {
2730    let Some(entity_linking) = entity_linking else {
2731        return Ok(());
2732    };
2733    let ontology =
2734        ontology.ok_or_else(|| "entity_linking requires `ontology` answers".to_string())?;
2735    let concepts = ontology
2736        .concepts
2737        .iter()
2738        .map(|concept| (concept.id.as_str(), concept))
2739        .collect::<BTreeMap<_, _>>();
2740    let mut strategy_ids = BTreeSet::new();
2741    for (strategy_index, strategy) in entity_linking.strategies.iter().enumerate() {
2742        let path = format!("entity_linking.strategies[{strategy_index}]");
2743        validate_url_safe_id(&strategy.id, &format!("{path}.id"))?;
2744        if !strategy_ids.insert(strategy.id.clone()) {
2745            return Err(format!("{path}.id duplicates strategy `{}`", strategy.id));
2746        }
2747        let confidence = strategy
2748            .confidence
2749            .as_f64()
2750            .ok_or_else(|| format!("{path}.confidence must be a number"))?;
2751        if !(0.0..=1.0).contains(&confidence) {
2752            return Err(format!("{path}.confidence must be between 0.0 and 1.0"));
2753        }
2754        require_non_empty(
2755            &strategy.match_fields.source_field,
2756            &format!("{path}.match.source_field"),
2757        )?;
2758        require_non_empty(
2759            &strategy.match_fields.target_field,
2760            &format!("{path}.match.target_field"),
2761        )?;
2762        let concept = concepts.get(strategy.applies_to.as_str()).ok_or_else(|| {
2763            format!(
2764                "{path}.applies_to points to unknown concept `{}`",
2765                strategy.applies_to
2766            )
2767        })?;
2768        if let Some(backing) = &concept.backed_by {
2769            let fields = record_fields.get(&backing.record).ok_or_else(|| {
2770                format!(
2771                    "{path}.applies_to concept `{}` points to unknown backing record `{}`",
2772                    concept.id, backing.record
2773                )
2774            })?;
2775            if !fields.contains(&strategy.match_fields.target_field) {
2776                return Err(format!(
2777                    "{path}.match.target_field points to unknown field `{}` on record `{}`",
2778                    strategy.match_fields.target_field, backing.record
2779                ));
2780            }
2781        } else {
2782            let Some(source_type) = &strategy.source_type else {
2783                return Err(format!(
2784                    "{path}.source_type is required for unbacked concept `{}`",
2785                    concept.id
2786                ));
2787            };
2788            require_non_empty(source_type, &format!("{path}.source_type"))?;
2789            if source_type == "record" {
2790                return Err(format!(
2791                    "{path}.source_type must be non-record for unbacked concept `{}`",
2792                    concept.id
2793                ));
2794            }
2795        }
2796    }
2797    Ok(())
2798}
2799
2800fn normalize_semantic_alias(alias: &str) -> String {
2801    alias
2802        .split_whitespace()
2803        .collect::<Vec<_>>()
2804        .join(" ")
2805        .to_lowercase()
2806}
2807
2808fn validate_retrieval_binding_answers(
2809    retrieval_bindings: Option<&RetrievalBindingsAnswer>,
2810    ontology: Option<&OntologyAnswers>,
2811) -> Result<(), String> {
2812    let Some(retrieval_bindings) = retrieval_bindings else {
2813        return Ok(());
2814    };
2815    let ontology =
2816        ontology.ok_or_else(|| "retrieval_bindings require `ontology` answers".to_string())?;
2817    let schema = retrieval_bindings
2818        .schema
2819        .as_deref()
2820        .unwrap_or("greentic.sorla.retrieval-bindings.v1");
2821    if schema != "greentic.sorla.retrieval-bindings.v1" {
2822        return Err(format!(
2823            "retrieval_bindings.schema must be `greentic.sorla.retrieval-bindings.v1`, got `{schema}`"
2824        ));
2825    }
2826
2827    let concept_ids = ontology
2828        .concepts
2829        .iter()
2830        .map(|concept| concept.id.clone())
2831        .collect::<BTreeSet<_>>();
2832    let relationship_ids = ontology
2833        .relationships
2834        .iter()
2835        .map(|relationship| relationship.id.clone())
2836        .collect::<BTreeSet<_>>();
2837
2838    let mut provider_ids = BTreeSet::new();
2839    for (provider_index, provider) in retrieval_bindings.providers.iter().enumerate() {
2840        let path = format!("retrieval_bindings.providers[{provider_index}]");
2841        validate_url_safe_id(&provider.id, &format!("{path}.id"))?;
2842        if !provider_ids.insert(provider.id.clone()) {
2843            return Err(format!("{path}.id duplicates provider `{}`", provider.id));
2844        }
2845        require_non_empty(&provider.category, &format!("{path}.category"))?;
2846        for (capability_index, capability) in provider.required_capabilities.iter().enumerate() {
2847            require_non_empty(
2848                capability,
2849                &format!("{path}.required_capabilities[{capability_index}]"),
2850            )?;
2851        }
2852    }
2853
2854    let mut scope_ids = BTreeSet::new();
2855    for (scope_index, scope) in retrieval_bindings.scopes.iter().enumerate() {
2856        let path = format!("retrieval_bindings.scopes[{scope_index}]");
2857        validate_url_safe_id(&scope.id, &format!("{path}.id"))?;
2858        if !scope_ids.insert(scope.id.clone()) {
2859            return Err(format!("{path}.id duplicates scope `{}`", scope.id));
2860        }
2861        if !provider_ids.contains(&scope.provider) {
2862            return Err(format!(
2863                "{path}.provider points to unknown provider `{}`",
2864                scope.provider
2865            ));
2866        }
2867        match (&scope.applies_to.concept, &scope.applies_to.relationship) {
2868            (Some(concept), None) if concept_ids.contains(concept) => {}
2869            (None, Some(relationship)) if relationship_ids.contains(relationship) => {}
2870            (Some(concept), None) => {
2871                return Err(format!(
2872                    "{path}.applies_to.concept points to unknown concept `{concept}`"
2873                ));
2874            }
2875            (None, Some(relationship)) => {
2876                return Err(format!(
2877                    "{path}.applies_to.relationship points to unknown relationship `{relationship}`"
2878                ));
2879            }
2880            (Some(_), Some(_)) | (None, None) => {
2881                return Err(format!(
2882                    "{path}.applies_to must declare exactly one of concept or relationship"
2883                ));
2884            }
2885        }
2886        validate_choice(
2887            scope.permission.as_deref(),
2888            &["inherit", "public-metadata-only", "requires-policy"],
2889            &format!("{path}.permission"),
2890        )?;
2891        if let Some(filters) = &scope.filters
2892            && let Some(entity_scope) = &filters.entity_scope
2893        {
2894            for (rule_index, rule) in entity_scope.include_related.iter().enumerate() {
2895                let rule_path =
2896                    format!("{path}.filters.entity_scope.include_related[{rule_index}]");
2897                if !relationship_ids.contains(&rule.relationship) {
2898                    return Err(format!(
2899                        "{rule_path}.relationship points to unknown relationship `{}`",
2900                        rule.relationship
2901                    ));
2902                }
2903                validate_choice(
2904                    Some(rule.direction.as_str()),
2905                    &["incoming", "outgoing", "both"],
2906                    &format!("{rule_path}.direction"),
2907                )?;
2908                if rule.max_depth > 5 {
2909                    return Err(format!("{rule_path}.max_depth must be between 0 and 5"));
2910                }
2911            }
2912        }
2913    }
2914
2915    Ok(())
2916}
2917
2918fn validate_ontology_answer_cycles(ontology: &OntologyAnswers) -> Result<(), String> {
2919    let parents = ontology
2920        .concepts
2921        .iter()
2922        .map(|concept| (concept.id.as_str(), concept.extends.as_slice()))
2923        .collect::<BTreeMap<_, _>>();
2924    for concept in parents.keys() {
2925        visit_ontology_answer_parent(
2926            concept,
2927            &parents,
2928            &mut BTreeSet::new(),
2929            &mut BTreeSet::new(),
2930        )?;
2931    }
2932    Ok(())
2933}
2934
2935fn visit_ontology_answer_parent<'a>(
2936    concept: &'a str,
2937    parents: &BTreeMap<&'a str, &'a [String]>,
2938    visiting: &mut BTreeSet<&'a str>,
2939    visited: &mut BTreeSet<&'a str>,
2940) -> Result<(), String> {
2941    if visited.contains(concept) {
2942        return Ok(());
2943    }
2944    if !visiting.insert(concept) {
2945        return Err(format!(
2946            "ontology.concepts contains inheritance cycle including `{concept}`"
2947        ));
2948    }
2949    if let Some(parent_ids) = parents.get(concept) {
2950        for parent in *parent_ids {
2951            visit_ontology_answer_parent(parent, parents, visiting, visited)?;
2952        }
2953    }
2954    visiting.remove(concept);
2955    visited.insert(concept);
2956    Ok(())
2957}
2958
2959fn validate_url_safe_id(value: &str, path: &str) -> Result<(), String> {
2960    require_non_empty(value, path)?;
2961    if !value
2962        .chars()
2963        .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '-'))
2964    {
2965        return Err(format!("field `{path}` must be URL-safe"));
2966    }
2967    Ok(())
2968}
2969
2970fn require_non_empty(value: &str, path: &str) -> Result<(), String> {
2971    if value.trim().is_empty() {
2972        return Err(format!("field `{path}` is required"));
2973    }
2974    Ok(())
2975}
2976
2977fn validate_named_answers(items: &[NamedAnswer], path: &str) -> Result<BTreeSet<String>, String> {
2978    let mut names = BTreeSet::new();
2979    for (index, item) in items.iter().enumerate() {
2980        require_non_empty(&item.name, &format!("{path}[{index}].name"))?;
2981        if !names.insert(item.name.clone()) {
2982            return Err(format!("{path}[{index}].name duplicates `{}`", item.name));
2983        }
2984    }
2985    Ok(names)
2986}
2987
2988fn validate_endpoint_fields(fields: &[FieldAnswer], path: &str) -> Result<(), String> {
2989    let mut names = BTreeSet::new();
2990    for (index, field) in fields.iter().enumerate() {
2991        require_non_empty(&field.name, &format!("{path}[{index}].name"))?;
2992        require_non_empty(&field.type_name, &format!("{path}[{index}].type"))?;
2993        validate_enum_values(&field.enum_values, &format!("{path}[{index}].enum_values"))?;
2994        if !names.insert(field.name.clone()) {
2995            return Err(format!(
2996                "{path}[{index}].name duplicates field `{}`",
2997                field.name
2998            ));
2999        }
3000    }
3001    Ok(())
3002}
3003
3004fn validate_enum_values(values: &[String], path: &str) -> Result<(), String> {
3005    let mut seen = BTreeSet::new();
3006    for (index, value) in values.iter().enumerate() {
3007        require_non_empty(value, &format!("{path}[{index}]"))?;
3008        if !seen.insert(value.clone()) {
3009            return Err(format!("{path}[{index}] duplicates enum value `{value}`"));
3010        }
3011    }
3012    Ok(())
3013}
3014
3015fn validate_provider_requirement_answer(
3016    requirement: &ProviderRequirementAnswer,
3017    path: &str,
3018) -> Result<(), String> {
3019    require_non_empty(&requirement.category, &format!("{path}.category"))?;
3020    let mut capabilities = BTreeSet::new();
3021    for (index, capability) in requirement.capabilities.iter().enumerate() {
3022        require_non_empty(capability, &format!("{path}.capabilities[{index}]"))?;
3023        if !capabilities.insert(capability.clone()) {
3024            return Err(format!(
3025                "{path}.capabilities[{index}] duplicates capability `{capability}`"
3026            ));
3027        }
3028    }
3029    Ok(())
3030}
3031
3032fn validate_declared_references(
3033    references: &[String],
3034    declared: &BTreeSet<String>,
3035    path: &str,
3036) -> Result<(), String> {
3037    if declared.is_empty() {
3038        return Ok(());
3039    }
3040    for (index, reference) in references.iter().enumerate() {
3041        if !declared.contains(reference) {
3042            return Err(format!(
3043                "{path}[{index}] points to undeclared item `{reference}`"
3044            ));
3045        }
3046    }
3047    Ok(())
3048}
3049
3050fn normalize_text_list(values: Vec<String>) -> Vec<String> {
3051    let mut normalized = values
3052        .into_iter()
3053        .map(|value| value.trim().to_string())
3054        .filter(|value| !value.is_empty())
3055        .collect::<Vec<_>>();
3056    normalized.sort();
3057    normalized.dedup();
3058    normalized
3059}
3060
3061fn resolve_create_answers(answers: &AnswersDocument) -> Result<ResolvedAnswers, String> {
3062    let package = answers.package.as_ref().ok_or_else(|| {
3063        "create flow requires the `package` section with at least `package.name` and `package.version`".to_string()
3064    })?;
3065    let default_source = answers
3066        .records
3067        .as_ref()
3068        .and_then(|records| records.default_source.clone())
3069        .unwrap_or_else(|| "native".to_string());
3070    let external_ref_system = answers
3071        .records
3072        .as_ref()
3073        .and_then(|records| records.external_ref_system.clone());
3074
3075    if matches!(default_source.as_str(), "external" | "hybrid")
3076        && external_ref_system
3077            .as_deref()
3078            .unwrap_or("")
3079            .trim()
3080            .is_empty()
3081    {
3082        return Err(format!(
3083            "field `records.external_ref_system` is required when `records.default_source` is `{default_source}`"
3084        ));
3085    }
3086
3087    let mut artifacts = normalize_artifacts(
3088        answers
3089            .output
3090            .as_ref()
3091            .and_then(|output| output.artifacts.clone())
3092            .unwrap_or_else(default_artifacts),
3093    )?;
3094
3095    let include_agent_tools = answers
3096        .output
3097        .as_ref()
3098        .and_then(|output| output.include_agent_tools)
3099        .unwrap_or(true);
3100    if include_agent_tools {
3101        if !artifacts
3102            .iter()
3103            .any(|artifact| artifact == "agent-tools.json")
3104        {
3105            artifacts.push("agent-tools.json".to_string());
3106        }
3107    } else {
3108        artifacts.retain(|artifact| artifact != "agent-tools.json");
3109    }
3110    artifacts.sort();
3111    artifacts.dedup();
3112    let agent_endpoint_values = resolve_agent_endpoint_answers(answers.agent_endpoints.as_ref());
3113
3114    let resolved = ResolvedAnswers {
3115        schema_version: answers.schema_version.clone(),
3116        flow: "create".to_string(),
3117        output_dir: answers.output_dir.clone(),
3118        locale: selected_locale(answers.locale.as_deref(), None),
3119        package_name: package.name.clone().unwrap(),
3120        package_version: package.version.clone().unwrap(),
3121        storage_category: answers
3122            .providers
3123            .as_ref()
3124            .and_then(|providers| providers.storage_category.clone())
3125            .unwrap_or_else(|| "storage".to_string()),
3126        external_ref_category: if matches!(default_source.as_str(), "external" | "hybrid") {
3127            Some(
3128                answers
3129                    .providers
3130                    .as_ref()
3131                    .and_then(|providers| providers.external_ref_category.clone())
3132                    .unwrap_or_else(|| "external-ref".to_string()),
3133            )
3134        } else {
3135            None
3136        },
3137        provider_hints: answers
3138            .providers
3139            .as_ref()
3140            .and_then(|providers| providers.hints.clone())
3141            .unwrap_or_default(),
3142        default_source,
3143        external_ref_system,
3144        record_items: answers
3145            .records
3146            .as_ref()
3147            .map(|records| records.items.clone())
3148            .unwrap_or_default(),
3149        ontology: answers.ontology.clone(),
3150        semantic_aliases: answers.semantic_aliases.clone(),
3151        entity_linking: answers.entity_linking.clone(),
3152        retrieval_bindings: answers.retrieval_bindings.clone(),
3153        actions: answers.actions.clone(),
3154        event_items: answers
3155            .events
3156            .as_ref()
3157            .map(|events| events.items.clone())
3158            .unwrap_or_default(),
3159        projection_items: answers
3160            .projections
3161            .as_ref()
3162            .map(|projections| projections.items.clone())
3163            .unwrap_or_default(),
3164        provider_requirements: answers.provider_requirements.clone(),
3165        policies: answers.policies.clone(),
3166        approvals: answers.approvals.clone(),
3167        migration_items: answers
3168            .migrations
3169            .as_ref()
3170            .map(|migrations| migrations.items.clone())
3171            .unwrap_or_default(),
3172        events_enabled: answers
3173            .events
3174            .as_ref()
3175            .and_then(|events| events.enabled)
3176            .unwrap_or(true),
3177        projection_mode: answers
3178            .projections
3179            .as_ref()
3180            .and_then(|projections| projections.mode.clone())
3181            .unwrap_or_else(|| "current-state".to_string()),
3182        compatibility_mode: answers
3183            .migrations
3184            .as_ref()
3185            .and_then(|migrations| migrations.compatibility.clone())
3186            .unwrap_or_else(|| "additive".to_string()),
3187        agent_endpoints_enabled: agent_endpoint_values.enabled,
3188        agent_endpoint_ids: agent_endpoint_values.ids,
3189        agent_endpoint_default_risk: agent_endpoint_values.default_risk,
3190        agent_endpoint_default_approval: agent_endpoint_values.default_approval,
3191        agent_endpoint_exports: agent_endpoint_values.exports,
3192        agent_endpoint_provider_category: agent_endpoint_values.provider_category,
3193        agent_endpoint_items: agent_endpoint_values.items,
3194        include_agent_tools,
3195        artifacts,
3196    };
3197
3198    Ok(resolved)
3199}
3200
3201fn resolve_update_answers(
3202    answers: &AnswersDocument,
3203    previous: ResolvedAnswers,
3204) -> Result<ResolvedAnswers, String> {
3205    if let Some(package) = &answers.package
3206        && let Some(name) = &package.name
3207        && name != &previous.package_name
3208    {
3209        return Err(format!(
3210            "update flow cannot change `package.name` from `{}` to `{}`",
3211            previous.package_name, name
3212        ));
3213    }
3214
3215    let default_source = answers
3216        .records
3217        .as_ref()
3218        .and_then(|records| records.default_source.clone())
3219        .unwrap_or_else(|| previous.default_source.clone());
3220    let external_ref_system = answers
3221        .records
3222        .as_ref()
3223        .and_then(|records| records.external_ref_system.clone())
3224        .or_else(|| previous.external_ref_system.clone());
3225
3226    if matches!(default_source.as_str(), "external" | "hybrid")
3227        && external_ref_system
3228            .as_deref()
3229            .unwrap_or("")
3230            .trim()
3231            .is_empty()
3232    {
3233        return Err(format!(
3234            "field `records.external_ref_system` is required when `records.default_source` is `{default_source}`"
3235        ));
3236    }
3237
3238    let include_agent_tools = answers
3239        .output
3240        .as_ref()
3241        .and_then(|output| output.include_agent_tools)
3242        .unwrap_or(previous.include_agent_tools);
3243
3244    let mut artifacts = normalize_artifacts(
3245        answers
3246            .output
3247            .as_ref()
3248            .and_then(|output| output.artifacts.clone())
3249            .unwrap_or_else(|| previous.artifacts.clone()),
3250    )?;
3251    if include_agent_tools {
3252        if !artifacts
3253            .iter()
3254            .any(|artifact| artifact == "agent-tools.json")
3255        {
3256            artifacts.push("agent-tools.json".to_string());
3257        }
3258    } else {
3259        artifacts.retain(|artifact| artifact != "agent-tools.json");
3260    }
3261    artifacts.sort();
3262    artifacts.dedup();
3263    let agent_endpoint_values =
3264        resolve_agent_endpoint_update_answers(answers.agent_endpoints.as_ref(), &previous);
3265
3266    Ok(ResolvedAnswers {
3267        schema_version: answers.schema_version.clone(),
3268        flow: "update".to_string(),
3269        output_dir: previous.output_dir,
3270        locale: selected_locale(answers.locale.as_deref(), Some(previous.locale.as_str())),
3271        package_name: previous.package_name,
3272        package_version: answers
3273            .package
3274            .as_ref()
3275            .and_then(|package| package.version.clone())
3276            .unwrap_or(previous.package_version),
3277        storage_category: answers
3278            .providers
3279            .as_ref()
3280            .and_then(|providers| providers.storage_category.clone())
3281            .unwrap_or(previous.storage_category),
3282        external_ref_category: if matches!(default_source.as_str(), "external" | "hybrid") {
3283            Some(
3284                answers
3285                    .providers
3286                    .as_ref()
3287                    .and_then(|providers| providers.external_ref_category.clone())
3288                    .or(previous.external_ref_category)
3289                    .unwrap_or_else(|| "external-ref".to_string()),
3290            )
3291        } else {
3292            None
3293        },
3294        provider_hints: answers
3295            .providers
3296            .as_ref()
3297            .and_then(|providers| providers.hints.clone())
3298            .unwrap_or(previous.provider_hints),
3299        default_source,
3300        external_ref_system,
3301        record_items: answers
3302            .records
3303            .as_ref()
3304            .and_then(|records| {
3305                if records.items.is_empty() {
3306                    None
3307                } else {
3308                    Some(records.items.clone())
3309                }
3310            })
3311            .unwrap_or(previous.record_items),
3312        ontology: answers.ontology.clone().or(previous.ontology),
3313        semantic_aliases: answers
3314            .semantic_aliases
3315            .clone()
3316            .or(previous.semantic_aliases),
3317        entity_linking: answers.entity_linking.clone().or(previous.entity_linking),
3318        retrieval_bindings: answers
3319            .retrieval_bindings
3320            .clone()
3321            .or(previous.retrieval_bindings),
3322        actions: if answers.actions.is_empty() {
3323            previous.actions
3324        } else {
3325            answers.actions.clone()
3326        },
3327        event_items: answers
3328            .events
3329            .as_ref()
3330            .and_then(|events| {
3331                if events.items.is_empty() {
3332                    None
3333                } else {
3334                    Some(events.items.clone())
3335                }
3336            })
3337            .unwrap_or(previous.event_items),
3338        projection_items: answers
3339            .projections
3340            .as_ref()
3341            .and_then(|projections| {
3342                if projections.items.is_empty() {
3343                    None
3344                } else {
3345                    Some(projections.items.clone())
3346                }
3347            })
3348            .unwrap_or(previous.projection_items),
3349        provider_requirements: if answers.provider_requirements.is_empty() {
3350            previous.provider_requirements
3351        } else {
3352            answers.provider_requirements.clone()
3353        },
3354        policies: if answers.policies.is_empty() {
3355            previous.policies
3356        } else {
3357            answers.policies.clone()
3358        },
3359        approvals: if answers.approvals.is_empty() {
3360            previous.approvals
3361        } else {
3362            answers.approvals.clone()
3363        },
3364        migration_items: answers
3365            .migrations
3366            .as_ref()
3367            .and_then(|migrations| {
3368                if migrations.items.is_empty() {
3369                    None
3370                } else {
3371                    Some(migrations.items.clone())
3372                }
3373            })
3374            .unwrap_or(previous.migration_items),
3375        events_enabled: answers
3376            .events
3377            .as_ref()
3378            .and_then(|events| events.enabled)
3379            .unwrap_or(previous.events_enabled),
3380        projection_mode: answers
3381            .projections
3382            .as_ref()
3383            .and_then(|projections| projections.mode.clone())
3384            .unwrap_or(previous.projection_mode),
3385        compatibility_mode: answers
3386            .migrations
3387            .as_ref()
3388            .and_then(|migrations| migrations.compatibility.clone())
3389            .unwrap_or(previous.compatibility_mode),
3390        agent_endpoints_enabled: agent_endpoint_values.enabled,
3391        agent_endpoint_ids: agent_endpoint_values.ids,
3392        agent_endpoint_default_risk: agent_endpoint_values.default_risk,
3393        agent_endpoint_default_approval: agent_endpoint_values.default_approval,
3394        agent_endpoint_exports: agent_endpoint_values.exports,
3395        agent_endpoint_provider_category: agent_endpoint_values.provider_category,
3396        agent_endpoint_items: agent_endpoint_values.items,
3397        include_agent_tools,
3398        artifacts,
3399    })
3400}
3401
3402struct ResolvedAgentEndpointAnswers {
3403    enabled: bool,
3404    ids: Vec<String>,
3405    default_risk: String,
3406    default_approval: String,
3407    exports: Vec<String>,
3408    provider_category: Option<String>,
3409    items: Vec<AgentEndpointItemAnswer>,
3410}
3411
3412fn resolve_agent_endpoint_answers(
3413    answers: Option<&AgentEndpointAnswers>,
3414) -> ResolvedAgentEndpointAnswers {
3415    let enabled = answers
3416        .and_then(|answers| answers.enabled)
3417        .unwrap_or_else(|| answers.is_some_and(|answers| !answers.items.is_empty()));
3418    let ids = if enabled {
3419        normalize_text_list(
3420            answers
3421                .and_then(|answers| answers.ids.clone())
3422                .unwrap_or_default(),
3423        )
3424    } else {
3425        Vec::new()
3426    };
3427    let exports = if enabled {
3428        normalize_text_list(
3429            answers
3430                .and_then(|answers| answers.exports.clone())
3431                .unwrap_or_else(default_agent_endpoint_exports),
3432        )
3433    } else {
3434        Vec::new()
3435    };
3436
3437    ResolvedAgentEndpointAnswers {
3438        enabled,
3439        ids,
3440        default_risk: answers
3441            .and_then(|answers| answers.default_risk.clone())
3442            .unwrap_or_else(|| "medium".to_string()),
3443        default_approval: answers
3444            .and_then(|answers| answers.default_approval.clone())
3445            .unwrap_or_else(|| "policy-driven".to_string()),
3446        exports,
3447        provider_category: answers
3448            .and_then(|answers| answers.provider_category.clone())
3449            .map(|value| value.trim().to_string())
3450            .filter(|value| enabled && !value.is_empty()),
3451        items: if enabled {
3452            answers
3453                .map(|answers| answers.items.clone())
3454                .unwrap_or_default()
3455        } else {
3456            Vec::new()
3457        },
3458    }
3459}
3460
3461fn resolve_agent_endpoint_update_answers(
3462    answers: Option<&AgentEndpointAnswers>,
3463    previous: &ResolvedAnswers,
3464) -> ResolvedAgentEndpointAnswers {
3465    let Some(answers) = answers else {
3466        return ResolvedAgentEndpointAnswers {
3467            enabled: previous.agent_endpoints_enabled,
3468            ids: previous.agent_endpoint_ids.clone(),
3469            default_risk: previous.agent_endpoint_default_risk.clone(),
3470            default_approval: previous.agent_endpoint_default_approval.clone(),
3471            exports: previous.agent_endpoint_exports.clone(),
3472            provider_category: previous.agent_endpoint_provider_category.clone(),
3473            items: previous.agent_endpoint_items.clone(),
3474        };
3475    };
3476
3477    let enabled = answers.enabled.unwrap_or(previous.agent_endpoints_enabled);
3478    ResolvedAgentEndpointAnswers {
3479        enabled,
3480        ids: if enabled {
3481            answers
3482                .ids
3483                .clone()
3484                .map(normalize_text_list)
3485                .unwrap_or_else(|| previous.agent_endpoint_ids.clone())
3486        } else {
3487            Vec::new()
3488        },
3489        default_risk: answers
3490            .default_risk
3491            .clone()
3492            .unwrap_or_else(|| previous.agent_endpoint_default_risk.clone()),
3493        default_approval: answers
3494            .default_approval
3495            .clone()
3496            .unwrap_or_else(|| previous.agent_endpoint_default_approval.clone()),
3497        exports: if enabled {
3498            answers
3499                .exports
3500                .clone()
3501                .map(normalize_text_list)
3502                .unwrap_or_else(|| previous.agent_endpoint_exports.clone())
3503        } else {
3504            Vec::new()
3505        },
3506        provider_category: answers
3507            .provider_category
3508            .clone()
3509            .map(|value| value.trim().to_string())
3510            .filter(|value| enabled && !value.is_empty())
3511            .or_else(|| {
3512                if enabled {
3513                    previous.agent_endpoint_provider_category.clone()
3514                } else {
3515                    None
3516                }
3517            }),
3518        items: if enabled {
3519            if answers.items.is_empty() {
3520                previous.agent_endpoint_items.clone()
3521            } else {
3522                answers.items.clone()
3523            }
3524        } else {
3525            Vec::new()
3526        },
3527    }
3528}
3529
3530fn default_agent_endpoint_exports() -> Vec<String> {
3531    ["openapi", "arazzo", "mcp", "llms_txt"]
3532        .into_iter()
3533        .map(str::to_string)
3534        .collect()
3535}
3536
3537fn default_agent_endpoint_risk() -> String {
3538    "medium".to_string()
3539}
3540
3541fn default_agent_endpoint_approval() -> String {
3542    "policy-driven".to_string()
3543}
3544
3545fn normalize_artifacts(artifacts: Vec<String>) -> Result<Vec<String>, String> {
3546    let allowed: BTreeSet<&str> = default_schema().artifact_references.into_iter().collect();
3547    let mut normalized = Vec::new();
3548    for artifact in artifacts {
3549        if !allowed.contains(artifact.as_str()) {
3550            return Err(format!(
3551                "output.artifacts contains unsupported artifact `{artifact}`"
3552            ));
3553        }
3554        normalized.push(artifact);
3555    }
3556    normalized.sort();
3557    normalized.dedup();
3558    Ok(normalized)
3559}
3560
3561fn default_artifacts() -> Vec<String> {
3562    default_schema()
3563        .artifact_references
3564        .into_iter()
3565        .map(str::to_string)
3566        .collect()
3567}
3568
3569fn read_lock_file(path: &Path) -> Result<ResolvedAnswers, String> {
3570    let contents = fs::read_to_string(path).map_err(|err| {
3571        format!(
3572            "update flow requires existing wizard state at {}: {err}",
3573            path.display()
3574        )
3575    })?;
3576    serde_json::from_str(&contents).map_err(|err| {
3577        format!(
3578            "failed to parse existing wizard state {}: {err}",
3579            path.display()
3580        )
3581    })
3582}
3583
3584fn write_lock_file(path: &Path, resolved: &ResolvedAnswers) -> Result<(), String> {
3585    let contents = serde_json::to_vec_pretty(resolved).map_err(|err| err.to_string())?;
3586    fs::write(path, contents)
3587        .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))
3588}
3589
3590fn write_generated_json_aliases(
3591    generated_dir: &Path,
3592    file_names: &[&str],
3593    bytes: &[u8],
3594) -> Result<Vec<PathBuf>, String> {
3595    let mut written = Vec::new();
3596    for file_name in file_names {
3597        let path = generated_dir.join(file_name);
3598        fs::write(&path, bytes)
3599            .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
3600        written.push(path);
3601    }
3602    Ok(written)
3603}
3604
3605fn write_generated_block(path: &Path, generated_yaml: &str) -> Result<bool, String> {
3606    let block = format!("{GENERATED_BEGIN}\n{generated_yaml}{GENERATED_END}\n");
3607    let existing = if path.exists() {
3608        Some(
3609            fs::read_to_string(path)
3610                .map_err(|err| format!("failed to read package file {}: {err}", path.display()))?,
3611        )
3612    } else {
3613        None
3614    };
3615
3616    let (contents, preserved_user_content) = if let Some(existing) = existing {
3617        if let (Some(start), Some(end)) =
3618            (existing.find(GENERATED_BEGIN), existing.find(GENERATED_END))
3619        {
3620            let end_index = end + GENERATED_END.len();
3621            let suffix = existing[end_index..]
3622                .strip_prefix('\n')
3623                .unwrap_or(&existing[end_index..]);
3624            let mut updated = String::new();
3625            updated.push_str(&existing[..start]);
3626            updated.push_str(&block);
3627            updated.push_str(suffix);
3628            (updated, true)
3629        } else {
3630            let mut updated = existing;
3631            if !updated.ends_with('\n') {
3632                updated.push('\n');
3633            }
3634            updated.push_str(&block);
3635            (updated, true)
3636        }
3637    } else {
3638        (block, false)
3639    };
3640
3641    if let Some(parent) = path.parent() {
3642        fs::create_dir_all(parent)
3643            .map_err(|err| format!("failed to create directory {}: {err}", parent.display()))?;
3644    }
3645
3646    fs::write(path, contents)
3647        .map_err(|err| format!("failed to write package file {}: {err}", path.display()))?;
3648    Ok(preserved_user_content)
3649}
3650
3651fn render_package_yaml(resolved: &ResolvedAnswers) -> String {
3652    if has_rich_domain_answers(resolved) {
3653        return render_rich_package_yaml(resolved);
3654    }
3655
3656    let record_name = format!("{}Record", to_pascal_case(&resolved.package_name));
3657    let mut lines = vec![
3658        format!("package:"),
3659        format!("  name: {}", resolved.package_name),
3660        format!("  version: {}", resolved.package_version),
3661        "records:".to_string(),
3662        format!("  - name: {record_name}"),
3663        format!("    source: {}", resolved.default_source),
3664    ];
3665
3666    if matches!(resolved.default_source.as_str(), "external" | "hybrid") {
3667        lines.push("    external_ref:".to_string());
3668        lines.push(format!(
3669            "      system: {}",
3670            resolved
3671                .external_ref_system
3672                .as_deref()
3673                .unwrap_or("external-system")
3674        ));
3675        lines.push("      key: record_id".to_string());
3676        lines.push("      authoritative: true".to_string());
3677    }
3678
3679    lines.push("    fields:".to_string());
3680    match resolved.default_source.as_str() {
3681        "native" => {
3682            lines.push("      - name: record_id".to_string());
3683            lines.push("        type: string".to_string());
3684            lines.push("      - name: workflow_state".to_string());
3685            lines.push("        type: string".to_string());
3686        }
3687        "external" => {
3688            lines.push("      - name: record_id".to_string());
3689            lines.push("        type: string".to_string());
3690            lines.push("      - name: external_snapshot".to_string());
3691            lines.push("        type: string".to_string());
3692        }
3693        "hybrid" => {
3694            lines.push("      - name: record_id".to_string());
3695            lines.push("        type: string".to_string());
3696            lines.push("        authority: external".to_string());
3697            lines.push("      - name: workflow_state".to_string());
3698            lines.push("        type: string".to_string());
3699            lines.push("        authority: local".to_string());
3700        }
3701        _ => {}
3702    }
3703
3704    render_ontology(resolved, &mut lines);
3705
3706    if resolved.events_enabled {
3707        let event_name = format!("{}Changed", record_name);
3708        lines.push("events:".to_string());
3709        lines.push(format!("  - name: {event_name}"));
3710        lines.push(format!("    record: {record_name}"));
3711        lines.push("    kind: domain".to_string());
3712        lines.push("    emits:".to_string());
3713        lines.push("      - name: record_id".to_string());
3714        lines.push("        type: string".to_string());
3715
3716        lines.push("projections:".to_string());
3717        lines.push(format!("  - name: {}Projection", record_name));
3718        lines.push(format!("    record: {record_name}"));
3719        lines.push(format!("    source_event: {event_name}"));
3720        lines.push(format!("    mode: {}", resolved.projection_mode));
3721    } else {
3722        lines.push("events: []".to_string());
3723        lines.push("projections: []".to_string());
3724    }
3725
3726    lines.push("provider_requirements:".to_string());
3727    lines.push(format!("  - category: {}", resolved.storage_category));
3728    lines.push("    capabilities:".to_string());
3729    lines.push("      - event-log".to_string());
3730    lines.push("      - projections".to_string());
3731    if let Some(category) = &resolved.external_ref_category {
3732        lines.push(format!("  - category: {category}"));
3733        lines.push("    capabilities:".to_string());
3734        lines.push("      - lookup".to_string());
3735    }
3736    if resolved.agent_endpoints_enabled
3737        && let Some(category) = &resolved.agent_endpoint_provider_category
3738    {
3739        lines.push(format!("  - category: {category}"));
3740        lines.push("    capabilities:".to_string());
3741        lines.push("      - agent-endpoint-handoff".to_string());
3742    }
3743
3744    lines.push("migrations:".to_string());
3745    lines.push(format!("  - name: {}-compatibility", resolved.package_name));
3746    lines.push(format!(
3747        "    compatibility: {}",
3748        resolved.compatibility_mode
3749    ));
3750    if resolved.events_enabled {
3751        lines.push("    projection_updates:".to_string());
3752        lines.push(format!(
3753            "      - {}Projection",
3754            to_pascal_case(&resolved.package_name) + "Record"
3755        ));
3756    } else {
3757        lines.push("    projection_updates: []".to_string());
3758    }
3759
3760    if resolved.agent_endpoints_enabled {
3761        lines.push("agent_endpoints:".to_string());
3762        for id in &resolved.agent_endpoint_ids {
3763            let title = title_from_identifier(id);
3764            lines.push(format!("  - id: {id}"));
3765            lines.push(format!("    title: {title}"));
3766            lines.push(format!(
3767                "    intent: Request the generated `{id}` business action through downstream agent handoff metadata."
3768            ));
3769            lines.push("    inputs:".to_string());
3770            lines.push("      - name: record_id".to_string());
3771            lines.push("        type: string".to_string());
3772            lines.push("        required: true".to_string());
3773            lines.push("    outputs:".to_string());
3774            lines.push("      - name: status".to_string());
3775            lines.push("        type: string".to_string());
3776            lines.push("    side_effects:".to_string());
3777            lines.push(format!("      - agent.{id}.request"));
3778            lines.push(format!(
3779                "    risk: {}",
3780                resolved.agent_endpoint_default_risk
3781            ));
3782            lines.push(format!(
3783                "    approval: {}",
3784                resolved.agent_endpoint_default_approval
3785            ));
3786            if let Some(category) = &resolved.agent_endpoint_provider_category {
3787                lines.push("    provider_requirements:".to_string());
3788                lines.push(format!("      - category: {category}"));
3789                lines.push("        capabilities:".to_string());
3790                lines.push("          - agent-endpoint-handoff".to_string());
3791            }
3792            lines.push("    agent_visibility:".to_string());
3793            lines.push(format!(
3794                "      openapi: {}",
3795                resolved
3796                    .agent_endpoint_exports
3797                    .iter()
3798                    .any(|item| item == "openapi")
3799            ));
3800            lines.push(format!(
3801                "      arazzo: {}",
3802                resolved
3803                    .agent_endpoint_exports
3804                    .iter()
3805                    .any(|item| item == "arazzo")
3806            ));
3807            lines.push(format!(
3808                "      mcp: {}",
3809                resolved
3810                    .agent_endpoint_exports
3811                    .iter()
3812                    .any(|item| item == "mcp")
3813            ));
3814            lines.push(format!(
3815                "      llms_txt: {}",
3816                resolved
3817                    .agent_endpoint_exports
3818                    .iter()
3819                    .any(|item| item == "llms_txt")
3820            ));
3821            lines.push("    examples:".to_string());
3822            lines.push(format!("      - name: {id}-example"));
3823            lines.push(format!("        summary: Example request for {title}."));
3824            lines.push("        input:".to_string());
3825            lines.push("          record_id: record-123".to_string());
3826            lines.push("        expected_output:".to_string());
3827            lines.push("          status: accepted".to_string());
3828        }
3829    } else {
3830        lines.push("agent_endpoints: []".to_string());
3831    }
3832
3833    lines.join("\n") + "\n"
3834}
3835
3836fn has_rich_domain_answers(resolved: &ResolvedAnswers) -> bool {
3837    !resolved.record_items.is_empty()
3838        || !resolved.actions.is_empty()
3839        || !resolved.event_items.is_empty()
3840        || !resolved.projection_items.is_empty()
3841        || !resolved.provider_requirements.is_empty()
3842        || !resolved.policies.is_empty()
3843        || !resolved.approvals.is_empty()
3844        || !resolved.migration_items.is_empty()
3845        || !resolved.agent_endpoint_items.is_empty()
3846        || resolved.ontology.is_some()
3847        || resolved.retrieval_bindings.is_some()
3848}
3849
3850fn render_rich_package_yaml(resolved: &ResolvedAnswers) -> String {
3851    let mut lines = vec![
3852        "package:".to_string(),
3853        format!("  name: {}", yaml_scalar_string(&resolved.package_name)),
3854        format!(
3855            "  version: {}",
3856            yaml_scalar_string(&resolved.package_version)
3857        ),
3858    ];
3859
3860    render_records(resolved, &mut lines);
3861    render_ontology(resolved, &mut lines);
3862    render_semantic_aliases(resolved, &mut lines);
3863    render_entity_linking(resolved, &mut lines);
3864    render_retrieval_bindings(resolved, &mut lines);
3865    render_actions(&resolved.actions, &mut lines);
3866    render_events(resolved, &mut lines);
3867    render_projections(resolved, &mut lines);
3868    render_provider_requirements(resolved, &mut lines);
3869    render_named_section("policies", &resolved.policies, &mut lines);
3870    render_named_section("approvals", &resolved.approvals, &mut lines);
3871    render_migrations(resolved, &mut lines);
3872    render_agent_endpoints(resolved, &mut lines);
3873
3874    lines.join("\n") + "\n"
3875}
3876
3877fn render_ontology(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
3878    let Some(ontology) = &resolved.ontology else {
3879        return;
3880    };
3881
3882    lines.push("ontology:".to_string());
3883    lines.push(format!(
3884        "  schema: {}",
3885        yaml_scalar_string(
3886            ontology
3887                .schema
3888                .as_deref()
3889                .unwrap_or("greentic.sorla.ontology.v1")
3890        )
3891    ));
3892    if ontology.concepts.is_empty() {
3893        lines.push("  concepts: []".to_string());
3894    } else {
3895        lines.push("  concepts:".to_string());
3896        for concept in &ontology.concepts {
3897            lines.push(format!("    - id: {}", yaml_scalar_string(&concept.id)));
3898            lines.push(format!("      kind: {}", yaml_scalar_string(&concept.kind)));
3899            if let Some(description) = &concept.description {
3900                lines.push(format!(
3901                    "      description: {}",
3902                    yaml_scalar_string(description)
3903                ));
3904            }
3905            if !concept.extends.is_empty() {
3906                render_string_list("      extends", &concept.extends, lines);
3907            }
3908            if let Some(backing) = &concept.backed_by {
3909                render_ontology_backing("      backed_by", backing, lines);
3910            }
3911            if let Some(sensitivity) = &concept.sensitivity {
3912                render_ontology_sensitivity("      sensitivity", sensitivity, lines);
3913            }
3914            render_ontology_policy_hooks("      policy_hooks", &concept.policy_hooks, lines);
3915            render_ontology_provider_requirements(
3916                "      provider_requirements",
3917                &concept.provider_requirements,
3918                lines,
3919            );
3920        }
3921    }
3922
3923    if ontology.relationships.is_empty() {
3924        lines.push("  relationships: []".to_string());
3925    } else {
3926        lines.push("  relationships:".to_string());
3927        for relationship in &ontology.relationships {
3928            lines.push(format!(
3929                "    - id: {}",
3930                yaml_scalar_string(&relationship.id)
3931            ));
3932            if let Some(label) = &relationship.label {
3933                lines.push(format!("      label: {}", yaml_scalar_string(label)));
3934            }
3935            lines.push(format!(
3936                "      from: {}",
3937                yaml_scalar_string(&relationship.from)
3938            ));
3939            lines.push(format!(
3940                "      to: {}",
3941                yaml_scalar_string(&relationship.to)
3942            ));
3943            if let Some(cardinality) = &relationship.cardinality {
3944                lines.push("      cardinality:".to_string());
3945                lines.push(format!(
3946                    "        from: {}",
3947                    yaml_scalar_string(&cardinality.from)
3948                ));
3949                lines.push(format!(
3950                    "        to: {}",
3951                    yaml_scalar_string(&cardinality.to)
3952                ));
3953            }
3954            if let Some(backing) = &relationship.backed_by {
3955                render_ontology_backing("      backed_by", backing, lines);
3956            }
3957            if let Some(sensitivity) = &relationship.sensitivity {
3958                render_ontology_sensitivity("      sensitivity", sensitivity, lines);
3959            }
3960            render_ontology_policy_hooks("      policy_hooks", &relationship.policy_hooks, lines);
3961            render_ontology_provider_requirements(
3962                "      provider_requirements",
3963                &relationship.provider_requirements,
3964                lines,
3965            );
3966        }
3967    }
3968
3969    if ontology.constraints.is_empty() {
3970        lines.push("  constraints: []".to_string());
3971    } else {
3972        lines.push("  constraints:".to_string());
3973        for constraint in &ontology.constraints {
3974            lines.push(format!("    - id: {}", yaml_scalar_string(&constraint.id)));
3975            lines.push("      applies_to:".to_string());
3976            lines.push(format!(
3977                "        concept: {}",
3978                yaml_scalar_string(&constraint.applies_to.concept)
3979            ));
3980            if let Some(policy) = &constraint.requires_policy {
3981                lines.push(format!(
3982                    "      requires_policy: {}",
3983                    yaml_scalar_string(policy)
3984                ));
3985            }
3986        }
3987    }
3988}
3989
3990fn render_ontology_backing(
3991    section: &str,
3992    backing: &OntologyBackingAnswer,
3993    lines: &mut Vec<String>,
3994) {
3995    lines.push(format!("{section}:"));
3996    let nested = " ".repeat(section.len() - section.trim_start().len() + 2);
3997    lines.push(format!(
3998        "{nested}record: {}",
3999        yaml_scalar_string(&backing.record)
4000    ));
4001    if let Some(from_field) = &backing.from_field {
4002        lines.push(format!(
4003            "{nested}from_field: {}",
4004            yaml_scalar_string(from_field)
4005        ));
4006    }
4007    if let Some(to_field) = &backing.to_field {
4008        lines.push(format!(
4009            "{nested}to_field: {}",
4010            yaml_scalar_string(to_field)
4011        ));
4012    }
4013}
4014
4015fn render_ontology_sensitivity(
4016    section: &str,
4017    sensitivity: &OntologySensitivityAnswer,
4018    lines: &mut Vec<String>,
4019) {
4020    lines.push(format!("{section}:"));
4021    let nested = " ".repeat(section.len() - section.trim_start().len() + 2);
4022    if let Some(classification) = &sensitivity.classification {
4023        lines.push(format!(
4024            "{nested}classification: {}",
4025            yaml_scalar_string(classification)
4026        ));
4027    }
4028    if let Some(pii) = sensitivity.pii {
4029        lines.push(format!("{nested}pii: {pii}"));
4030    }
4031}
4032
4033fn render_ontology_policy_hooks(
4034    section: &str,
4035    hooks: &[OntologyPolicyHookAnswer],
4036    lines: &mut Vec<String>,
4037) {
4038    if hooks.is_empty() {
4039        return;
4040    }
4041    lines.push(format!("{section}:"));
4042    for hook in hooks {
4043        lines.push(format!(
4044            "{}- policy: {}",
4045            " ".repeat(8),
4046            yaml_scalar_string(&hook.policy)
4047        ));
4048        if let Some(reason) = &hook.reason {
4049            lines.push(format!(
4050                "{}reason: {}",
4051                " ".repeat(10),
4052                yaml_scalar_string(reason)
4053            ));
4054        }
4055    }
4056}
4057
4058fn render_ontology_provider_requirements(
4059    section: &str,
4060    requirements: &[ProviderRequirementAnswer],
4061    lines: &mut Vec<String>,
4062) {
4063    if requirements.is_empty() {
4064        return;
4065    }
4066    lines.push(format!("{section}:"));
4067    for requirement in requirements {
4068        render_provider_requirement("        -", requirement, lines);
4069    }
4070}
4071
4072fn render_semantic_aliases(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4073    let Some(aliases) = &resolved.semantic_aliases else {
4074        return;
4075    };
4076    lines.push("semantic_aliases:".to_string());
4077    render_alias_map("  concepts", &aliases.concepts, lines);
4078    render_alias_map("  relationships", &aliases.relationships, lines);
4079}
4080
4081fn render_alias_map(
4082    section: &str,
4083    aliases: &BTreeMap<String, Vec<String>>,
4084    lines: &mut Vec<String>,
4085) {
4086    if aliases.is_empty() {
4087        lines.push(format!("{section}: {{}}"));
4088        return;
4089    }
4090    lines.push(format!("{section}:"));
4091    for (target, values) in aliases {
4092        let values = normalize_text_list(values.clone());
4093        if values.is_empty() {
4094            lines.push(format!("    {}: []", yaml_scalar_string(target)));
4095        } else {
4096            lines.push(format!("    {}:", yaml_scalar_string(target)));
4097            for value in values {
4098                lines.push(format!("      - {}", yaml_scalar_string(&value)));
4099            }
4100        }
4101    }
4102}
4103
4104fn render_entity_linking(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4105    let Some(entity_linking) = &resolved.entity_linking else {
4106        return;
4107    };
4108    lines.push("entity_linking:".to_string());
4109    if entity_linking.strategies.is_empty() {
4110        lines.push("  strategies: []".to_string());
4111        return;
4112    }
4113    lines.push("  strategies:".to_string());
4114    for strategy in &entity_linking.strategies {
4115        lines.push(format!("    - id: {}", yaml_scalar_string(&strategy.id)));
4116        lines.push(format!(
4117            "      applies_to: {}",
4118            yaml_scalar_string(&strategy.applies_to)
4119        ));
4120        if let Some(source_type) = &strategy.source_type {
4121            lines.push(format!(
4122                "      source_type: {}",
4123                yaml_scalar_string(source_type)
4124            ));
4125        }
4126        lines.push("      match:".to_string());
4127        lines.push(format!(
4128            "        source_field: {}",
4129            yaml_scalar_string(&strategy.match_fields.source_field)
4130        ));
4131        lines.push(format!(
4132            "        target_field: {}",
4133            yaml_scalar_string(&strategy.match_fields.target_field)
4134        ));
4135        lines.push(format!("      confidence: {}", strategy.confidence));
4136        if let Some(sensitivity) = &strategy.sensitivity {
4137            render_ontology_sensitivity("      sensitivity", sensitivity, lines);
4138        }
4139    }
4140}
4141
4142fn render_retrieval_bindings(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4143    let Some(retrieval_bindings) = &resolved.retrieval_bindings else {
4144        return;
4145    };
4146    lines.push("retrieval_bindings:".to_string());
4147    lines.push(format!(
4148        "  schema: {}",
4149        yaml_scalar_string(
4150            retrieval_bindings
4151                .schema
4152                .as_deref()
4153                .unwrap_or("greentic.sorla.retrieval-bindings.v1")
4154        )
4155    ));
4156    if retrieval_bindings.providers.is_empty() {
4157        lines.push("  providers: []".to_string());
4158    } else {
4159        lines.push("  providers:".to_string());
4160        for provider in &retrieval_bindings.providers {
4161            lines.push(format!("    - id: {}", yaml_scalar_string(&provider.id)));
4162            lines.push(format!(
4163                "      category: {}",
4164                yaml_scalar_string(&provider.category)
4165            ));
4166            render_string_list(
4167                "      required_capabilities",
4168                &provider.required_capabilities,
4169                lines,
4170            );
4171        }
4172    }
4173
4174    if retrieval_bindings.scopes.is_empty() {
4175        lines.push("  scopes: []".to_string());
4176    } else {
4177        lines.push("  scopes:".to_string());
4178        for scope in &retrieval_bindings.scopes {
4179            lines.push(format!("    - id: {}", yaml_scalar_string(&scope.id)));
4180            lines.push("      applies_to:".to_string());
4181            if let Some(concept) = &scope.applies_to.concept {
4182                lines.push(format!("        concept: {}", yaml_scalar_string(concept)));
4183            }
4184            if let Some(relationship) = &scope.applies_to.relationship {
4185                lines.push(format!(
4186                    "        relationship: {}",
4187                    yaml_scalar_string(relationship)
4188                ));
4189            }
4190            lines.push(format!(
4191                "      provider: {}",
4192                yaml_scalar_string(&scope.provider)
4193            ));
4194            if let Some(filters) = &scope.filters
4195                && let Some(entity_scope) = &filters.entity_scope
4196            {
4197                lines.push("      filters:".to_string());
4198                lines.push("        entity_scope:".to_string());
4199                lines.push(format!(
4200                    "          include_self: {}",
4201                    entity_scope.include_self.unwrap_or(false)
4202                ));
4203                if entity_scope.include_related.is_empty() {
4204                    lines.push("          include_related: []".to_string());
4205                } else {
4206                    lines.push("          include_related:".to_string());
4207                    for rule in &entity_scope.include_related {
4208                        lines.push(format!(
4209                            "            - relationship: {}",
4210                            yaml_scalar_string(&rule.relationship)
4211                        ));
4212                        lines.push(format!(
4213                            "              direction: {}",
4214                            yaml_scalar_string(&rule.direction)
4215                        ));
4216                        lines.push(format!("              max_depth: {}", rule.max_depth));
4217                    }
4218                }
4219            }
4220            if let Some(permission) = &scope.permission {
4221                lines.push(format!(
4222                    "      permission: {}",
4223                    yaml_scalar_string(permission)
4224                ));
4225            }
4226        }
4227    }
4228}
4229
4230fn render_records(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4231    if resolved.record_items.is_empty() {
4232        lines.push("records: []".to_string());
4233        return;
4234    }
4235
4236    lines.push("records:".to_string());
4237    for record in &resolved.record_items {
4238        lines.push(format!("  - name: {}", yaml_scalar_string(&record.name)));
4239        let source = record
4240            .source
4241            .as_deref()
4242            .unwrap_or(resolved.default_source.as_str());
4243        lines.push(format!("    source: {}", yaml_scalar_string(source)));
4244        let external_ref = record.external_ref.as_ref();
4245        if let Some(external_ref) = external_ref {
4246            lines.push("    external_ref:".to_string());
4247            lines.push(format!(
4248                "      system: {}",
4249                yaml_scalar_string(&external_ref.system)
4250            ));
4251            lines.push(format!(
4252                "      key: {}",
4253                yaml_scalar_string(&external_ref.key)
4254            ));
4255            lines.push(format!(
4256                "      authoritative: {}",
4257                external_ref.authoritative
4258            ));
4259        } else if matches!(source, "external" | "hybrid") {
4260            lines.push("    external_ref:".to_string());
4261            lines.push(format!(
4262                "      system: {}",
4263                yaml_scalar_string(
4264                    resolved
4265                        .external_ref_system
4266                        .as_deref()
4267                        .unwrap_or("external-system")
4268                )
4269            ));
4270            lines.push("      key: record_id".to_string());
4271            lines.push("      authoritative: true".to_string());
4272        }
4273        render_schema_fields("    fields", &record.fields, lines, false);
4274    }
4275}
4276
4277fn render_actions(actions: &[NamedAnswer], lines: &mut Vec<String>) {
4278    render_named_section("actions", actions, lines);
4279}
4280
4281fn render_named_section(section: &str, items: &[NamedAnswer], lines: &mut Vec<String>) {
4282    if items.is_empty() {
4283        lines.push(format!("{section}: []"));
4284        return;
4285    }
4286
4287    lines.push(format!("{section}:"));
4288    for item in items {
4289        lines.push(format!("  - name: {}", yaml_scalar_string(&item.name)));
4290    }
4291}
4292
4293fn render_events(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4294    if !resolved.events_enabled || resolved.event_items.is_empty() {
4295        lines.push("events: []".to_string());
4296        return;
4297    }
4298
4299    lines.push("events:".to_string());
4300    for event in &resolved.event_items {
4301        lines.push(format!("  - name: {}", yaml_scalar_string(&event.name)));
4302        lines.push(format!("    record: {}", yaml_scalar_string(&event.record)));
4303        lines.push(format!(
4304            "    kind: {}",
4305            yaml_scalar_string(event.kind.as_deref().unwrap_or("domain"))
4306        ));
4307        if event.emits.is_empty() {
4308            lines.push("    emits: []".to_string());
4309        } else {
4310            lines.push("    emits:".to_string());
4311            for field in &event.emits {
4312                lines.push(format!("      - name: {}", yaml_scalar_string(&field.name)));
4313                lines.push(format!(
4314                    "        type: {}",
4315                    yaml_scalar_string(&field.type_name)
4316                ));
4317            }
4318        }
4319    }
4320}
4321
4322fn render_projections(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4323    if resolved.projection_items.is_empty() {
4324        lines.push("projections: []".to_string());
4325        return;
4326    }
4327
4328    lines.push("projections:".to_string());
4329    for projection in &resolved.projection_items {
4330        lines.push(format!(
4331            "  - name: {}",
4332            yaml_scalar_string(&projection.name)
4333        ));
4334        lines.push(format!(
4335            "    record: {}",
4336            yaml_scalar_string(&projection.record)
4337        ));
4338        lines.push(format!(
4339            "    source_event: {}",
4340            yaml_scalar_string(&projection.source_event)
4341        ));
4342        lines.push(format!(
4343            "    mode: {}",
4344            yaml_scalar_string(
4345                projection
4346                    .mode
4347                    .as_deref()
4348                    .unwrap_or(resolved.projection_mode.as_str())
4349            )
4350        ));
4351    }
4352}
4353
4354fn render_provider_requirements(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4355    if resolved.provider_requirements.is_empty() {
4356        lines.push("provider_requirements:".to_string());
4357        lines.push(format!(
4358            "  - category: {}",
4359            yaml_scalar_string(&resolved.storage_category)
4360        ));
4361        lines.push("    capabilities:".to_string());
4362        lines.push("      - event-log".to_string());
4363        lines.push("      - projections".to_string());
4364        return;
4365    }
4366
4367    lines.push("provider_requirements:".to_string());
4368    for requirement in &resolved.provider_requirements {
4369        render_provider_requirement("  -", requirement, lines);
4370    }
4371}
4372
4373fn render_provider_requirement(
4374    list_prefix: &str,
4375    requirement: &ProviderRequirementAnswer,
4376    lines: &mut Vec<String>,
4377) {
4378    lines.push(format!(
4379        "{list_prefix} category: {}",
4380        yaml_scalar_string(&requirement.category)
4381    ));
4382    if requirement.capabilities.is_empty() {
4383        lines.push("    capabilities: []".to_string());
4384    } else {
4385        lines.push("    capabilities:".to_string());
4386        for capability in &requirement.capabilities {
4387            lines.push(format!("      - {}", yaml_scalar_string(capability)));
4388        }
4389    }
4390}
4391
4392fn render_migrations(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4393    if resolved.migration_items.is_empty() {
4394        lines.push("migrations:".to_string());
4395        lines.push(format!(
4396            "  - name: {}",
4397            yaml_scalar_string(&format!("{}-compatibility", resolved.package_name))
4398        ));
4399        lines.push(format!(
4400            "    compatibility: {}",
4401            yaml_scalar_string(&resolved.compatibility_mode)
4402        ));
4403        lines.push("    projection_updates: []".to_string());
4404        return;
4405    }
4406
4407    lines.push("migrations:".to_string());
4408    for migration in &resolved.migration_items {
4409        lines.push(format!("  - name: {}", yaml_scalar_string(&migration.name)));
4410        lines.push(format!(
4411            "    compatibility: {}",
4412            yaml_scalar_string(
4413                migration
4414                    .compatibility
4415                    .as_deref()
4416                    .unwrap_or(resolved.compatibility_mode.as_str())
4417            )
4418        ));
4419        render_string_list(
4420            "    projection_updates",
4421            &migration.projection_updates,
4422            lines,
4423        );
4424        if migration.backfills.is_empty() {
4425            lines.push("    backfills: []".to_string());
4426        } else {
4427            lines.push("    backfills:".to_string());
4428            for backfill in &migration.backfills {
4429                lines.push(format!(
4430                    "      - record: {}",
4431                    yaml_scalar_string(&backfill.record)
4432                ));
4433                lines.push(format!(
4434                    "        field: {}",
4435                    yaml_scalar_string(&backfill.field)
4436                ));
4437                lines.push("        default:".to_string());
4438                render_json_value(&backfill.default, 10, lines);
4439            }
4440        }
4441        if let Some(idempotence_key) = &migration.idempotence_key {
4442            lines.push(format!(
4443                "    idempotence_key: {}",
4444                yaml_scalar_string(idempotence_key)
4445            ));
4446        }
4447        if let Some(notes) = &migration.notes {
4448            lines.push(format!("    notes: {}", yaml_scalar_string(notes)));
4449        }
4450    }
4451}
4452
4453fn render_agent_endpoints(resolved: &ResolvedAnswers, lines: &mut Vec<String>) {
4454    if !resolved.agent_endpoints_enabled || resolved.agent_endpoint_items.is_empty() {
4455        lines.push("agent_endpoints: []".to_string());
4456        return;
4457    }
4458
4459    lines.push("agent_endpoints:".to_string());
4460    for endpoint in &resolved.agent_endpoint_items {
4461        lines.push(format!("  - id: {}", yaml_scalar_string(&endpoint.id)));
4462        lines.push(format!(
4463            "    title: {}",
4464            yaml_scalar_string(&endpoint.title)
4465        ));
4466        lines.push(format!(
4467            "    intent: {}",
4468            yaml_scalar_string(&endpoint.intent)
4469        ));
4470        if let Some(description) = &endpoint.description {
4471            lines.push(format!(
4472                "    description: {}",
4473                yaml_scalar_string(description)
4474            ));
4475        }
4476        render_schema_fields("    inputs", &endpoint.inputs, lines, true);
4477        render_schema_fields("    outputs", &endpoint.outputs, lines, false);
4478        render_string_list("    side_effects", &endpoint.side_effects, lines);
4479        if let Some(emits) = &endpoint.emits {
4480            lines.push("    emits:".to_string());
4481            lines.push(format!("      event: {}", yaml_scalar_string(&emits.event)));
4482            lines.push(format!(
4483                "      stream: {}",
4484                yaml_scalar_string(&emits.stream)
4485            ));
4486            lines.push("      payload:".to_string());
4487            render_json_value(&emits.payload, 8, lines);
4488        }
4489        lines.push(format!(
4490            "    risk: {}",
4491            yaml_scalar_string(
4492                endpoint
4493                    .risk
4494                    .as_deref()
4495                    .unwrap_or(resolved.agent_endpoint_default_risk.as_str())
4496            )
4497        ));
4498        lines.push(format!(
4499            "    approval: {}",
4500            yaml_scalar_string(
4501                endpoint
4502                    .approval
4503                    .as_deref()
4504                    .unwrap_or(resolved.agent_endpoint_default_approval.as_str())
4505            )
4506        ));
4507        if !endpoint.provider_requirements.is_empty() {
4508            lines.push("    provider_requirements:".to_string());
4509            for requirement in &endpoint.provider_requirements {
4510                lines.push(format!(
4511                    "      - category: {}",
4512                    yaml_scalar_string(&requirement.category)
4513                ));
4514                if requirement.capabilities.is_empty() {
4515                    lines.push("        capabilities: []".to_string());
4516                } else {
4517                    lines.push("        capabilities:".to_string());
4518                    for capability in &requirement.capabilities {
4519                        lines.push(format!("          - {}", yaml_scalar_string(capability)));
4520                    }
4521                }
4522            }
4523        }
4524        render_endpoint_backing(&endpoint.backing, lines);
4525        if let Some(visibility) = &endpoint.agent_visibility {
4526            lines.push("    agent_visibility:".to_string());
4527            lines.push(format!(
4528                "      openapi: {}",
4529                visibility.openapi.unwrap_or(true)
4530            ));
4531            lines.push(format!(
4532                "      arazzo: {}",
4533                visibility.arazzo.unwrap_or(true)
4534            ));
4535            lines.push(format!("      mcp: {}", visibility.mcp.unwrap_or(true)));
4536            lines.push(format!(
4537                "      llms_txt: {}",
4538                visibility.llms_txt.unwrap_or(true)
4539            ));
4540        }
4541        if !endpoint.examples.is_empty() {
4542            lines.push("    examples:".to_string());
4543            for example in &endpoint.examples {
4544                lines.push(format!(
4545                    "      - name: {}",
4546                    yaml_scalar_string(&example.name)
4547                ));
4548                lines.push(format!(
4549                    "        summary: {}",
4550                    yaml_scalar_string(&example.summary)
4551                ));
4552                lines.push("        input:".to_string());
4553                render_json_value(&example.input, 10, lines);
4554                lines.push("        expected_output:".to_string());
4555                render_json_value(&example.expected_output, 10, lines);
4556            }
4557        }
4558    }
4559}
4560
4561fn render_schema_fields(
4562    section: &str,
4563    fields: &[FieldAnswer],
4564    lines: &mut Vec<String>,
4565    include_endpoint_properties: bool,
4566) {
4567    if fields.is_empty() {
4568        lines.push(format!("{section}: []"));
4569        return;
4570    }
4571
4572    lines.push(format!("{section}:"));
4573    for field in fields {
4574        lines.push(format!("      - name: {}", yaml_scalar_string(&field.name)));
4575        lines.push(format!(
4576            "        type: {}",
4577            yaml_scalar_string(&field.type_name)
4578        ));
4579        if let Some(authority) = &field.authority {
4580            lines.push(format!(
4581                "        authority: {}",
4582                yaml_scalar_string(authority)
4583            ));
4584        }
4585        if let Some(required) = field.required {
4586            lines.push(format!("        required: {required}"));
4587        }
4588        if let Some(sensitive) = field.sensitive {
4589            lines.push(format!("        sensitive: {sensitive}"));
4590        }
4591        if !field.enum_values.is_empty() {
4592            render_string_list("        enum_values", &field.enum_values, lines);
4593        }
4594        if let Some(reference) = &field.references {
4595            lines.push("        references:".to_string());
4596            lines.push(format!(
4597                "          record: {}",
4598                yaml_scalar_string(&reference.record)
4599            ));
4600            lines.push(format!(
4601                "          field: {}",
4602                yaml_scalar_string(&reference.field)
4603            ));
4604        }
4605        if include_endpoint_properties && let Some(description) = &field.description {
4606            lines.push(format!(
4607                "        description: {}",
4608                yaml_scalar_string(description)
4609            ));
4610        }
4611    }
4612}
4613
4614fn render_endpoint_backing(backing: &AgentEndpointBackingAnswer, lines: &mut Vec<String>) {
4615    if backing.actions.is_empty()
4616        && backing.events.is_empty()
4617        && backing.flows.is_empty()
4618        && backing.policies.is_empty()
4619        && backing.approvals.is_empty()
4620    {
4621        return;
4622    }
4623
4624    lines.push("    backing:".to_string());
4625    render_string_list("      actions", &backing.actions, lines);
4626    render_string_list("      events", &backing.events, lines);
4627    if !backing.flows.is_empty() {
4628        render_string_list("      flows", &backing.flows, lines);
4629    }
4630    render_string_list("      policies", &backing.policies, lines);
4631    render_string_list("      approvals", &backing.approvals, lines);
4632}
4633
4634fn render_string_list(section: &str, values: &[String], lines: &mut Vec<String>) {
4635    if values.is_empty() {
4636        lines.push(format!("{section}: []"));
4637    } else {
4638        lines.push(format!("{section}:"));
4639        for value in values {
4640            lines.push(format!(
4641                "{}- {}",
4642                " ".repeat(section.len() - section.trim_start().len() + 2),
4643                yaml_scalar_string(value)
4644            ));
4645        }
4646    }
4647}
4648
4649fn render_json_value(value: &serde_json::Value, indent: usize, lines: &mut Vec<String>) {
4650    let spaces = " ".repeat(indent);
4651    match value {
4652        serde_json::Value::Object(map) => {
4653            if map.is_empty() {
4654                lines.push(format!("{spaces}{{}}"));
4655            } else {
4656                for (key, value) in map {
4657                    match value {
4658                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
4659                            lines.push(format!("{spaces}{}:", yaml_key(key)));
4660                            render_json_value(value, indent + 2, lines);
4661                        }
4662                        _ => lines.push(format!(
4663                            "{spaces}{}: {}",
4664                            yaml_key(key),
4665                            yaml_scalar_value(value)
4666                        )),
4667                    }
4668                }
4669            }
4670        }
4671        serde_json::Value::Array(values) => {
4672            if values.is_empty() {
4673                lines.push(format!("{spaces}[]"));
4674            } else {
4675                for value in values {
4676                    match value {
4677                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
4678                            lines.push(format!("{spaces}-"));
4679                            render_json_value(value, indent + 2, lines);
4680                        }
4681                        _ => lines.push(format!("{spaces}- {}", yaml_scalar_value(value))),
4682                    }
4683                }
4684            }
4685        }
4686        _ => lines.push(format!("{spaces}{}", yaml_scalar_value(value))),
4687    }
4688}
4689
4690fn yaml_scalar_value(value: &serde_json::Value) -> String {
4691    match value {
4692        serde_json::Value::Null => "null".to_string(),
4693        serde_json::Value::Bool(value) => value.to_string(),
4694        serde_json::Value::Number(value) => value.to_string(),
4695        serde_json::Value::String(value) => yaml_scalar_string(value),
4696        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
4697            serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
4698        }
4699    }
4700}
4701
4702fn yaml_key(value: &str) -> String {
4703    if value
4704        .chars()
4705        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
4706    {
4707        value.to_string()
4708    } else {
4709        yaml_scalar_string(value)
4710    }
4711}
4712
4713fn yaml_scalar_string(value: &str) -> String {
4714    if !value.is_empty()
4715        && value
4716            .chars()
4717            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | '{' | '}'))
4718        && !matches!(
4719            value,
4720            "true" | "false" | "null" | "yes" | "no" | "on" | "off"
4721        )
4722    {
4723        value.to_string()
4724    } else {
4725        serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
4726    }
4727}
4728
4729fn build_launcher_handoff_manifest(
4730    resolved: &ResolvedAnswers,
4731) -> BTreeMap<&'static str, serde_json::Value> {
4732    let mut map = BTreeMap::new();
4733    map.insert(
4734        "handoff_kind",
4735        serde_json::Value::String("launcher".to_string()),
4736    );
4737    map.insert(
4738        "handoff_owner",
4739        serde_json::Value::String("gtc".to_string()),
4740    );
4741    map.insert(
4742        "handoff_role",
4743        serde_json::Value::String("extension-metadata".to_string()),
4744    );
4745    map.insert(
4746        "package_name",
4747        serde_json::Value::String(resolved.package_name.clone()),
4748    );
4749    map.insert(
4750        "package_version",
4751        serde_json::Value::String(resolved.package_version.clone()),
4752    );
4753    map.insert(
4754        "package_kind",
4755        serde_json::Value::String("greentic-sorla-package".to_string()),
4756    );
4757    map.insert(
4758        "ir_version",
4759        serde_json::Value::String("sorla-ir/v1".to_string()),
4760    );
4761    map.insert("locale", serde_json::Value::String(resolved.locale.clone()));
4762    map.insert("flow", serde_json::Value::String(resolved.flow.clone()));
4763    map.insert(
4764        "provider_repo",
4765        serde_json::Value::String("greentic-sorla-providers".to_string()),
4766    );
4767    map.insert(
4768        "binding_mode",
4769        serde_json::Value::String("abstract-category-resolution".to_string()),
4770    );
4771    map.insert(
4772        "locale_metadata",
4773        serde_json::json!({
4774            "default_locale": resolved.locale,
4775            "fallback_locale": "en",
4776            "schema_localized": true
4777        }),
4778    );
4779    map.insert(
4780        "compatibility_metadata",
4781        serde_json::json!({
4782            "schema_version": resolved.schema_version,
4783            "wizard_version": env!("CARGO_PKG_VERSION"),
4784            "supports_partial_answers": true,
4785            "generated_content_strategy": "rewrite-generated-blocks-only",
4786            "user_content_strategy": "preserve-outside-generated-blocks",
4787        }),
4788    );
4789    map.insert(
4790        "provider_requirement_declarations",
4791        serde_json::to_value(build_provider_handoff_manifest(resolved))
4792            .expect("provider requirement manifest is serializable"),
4793    );
4794    map.insert(
4795        "gtc_handoff",
4796        serde_json::json!({
4797            "stage": "launcher",
4798            "owner": "gtc",
4799            "final_assembly_owner": "gtc",
4800        }),
4801    );
4802    map.insert(
4803        "artifacts",
4804        serde_json::Value::Array(
4805            resolved
4806                .artifacts
4807                .iter()
4808                .cloned()
4809                .map(serde_json::Value::String)
4810                .collect(),
4811        ),
4812    );
4813    map
4814}
4815
4816fn build_provider_handoff_manifest(
4817    resolved: &ResolvedAnswers,
4818) -> BTreeMap<&'static str, serde_json::Value> {
4819    let mut map = BTreeMap::new();
4820    map.insert(
4821        "handoff_kind",
4822        serde_json::Value::String("provider-requirements".to_string()),
4823    );
4824    map.insert(
4825        "handoff_owner",
4826        serde_json::Value::String("gtc".to_string()),
4827    );
4828    map.insert(
4829        "handoff_stage",
4830        serde_json::Value::String("launcher".to_string()),
4831    );
4832    map.insert(
4833        "provider_repo",
4834        serde_json::Value::String("greentic-sorla-providers".to_string()),
4835    );
4836    map.insert(
4837        "binding_mode",
4838        serde_json::Value::String("abstract-category-resolution".to_string()),
4839    );
4840    map.insert(
4841        "required_capability_categories",
4842        serde_json::json!([resolved.storage_category]),
4843    );
4844
4845    let mut optional_capabilities = Vec::new();
4846    if let Some(category) = &resolved.external_ref_category {
4847        optional_capabilities.push(category.clone());
4848    }
4849    if let Some(category) = &resolved.agent_endpoint_provider_category {
4850        optional_capabilities.push(category.clone());
4851    }
4852    optional_capabilities.push("evidence-store".to_string());
4853    map.insert(
4854        "optional_capability_categories",
4855        serde_json::json!(optional_capabilities),
4856    );
4857    map.insert(
4858        "provider_requirement_declarations",
4859        serde_json::json!([
4860            {
4861                "name": "storage",
4862                "category": resolved.storage_category,
4863                "required": true,
4864            },
4865            {
4866                "name": "external_ref",
4867                "category": resolved.external_ref_category,
4868                "required": matches!(resolved.default_source.as_str(), "external" | "hybrid"),
4869            },
4870            {
4871                "name": "evidence",
4872                "category": "evidence-store",
4873                "required": false,
4874            },
4875            {
4876                "name": "agent_endpoint_handoff",
4877                "category": resolved.agent_endpoint_provider_category,
4878                "required": resolved.agent_endpoints_enabled,
4879            }
4880        ]),
4881    );
4882    map
4883}
4884
4885fn build_locale_handoff_manifest(
4886    resolved: &ResolvedAnswers,
4887) -> BTreeMap<&'static str, serde_json::Value> {
4888    let mut map = BTreeMap::new();
4889    map.insert(
4890        "handoff_kind",
4891        serde_json::Value::String("locale".to_string()),
4892    );
4893    map.insert(
4894        "handoff_owner",
4895        serde_json::Value::String("gtc".to_string()),
4896    );
4897    map.insert(
4898        "handoff_stage",
4899        serde_json::Value::String("launcher".to_string()),
4900    );
4901    map.insert(
4902        "default_locale",
4903        serde_json::Value::String(resolved.locale.clone()),
4904    );
4905    map.insert(
4906        "fallback_locale",
4907        serde_json::Value::String("en".to_string()),
4908    );
4909    map.insert(
4910        "schema_version",
4911        serde_json::Value::String(resolved.schema_version.clone()),
4912    );
4913    map.insert(
4914        "reserved_core_keys",
4915        serde_json::json!([
4916            "wizard.title",
4917            "wizard.description",
4918            "wizard.section.title",
4919            "wizard.question.label",
4920            "wizard.question.help",
4921            "wizard.validation.message",
4922            "wizard.action.create.label",
4923            "wizard.action.update.label",
4924        ]),
4925    );
4926    map
4927}
4928
4929fn build_interactive_qa_spec(locale: &str) -> serde_json::Value {
4930    serde_json::json!({
4931        "id": "greentic-sorla-wizard",
4932        "title": "Greentic SoRLa Wizard",
4933        "version": default_schema().schema_version,
4934        "description": "Interactive wizard for creating or updating a SoRLa package.",
4935        "presentation": {
4936            "default_locale": locale,
4937            "intro": "Answer the questions below to create or update a SoRLa package. Press Enter to accept defaults."
4938        },
4939        "progress_policy": {
4940            "skip_answered": true,
4941            "autofill_defaults": false,
4942            "treat_default_as_answered": false
4943        },
4944        "questions": [
4945            {
4946                "id": "flow",
4947                "type": "enum",
4948                "title": "Wizard flow",
4949                "title_i18n": { "key": "wizard.flow.label" },
4950                "description": "Choose whether to create a new package or update an existing generated package.",
4951                "required": true,
4952                "default_value": "create",
4953                "choices": ["create", "update"]
4954            },
4955            {
4956                "id": "output_dir",
4957                "type": "string",
4958                "title": "Output directory",
4959                "title_i18n": { "key": "wizard.output_dir.label" },
4960                "description": "Directory where sorla.yaml and generated handoff metadata will be written.",
4961                "required": true,
4962                "default_value": "."
4963            },
4964            {
4965                "id": "locale",
4966                "type": "string",
4967                "title": "Locale",
4968                "title_i18n": { "key": "wizard.locale.label" },
4969                "description": "Locale used for generated metadata and interactive prompts.",
4970                "required": false,
4971                "default_value": locale
4972            },
4973            {
4974                "id": "package_name",
4975                "type": "string",
4976                "title": "Package name",
4977                "title_i18n": { "key": "wizard.questions.package_name.label" },
4978                "description": "Stable source layout identifier written into sorla.yaml.",
4979                "required": true,
4980                "visible_if": {
4981                    "op": "eq",
4982                    "left": { "op": "answer", "path": "flow" },
4983                    "right": { "op": "literal", "value": "create" }
4984                }
4985            },
4986            {
4987                "id": "package_version",
4988                "type": "string",
4989                "title": "Package version",
4990                "title_i18n": { "key": "wizard.questions.package_version.label" },
4991                "description": "Version for the new source layout.",
4992                "required": true,
4993                "default_value": "0.1.0",
4994                "visible_if": {
4995                    "op": "eq",
4996                    "left": { "op": "answer", "path": "flow" },
4997                    "right": { "op": "literal", "value": "create" }
4998                }
4999            },
5000            {
5001                "id": "storage_category",
5002                "type": "enum",
5003                "title": "Storage provider category",
5004                "title_i18n": { "key": "wizard.questions.storage_provider.label" },
5005                "description": "Provider category required for source storage and generated handoff metadata.",
5006                "required": true,
5007                "default_value": "storage",
5008                "choices": ["storage"]
5009            },
5010            {
5011                "id": "default_source",
5012                "type": "enum",
5013                "title": "Default record source",
5014                "title_i18n": { "key": "wizard.questions.default_source.label" },
5015                "description": "Choose whether records are native, external, or hybrid.",
5016                "required": true,
5017                "default_value": "native",
5018                "choices": ["native", "external", "hybrid"]
5019            },
5020            {
5021                "id": "external_ref_system",
5022                "type": "string",
5023                "title": "External reference system",
5024                "title_i18n": { "key": "wizard.questions.external_system.label" },
5025                "description": "External system identifier used when the source layout references authoritative external records.",
5026                "required": true,
5027                "visible_if": {
5028                    "op": "or",
5029                    "expressions": [
5030                        {
5031                            "op": "eq",
5032                            "left": { "op": "answer", "path": "default_source" },
5033                            "right": { "op": "literal", "value": "external" }
5034                        },
5035                        {
5036                            "op": "eq",
5037                            "left": { "op": "answer", "path": "default_source" },
5038                            "right": { "op": "literal", "value": "hybrid" }
5039                        }
5040                    ]
5041                }
5042            },
5043            {
5044                "id": "external_ref_category",
5045                "type": "enum",
5046                "title": "External reference provider category",
5047                "title_i18n": { "key": "wizard.questions.external_ref_provider.label" },
5048                "description": "Provider category used to resolve external references.",
5049                "required": false,
5050                "default_value": "external-ref",
5051                "choices": ["external-ref"],
5052                "visible_if": {
5053                    "op": "or",
5054                    "expressions": [
5055                        {
5056                            "op": "eq",
5057                            "left": { "op": "answer", "path": "default_source" },
5058                            "right": { "op": "literal", "value": "external" }
5059                        },
5060                        {
5061                            "op": "eq",
5062                            "left": { "op": "answer", "path": "default_source" },
5063                            "right": { "op": "literal", "value": "hybrid" }
5064                        }
5065                    ]
5066                }
5067            },
5068            {
5069                "id": "events_enabled",
5070                "type": "boolean",
5071                "title": "Enable events",
5072                "title_i18n": { "key": "wizard.questions.events_enabled.label" },
5073                "description": "Generate event and projection placeholders for this source layout.",
5074                "required": true,
5075                "default_value": "true"
5076            },
5077            {
5078                "id": "projection_mode",
5079                "type": "enum",
5080                "title": "Projection mode",
5081                "title_i18n": { "key": "wizard.questions.projection_mode.label" },
5082                "description": "Projection strategy for generated source and handoff output.",
5083                "required": true,
5084                "default_value": "current-state",
5085                "choices": ["current-state", "audit-trail"]
5086            },
5087            {
5088                "id": "compatibility_mode",
5089                "type": "enum",
5090                "title": "Compatibility mode",
5091                "title_i18n": { "key": "wizard.questions.compatibility_mode.label" },
5092                "description": "Compatibility mode used for migration metadata.",
5093                "required": true,
5094                "default_value": "additive",
5095                "choices": ["additive", "backward-compatible", "breaking"]
5096            },
5097            {
5098                "id": "agent_endpoints_enabled",
5099                "type": "boolean",
5100                "title": "Expose agentic endpoints",
5101                "title_i18n": { "key": "wizard.questions.agent_endpoints_enabled.label" },
5102                "description": "Generate agent endpoint declarations in sorla.yaml.",
5103                "required": true,
5104                "default_value": "false"
5105            },
5106            {
5107                "id": "agent_endpoint_ids",
5108                "type": "string",
5109                "title": "Endpoint identifiers",
5110                "title_i18n": { "key": "wizard.questions.agent_endpoint_ids.label" },
5111                "description": "Comma-separated endpoint IDs, such as create_customer_contact.",
5112                "required": true,
5113                "visible_if": {
5114                    "op": "eq",
5115                    "left": { "op": "answer", "path": "agent_endpoints_enabled" },
5116                    "right": { "op": "literal", "value": true }
5117                }
5118            },
5119            {
5120                "id": "agent_endpoint_default_risk",
5121                "type": "enum",
5122                "title": "Default endpoint risk",
5123                "title_i18n": { "key": "wizard.questions.agent_endpoint_default_risk.label" },
5124                "description": "Risk level for generated agent endpoints.",
5125                "required": true,
5126                "default_value": "medium",
5127                "choices": ["low", "medium", "high"],
5128                "visible_if": {
5129                    "op": "eq",
5130                    "left": { "op": "answer", "path": "agent_endpoints_enabled" },
5131                    "right": { "op": "literal", "value": true }
5132                }
5133            },
5134            {
5135                "id": "agent_endpoint_default_approval",
5136                "type": "enum",
5137                "title": "Default approval behavior",
5138                "title_i18n": { "key": "wizard.questions.agent_endpoint_default_approval.label" },
5139                "description": "Approval behavior for generated agent endpoints.",
5140                "required": true,
5141                "default_value": "policy-driven",
5142                "choices": ["none", "optional", "required", "policy-driven"],
5143                "visible_if": {
5144                    "op": "eq",
5145                    "left": { "op": "answer", "path": "agent_endpoints_enabled" },
5146                    "right": { "op": "literal", "value": true }
5147                }
5148            },
5149            {
5150                "id": "agent_endpoint_exports",
5151                "type": "string",
5152                "title": "Agent-facing export targets",
5153                "title_i18n": { "key": "wizard.questions.agent_endpoint_exports.label" },
5154                "description": "Comma-separated export targets: openapi, arazzo, mcp, llms_txt.",
5155                "required": true,
5156                "default_value": "openapi,arazzo,mcp,llms_txt",
5157                "visible_if": {
5158                    "op": "eq",
5159                    "left": { "op": "answer", "path": "agent_endpoints_enabled" },
5160                    "right": { "op": "literal", "value": true }
5161                }
5162            },
5163            {
5164                "id": "agent_endpoint_provider_category",
5165                "type": "string",
5166                "title": "Default provider category",
5167                "title_i18n": { "key": "wizard.questions.agent_endpoint_provider_category.label" },
5168                "description": "Abstract provider category for downstream agent endpoint handoff.",
5169                "required": false,
5170                "default_value": "api-gateway",
5171                "visible_if": {
5172                    "op": "eq",
5173                    "left": { "op": "answer", "path": "agent_endpoints_enabled" },
5174                    "right": { "op": "literal", "value": true }
5175                }
5176            },
5177            {
5178                "id": "include_agent_tools",
5179                "type": "boolean",
5180                "title": "Include agent tools",
5181                "title_i18n": { "key": "wizard.questions.include_agent_tools.label" },
5182                "description": "Generate agent-tools.json as part of the artifact set.",
5183                "required": true,
5184                "default_value": "true"
5185            }
5186        ]
5187    })
5188}
5189
5190#[cfg(feature = "cli")]
5191fn load_interactive_i18n(locale: &str) -> Option<ResolvedI18nMap> {
5192    let raw = included_locale_json(locale).or_else(|| included_locale_json("en"))?;
5193    let map = serde_json::from_str::<BTreeMap<String, String>>(raw).ok()?;
5194    let mut resolved = ResolvedI18nMap::new();
5195    for (key, value) in map {
5196        resolved.insert(key, value);
5197    }
5198    Some(resolved)
5199}
5200
5201fn included_locale_json(locale: &str) -> Option<&'static str> {
5202    embedded_i18n::locale_json(locale)
5203}
5204
5205#[cfg(feature = "cli")]
5206fn prompt_interactive_answer(
5207    question_id: &str,
5208    question: &serde_json::Value,
5209) -> Result<serde_json::Value, QaLibError> {
5210    let title = question
5211        .get("title")
5212        .and_then(serde_json::Value::as_str)
5213        .unwrap_or(question_id);
5214    let description = question
5215        .get("description")
5216        .and_then(serde_json::Value::as_str)
5217        .unwrap_or("");
5218    let kind = question
5219        .get("type")
5220        .and_then(serde_json::Value::as_str)
5221        .unwrap_or("string");
5222    let default = question.get("default");
5223
5224    println!();
5225    println!("{title}");
5226    if !description.trim().is_empty() {
5227        println!("{description}");
5228    }
5229    if let Some(choices) = question
5230        .get("choices")
5231        .and_then(serde_json::Value::as_array)
5232    {
5233        let rendered = choices
5234            .iter()
5235            .filter_map(serde_json::Value::as_str)
5236            .collect::<Vec<_>>()
5237            .join(", ");
5238        if !rendered.is_empty() {
5239            println!("Choices: {rendered}");
5240        }
5241    }
5242
5243    loop {
5244        print!("> ");
5245        io::stdout()
5246            .flush()
5247            .map_err(|err| QaLibError::Component(err.to_string()))?;
5248        let mut line = String::new();
5249        let read = io::stdin()
5250            .read_line(&mut line)
5251            .map_err(|err| QaLibError::Component(err.to_string()))?;
5252        if read == 0 {
5253            return Err(QaLibError::Component("stdin closed".to_string()));
5254        }
5255
5256        let input = line.trim();
5257        if input.is_empty() {
5258            if let Some(default) = default {
5259                return default_value_for_kind(kind, default).ok_or_else(|| {
5260                    QaLibError::Component(format!(
5261                        "invalid default value for interactive question `{question_id}`"
5262                    ))
5263                });
5264            }
5265            if question
5266                .get("required")
5267                .and_then(serde_json::Value::as_bool)
5268                .unwrap_or(false)
5269            {
5270                println!("A value is required.");
5271                continue;
5272            }
5273            return Ok(serde_json::Value::Null);
5274        }
5275
5276        match kind {
5277            "string" => return Ok(serde_json::Value::String(input.to_string())),
5278            "boolean" => match input.to_ascii_lowercase().as_str() {
5279                "y" | "yes" | "true" | "1" => return Ok(serde_json::Value::Bool(true)),
5280                "n" | "no" | "false" | "0" => return Ok(serde_json::Value::Bool(false)),
5281                _ => {
5282                    println!("Enter yes or no.");
5283                    continue;
5284                }
5285            },
5286            "enum" => {
5287                let choices = question
5288                    .get("choices")
5289                    .and_then(serde_json::Value::as_array)
5290                    .cloned()
5291                    .unwrap_or_default();
5292                if choices
5293                    .iter()
5294                    .filter_map(serde_json::Value::as_str)
5295                    .any(|choice| choice == input)
5296                {
5297                    return Ok(serde_json::Value::String(input.to_string()));
5298                }
5299                println!("Enter one of the listed choices.");
5300            }
5301            other => {
5302                return Err(QaLibError::Component(format!(
5303                    "unsupported interactive question type `{other}` for `{question_id}`"
5304                )));
5305            }
5306        }
5307    }
5308}
5309
5310fn default_value_for_kind(kind: &str, value: &serde_json::Value) -> Option<serde_json::Value> {
5311    match kind {
5312        "string" | "enum" => value
5313            .as_str()
5314            .map(|text| serde_json::Value::String(text.to_string())),
5315        "boolean" => value
5316            .as_str()
5317            .and_then(|text| match text.to_ascii_lowercase().as_str() {
5318                "true" | "yes" | "y" | "1" => Some(serde_json::Value::Bool(true)),
5319                "false" | "no" | "n" | "0" => Some(serde_json::Value::Bool(false)),
5320                _ => None,
5321            }),
5322        _ => None,
5323    }
5324}
5325
5326fn answers_document_from_qa_answers(answers: serde_json::Value) -> Result<AnswersDocument, String> {
5327    let object = answers
5328        .as_object()
5329        .ok_or_else(|| "interactive wizard did not produce an answers object".to_string())?;
5330    let flow = get_required_string(object, "flow")?;
5331    let output_dir = get_required_string(object, "output_dir")?;
5332    let locale = object
5333        .get("locale")
5334        .and_then(serde_json::Value::as_str)
5335        .map(str::to_string)
5336        .filter(|value| !value.trim().is_empty());
5337
5338    let package = if flow == "create" {
5339        Some(PackageAnswers {
5340            name: Some(get_required_string(object, "package_name")?),
5341            version: Some(get_required_string(object, "package_version")?),
5342        })
5343    } else {
5344        None
5345    };
5346
5347    let default_source = get_required_string(object, "default_source")?;
5348    let external_ref_system = object
5349        .get("external_ref_system")
5350        .and_then(serde_json::Value::as_str)
5351        .map(str::to_string)
5352        .filter(|value| !value.trim().is_empty());
5353    let external_ref_category = object
5354        .get("external_ref_category")
5355        .and_then(serde_json::Value::as_str)
5356        .map(str::to_string)
5357        .filter(|value| !value.trim().is_empty());
5358
5359    Ok(AnswersDocument {
5360        schema_version: default_schema().schema_version.to_string(),
5361        flow,
5362        output_dir,
5363        locale,
5364        package,
5365        providers: Some(ProviderAnswers {
5366            storage_category: Some(get_required_string(object, "storage_category")?),
5367            external_ref_category,
5368            hints: None,
5369        }),
5370        actions: Vec::new(),
5371        records: Some(RecordAnswers {
5372            default_source: Some(default_source),
5373            external_ref_system,
5374            items: Vec::new(),
5375        }),
5376        ontology: None,
5377        semantic_aliases: None,
5378        entity_linking: None,
5379        retrieval_bindings: None,
5380        events: Some(EventAnswers {
5381            enabled: Some(get_required_bool(object, "events_enabled")?),
5382            items: Vec::new(),
5383        }),
5384        projections: Some(ProjectionAnswers {
5385            mode: Some(get_required_string(object, "projection_mode")?),
5386            items: Vec::new(),
5387        }),
5388        provider_requirements: Vec::new(),
5389        policies: Vec::new(),
5390        approvals: Vec::new(),
5391        migrations: Some(MigrationAnswers {
5392            compatibility: Some(get_required_string(object, "compatibility_mode")?),
5393            items: Vec::new(),
5394        }),
5395        agent_endpoints: Some(AgentEndpointAnswers {
5396            enabled: Some(get_required_bool(object, "agent_endpoints_enabled")?),
5397            ids: object
5398                .get("agent_endpoint_ids")
5399                .and_then(serde_json::Value::as_str)
5400                .map(split_csv_answer),
5401            default_risk: object
5402                .get("agent_endpoint_default_risk")
5403                .and_then(serde_json::Value::as_str)
5404                .map(str::to_string),
5405            default_approval: object
5406                .get("agent_endpoint_default_approval")
5407                .and_then(serde_json::Value::as_str)
5408                .map(str::to_string),
5409            exports: object
5410                .get("agent_endpoint_exports")
5411                .and_then(serde_json::Value::as_str)
5412                .map(split_csv_answer),
5413            provider_category: object
5414                .get("agent_endpoint_provider_category")
5415                .and_then(serde_json::Value::as_str)
5416                .map(str::to_string)
5417                .filter(|value| !value.trim().is_empty()),
5418            items: Vec::new(),
5419        }),
5420        output: Some(OutputAnswers {
5421            include_agent_tools: Some(get_required_bool(object, "include_agent_tools")?),
5422            artifacts: None,
5423        }),
5424    })
5425}
5426
5427fn split_csv_answer(value: &str) -> Vec<String> {
5428    value
5429        .split(',')
5430        .map(|item| item.trim().to_string())
5431        .filter(|item| !item.is_empty())
5432        .collect()
5433}
5434
5435fn get_required_string(
5436    answers: &serde_json::Map<String, serde_json::Value>,
5437    key: &str,
5438) -> Result<String, String> {
5439    answers
5440        .get(key)
5441        .and_then(serde_json::Value::as_str)
5442        .map(str::to_string)
5443        .filter(|value| !value.trim().is_empty())
5444        .ok_or_else(|| format!("interactive wizard did not produce required answer `{key}`"))
5445}
5446
5447fn get_required_bool(
5448    answers: &serde_json::Map<String, serde_json::Value>,
5449    key: &str,
5450) -> Result<bool, String> {
5451    answers
5452        .get(key)
5453        .and_then(serde_json::Value::as_bool)
5454        .ok_or_else(|| format!("interactive wizard did not produce required answer `{key}`"))
5455}
5456
5457#[cfg(feature = "cli")]
5458fn format_qa_error(err: QaLibError) -> String {
5459    match err {
5460        QaLibError::NeedsInteraction => "wizard QA flow requires interactive input".to_string(),
5461        other => format!("wizard QA flow failed: {other}"),
5462    }
5463}
5464
5465fn selected_locale(answer_locale: Option<&str>, previous_locale: Option<&str>) -> String {
5466    answer_locale
5467        .map(str::trim)
5468        .filter(|value| !value.is_empty())
5469        .map(str::to_string)
5470        .or_else(|| {
5471            std::env::var("SORLA_LOCALE")
5472                .ok()
5473                .map(|value| value.trim().to_string())
5474                .filter(|value| !value.is_empty())
5475        })
5476        .or_else(|| previous_locale.map(str::to_string))
5477        .unwrap_or_else(|| "en".to_string())
5478}
5479
5480fn sync_generated_artifacts(
5481    generated_dir: &Path,
5482    resolved: &ResolvedAnswers,
5483) -> Result<Vec<PathBuf>, String> {
5484    let mut desired = BTreeSet::new();
5485    let mut written = Vec::new();
5486
5487    for artifact in &resolved.artifacts {
5488        let path = generated_dir.join(artifact);
5489        desired.insert(path.clone());
5490        write_artifact_file(&path, artifact, resolved)?;
5491        written.push(path);
5492    }
5493
5494    if resolved.include_agent_tools
5495        && !resolved
5496            .artifacts
5497            .iter()
5498            .any(|artifact| artifact == "agent-tools.json")
5499    {
5500        let path = generated_dir.join("agent-tools.json");
5501        desired.insert(path.clone());
5502        write_artifact_file(&path, "agent-tools.json", resolved)?;
5503        written.push(path);
5504    }
5505
5506    let known = default_artifacts()
5507        .into_iter()
5508        .map(|artifact| generated_dir.join(artifact))
5509        .collect::<Vec<_>>();
5510    for path in known {
5511        if path.exists() && !desired.contains(&path) {
5512            fs::remove_file(&path).map_err(|err| {
5513                format!(
5514                    "failed to remove stale generated file {}: {err}",
5515                    path.display()
5516                )
5517            })?;
5518        }
5519    }
5520
5521    Ok(written)
5522}
5523
5524fn write_artifact_file(
5525    path: &Path,
5526    artifact: &str,
5527    resolved: &ResolvedAnswers,
5528) -> Result<(), String> {
5529    if let Some(parent) = path.parent() {
5530        fs::create_dir_all(parent)
5531            .map_err(|err| format!("failed to create directory {}: {err}", parent.display()))?;
5532    }
5533
5534    if artifact == "agent-tools.json" {
5535        let provider_categories = [
5536            Some(resolved.storage_category.clone()),
5537            resolved.external_ref_category.clone(),
5538            resolved.agent_endpoint_provider_category.clone(),
5539        ]
5540        .into_iter()
5541        .flatten()
5542        .collect::<Vec<_>>();
5543        let payload = serde_json::json!({
5544            "package": resolved.package_name,
5545            "locale": resolved.locale,
5546            "provider_categories": provider_categories,
5547            "agent_endpoints": resolved.agent_endpoint_ids,
5548        });
5549        let bytes = serde_json::to_vec_pretty(&payload).map_err(|err| err.to_string())?;
5550        fs::write(path, bytes)
5551            .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
5552        return Ok(());
5553    }
5554
5555    if artifact == "provider-requirements.json" {
5556        let bytes = serde_json::to_vec_pretty(&build_provider_handoff_manifest(resolved))
5557            .map_err(|err| err.to_string())?;
5558        fs::write(path, bytes)
5559            .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
5560        return Ok(());
5561    }
5562
5563    if artifact == "locale-manifest.json" {
5564        let bytes = serde_json::to_vec_pretty(&build_locale_handoff_manifest(resolved))
5565            .map_err(|err| err.to_string())?;
5566        fs::write(path, bytes)
5567            .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
5568        return Ok(());
5569    }
5570
5571    let payload = serde_json::json!({
5572        "artifact": artifact,
5573        "package_name": resolved.package_name,
5574        "package_version": resolved.package_version,
5575        "default_source": resolved.default_source,
5576        "projection_mode": resolved.projection_mode,
5577        "compatibility_mode": resolved.compatibility_mode,
5578    });
5579    let mut bytes = Vec::new();
5580    ciborium::ser::into_writer(&payload, &mut bytes).map_err(|err| err.to_string())?;
5581    fs::write(path, bytes)
5582        .map_err(|err| format!("failed to write generated file {}: {err}", path.display()))?;
5583    Ok(())
5584}
5585
5586fn relative_to_output(output_dir: &Path, path: &Path) -> String {
5587    path.strip_prefix(output_dir)
5588        .unwrap_or(path)
5589        .display()
5590        .to_string()
5591}
5592
5593fn to_pascal_case(input: &str) -> String {
5594    input
5595        .split(|ch: char| !ch.is_ascii_alphanumeric())
5596        .filter(|segment| !segment.is_empty())
5597        .map(|segment| {
5598            let mut chars = segment.chars();
5599            match chars.next() {
5600                Some(first) => {
5601                    let mut value = String::new();
5602                    value.push(first.to_ascii_uppercase());
5603                    value.push_str(chars.as_str());
5604                    value
5605                }
5606                None => String::new(),
5607            }
5608        })
5609        .collect::<Vec<_>>()
5610        .join("")
5611}
5612
5613fn title_from_identifier(input: &str) -> String {
5614    input
5615        .split(|ch: char| !ch.is_ascii_alphanumeric())
5616        .filter(|segment| !segment.is_empty())
5617        .map(|segment| segment.to_ascii_lowercase())
5618        .collect::<Vec<_>>()
5619        .join(" ")
5620}
5621
5622pub fn default_schema() -> WizardSchema {
5623    default_schema_for_locale(&selected_locale(None, None))
5624}
5625
5626fn default_schema_for_locale(locale: &str) -> WizardSchema {
5627    WizardSchema {
5628        schema_version: "0.5",
5629        wizard_version: "0.5",
5630        package_version: "0.1.0",
5631        locale: locale.to_string(),
5632        fallback_locale: "en",
5633        supported_modes: vec![SchemaFlow::Create, SchemaFlow::Update],
5634        provider_repo: "greentic-sorla-providers",
5635        generated_content_strategy: "rewrite-generated-blocks-only",
5636        user_content_strategy: "preserve-outside-generated-blocks",
5637        artifact_references: vec![
5638            "model.cbor",
5639            "actions.cbor",
5640            "events.cbor",
5641            "projections.cbor",
5642            "policies.cbor",
5643            "approvals.cbor",
5644            "views.cbor",
5645            "external-sources.cbor",
5646            "compatibility.cbor",
5647            "provider-contract.cbor",
5648            "package-manifest.cbor",
5649            "agent-tools.json",
5650            "provider-requirements.json",
5651            "locale-manifest.json",
5652        ],
5653        sections: vec![
5654            WizardSection {
5655                id: "package-bootstrap",
5656                title_key: "wizard.sections.package.title",
5657                description_key: "wizard.sections.package.description",
5658                flows: vec![SchemaFlow::Create],
5659                questions: vec![
5660                    WizardQuestion {
5661                        id: "package.name",
5662                        label_key: "wizard.questions.package_name.label",
5663                        help_key: Some("wizard.questions.package_name.help"),
5664                        kind: WizardQuestionKind::Text,
5665                        required: true,
5666                        default_value: None,
5667                        choices: vec![],
5668                        visibility: None,
5669                    },
5670                    WizardQuestion {
5671                        id: "package.version",
5672                        label_key: "wizard.questions.package_version.label",
5673                        help_key: Some("wizard.questions.package_version.help"),
5674                        kind: WizardQuestionKind::Text,
5675                        required: true,
5676                        default_value: Some("0.1.0"),
5677                        choices: vec![],
5678                        visibility: None,
5679                    },
5680                ],
5681            },
5682            WizardSection {
5683                id: "package-update",
5684                title_key: "wizard.sections.update.title",
5685                description_key: "wizard.sections.update.description",
5686                flows: vec![SchemaFlow::Update],
5687                questions: vec![
5688                    WizardQuestion {
5689                        id: "update.mode",
5690                        label_key: "wizard.questions.update_mode.label",
5691                        help_key: Some("wizard.questions.update_mode.help"),
5692                        kind: WizardQuestionKind::SingleSelect,
5693                        required: true,
5694                        default_value: Some("safe-update"),
5695                        choices: vec![
5696                            WizardChoice {
5697                                value: "safe-update",
5698                                label_key: "wizard.choices.update_mode.safe",
5699                            },
5700                            WizardChoice {
5701                                value: "refresh-generated",
5702                                label_key: "wizard.choices.update_mode.refresh",
5703                            },
5704                        ],
5705                        visibility: None,
5706                    },
5707                    WizardQuestion {
5708                        id: "update.partial_answers",
5709                        label_key: "wizard.questions.partial_answers.label",
5710                        help_key: Some("wizard.questions.partial_answers.help"),
5711                        kind: WizardQuestionKind::Boolean,
5712                        required: true,
5713                        default_value: Some("true"),
5714                        choices: vec![],
5715                        visibility: None,
5716                    },
5717                ],
5718            },
5719            WizardSection {
5720                id: "provider-requirements",
5721                title_key: "wizard.sections.providers.title",
5722                description_key: "wizard.sections.providers.description",
5723                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5724                questions: vec![
5725                    WizardQuestion {
5726                        id: "providers.storage.category",
5727                        label_key: "wizard.questions.storage_provider.label",
5728                        help_key: Some("wizard.questions.storage_provider.help"),
5729                        kind: WizardQuestionKind::SingleSelect,
5730                        required: true,
5731                        default_value: Some("storage"),
5732                        choices: vec![WizardChoice {
5733                            value: "storage",
5734                            label_key: "wizard.choices.provider_category.storage",
5735                        }],
5736                        visibility: None,
5737                    },
5738                    WizardQuestion {
5739                        id: "providers.external_ref.category",
5740                        label_key: "wizard.questions.external_ref_provider.label",
5741                        help_key: Some("wizard.questions.external_ref_provider.help"),
5742                        kind: WizardQuestionKind::SingleSelect,
5743                        required: false,
5744                        default_value: Some("external-ref"),
5745                        choices: vec![WizardChoice {
5746                            value: "external-ref",
5747                            label_key: "wizard.choices.provider_category.external_ref",
5748                        }],
5749                        visibility: Some(SchemaVisibility {
5750                            depends_on: "records.has_external_or_hybrid",
5751                            equals: "true",
5752                        }),
5753                    },
5754                    WizardQuestion {
5755                        id: "providers.hints",
5756                        label_key: "wizard.questions.provider_hints.label",
5757                        help_key: Some("wizard.questions.provider_hints.help"),
5758                        kind: WizardQuestionKind::TextList,
5759                        required: false,
5760                        default_value: None,
5761                        choices: vec![],
5762                        visibility: None,
5763                    },
5764                ],
5765            },
5766            WizardSection {
5767                id: "external-sources",
5768                title_key: "wizard.sections.external_sources.title",
5769                description_key: "wizard.sections.external_sources.description",
5770                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5771                questions: vec![
5772                    WizardQuestion {
5773                        id: "records.default_source",
5774                        label_key: "wizard.questions.default_source.label",
5775                        help_key: Some("wizard.questions.default_source.help"),
5776                        kind: WizardQuestionKind::SingleSelect,
5777                        required: true,
5778                        default_value: Some("native"),
5779                        choices: vec![
5780                            WizardChoice {
5781                                value: "native",
5782                                label_key: "wizard.choices.record_source.native",
5783                            },
5784                            WizardChoice {
5785                                value: "external",
5786                                label_key: "wizard.choices.record_source.external",
5787                            },
5788                            WizardChoice {
5789                                value: "hybrid",
5790                                label_key: "wizard.choices.record_source.hybrid",
5791                            },
5792                        ],
5793                        visibility: None,
5794                    },
5795                    WizardQuestion {
5796                        id: "records.external_ref.system",
5797                        label_key: "wizard.questions.external_system.label",
5798                        help_key: Some("wizard.questions.external_system.help"),
5799                        kind: WizardQuestionKind::Text,
5800                        required: false,
5801                        default_value: None,
5802                        choices: vec![],
5803                        visibility: Some(SchemaVisibility {
5804                            depends_on: "records.default_source",
5805                            equals: "external-or-hybrid",
5806                        }),
5807                    },
5808                ],
5809            },
5810            WizardSection {
5811                id: "events-projections",
5812                title_key: "wizard.sections.events.title",
5813                description_key: "wizard.sections.events.description",
5814                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5815                questions: vec![
5816                    WizardQuestion {
5817                        id: "events.enabled",
5818                        label_key: "wizard.questions.events_enabled.label",
5819                        help_key: Some("wizard.questions.events_enabled.help"),
5820                        kind: WizardQuestionKind::Boolean,
5821                        required: true,
5822                        default_value: Some("true"),
5823                        choices: vec![],
5824                        visibility: None,
5825                    },
5826                    WizardQuestion {
5827                        id: "projections.mode",
5828                        label_key: "wizard.questions.projection_mode.label",
5829                        help_key: Some("wizard.questions.projection_mode.help"),
5830                        kind: WizardQuestionKind::SingleSelect,
5831                        required: true,
5832                        default_value: Some("current-state"),
5833                        choices: vec![
5834                            WizardChoice {
5835                                value: "current-state",
5836                                label_key: "wizard.choices.projection_mode.current_state",
5837                            },
5838                            WizardChoice {
5839                                value: "audit-trail",
5840                                label_key: "wizard.choices.projection_mode.audit_trail",
5841                            },
5842                        ],
5843                        visibility: None,
5844                    },
5845                ],
5846            },
5847            WizardSection {
5848                id: "ontology",
5849                title_key: "wizard.sections.ontology.title",
5850                description_key: "wizard.sections.ontology.description",
5851                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5852                questions: vec![
5853                    WizardQuestion {
5854                        id: "ontology.schema",
5855                        label_key: "wizard.questions.ontology_schema.label",
5856                        help_key: Some("wizard.questions.ontology_schema.help"),
5857                        kind: WizardQuestionKind::Text,
5858                        required: false,
5859                        default_value: Some("greentic.sorla.ontology.v1"),
5860                        choices: vec![],
5861                        visibility: None,
5862                    },
5863                    WizardQuestion {
5864                        id: "ontology.concepts",
5865                        label_key: "wizard.questions.ontology_concepts.label",
5866                        help_key: Some("wizard.questions.ontology_concepts.help"),
5867                        kind: WizardQuestionKind::TextList,
5868                        required: false,
5869                        default_value: None,
5870                        choices: vec![],
5871                        visibility: None,
5872                    },
5873                    WizardQuestion {
5874                        id: "ontology.relationships",
5875                        label_key: "wizard.questions.ontology_relationships.label",
5876                        help_key: Some("wizard.questions.ontology_relationships.help"),
5877                        kind: WizardQuestionKind::TextList,
5878                        required: false,
5879                        default_value: None,
5880                        choices: vec![],
5881                        visibility: None,
5882                    },
5883                    WizardQuestion {
5884                        id: "retrieval_bindings.scopes",
5885                        label_key: "wizard.questions.retrieval_bindings_scopes.label",
5886                        help_key: Some("wizard.questions.retrieval_bindings_scopes.help"),
5887                        kind: WizardQuestionKind::TextList,
5888                        required: false,
5889                        default_value: None,
5890                        choices: vec![],
5891                        visibility: None,
5892                    },
5893                ],
5894            },
5895            WizardSection {
5896                id: "compatibility",
5897                title_key: "wizard.sections.compatibility.title",
5898                description_key: "wizard.sections.compatibility.description",
5899                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5900                questions: vec![WizardQuestion {
5901                    id: "migrations.compatibility",
5902                    label_key: "wizard.questions.compatibility_mode.label",
5903                    help_key: Some("wizard.questions.compatibility_mode.help"),
5904                    kind: WizardQuestionKind::SingleSelect,
5905                    required: true,
5906                    default_value: Some("additive"),
5907                    choices: vec![
5908                        WizardChoice {
5909                            value: "additive",
5910                            label_key: "wizard.choices.compatibility.additive",
5911                        },
5912                        WizardChoice {
5913                            value: "backward-compatible",
5914                            label_key: "wizard.choices.compatibility.backward_compatible",
5915                        },
5916                        WizardChoice {
5917                            value: "breaking",
5918                            label_key: "wizard.choices.compatibility.breaking",
5919                        },
5920                    ],
5921                    visibility: None,
5922                }],
5923            },
5924            WizardSection {
5925                id: "agent-endpoints",
5926                title_key: "wizard.sections.agent_endpoints.title",
5927                description_key: "wizard.sections.agent_endpoints.description",
5928                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
5929                questions: vec![
5930                    WizardQuestion {
5931                        id: "agent_endpoints.enabled",
5932                        label_key: "wizard.questions.agent_endpoints_enabled.label",
5933                        help_key: Some("wizard.questions.agent_endpoints_enabled.help"),
5934                        kind: WizardQuestionKind::Boolean,
5935                        required: true,
5936                        default_value: Some("false"),
5937                        choices: vec![],
5938                        visibility: None,
5939                    },
5940                    WizardQuestion {
5941                        id: "agent_endpoints.ids",
5942                        label_key: "wizard.questions.agent_endpoint_ids.label",
5943                        help_key: Some("wizard.questions.agent_endpoint_ids.help"),
5944                        kind: WizardQuestionKind::TextList,
5945                        required: false,
5946                        default_value: None,
5947                        choices: vec![],
5948                        visibility: Some(SchemaVisibility {
5949                            depends_on: "agent_endpoints.enabled",
5950                            equals: "true",
5951                        }),
5952                    },
5953                    WizardQuestion {
5954                        id: "agent_endpoints.default_risk",
5955                        label_key: "wizard.questions.agent_endpoint_default_risk.label",
5956                        help_key: Some("wizard.questions.agent_endpoint_default_risk.help"),
5957                        kind: WizardQuestionKind::SingleSelect,
5958                        required: true,
5959                        default_value: Some("medium"),
5960                        choices: vec![
5961                            WizardChoice {
5962                                value: "low",
5963                                label_key: "wizard.choices.agent_endpoint_risk.low",
5964                            },
5965                            WizardChoice {
5966                                value: "medium",
5967                                label_key: "wizard.choices.agent_endpoint_risk.medium",
5968                            },
5969                            WizardChoice {
5970                                value: "high",
5971                                label_key: "wizard.choices.agent_endpoint_risk.high",
5972                            },
5973                        ],
5974                        visibility: Some(SchemaVisibility {
5975                            depends_on: "agent_endpoints.enabled",
5976                            equals: "true",
5977                        }),
5978                    },
5979                    WizardQuestion {
5980                        id: "agent_endpoints.default_approval",
5981                        label_key: "wizard.questions.agent_endpoint_default_approval.label",
5982                        help_key: Some("wizard.questions.agent_endpoint_default_approval.help"),
5983                        kind: WizardQuestionKind::SingleSelect,
5984                        required: true,
5985                        default_value: Some("policy-driven"),
5986                        choices: vec![
5987                            WizardChoice {
5988                                value: "none",
5989                                label_key: "wizard.choices.agent_endpoint_approval.none",
5990                            },
5991                            WizardChoice {
5992                                value: "optional",
5993                                label_key: "wizard.choices.agent_endpoint_approval.optional",
5994                            },
5995                            WizardChoice {
5996                                value: "required",
5997                                label_key: "wizard.choices.agent_endpoint_approval.required",
5998                            },
5999                            WizardChoice {
6000                                value: "policy-driven",
6001                                label_key: "wizard.choices.agent_endpoint_approval.policy_driven",
6002                            },
6003                        ],
6004                        visibility: Some(SchemaVisibility {
6005                            depends_on: "agent_endpoints.enabled",
6006                            equals: "true",
6007                        }),
6008                    },
6009                    WizardQuestion {
6010                        id: "agent_endpoints.exports",
6011                        label_key: "wizard.questions.agent_endpoint_exports.label",
6012                        help_key: Some("wizard.questions.agent_endpoint_exports.help"),
6013                        kind: WizardQuestionKind::MultiSelect,
6014                        required: true,
6015                        default_value: Some("openapi,arazzo,mcp,llms_txt"),
6016                        choices: vec![
6017                            WizardChoice {
6018                                value: "openapi",
6019                                label_key: "wizard.choices.agent_endpoint_export.openapi",
6020                            },
6021                            WizardChoice {
6022                                value: "arazzo",
6023                                label_key: "wizard.choices.agent_endpoint_export.arazzo",
6024                            },
6025                            WizardChoice {
6026                                value: "mcp",
6027                                label_key: "wizard.choices.agent_endpoint_export.mcp",
6028                            },
6029                            WizardChoice {
6030                                value: "llms_txt",
6031                                label_key: "wizard.choices.agent_endpoint_export.llms_txt",
6032                            },
6033                        ],
6034                        visibility: Some(SchemaVisibility {
6035                            depends_on: "agent_endpoints.enabled",
6036                            equals: "true",
6037                        }),
6038                    },
6039                    WizardQuestion {
6040                        id: "agent_endpoints.provider_category",
6041                        label_key: "wizard.questions.agent_endpoint_provider_category.label",
6042                        help_key: Some("wizard.questions.agent_endpoint_provider_category.help"),
6043                        kind: WizardQuestionKind::Text,
6044                        required: false,
6045                        default_value: Some("api-gateway"),
6046                        choices: vec![],
6047                        visibility: Some(SchemaVisibility {
6048                            depends_on: "agent_endpoints.enabled",
6049                            equals: "true",
6050                        }),
6051                    },
6052                ],
6053            },
6054            WizardSection {
6055                id: "output-preferences",
6056                title_key: "wizard.sections.output.title",
6057                description_key: "wizard.sections.output.description",
6058                flows: vec![SchemaFlow::Create, SchemaFlow::Update],
6059                questions: vec![
6060                    WizardQuestion {
6061                        id: "output.include_agent_tools",
6062                        label_key: "wizard.questions.include_agent_tools.label",
6063                        help_key: Some("wizard.questions.include_agent_tools.help"),
6064                        kind: WizardQuestionKind::Boolean,
6065                        required: true,
6066                        default_value: Some("true"),
6067                        choices: vec![],
6068                        visibility: None,
6069                    },
6070                    WizardQuestion {
6071                        id: "output.artifacts",
6072                        label_key: "wizard.questions.output_artifacts.label",
6073                        help_key: Some("wizard.questions.output_artifacts.help"),
6074                        kind: WizardQuestionKind::MultiSelect,
6075                        required: true,
6076                        default_value: Some(
6077                            "model.cbor,actions.cbor,events.cbor,projections.cbor,policies.cbor,approvals.cbor,views.cbor,external-sources.cbor,compatibility.cbor,provider-contract.cbor,package-manifest.cbor,agent-tools.json,provider-requirements.json,locale-manifest.json",
6078                        ),
6079                        choices: vec![
6080                            WizardChoice {
6081                                value: "model.cbor",
6082                                label_key: "wizard.artifacts.model.cbor",
6083                            },
6084                            WizardChoice {
6085                                value: "actions.cbor",
6086                                label_key: "wizard.artifacts.actions.cbor",
6087                            },
6088                            WizardChoice {
6089                                value: "events.cbor",
6090                                label_key: "wizard.artifacts.events.cbor",
6091                            },
6092                            WizardChoice {
6093                                value: "projections.cbor",
6094                                label_key: "wizard.artifacts.projections.cbor",
6095                            },
6096                            WizardChoice {
6097                                value: "policies.cbor",
6098                                label_key: "wizard.artifacts.policies.cbor",
6099                            },
6100                            WizardChoice {
6101                                value: "approvals.cbor",
6102                                label_key: "wizard.artifacts.approvals.cbor",
6103                            },
6104                            WizardChoice {
6105                                value: "views.cbor",
6106                                label_key: "wizard.artifacts.views.cbor",
6107                            },
6108                            WizardChoice {
6109                                value: "external-sources.cbor",
6110                                label_key: "wizard.artifacts.external-sources.cbor",
6111                            },
6112                            WizardChoice {
6113                                value: "compatibility.cbor",
6114                                label_key: "wizard.artifacts.compatibility.cbor",
6115                            },
6116                            WizardChoice {
6117                                value: "provider-contract.cbor",
6118                                label_key: "wizard.artifacts.provider-contract.cbor",
6119                            },
6120                            WizardChoice {
6121                                value: "package-manifest.cbor",
6122                                label_key: "wizard.artifacts.package-manifest.cbor",
6123                            },
6124                            WizardChoice {
6125                                value: "agent-tools.json",
6126                                label_key: "wizard.artifacts.agent-tools.json",
6127                            },
6128                            WizardChoice {
6129                                value: "provider-requirements.json",
6130                                label_key: "wizard.artifacts.provider-requirements.json",
6131                            },
6132                            WizardChoice {
6133                                value: "locale-manifest.json",
6134                                label_key: "wizard.artifacts.locale-manifest.json",
6135                            },
6136                        ],
6137                        visibility: None,
6138                    },
6139                ],
6140            },
6141        ],
6142    }
6143}
6144
6145#[cfg(test)]
6146mod tests {
6147    use super::*;
6148    use clap::CommandFactory;
6149    use std::time::{SystemTime, UNIX_EPOCH};
6150
6151    #[test]
6152    fn public_help_includes_wizard_and_pack_surface() {
6153        let help = Cli::command().render_long_help().to_string();
6154        assert!(help.contains("wizard"));
6155        assert!(help.contains("--schema"));
6156        assert!(help.contains("--answers"));
6157        assert!(help.contains("--pack-out"));
6158        assert!(help.contains("pack"));
6159        assert!(help.contains("--out"));
6160        assert!(!help.contains("__inspect-product-shape"));
6161    }
6162
6163    #[test]
6164    fn pack_metadata_schema_helpers_are_deterministic() {
6165        for (schema, expected_id) in [
6166            (sorx_validation_schema_json(), SORX_VALIDATION_SCHEMA),
6167            (
6168                sorx_exposure_policy_schema_json(),
6169                SORX_EXPOSURE_POLICY_SCHEMA,
6170            ),
6171            (sorx_compatibility_schema_json(), SORX_COMPATIBILITY_SCHEMA),
6172            (
6173                ontology_schema_json(),
6174                greentic_sorla_pack::ONTOLOGY_EXTENSION_ID,
6175            ),
6176            (
6177                retrieval_bindings_schema_json(),
6178                greentic_sorla_pack::RETRIEVAL_BINDINGS_SCHEMA,
6179            ),
6180        ] {
6181            let first = serde_json::to_string_pretty(&schema).expect("schema serializes");
6182            let second = serde_json::to_string_pretty(&schema).expect("schema serializes again");
6183            assert_eq!(first, second);
6184            assert_eq!(schema["$id"], expected_id);
6185        }
6186    }
6187
6188    #[test]
6189    fn pack_validation_inspection_json_is_compact() {
6190        let dir = unique_temp_dir();
6191        let pack_path = dir.join("landlord.gtpack");
6192        build_sorla_gtpack(&SorlaGtpackOptions {
6193            input_path: PathBuf::from("../../tests/e2e/fixtures/landlord_sor_v1.yaml"),
6194            name: "landlord-tenant-sor".to_string(),
6195            version: "0.1.0".to_string(),
6196            out_path: pack_path.clone(),
6197        })
6198        .expect("pack builds");
6199        let inspection = inspect_sorla_gtpack(&pack_path).expect("pack inspects");
6200        let validation = validation_inspection_json(&inspection);
6201
6202        assert_eq!(validation["schema"], SORX_VALIDATION_SCHEMA);
6203        assert_eq!(validation["package"]["name"], "landlord-tenant-sor");
6204        assert_eq!(validation["validation"]["suite_count"], 4);
6205        assert_eq!(
6206            validation["exposure"]["default_visibility"],
6207            serde_json::json!("private")
6208        );
6209        assert_eq!(
6210            validation["compatibility"]["state_mode"],
6211            serde_json::json!("isolated_required")
6212        );
6213        assert!(validation["ontology"].is_null());
6214    }
6215
6216    #[test]
6217    fn pack_metadata_subcommands_parse() {
6218        Cli::try_parse_from(["greentic-sorla", "pack", "schema", "validation"])
6219            .expect("validation schema command parses");
6220        Cli::try_parse_from(["greentic-sorla", "pack", "schema", "exposure-policy"])
6221            .expect("exposure schema command parses");
6222        Cli::try_parse_from(["greentic-sorla", "pack", "schema", "compatibility"])
6223            .expect("compatibility schema command parses");
6224        Cli::try_parse_from(["greentic-sorla", "pack", "schema", "ontology"])
6225            .expect("ontology schema command parses");
6226        Cli::try_parse_from(["greentic-sorla", "pack", "schema", "retrieval-bindings"])
6227            .expect("retrieval bindings schema command parses");
6228        Cli::try_parse_from(["greentic-sorla", "pack", "validation-inspect", "x.gtpack"])
6229            .expect("validation-inspect command parses");
6230        Cli::try_parse_from(["greentic-sorla", "pack", "validation-doctor", "x.gtpack"])
6231            .expect("validation-doctor command parses");
6232    }
6233
6234    #[test]
6235    fn public_facade_builds_preview_and_gtpack_bytes() {
6236        let answers_path = Path::new(env!("CARGO_MANIFEST_DIR"))
6237            .join("examples")
6238            .join("answers")
6239            .join("create_minimal.json");
6240        let input: serde_json::Value =
6241            serde_json::from_str(&fs::read_to_string(answers_path).expect("example answers read"))
6242                .expect("example answers parse");
6243
6244        let schema = schema_for_answers().expect("schema emits");
6245        assert_eq!(schema["schema_version"], "0.5");
6246
6247        let model = normalize_answers(input, NormalizeOptions).expect("answers normalize");
6248        assert_eq!(model.package_name, "tenancy");
6249
6250        let report = validate_model(&model, ValidateOptions);
6251        assert!(!report.has_errors(), "{report:?}");
6252
6253        let preview = generate_preview(&model, PreviewOptions).expect("preview generates");
6254        assert_eq!(preview.summary.package_name, "tenancy");
6255        assert!(preview.summary.records >= 1);
6256
6257        let entries =
6258            build_gtpack_entries(&model, PackBuildOptions::default()).expect("pack entries build");
6259        let entries_again = build_gtpack_entries(&model, PackBuildOptions::default())
6260            .expect("pack entries build again");
6261        assert_eq!(entries, entries_again);
6262        assert!(
6263            entries
6264                .iter()
6265                .any(|entry| entry.path == "assets/sorla/model.cbor")
6266        );
6267
6268        let pack = build_gtpack_bytes(&model, PackBuildOptions::default()).expect("pack builds");
6269        assert_eq!(pack.filename, "tenancy.gtpack");
6270        assert_eq!(pack.sha256, sha256_hex_public(&pack.bytes));
6271        assert_eq!(pack.metadata.pack_id, "tenancy");
6272
6273        let inspection = inspect_gtpack_bytes(&pack.bytes).expect("bytes inspect");
6274        assert_eq!(inspection.name, "tenancy");
6275
6276        let doctor = doctor_gtpack_bytes(&pack.bytes);
6277        assert!(!doctor.has_errors(), "{doctor:?}");
6278    }
6279
6280    #[test]
6281    fn public_facade_lists_designer_node_types() {
6282        let answers_path = Path::new(env!("CARGO_MANIFEST_DIR"))
6283            .join("examples")
6284            .join("answers")
6285            .join("create_agent_endpoints.json");
6286        let input: serde_json::Value =
6287            serde_json::from_str(&fs::read_to_string(answers_path).expect("example answers read"))
6288                .expect("example answers parse");
6289        let model = normalize_answers(input, NormalizeOptions).expect("answers normalize");
6290        let node_types = list_designer_node_types(&model, DesignerNodeTypeOptions::default())
6291            .expect("node types generate");
6292        assert_eq!(
6293            node_types.schema,
6294            greentic_sorla_pack::DESIGNER_NODE_TYPES_SCHEMA
6295        );
6296        assert!(!node_types.node_types.is_empty());
6297        assert!(
6298            node_types.node_types[0]
6299                .metadata
6300                .endpoint
6301                .contract_hash
6302                .starts_with("sha256:")
6303        );
6304    }
6305
6306    #[test]
6307    fn cli_schema_includes_create_and_update_modes() {
6308        let schema = default_schema();
6309        assert!(schema.supported_modes.contains(&SchemaFlow::Create));
6310        assert!(schema.supported_modes.contains(&SchemaFlow::Update));
6311        assert_eq!(schema.fallback_locale, "en");
6312        assert!(
6313            schema
6314                .artifact_references
6315                .contains(&"provider-requirements.json")
6316        );
6317        assert!(
6318            schema
6319                .sections
6320                .iter()
6321                .any(|section| section.id == "output-preferences")
6322        );
6323        let agent_section = schema
6324            .sections
6325            .iter()
6326            .find(|section| section.id == "agent-endpoints")
6327            .expect("schema should include agent endpoints section");
6328        assert!(agent_section.flows.contains(&SchemaFlow::Create));
6329        assert!(agent_section.flows.contains(&SchemaFlow::Update));
6330        assert!(agent_section.questions.iter().any(|question| {
6331            question.id == "agent_endpoints.ids"
6332                && question.visibility
6333                    == Some(SchemaVisibility {
6334                        depends_on: "agent_endpoints.enabled",
6335                        equals: "true",
6336                    })
6337        }));
6338    }
6339
6340    #[test]
6341    fn schema_references_existing_english_i18n_keys() {
6342        let i18n_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
6343            .join("../..")
6344            .join("i18n/en.json");
6345        let raw = fs::read_to_string(i18n_path).expect("English i18n file should be readable");
6346        let keys: BTreeMap<String, String> =
6347            serde_json::from_str(&raw).expect("English i18n should parse");
6348        let schema = default_schema();
6349
6350        for section in &schema.sections {
6351            assert!(keys.contains_key(section.title_key));
6352            assert!(keys.contains_key(section.description_key));
6353            for question in &section.questions {
6354                assert!(keys.contains_key(question.label_key));
6355                if let Some(help_key) = question.help_key {
6356                    assert!(keys.contains_key(help_key));
6357                }
6358                for choice in &question.choices {
6359                    assert!(keys.contains_key(choice.label_key));
6360                }
6361            }
6362        }
6363    }
6364
6365    #[test]
6366    fn schema_uses_locale_environment_with_fallback() {
6367        unsafe {
6368            std::env::set_var("SORLA_LOCALE", "fr");
6369        }
6370        let schema = default_schema();
6371        unsafe {
6372            std::env::remove_var("SORLA_LOCALE");
6373        }
6374        assert_eq!(schema.locale, "fr");
6375        assert_eq!(schema.fallback_locale, "en");
6376    }
6377
6378    #[test]
6379    fn wizard_schema_accepts_explicit_locale() {
6380        let cli = Cli::try_parse_from(["greentic-sorla", "wizard", "--locale", "es", "--schema"])
6381            .expect("wizard schema should accept explicit locale");
6382        let Commands::Wizard(args) = cli.command else {
6383            panic!("expected wizard command");
6384        };
6385
6386        let schema = default_schema_for_locale(&selected_locale(args.locale.as_deref(), None));
6387        assert_eq!(schema.locale, "es");
6388    }
6389
6390    #[test]
6391    fn localized_help_accepts_locale_after_help_flag() {
6392        let root_help = localized_help_for_args(&[
6393            OsString::from("greentic-sorla"),
6394            OsString::from("--help"),
6395            OsString::from("--locale"),
6396            OsString::from("de"),
6397        ])
6398        .expect("root help should localize");
6399        assert!(root_help.contains("SoRLa-Wizard"));
6400        assert!(root_help.contains("Schema generieren oder Antwortdokumente anwenden."));
6401
6402        let wizard_help = localized_help_for_args(&[
6403            OsString::from("greentic-sorla"),
6404            OsString::from("wizard"),
6405            OsString::from("--help"),
6406            OsString::from("--locale"),
6407            OsString::from("de"),
6408        ])
6409        .expect("wizard help should localize");
6410        assert!(wizard_help.contains("Schema generieren oder Antwortdokumente anwenden."));
6411        assert!(wizard_help.contains("Paketname"));
6412        assert!(wizard_help.contains("Kategorie des Speicher-Providers"));
6413    }
6414
6415    #[test]
6416    fn embedded_i18n_catalogs_are_available_without_filesystem_lookups() {
6417        let resolved = load_interactive_i18n("es").expect("embedded locale should load");
6418        assert_eq!(
6419            resolved.get("wizard.title").map(String::as_str),
6420            Some("Asistente de SoRLa")
6421        );
6422        assert_eq!(
6423            resolved.get("wizard.flow.label").map(String::as_str),
6424            Some("Flujo del asistente")
6425        );
6426    }
6427
6428    #[test]
6429    fn bundled_i18n_catalogs_cover_supported_locales() {
6430        let raw_locales = included_locale_json("locales").expect("locales list is bundled");
6431        let locales: Vec<String> =
6432            serde_json::from_str(raw_locales).expect("locales list should parse");
6433
6434        for locale in locales {
6435            let raw = included_locale_json(&locale)
6436                .unwrap_or_else(|| panic!("locale `{locale}` should be bundled"));
6437            serde_json::from_str::<BTreeMap<String, String>>(raw)
6438                .unwrap_or_else(|err| panic!("bundled locale `{locale}` should parse: {err}"));
6439        }
6440    }
6441
6442    #[test]
6443    fn create_flow_generates_package_and_lock_file() {
6444        let dir = unique_temp_dir();
6445        let answers_path = dir.join("create.json");
6446        let output_dir = dir.join("workspace");
6447        fs::create_dir_all(&output_dir).unwrap();
6448
6449        fs::write(
6450            &answers_path,
6451            format!(
6452                r#"{{
6453  "schema_version": "0.4",
6454  "flow": "create",
6455  "output_dir": "{}",
6456  "package": {{
6457    "name": "tenancy",
6458    "version": "0.2.0"
6459  }},
6460  "records": {{
6461    "default_source": "hybrid",
6462    "external_ref_system": "crm"
6463  }},
6464  "providers": {{
6465    "hints": ["crm"]
6466  }}
6467}}"#,
6468                output_dir.display()
6469            ),
6470        )
6471        .unwrap();
6472
6473        run([
6474            "greentic-sorla",
6475            "wizard",
6476            "--answers",
6477            answers_path.to_str().unwrap(),
6478        ])
6479        .unwrap();
6480
6481        let package_yaml = fs::read_to_string(output_dir.join("sorla.yaml")).unwrap();
6482        assert!(package_yaml.contains("package:"));
6483        assert!(package_yaml.contains("source: hybrid"));
6484        assert!(package_yaml.contains(GENERATED_BEGIN));
6485
6486        let lock = fs::read_to_string(
6487            output_dir
6488                .join(".greentic-sorla")
6489                .join("generated")
6490                .join(LOCK_FILENAME),
6491        )
6492        .unwrap();
6493        assert!(lock.contains("\"package_name\": \"tenancy\""));
6494        assert!(lock.contains("\"locale\":"));
6495        assert!(
6496            output_dir
6497                .join(".greentic-sorla")
6498                .join("generated")
6499                .join("model.cbor")
6500                .exists()
6501        );
6502        let manifest = fs::read_to_string(
6503            output_dir
6504                .join(".greentic-sorla")
6505                .join("generated")
6506                .join("launcher-handoff.json"),
6507        )
6508        .unwrap();
6509        assert!(manifest.contains("\"package_kind\": \"greentic-sorla-package\""));
6510        assert!(manifest.contains("\"fallback_locale\": \"en\""));
6511        assert!(manifest.contains("\"handoff_owner\": \"gtc\""));
6512        assert!(manifest.contains("\"stage\": \"launcher\""));
6513
6514        let legacy_manifest = fs::read_to_string(
6515            output_dir
6516                .join(".greentic-sorla")
6517                .join("generated")
6518                .join("package-manifest.json"),
6519        )
6520        .unwrap();
6521        assert_eq!(manifest, legacy_manifest);
6522
6523        let locale_manifest = fs::read_to_string(
6524            output_dir
6525                .join(".greentic-sorla")
6526                .join("generated")
6527                .join("locale-manifest.json"),
6528        )
6529        .unwrap();
6530        assert!(locale_manifest.contains("\"default_locale\": \"en\""));
6531        assert!(locale_manifest.contains("\"handoff_kind\": \"locale\""));
6532
6533        let provider_manifest = fs::read_to_string(
6534            output_dir
6535                .join(".greentic-sorla")
6536                .join("generated")
6537                .join("provider-requirements.json"),
6538        )
6539        .unwrap();
6540        assert!(provider_manifest.contains("\"name\": \"storage\""));
6541        assert!(provider_manifest.contains("\"handoff_kind\": \"provider-requirements\""));
6542    }
6543
6544    #[test]
6545    fn wizard_answers_can_generate_gtpack_without_repo_fixture() {
6546        let dir = unique_temp_dir();
6547        let answers_path = dir.join("create-pack.json");
6548        let output_dir = dir.join("workspace");
6549        fs::create_dir_all(&output_dir).unwrap();
6550
6551        fs::write(
6552            &answers_path,
6553            format!(
6554                r#"{{
6555  "schema_version": "0.4",
6556  "flow": "create",
6557  "output_dir": "{}",
6558  "package": {{
6559    "name": "landlord-tenant-sor",
6560    "version": "0.1.0"
6561  }},
6562  "records": {{
6563    "default_source": "native"
6564  }},
6565  "events": {{
6566    "enabled": true
6567  }},
6568  "agent_endpoints": {{
6569    "enabled": true,
6570    "ids": ["create_tenant"],
6571    "default_risk": "medium",
6572    "default_approval": "policy-driven",
6573    "exports": ["openapi", "arazzo", "mcp", "llms_txt"],
6574    "provider_category": "storage"
6575  }}
6576}}"#,
6577                output_dir.display()
6578            ),
6579        )
6580        .unwrap();
6581
6582        run([
6583            "greentic-sorla",
6584            "wizard",
6585            "--answers",
6586            answers_path.to_str().unwrap(),
6587            "--pack-out",
6588            "landlord-tenant-sor.gtpack",
6589        ])
6590        .unwrap();
6591
6592        let pack_path = output_dir.join("landlord-tenant-sor.gtpack");
6593        assert!(pack_path.exists());
6594        doctor_sorla_gtpack(&pack_path).expect("wizard-generated pack should doctor");
6595        let inspection = inspect_sorla_gtpack(&pack_path).expect("wizard pack should inspect");
6596        assert_eq!(inspection.name, "landlord-tenant-sor");
6597        assert_eq!(inspection.extension, "greentic.sorx.runtime.v1");
6598
6599        let validation_manifest_path = output_dir
6600            .join(".greentic-sorla")
6601            .join("generated")
6602            .join("assets")
6603            .join("sorx")
6604            .join("tests")
6605            .join("test-manifest.json");
6606        let validation_manifest = fs::read_to_string(validation_manifest_path)
6607            .expect("wizard should write generated validation manifest");
6608        let validation: serde_json::Value =
6609            serde_json::from_str(&validation_manifest).expect("validation manifest is JSON");
6610        assert_eq!(validation["schema"], "greentic.sorx.validation.v1");
6611        assert_eq!(validation["package"]["name"], "landlord-tenant-sor");
6612        assert!(
6613            validation["promotion_requires"]
6614                .as_array()
6615                .expect("promotion requirements")
6616                .iter()
6617                .any(|suite| suite == "contract")
6618        );
6619    }
6620
6621    #[test]
6622    fn landlord_tenant_pack_example_answers_generate_gtpack() {
6623        let dir = unique_temp_dir();
6624        let answers_path = dir.join("landlord-tenant-pack.json");
6625        let output_dir = dir.join("workspace");
6626        fs::create_dir_all(&output_dir).unwrap();
6627        let example_path = Path::new(env!("CARGO_MANIFEST_DIR"))
6628            .join("../..")
6629            .join("examples")
6630            .join("landlord-tenant")
6631            .join("answers.json");
6632        let example = fs::read_to_string(example_path).expect("example answers should read");
6633        let patched = example.replace(
6634            r#""output_dir": "examples/landlord-tenant""#,
6635            &format!(r#""output_dir": "{}""#, output_dir.display()),
6636        );
6637        fs::write(&answers_path, patched).unwrap();
6638
6639        run([
6640            "greentic-sorla",
6641            "wizard",
6642            "--answers",
6643            answers_path.to_str().unwrap(),
6644            "--pack-out",
6645            "landlord-tenant-sor.gtpack",
6646        ])
6647        .unwrap();
6648
6649        let pack_path = output_dir.join("landlord-tenant-sor.gtpack");
6650        doctor_sorla_gtpack(&pack_path).expect("example pack should doctor");
6651        let inspection = inspect_sorla_gtpack(&pack_path).expect("example pack should inspect");
6652        assert_eq!(inspection.name, "landlord-tenant-sor");
6653        let package_yaml = fs::read_to_string(output_dir.join("sorla.yaml")).unwrap();
6654        assert!(package_yaml.contains("name: Landlord"));
6655        assert!(package_yaml.contains("name: MaintenanceRequest"));
6656        assert!(package_yaml.contains("ontology:"));
6657        assert!(package_yaml.contains("semantic_aliases:"));
6658        assert!(package_yaml.contains("entity_linking:"));
6659        assert!(package_yaml.contains("id: Tenant"));
6660        assert!(package_yaml.contains("id: occupies"));
6661        assert!(package_yaml.contains("id: tenant_email_match"));
6662        assert!(package_yaml.contains("id: create_tenant"));
6663        assert!(package_yaml.contains("event: TenantCreated"));
6664        assert!(!package_yaml.contains("LandlordTenantSorRecord"));
6665        assert_eq!(
6666            inspection
6667                .ontology
6668                .as_ref()
6669                .expect("ontology should inspect")
6670                .concept_count,
6671            6
6672        );
6673        assert_eq!(
6674            inspection
6675                .optional_artifacts
6676                .get("assets/sorla/mcp-tools.json"),
6677            Some(&true)
6678        );
6679    }
6680
6681    #[test]
6682    fn ontology_business_answers_generate_deterministic_handoff_pack() {
6683        let dir = unique_temp_dir();
6684        let answers_a = dir.join("ontology-business-a.json");
6685        let answers_b = dir.join("ontology-business-b.json");
6686        let output_a = dir.join("workspace-a");
6687        let output_b = dir.join("workspace-b");
6688        fs::create_dir_all(&output_a).unwrap();
6689        fs::create_dir_all(&output_b).unwrap();
6690
6691        let example_path = Path::new(env!("CARGO_MANIFEST_DIR"))
6692            .join("../..")
6693            .join("examples")
6694            .join("ontology-business")
6695            .join("answers.json");
6696        let example = fs::read_to_string(example_path).expect("ontology example answers read");
6697        let mut first: serde_json::Value =
6698            serde_json::from_str(&example).expect("ontology example answers parse");
6699        first["output_dir"] = serde_json::Value::String(output_a.display().to_string());
6700        let mut second = first.clone();
6701        second["output_dir"] = serde_json::Value::String(output_b.display().to_string());
6702        fs::write(&answers_a, serde_json::to_vec_pretty(&first).unwrap()).unwrap();
6703        fs::write(&answers_b, serde_json::to_vec_pretty(&second).unwrap()).unwrap();
6704
6705        run([
6706            "greentic-sorla",
6707            "wizard",
6708            "--answers",
6709            answers_a.to_str().unwrap(),
6710            "--pack-out",
6711            "ontology-business.gtpack",
6712        ])
6713        .unwrap();
6714        run([
6715            "greentic-sorla",
6716            "wizard",
6717            "--answers",
6718            answers_b.to_str().unwrap(),
6719            "--pack-out",
6720            "ontology-business.gtpack",
6721        ])
6722        .unwrap();
6723
6724        let package_yaml = fs::read_to_string(output_a.join("sorla.yaml")).unwrap();
6725        assert!(package_yaml.contains("ontology:"));
6726        assert!(package_yaml.contains("semantic_aliases:"));
6727        assert!(package_yaml.contains("entity_linking:"));
6728        assert!(package_yaml.contains("retrieval_bindings:"));
6729
6730        let pack_a = output_a.join("ontology-business.gtpack");
6731        let pack_b = output_b.join("ontology-business.gtpack");
6732        assert_eq!(fs::read(&pack_a).unwrap(), fs::read(&pack_b).unwrap());
6733        doctor_sorla_gtpack(&pack_a).expect("ontology business pack should doctor");
6734        let inspection = inspect_sorla_gtpack(&pack_a).expect("ontology business pack inspect");
6735        assert!(inspection.ontology.is_some());
6736        assert!(inspection.retrieval_bindings.is_some());
6737        let validation = inspection.validation.as_ref().expect("validation summary");
6738        assert!(
6739            validation
6740                .promotion_requires
6741                .contains(&"ontology".to_string())
6742        );
6743        assert!(
6744            validation
6745                .promotion_requires
6746                .contains(&"retrieval".to_string())
6747        );
6748
6749        let validation_json = validation_inspection_json(&inspection);
6750        assert_eq!(
6751            validation_json["retrieval_bindings"]["schema"],
6752            "greentic.sorla.retrieval-bindings.v1"
6753        );
6754
6755        let generated_dir = output_a.join(".greentic-sorla").join("generated");
6756        for file in [
6757            "launcher-handoff.json",
6758            "provider-requirements.json",
6759            "assets/sorx/tests/test-manifest.json",
6760        ] {
6761            let text = fs::read_to_string(generated_dir.join(file)).unwrap();
6762            assert!(!text.contains(output_a.to_str().unwrap()));
6763            assert!(!text.to_ascii_lowercase().contains("tenant_id"));
6764            assert!(!text.to_ascii_lowercase().contains("password"));
6765            assert!(!text.to_ascii_lowercase().contains("api_key"));
6766        }
6767    }
6768
6769    #[test]
6770    fn wizard_answers_generate_ontology_section() {
6771        let dir = unique_temp_dir();
6772        let answers_path = dir.join("ontology.json");
6773        let output_dir = dir.join("workspace");
6774        fs::create_dir_all(&output_dir).unwrap();
6775
6776        fs::write(
6777            &answers_path,
6778            format!(
6779                r#"{{
6780  "schema_version": "0.5",
6781  "flow": "create",
6782  "output_dir": "{}",
6783  "package": {{
6784    "name": "ontology-demo",
6785    "version": "0.1.0"
6786  }},
6787  "records": {{
6788    "default_source": "native",
6789    "items": [
6790      {{
6791        "name": "Customer",
6792        "fields": [
6793          {{ "name": "id", "type": "string" }}
6794        ]
6795      }},
6796      {{
6797        "name": "Contract",
6798        "fields": [
6799          {{ "name": "id", "type": "string" }}
6800        ]
6801      }},
6802      {{
6803        "name": "CustomerContract",
6804        "fields": [
6805          {{ "name": "customer_id", "type": "string" }},
6806          {{ "name": "contract_id", "type": "string" }}
6807        ]
6808      }}
6809    ]
6810  }},
6811  "ontology": {{
6812    "concepts": [
6813      {{ "id": "Party", "kind": "abstract" }},
6814      {{
6815        "id": "Customer",
6816        "kind": "entity",
6817        "extends": ["Party"],
6818        "backed_by": {{ "record": "Customer" }},
6819        "sensitivity": {{ "classification": "confidential", "pii": true }}
6820      }},
6821      {{
6822        "id": "Contract",
6823        "kind": "entity",
6824        "backed_by": {{ "record": "Contract" }}
6825      }}
6826    ],
6827    "relationships": [
6828      {{
6829        "id": "has_contract",
6830        "from": "Customer",
6831        "to": "Contract",
6832        "cardinality": {{ "from": "one", "to": "many" }},
6833        "backed_by": {{
6834          "record": "CustomerContract",
6835          "from_field": "customer_id",
6836          "to_field": "contract_id"
6837        }}
6838      }}
6839    ]
6840  }}
6841}}"#,
6842                output_dir.display()
6843            ),
6844        )
6845        .unwrap();
6846
6847        run([
6848            "greentic-sorla",
6849            "wizard",
6850            "--answers",
6851            answers_path.to_str().unwrap(),
6852        ])
6853        .unwrap();
6854
6855        let package_yaml = fs::read_to_string(output_dir.join("sorla.yaml")).unwrap();
6856        assert!(package_yaml.contains("ontology:"));
6857        assert!(package_yaml.contains("schema: greentic.sorla.ontology.v1"));
6858        assert!(package_yaml.contains("id: has_contract"));
6859        assert!(package_yaml.contains("from_field: customer_id"));
6860        serde_yaml::from_str::<serde_yaml::Value>(&package_yaml)
6861            .expect("generated ontology YAML should be valid YAML");
6862    }
6863
6864    #[test]
6865    fn wizard_schema_includes_optional_ontology_section() {
6866        let schema = default_schema();
6867        let ontology = schema
6868            .sections
6869            .iter()
6870            .find(|section| section.id == "ontology")
6871            .expect("ontology section should be present");
6872        assert!(
6873            ontology
6874                .questions
6875                .iter()
6876                .any(|question| question.id == "ontology.schema" && !question.required)
6877        );
6878    }
6879
6880    #[test]
6881    fn rich_answers_validate_cross_references_with_answer_paths() {
6882        let dir = unique_temp_dir();
6883        let answers_path = dir.join("invalid-rich.json");
6884        let output_dir = dir.join("workspace");
6885        fs::write(
6886            &answers_path,
6887            format!(
6888                r#"{{
6889  "schema_version": "0.5",
6890  "flow": "create",
6891  "output_dir": "{}",
6892  "package": {{
6893    "name": "invalid-rich",
6894    "version": "0.1.0"
6895  }},
6896  "records": {{
6897    "default_source": "native",
6898    "items": [
6899      {{
6900        "name": "Tenant",
6901        "fields": [
6902          {{
6903            "name": "landlord_id",
6904            "type": "string",
6905            "references": {{ "record": "Landlord", "field": "id" }}
6906          }}
6907        ]
6908      }}
6909    ]
6910  }}
6911}}"#,
6912                output_dir.display()
6913            ),
6914        )
6915        .unwrap();
6916
6917        let error = run([
6918            "greentic-sorla",
6919            "wizard",
6920            "--answers",
6921            answers_path.to_str().unwrap(),
6922        ])
6923        .expect_err("unknown record reference should fail");
6924
6925        assert!(error.contains("records.items[0].fields[0].references.record"));
6926        assert!(error.contains("Landlord"));
6927    }
6928
6929    #[test]
6930    fn validation_pack_example_answers_generate_gtpacks() {
6931        for (file_name, output_dir_literal, pack_name, pack_version) in [
6932            (
6933                "minimal_validation_pack.json",
6934                "target/greentic-sorla-minimal-validation-pack-example",
6935                "minimal-validation-sor",
6936                "0.1.0",
6937            ),
6938            (
6939                "landlord_tenant_validation_pack.json",
6940                "target/greentic-sorla-landlord-tenant-validation-pack-example",
6941                "landlord-tenant-sor",
6942                "0.1.0",
6943            ),
6944            (
6945                "landlord_tenant_exported_candidate_pack.json",
6946                "target/greentic-sorla-landlord-tenant-exported-candidate-pack-example",
6947                "landlord-tenant-sor",
6948                "0.1.0",
6949            ),
6950        ] {
6951            let dir = unique_temp_dir();
6952            let answers_path = dir.join(file_name);
6953            let output_dir = dir.join("workspace");
6954            fs::create_dir_all(&output_dir).unwrap();
6955            let example_path = Path::new(env!("CARGO_MANIFEST_DIR"))
6956                .join("examples")
6957                .join("answers")
6958                .join(file_name);
6959            let example = fs::read_to_string(example_path).expect("example answers should read");
6960            let patched = example.replace(
6961                &format!(r#""output_dir": "{output_dir_literal}""#),
6962                &format!(r#""output_dir": "{}""#, output_dir.display()),
6963            );
6964            fs::write(&answers_path, patched).unwrap();
6965
6966            let pack_filename = format!("{pack_name}.gtpack");
6967            run([
6968                "greentic-sorla",
6969                "wizard",
6970                "--answers",
6971                answers_path.to_str().unwrap(),
6972                "--pack-out",
6973                &pack_filename,
6974            ])
6975            .unwrap();
6976
6977            let pack_path = output_dir.join(pack_filename);
6978            doctor_sorla_gtpack(&pack_path).expect("validation example pack should doctor");
6979            let inspection =
6980                inspect_sorla_gtpack(&pack_path).expect("validation example should inspect");
6981            assert_eq!(inspection.name, pack_name);
6982            assert_eq!(inspection.version, pack_version);
6983            assert!(inspection.validation.is_some());
6984            assert!(inspection.exposure_policy.is_some());
6985            assert!(inspection.compatibility.is_some());
6986        }
6987    }
6988
6989    #[test]
6990    fn wizard_schema_rejects_pack_out() {
6991        let err = run([
6992            "greentic-sorla",
6993            "wizard",
6994            "--schema",
6995            "--pack-out",
6996            "schema.gtpack",
6997        ])
6998        .expect_err("schema mode should reject pack-out");
6999
7000        assert!(err.contains("`--pack-out` can only be used"));
7001    }
7002
7003    #[test]
7004    fn create_flow_generates_agent_endpoints_from_answers() {
7005        let dir = unique_temp_dir();
7006        let answers_path = dir.join("create-agent.json");
7007        let output_dir = dir.join("workspace");
7008        fs::create_dir_all(&output_dir).unwrap();
7009
7010        fs::write(
7011            &answers_path,
7012            format!(
7013                r#"{{
7014  "schema_version": "0.4",
7015  "flow": "create",
7016  "output_dir": "{}",
7017  "package": {{
7018    "name": "lead-capture",
7019    "version": "0.2.0"
7020  }},
7021  "agent_endpoints": {{
7022    "enabled": true,
7023    "ids": ["create_customer_contact", "request_partner_followup"],
7024    "default_risk": "medium",
7025    "default_approval": "policy-driven",
7026    "exports": ["openapi", "mcp"],
7027    "provider_category": "api-gateway"
7028  }}
7029}}"#,
7030                output_dir.display()
7031            ),
7032        )
7033        .unwrap();
7034
7035        run([
7036            "greentic-sorla",
7037            "wizard",
7038            "--answers",
7039            answers_path.to_str().unwrap(),
7040        ])
7041        .unwrap();
7042
7043        let package_yaml = fs::read_to_string(output_dir.join("sorla.yaml")).unwrap();
7044        assert!(package_yaml.contains("agent_endpoints:"));
7045        assert!(package_yaml.contains("id: create_customer_contact"));
7046        assert!(package_yaml.contains("id: request_partner_followup"));
7047        assert!(package_yaml.contains("category: api-gateway"));
7048        assert!(package_yaml.contains("openapi: true"));
7049        assert!(package_yaml.contains("arazzo: false"));
7050        assert!(package_yaml.contains("mcp: true"));
7051        assert!(package_yaml.contains("llms_txt: false"));
7052        serde_yaml::from_str::<serde_yaml::Value>(&package_yaml)
7053            .expect("generated agent endpoint YAML should be valid YAML");
7054
7055        let lock = fs::read_to_string(
7056            output_dir
7057                .join(".greentic-sorla")
7058                .join("generated")
7059                .join(LOCK_FILENAME),
7060        )
7061        .unwrap();
7062        assert!(lock.contains("\"agent_endpoints_enabled\": true"));
7063
7064        let provider_manifest = fs::read_to_string(
7065            output_dir
7066                .join(".greentic-sorla")
7067                .join("generated")
7068                .join("provider-requirements.json"),
7069        )
7070        .unwrap();
7071        assert!(provider_manifest.contains("\"agent_endpoint_handoff\""));
7072    }
7073
7074    #[test]
7075    fn interactive_wizard_uses_qa_and_reuses_answers_pipeline() {
7076        let dir = unique_temp_dir();
7077        let output_dir = dir.join("interactive-workspace");
7078        fs::create_dir_all(&output_dir).unwrap();
7079        let provider_output_dir = output_dir.clone();
7080
7081        let mut provider = move |question_id: &str, _question: &serde_json::Value| match question_id
7082        {
7083            "flow" => Ok(serde_json::json!("create")),
7084            "output_dir" => Ok(serde_json::json!(provider_output_dir.display().to_string())),
7085            "locale" => Ok(serde_json::json!("fr")),
7086            "package_name" => Ok(serde_json::json!("qa-demo")),
7087            "package_version" => Ok(serde_json::json!("0.3.0")),
7088            "storage_category" => Ok(serde_json::json!("storage")),
7089            "default_source" => Ok(serde_json::json!("external")),
7090            "external_ref_system" => Ok(serde_json::json!("crm")),
7091            "external_ref_category" => Ok(serde_json::json!("external-ref")),
7092            "events_enabled" => Ok(serde_json::json!(true)),
7093            "projection_mode" => Ok(serde_json::json!("current-state")),
7094            "compatibility_mode" => Ok(serde_json::json!("additive")),
7095            "agent_endpoints_enabled" => Ok(serde_json::json!(false)),
7096            "agent_endpoint_ids" => Ok(serde_json::json!("")),
7097            "agent_endpoint_default_risk" => Ok(serde_json::json!("medium")),
7098            "agent_endpoint_default_approval" => Ok(serde_json::json!("policy-driven")),
7099            "agent_endpoint_exports" => Ok(serde_json::json!("openapi,arazzo,mcp,llms_txt")),
7100            "agent_endpoint_provider_category" => Ok(serde_json::json!("api-gateway")),
7101            "include_agent_tools" => Ok(serde_json::json!(true)),
7102            other => panic!("unexpected interactive question: {other}"),
7103        };
7104
7105        let summary = run_interactive_wizard_with_provider("fr", &mut provider, None).unwrap();
7106        assert_eq!(summary.mode, "create");
7107        assert_eq!(summary.package_name, "qa-demo");
7108        assert_eq!(summary.locale, "fr");
7109
7110        let lock = fs::read_to_string(
7111            output_dir
7112                .join(".greentic-sorla")
7113                .join("generated")
7114                .join(LOCK_FILENAME),
7115        )
7116        .unwrap();
7117        assert!(lock.contains("\"default_source\": \"external\""));
7118        assert!(lock.contains("\"locale\": \"fr\""));
7119    }
7120
7121    #[test]
7122    fn update_flow_preserves_user_content_and_is_idempotent() {
7123        let dir = unique_temp_dir();
7124        let create_path = dir.join("create.json");
7125        let update_path = dir.join("update.json");
7126        let output_dir = dir.join("workspace");
7127        fs::create_dir_all(&output_dir).unwrap();
7128
7129        fs::write(
7130            &create_path,
7131            format!(
7132                r#"{{
7133  "schema_version": "0.4",
7134  "flow": "create",
7135  "output_dir": "{}",
7136  "package": {{
7137    "name": "tenancy",
7138    "version": "0.2.0"
7139  }}
7140}}"#,
7141                output_dir.display()
7142            ),
7143        )
7144        .unwrap();
7145        run([
7146            "greentic-sorla",
7147            "wizard",
7148            "--answers",
7149            create_path.to_str().unwrap(),
7150        ])
7151        .unwrap();
7152
7153        let package_path = output_dir.join("sorla.yaml");
7154        let existing = fs::read_to_string(&package_path).unwrap();
7155        fs::write(&package_path, format!("user-notes: keep-me\n{existing}")).unwrap();
7156
7157        fs::write(
7158            &update_path,
7159            format!(
7160                r#"{{
7161  "schema_version": "0.4",
7162  "flow": "update",
7163  "output_dir": "{}",
7164  "projections": {{
7165    "mode": "audit-trail"
7166  }},
7167  "output": {{
7168    "include_agent_tools": false,
7169    "artifacts": ["model.cbor", "events.cbor"]
7170  }}
7171}}"#,
7172                output_dir.display()
7173            ),
7174        )
7175        .unwrap();
7176
7177        run([
7178            "greentic-sorla",
7179            "wizard",
7180            "--answers",
7181            update_path.to_str().unwrap(),
7182        ])
7183        .unwrap();
7184        let first_updated = fs::read_to_string(&package_path).unwrap();
7185        assert!(first_updated.contains("user-notes: keep-me"));
7186        assert!(first_updated.contains("mode: audit-trail"));
7187        assert!(
7188            !output_dir
7189                .join(".greentic-sorla")
7190                .join("generated")
7191                .join("agent-tools.json")
7192                .exists()
7193        );
7194
7195        run([
7196            "greentic-sorla",
7197            "wizard",
7198            "--answers",
7199            update_path.to_str().unwrap(),
7200        ])
7201        .unwrap();
7202        let second_updated = fs::read_to_string(&package_path).unwrap();
7203        assert_eq!(first_updated, second_updated);
7204    }
7205
7206    #[test]
7207    fn validation_error_is_actionable() {
7208        let dir = unique_temp_dir();
7209        let answers_path = dir.join("invalid.json");
7210        fs::create_dir_all(&dir).unwrap();
7211        fs::write(
7212            &answers_path,
7213            format!(
7214                r#"{{
7215  "schema_version": "0.4",
7216  "flow": "create",
7217  "output_dir": "{}",
7218  "package": {{
7219    "version": "0.2.0"
7220  }}
7221}}"#,
7222                dir.display()
7223            ),
7224        )
7225        .unwrap();
7226
7227        let error = run([
7228            "greentic-sorla",
7229            "wizard",
7230            "--answers",
7231            answers_path.to_str().unwrap(),
7232        ])
7233        .expect_err("missing package.name should fail");
7234
7235        assert!(error.contains("package.name"));
7236    }
7237
7238    #[test]
7239    fn update_flow_uses_previous_locale_when_not_overridden() {
7240        let dir = unique_temp_dir();
7241        let create_path = dir.join("create.json");
7242        let update_path = dir.join("update.json");
7243        let output_dir = dir.join("workspace");
7244        fs::create_dir_all(&output_dir).unwrap();
7245
7246        fs::write(
7247            &create_path,
7248            format!(
7249                r#"{{
7250  "schema_version": "0.4",
7251  "flow": "create",
7252  "output_dir": "{}",
7253  "locale": "nl",
7254  "package": {{
7255    "name": "tenancy",
7256    "version": "0.2.0"
7257  }}
7258}}"#,
7259                output_dir.display()
7260            ),
7261        )
7262        .unwrap();
7263        run([
7264            "greentic-sorla",
7265            "wizard",
7266            "--answers",
7267            create_path.to_str().unwrap(),
7268        ])
7269        .unwrap();
7270
7271        fs::write(
7272            &update_path,
7273            format!(
7274                r#"{{
7275  "schema_version": "0.4",
7276  "flow": "update",
7277  "output_dir": "{}"
7278}}"#,
7279                output_dir.display()
7280            ),
7281        )
7282        .unwrap();
7283
7284        run([
7285            "greentic-sorla",
7286            "wizard",
7287            "--answers",
7288            update_path.to_str().unwrap(),
7289        ])
7290        .unwrap();
7291
7292        let lock = fs::read_to_string(
7293            output_dir
7294                .join(".greentic-sorla")
7295                .join("generated")
7296                .join(LOCK_FILENAME),
7297        )
7298        .unwrap();
7299        assert!(lock.contains("\"locale\": \"nl\""));
7300    }
7301
7302    fn unique_temp_dir() -> PathBuf {
7303        let nanos = SystemTime::now()
7304            .duration_since(UNIX_EPOCH)
7305            .unwrap()
7306            .as_nanos();
7307        let path = std::env::temp_dir().join(format!(
7308            "greentic-sorla-cli-tests-{}-{}",
7309            std::process::id(),
7310            nanos
7311        ));
7312        if path.exists() {
7313            fs::remove_dir_all(&path).unwrap();
7314        }
7315        fs::create_dir_all(&path).unwrap();
7316        path
7317    }
7318}