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 Wizard(WizardArgs),
205 Pack(PackArgs),
207 #[command(name = "__inspect-product-shape", hide = true)]
208 InspectProductShape,
209}
210
211#[derive(Debug, Args)]
212struct WizardArgs {
213 #[arg(long, action = ArgAction::SetTrue)]
215 schema: bool,
216 #[arg(long)]
218 locale: Option<String>,
219 #[arg(long, value_name = "FILE")]
221 answers: Option<PathBuf>,
222 #[arg(long, value_name = "FILE")]
224 pack_out: Option<PathBuf>,
225}
226
227#[derive(Debug, Args)]
228struct PackArgs {
229 #[arg(value_name = "FILE")]
231 input: Option<PathBuf>,
232 #[arg(long)]
234 name: Option<String>,
235 #[arg(long)]
237 version: Option<String>,
238 #[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 Doctor(PackPathArgs),
249 Inspect(PackPathArgs),
251 Schema(PackSchemaArgs),
253 ValidationInspect(PackPathArgs),
255 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 Validation,
269 ExposurePolicy,
271 Compatibility,
273 Ontology,
275 RetrievalBindings,
277}
278
279#[derive(Debug, Args)]
280struct PackPathArgs {
281 #[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 §ion.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}